From 70386c6b00a903deff9a641104911bc6c7ec51a5 Mon Sep 17 00:00:00 2001 From: Nayan Date: Tue, 30 Dec 2025 23:20:20 -0500 Subject: [PATCH 1/3] ui: fix Always Offroad button visibility (#1632) always offroad button fix --- selfdrive/ui/sunnypilot/layouts/settings/device.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/selfdrive/ui/sunnypilot/layouts/settings/device.py b/selfdrive/ui/sunnypilot/layouts/settings/device.py index 36c5fdb342..448a4faab6 100644 --- a/selfdrive/ui/sunnypilot/layouts/settings/device.py +++ b/selfdrive/ui/sunnypilot/layouts/settings/device.py @@ -202,7 +202,7 @@ class DeviceLayoutSP(DeviceLayout): self._scroller._items.remove(self._always_offroad_btn) if ui_state.is_offroad() and not always_offroad: self._scroller._items.insert(len(self._scroller._items) - 1, self._always_offroad_btn) - elif not ui_state.is_offroad(): + else: self._scroller._items.insert(0, self._always_offroad_btn) # Quiet Mode button From fb8f46cba9dc90af481719945c21eeecef8d6996 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Wed, 31 Dec 2025 00:08:36 -0500 Subject: [PATCH 2/3] Reimplement sunnypilot Terms of Service & sunnylink Consent Screens (#1633) * tos reimpl * nah * simpler * check consent on sunnylink panel - mici * slight cleanup * rename * keep it off * decouple * more rename * more decouple * a bit more * fix state * decouple more * a bit more * wrong type * rearrange * don't do that * final * lint * include * more --------- Co-authored-by: nayan --- common/params_keys.h | 2 + selfdrive/ui/layouts/onboarding.py | 47 +++++-- selfdrive/ui/mici/layouts/onboarding.py | 45 +++++-- selfdrive/ui/sunnypilot/layouts/onboarding.py | 116 ++++++++++++++++++ .../sunnypilot/layouts/settings/sunnylink.py | 34 +++-- selfdrive/ui/sunnypilot/mici/__init__.py | 0 .../ui/sunnypilot/mici/layouts/onboarding.py | 97 +++++++++++++++ .../ui/sunnypilot/mici/layouts/sunnylink.py | 47 ++++--- selfdrive/ui/tests/diff/replay.py | 4 +- .../ui/tests/test_ui/raylib_screenshots.py | 4 +- sunnypilot/selfdrive/assets/logo.png | 3 + sunnypilot/sunnylink/athena/sunnylinkd.py | 6 +- sunnypilot/sunnylink/params_metadata.json | 8 ++ system/hardware/hardwared.py | 3 +- system/version.py | 3 + 15 files changed, 376 insertions(+), 43 deletions(-) create mode 100644 selfdrive/ui/sunnypilot/layouts/onboarding.py create mode 100644 selfdrive/ui/sunnypilot/mici/__init__.py create mode 100644 selfdrive/ui/sunnypilot/mici/layouts/onboarding.py create mode 100644 sunnypilot/selfdrive/assets/logo.png diff --git a/common/params_keys.h b/common/params_keys.h index 89f70471c5..054ce97e6d 100644 --- a/common/params_keys.h +++ b/common/params_keys.h @@ -145,6 +145,7 @@ inline static std::unordered_map keys = { {"CarParamsSPPersistent", {PERSISTENT, BYTES}}, {"CarPlatformBundle", {PERSISTENT | BACKUP, JSON}}, {"ChevronInfo", {PERSISTENT | BACKUP, INT, "4"}}, + {"CompletedSunnylinkConsentVersion", {PERSISTENT, STRING, "0"}}, {"CustomAccIncrementsEnabled", {PERSISTENT | BACKUP, BOOL, "0"}}, {"CustomAccLongPressIncrement", {PERSISTENT | BACKUP, INT, "5"}}, {"CustomAccShortPressIncrement", {PERSISTENT | BACKUP, INT, "1"}}, @@ -154,6 +155,7 @@ inline static std::unordered_map keys = { {"EnableGithubRunner", {PERSISTENT | BACKUP, BOOL}}, {"GreenLightAlert", {PERSISTENT | BACKUP, BOOL, "0"}}, {"GithubRunnerSufficientVoltage", {CLEAR_ON_MANAGER_START , BOOL}}, + {"HasAcceptedTermsSP", {PERSISTENT, STRING, "0"}}, {"HideVEgoUI", {PERSISTENT | BACKUP, BOOL, "0"}}, {"IntelligentCruiseButtonManagement", {PERSISTENT | BACKUP , BOOL}}, {"InteractivityTimeout", {PERSISTENT | BACKUP, INT, "0"}}, diff --git a/selfdrive/ui/layouts/onboarding.py b/selfdrive/ui/layouts/onboarding.py index b19cebb266..c480a3ed9d 100644 --- a/selfdrive/ui/layouts/onboarding.py +++ b/selfdrive/ui/layouts/onboarding.py @@ -11,7 +11,9 @@ from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.button import Button, ButtonStyle from openpilot.system.ui.widgets.label import Label from openpilot.selfdrive.ui.ui_state import ui_state -from openpilot.system.version import terms_version, training_version +from openpilot.system.version import terms_version, training_version, terms_version_sp + +from openpilot.selfdrive.ui.sunnypilot.layouts.onboarding import SunnylinkOnboarding DEBUG = False @@ -33,6 +35,7 @@ class OnboardingState(IntEnum): TERMS = 0 ONBOARDING = 1 DECLINE = 2 + SUNNYLINK_CONSENT = 3 class TrainingGuide(Widget): @@ -110,14 +113,14 @@ class TermsPage(Widget): self._on_decline = on_decline self._title = Label(tr("Welcome to sunnypilot"), font_size=90, font_weight=FontWeight.BOLD, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT) - self._desc = Label(tr("You must accept the Terms and Conditions to use sunnypilot. Read the latest terms at https://comma.ai/terms before continuing."), + self._desc = Label(tr("You must accept the Terms of Service to use sunnypilot. Read the latest terms at https://sunnypilot.ai/terms before continuing."), font_size=90, font_weight=FontWeight.MEDIUM, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT) self._decline_btn = Button(tr("Decline"), click_callback=on_decline) self._accept_btn = Button(tr("Agree"), button_style=ButtonStyle.PRIMARY, click_callback=on_accept) def _render(self, _): - welcome_x = self._rect.x + 165 + welcome_x = self._rect.x + 95 welcome_y = self._rect.y + 165 welcome_rect = rl.Rectangle(welcome_x, welcome_y, self._rect.width - welcome_x, 90) self._title.render(welcome_rect) @@ -143,7 +146,7 @@ class TermsPage(Widget): class DeclinePage(Widget): def __init__(self, back_callback=None): super().__init__() - self._text = Label(tr("You must accept the Terms and Conditions in order to use sunnypilot."), + self._text = Label(tr("You must accept the Terms of Service in order to use sunnypilot."), font_size=90, font_weight=FontWeight.MEDIUM, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT) self._back_btn = Button(tr("Back"), click_callback=back_callback) self._uninstall_btn = Button(tr("Decline, uninstall sunnypilot"), button_style=ButtonStyle.DANGER, @@ -180,9 +183,21 @@ class OnboardingWindow(Widget): self._training_guide: TrainingGuide | None = None self._decline_page = DeclinePage(back_callback=self._on_decline_back) + # sunnylink consent pages + self._accepted_terms = self._accepted_terms and ui_state.params.get("HasAcceptedTermsSP") == terms_version_sp + self._sunnylink = SunnylinkOnboarding() + if not self._accepted_terms: + self._state = OnboardingState.TERMS + elif not self._sunnylink.completed: + self._state = OnboardingState.SUNNYLINK_CONSENT + elif not self._training_done: + self._state = OnboardingState.ONBOARDING + else: + self._state = OnboardingState.ONBOARDING + @property def completed(self) -> bool: - return self._accepted_terms and self._training_done + return self._accepted_terms and self._sunnylink.completed and self._training_done def _on_terms_declined(self): self._state = OnboardingState.DECLINE @@ -192,8 +207,12 @@ class OnboardingWindow(Widget): def _on_terms_accepted(self): ui_state.params.put("HasAcceptedTerms", terms_version) - self._state = OnboardingState.ONBOARDING - if self._training_done: + ui_state.params.put("HasAcceptedTermsSP", terms_version_sp) + if not self._sunnylink.completed: + self._state = OnboardingState.SUNNYLINK_CONSENT + elif not self._training_done: + self._state = OnboardingState.ONBOARDING + else: gui_app.set_modal_overlay(None) def _on_completed_training(self): @@ -206,8 +225,18 @@ class OnboardingWindow(Widget): if self._state == OnboardingState.TERMS: self._terms.render(self._rect) - if self._state == OnboardingState.ONBOARDING: - self._training_guide.render(self._rect) + elif self._state == OnboardingState.SUNNYLINK_CONSENT: + self._sunnylink.render(self._rect) + if self._sunnylink.completed: + if not self._training_done: + self._state = OnboardingState.ONBOARDING + else: + gui_app.set_modal_overlay(None) + elif self._state == OnboardingState.ONBOARDING: + if not self._training_done: + self._training_guide.render(self._rect) + else: + gui_app.set_modal_overlay(None) elif self._state == OnboardingState.DECLINE: self._decline_page.render(self._rect) return -1 diff --git a/selfdrive/ui/mici/layouts/onboarding.py b/selfdrive/ui/mici/layouts/onboarding.py index b175a3fd2e..c7fcd78530 100644 --- a/selfdrive/ui/mici/layouts/onboarding.py +++ b/selfdrive/ui/mici/layouts/onboarding.py @@ -17,13 +17,16 @@ from openpilot.selfdrive.ui.mici.onroad.driver_state import DriverStateRenderer from openpilot.selfdrive.ui.mici.onroad.driver_camera_dialog import DriverCameraDialog from openpilot.system.ui.widgets.label import gui_label from openpilot.system.ui.lib.multilang import tr -from openpilot.system.version import terms_version, training_version +from openpilot.system.version import terms_version, training_version, terms_version_sp + +from openpilot.selfdrive.ui.sunnypilot.mici.layouts.onboarding import SunnylinkOnboarding class OnboardingState(IntEnum): TERMS = 0 ONBOARDING = 1 DECLINE = 2 + SUNNYLINK_CONSENT = 3 class DriverCameraSetupDialog(DriverCameraDialog): @@ -412,10 +415,10 @@ class TermsPage(SetupTermsPage): super().__init__(on_accept, on_decline, "decline") info_txt = gui_app.texture("icons_mici/setup/green_info.png", 60, 60) - self._title_header = TermsHeader("terms & conditions", info_txt) + self._title_header = TermsHeader("terms of service", info_txt) - self._terms_label = UnifiedLabel("You must accept the Terms and Conditions to use sunnypilot. " + - "Read the latest terms at https://comma.ai/terms before continuing.", 36, + self._terms_label = UnifiedLabel("You must accept the Terms of Service to use sunnypilot. " + + "Read the latest terms at https://sunnypilot.ai/terms before continuing.", 36, FontWeight.ROMAN) @property @@ -449,6 +452,18 @@ class OnboardingWindow(Widget): self._training_guide = TrainingGuide(completed_callback=self._on_completed_training) self._decline_page = DeclinePage(back_callback=self._on_decline_back) + # sunnylink consent pages + self._accepted_terms = self._accepted_terms and ui_state.params.get("HasAcceptedTermsSP") == terms_version_sp + self._sunnylink = SunnylinkOnboarding() + if not self._accepted_terms: + self._state = OnboardingState.TERMS + elif not self._sunnylink.completed: + self._state = OnboardingState.SUNNYLINK_CONSENT + elif not self._training_done: + self._state = OnboardingState.ONBOARDING + else: + self._state = OnboardingState.ONBOARDING + def show_event(self): super().show_event() device.set_override_interactive_timeout(300) @@ -459,7 +474,7 @@ class OnboardingWindow(Widget): @property def completed(self) -> bool: - return self._accepted_terms and self._training_done + return self._accepted_terms and self._sunnylink.completed and self._training_done def _on_terms_declined(self): self._state = OnboardingState.DECLINE @@ -473,7 +488,13 @@ class OnboardingWindow(Widget): def _on_terms_accepted(self): ui_state.params.put("HasAcceptedTerms", terms_version) - self._state = OnboardingState.ONBOARDING + ui_state.params.put("HasAcceptedTermsSP", terms_version_sp) + if not self._sunnylink.completed: + self._state = OnboardingState.SUNNYLINK_CONSENT + elif not self._training_done: + self._state = OnboardingState.ONBOARDING + else: + self.close() def _on_completed_training(self): ui_state.params.put("CompletedTrainingVersion", training_version) @@ -482,8 +503,18 @@ class OnboardingWindow(Widget): def _render(self, _): if self._state == OnboardingState.TERMS: self._terms.render(self._rect) + elif self._state == OnboardingState.SUNNYLINK_CONSENT: + self._sunnylink.render(self._rect) + if self._sunnylink.completed: + if not self._training_done: + self._state = OnboardingState.ONBOARDING + else: + self.close() elif self._state == OnboardingState.ONBOARDING: - self._training_guide.render(self._rect) + if not self._training_done: + self._training_guide.render(self._rect) + else: + self.close() elif self._state == OnboardingState.DECLINE: self._decline_page.render(self._rect) return -1 diff --git a/selfdrive/ui/sunnypilot/layouts/onboarding.py b/selfdrive/ui/sunnypilot/layouts/onboarding.py new file mode 100644 index 0000000000..9ba9f07494 --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/onboarding.py @@ -0,0 +1,116 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import pyray as rl +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.ui.lib.application import FontWeight +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.button import Button, ButtonStyle +from openpilot.system.ui.widgets.label import Label +from openpilot.system.version import sunnylink_consent_version, sunnylink_consent_declined + + +class SunnylinkConsentPage(Widget): + def __init__(self, done_callback=None): + super().__init__() + self._done_callback = done_callback + self._step = 0 + + self._title = Label(tr("sunnylink"), font_size=90, font_weight=FontWeight.BOLD, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT) + + self._content = [ + { + "text": tr("sunnylink enables secured remote access to your comma device from anywhere, " + + "including settings management, remote monitoring, real-time dashboard, etc."), + "primary_btn": tr("Enable"), + "secondary_btn": tr("Disable"), + "highlight_primary": True + }, + { + "text": tr("sunnylink is designed to be enabled as part of sunnypilot's core functionality. " + + "If sunnylink is disabled, features such as settings management, remote monitoring, " + + "real-time dashboards will be unavailable."), + "secondary_btn": tr("Back"), + "danger_btn": tr("Disable"), + "highlight_primary": True + } + ] + + self._primary_btn = Button("", button_style=ButtonStyle.PRIMARY, click_callback=lambda: self._handle_choice("enable")) + self._secondary_btn = Button("", button_style=ButtonStyle.NORMAL, click_callback=lambda: self._handle_choice("secondary")) + self._danger_btn = Button("", button_style=ButtonStyle.DANGER, click_callback=lambda: self._handle_choice("disable")) + + def _handle_choice(self, choice): + if choice == "enable": + ui_state.params.put_bool("SunnylinkEnabled", True) + ui_state.params.put("CompletedSunnylinkConsentVersion", sunnylink_consent_version) + if self._done_callback: + self._done_callback() + elif choice == "secondary": + if self._step == 0: + self._step = 1 + elif self._step == 1: + self._step = 0 + elif choice == "disable": + ui_state.params.put_bool("SunnylinkEnabled", False) + ui_state.params.put("CompletedSunnylinkConsentVersion", sunnylink_consent_declined) + if self._done_callback: + self._done_callback() + + def _render(self, _): + step_data = self._content[self._step] + + welcome_x = self._rect.x + 95 + welcome_y = self._rect.y + 165 + welcome_rect = rl.Rectangle(welcome_x, welcome_y, self._rect.width - welcome_x, 90) + self._title.render(welcome_rect) + + desc_x = welcome_x + desc_y = welcome_y + 120 + desc_rect = rl.Rectangle(desc_x, desc_y, self._rect.width - desc_x, self._rect.height - desc_y - 250) + + desc_label = Label(step_data["text"], font_size=90, font_weight=FontWeight.MEDIUM, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT) + desc_label.render(desc_rect) + + btn_y = self._rect.y + self._rect.height - 160 - 45 + + if "danger_btn" in step_data: + btn_width = (self._rect.width - 45 * 3) / 2 + + self._secondary_btn.set_text(step_data["secondary_btn"]) + self._secondary_btn.render(rl.Rectangle(self._rect.x + 45, btn_y, btn_width, 160)) + + self._danger_btn.set_text(step_data["danger_btn"]) + self._danger_btn.render(rl.Rectangle(self._rect.x + 45 * 2 + btn_width, btn_y, btn_width, 160)) + + else: + btn_width = (self._rect.width - 45 * 3) / 2 + + self._secondary_btn.set_text(step_data["secondary_btn"]) + self._secondary_btn.render(rl.Rectangle(self._rect.x + 45, btn_y, btn_width, 160)) + + self._primary_btn.set_text(step_data["primary_btn"]) + self._primary_btn.render(rl.Rectangle(self._rect.x + 45 * 2 + btn_width, btn_y, btn_width, 160)) + + return -1 + + +class SunnylinkOnboarding: + def __init__(self): + self.consent_page = SunnylinkConsentPage(done_callback=self._on_done) + self.consent_done: bool = ui_state.params.get("CompletedSunnylinkConsentVersion") in {sunnylink_consent_version, sunnylink_consent_declined} + + @property + def completed(self) -> bool: + return self.consent_done + + def _on_done(self): + self.consent_done = True + + def render(self, rect): + if not self.consent_done: + self.consent_page.render(rect) diff --git a/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py b/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py index 2b5497fb56..00baf0cccf 100644 --- a/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py +++ b/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py @@ -4,23 +4,23 @@ Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. This file is part of sunnypilot and is licensed under the MIT License. See the LICENSE.md file in the root directory for more details. """ +import pyray as rl from cereal import custom +from openpilot.selfdrive.ui.sunnypilot.layouts.onboarding import SunnylinkConsentPage from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.sunnypilot.sunnylink.api import UNREGISTERED_SUNNYLINK_DONGLE_ID from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.sunnypilot.widgets.list_view import button_item_sp +from openpilot.system.ui.sunnypilot.widgets.list_view import toggle_item_sp from openpilot.system.ui.sunnypilot.widgets.sunnylink_pairing_dialog import SunnylinkPairingDialog +from openpilot.system.ui.widgets import Widget, DialogResult from openpilot.system.ui.widgets.button import ButtonStyle, Button from openpilot.system.ui.widgets.confirm_dialog import alert_dialog, ConfirmDialog from openpilot.system.ui.widgets.label import UnifiedLabel -from openpilot.system.ui.widgets.list_view import button_item, dual_button_item +from openpilot.system.ui.widgets.list_view import dual_button_item from openpilot.system.ui.widgets.scroller_tici import Scroller, LineSeparator -from openpilot.system.ui.widgets import Widget, DialogResult -from openpilot.system.ui.sunnypilot.widgets.list_view import toggle_item_sp -import pyray as rl - -if gui_app.sunnypilot_ui(): - from openpilot.system.ui.sunnypilot.widgets.list_view import button_item_sp as button_item +from openpilot.system.version import sunnylink_consent_version class SunnylinkHeader(Widget): @@ -160,14 +160,14 @@ class SunnylinkLayout(Widget): self._sunnylink_description = SunnylinkDescriptionItem() self._sunnylink_description.set_visible(False) - self._sponsor_btn = button_item( + self._sponsor_btn = button_item_sp( title=tr("Sponsor Status"), button_text=tr("SPONSOR"), description=tr( "Become a sponsor of sunnypilot to get early access to sunnylink features when they become available."), callback=lambda: self._handle_pair_btn(False) ) - self._pair_btn = button_item( + self._pair_btn = button_item_sp( title=tr("Pair GitHub Account"), button_text=tr("Not Paired"), description=tr( @@ -302,6 +302,22 @@ class SunnylinkLayout(Widget): self._restore_btn.set_text(tr("Restore Settings")) def _sunnylink_toggle_callback(self, state: bool): + sl_consent: bool = ui_state.params.get("CompletedSunnylinkConsentVersion") == sunnylink_consent_version + sl_enabled: bool = ui_state.params.get_bool("SunnylinkEnabled") + + if state and not sl_consent and not sl_enabled: + def on_consent_done(): + enabled = ui_state.params.get_bool("SunnylinkEnabled") + self._update_description(enabled) + gui_app.set_modal_overlay(None) + + sl_terms_dlg = SunnylinkConsentPage(done_callback=on_consent_done) + gui_app.set_modal_overlay(sl_terms_dlg) + else: + ui_state.params.put_bool("SunnylinkEnabled", state) + self._update_description(state) + + def _update_description(self, state: bool): if state: description = tr( "Welcome back!! We're excited to see you've enabled sunnylink again!") diff --git a/selfdrive/ui/sunnypilot/mici/__init__.py b/selfdrive/ui/sunnypilot/mici/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/selfdrive/ui/sunnypilot/mici/layouts/onboarding.py b/selfdrive/ui/sunnypilot/mici/layouts/onboarding.py new file mode 100644 index 0000000000..930853e55c --- /dev/null +++ b/selfdrive/ui/sunnypilot/mici/layouts/onboarding.py @@ -0,0 +1,97 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import pyray as rl +from openpilot.system.ui.lib.application import FontWeight, gui_app +from openpilot.system.ui.widgets.label import UnifiedLabel +from openpilot.system.ui.widgets.slider import SmallSlider +from openpilot.system.ui.mici_setup import TermsHeader, TermsPage as SetupTermsPage +from openpilot.system.version import sunnylink_consent_version, sunnylink_consent_declined +from openpilot.selfdrive.ui.ui_state import ui_state + + +class SunnylinkConsentPage(SetupTermsPage): + def __init__(self, on_accept=None, on_decline=None, left_text: str = "disable", right_text: str = "enable"): + super().__init__(on_accept, on_decline, left_text, continue_text=right_text) + + self._title_header = TermsHeader("sunnylink", + gui_app.texture("../../sunnypilot/selfdrive/assets/logo.png", 66, 60)) + + self._terms_label = UnifiedLabel("sunnylink enables secured remote access to your comma device from anywhere, " + + "including settings management, remote monitoring, real-time dashboard, etc.", + 36, FontWeight.ROMAN) + + @property + def _content_height(self): + return self._terms_label.rect.y + self._terms_label.rect.height - self._scroll_panel.get_offset() + + def _render(self, _): + super()._render(_) + return -1 + + def _render_content(self, scroll_offset): + self._title_header.set_position(self._rect.x + 16, self._rect.y + 12 + scroll_offset) + self._title_header.render() + + self._terms_label.render(rl.Rectangle( + self._rect.x + 16, + self._title_header.rect.y + self._title_header.rect.height + self.ITEM_SPACING, + self._rect.width - 100, + self._terms_label.get_content_height(int(self._rect.width - 100)), + )) + + +class SunnylinkConsentDisableConfirmPage(SunnylinkConsentPage): + def __init__(self, on_accept=None, on_decline=None): + super().__init__(on_accept=on_decline, on_decline=on_accept, left_text="enable", right_text="disable") + + # we flip the continue & disable buttons to use slider for disable + self._continue_slider = True + self._continue_button = SmallSlider("disable", confirm_callback=on_decline) + self._scroll_panel.set_enabled(lambda: not self._continue_button.is_pressed) + + self._title_header = TermsHeader("disable sunnylink?", + gui_app.texture("icons_mici/setup/red_warning.png", 66, 60)) + + self._terms_label = UnifiedLabel("sunnylink is designed to be enabled as part of sunnypilot's core functionality. " + + "If sunnylink is disabled, features such as settings management, " + + "remote monitoring, real-time dashboards will be unavailable.", + 36, FontWeight.ROMAN) + + +class SunnylinkOnboarding: + def __init__(self): + self.consent_done: bool = ui_state.params.get("CompletedSunnylinkConsentVersion") in {sunnylink_consent_version, sunnylink_consent_declined} + self.disable_confirm = False + + self.consent_page = SunnylinkConsentPage(on_decline=self._on_decline, on_accept=self._on_accept) + self.confirm_page = SunnylinkConsentDisableConfirmPage(on_decline=self._on_confirm_decline, on_accept=self._on_accept) + + @property + def completed(self) -> bool: + return self.consent_done + + def _on_accept(self): + ui_state.params.put("CompletedSunnylinkConsentVersion", sunnylink_consent_version) + ui_state.params.put_bool("SunnylinkEnabled", True) + self.consent_done = True + + def _on_decline(self): + self.disable_confirm = True + + def _on_confirm_decline(self): + ui_state.params.put_bool("SunnylinkEnabled", False) + ui_state.params.put("CompletedSunnylinkConsentVersion", sunnylink_consent_declined) + self.consent_done = True + + def render(self, rect): + if self.consent_done: + return + + if self.disable_confirm: + self.confirm_page.render(rect) + else: + self.consent_page.render(rect) diff --git a/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py b/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py index 2ab035c1cf..172ef7d2f8 100644 --- a/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py +++ b/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py @@ -8,16 +8,17 @@ from collections.abc import Callable import pyray as rl from cereal import custom -from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigConfirmationDialogV2 -from openpilot.selfdrive.ui.sunnypilot.mici.widgets.sunnylink_pairing_dialog import SunnylinkPairingDialog -from openpilot.sunnypilot.sunnylink.api import UNREGISTERED_SUNNYLINK_DONGLE_ID -from openpilot.system.ui.lib.multilang import tr - -from openpilot.system.ui.widgets.scroller import Scroller from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigToggle -from openpilot.system.ui.lib.application import gui_app, MousePos -from openpilot.system.ui.widgets import NavWidget +from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigConfirmationDialogV2 +from openpilot.selfdrive.ui.sunnypilot.mici.layouts.onboarding import SunnylinkConsentPage +from openpilot.selfdrive.ui.sunnypilot.mici.widgets.sunnylink_pairing_dialog import SunnylinkPairingDialog from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.sunnypilot.sunnylink.api import UNREGISTERED_SUNNYLINK_DONGLE_ID +from openpilot.system.ui.lib.application import gui_app, MousePos +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.widgets import NavWidget +from openpilot.system.ui.widgets.scroller import Scroller +from openpilot.system.version import sunnylink_consent_version, sunnylink_consent_declined class SunnylinkLayoutMici(NavWidget): @@ -28,9 +29,9 @@ class SunnylinkLayoutMici(NavWidget): self._backup_in_progress = False self._sunnylink_enabled = ui_state.params.get("SunnylinkEnabled") - self._sunnylink_toggle = BigToggle(text="", + self._sunnylink_toggle = BigToggle(text=tr("enable sunnylink"), initial_state=self._sunnylink_enabled, - toggle_callback=SunnylinkLayoutMici._sunnylink_toggle_callback) + toggle_callback=self._sunnylink_toggle_callback) self._sunnylink_sponsor_button = SunnylinkPairBigButton(sponsor_pairing=False) self._sunnylink_pair_button = SunnylinkPairBigButton(sponsor_pairing=True) self._backup_btn = BigButton(tr("backup settings"), "", "") @@ -38,7 +39,7 @@ class SunnylinkLayoutMici(NavWidget): self._restore_btn = BigButton(tr("restore settings"), "", "") self._restore_btn.set_click_callback(lambda: self._handle_backup_restore_btn(restore=True)) self._sunnylink_uploader_toggle = BigToggle(text=tr("sunnylink uploader"), initial_state=False, - toggle_callback=SunnylinkLayoutMici._sunnylink_uploader_callback) + toggle_callback=self._sunnylink_uploader_callback) self._scroller = Scroller([ self._sunnylink_toggle, @@ -51,8 +52,8 @@ class SunnylinkLayoutMici(NavWidget): def _update_state(self): super()._update_state() - self._sunnylink_enabled = ui_state.sunnylink_enabled - self._sunnylink_toggle.set_text(tr("enable sunnylink")) + self._sunnylink_enabled = ui_state.params.get("SunnylinkEnabled") + self._sunnylink_toggle.set_checked(self._sunnylink_enabled) self._sunnylink_pair_button.set_visible(self._sunnylink_enabled) self._sunnylink_sponsor_button.set_visible(self._sunnylink_enabled) self._backup_btn.set_visible(self._sunnylink_enabled) @@ -83,7 +84,25 @@ class SunnylinkLayoutMici(NavWidget): @staticmethod def _sunnylink_toggle_callback(state: bool): - ui_state.params.put_bool("SunnylinkEnabled", state) + sl_consent: bool = ui_state.params.get("CompletedSunnylinkConsentVersion") == sunnylink_consent_version + sl_enabled: bool = ui_state.params.get("SunnylinkEnabled") + + def sl_terms_accepted(): + ui_state.params.put("CompletedSunnylinkConsentVersion", sunnylink_consent_version) + ui_state.params.put_bool("SunnylinkEnabled", True) + gui_app.set_modal_overlay(None) + + def sl_terms_declined(): + ui_state.params.put("CompletedSunnylinkConsentVersion", sunnylink_consent_declined) + ui_state.params.put_bool("SunnylinkEnabled", False) + gui_app.set_modal_overlay(None) + + if state and not sl_consent and not sl_enabled: + sl_terms_dlg = SunnylinkConsentPage(on_accept=sl_terms_accepted, on_decline=sl_terms_declined) + gui_app.set_modal_overlay(sl_terms_dlg) + else: + ui_state.params.put_bool("SunnylinkEnabled", state) + ui_state.update_params() @staticmethod diff --git a/selfdrive/ui/tests/diff/replay.py b/selfdrive/ui/tests/diff/replay.py index 9da157660e..ce24bf9190 100755 --- a/selfdrive/ui/tests/diff/replay.py +++ b/selfdrive/ui/tests/diff/replay.py @@ -13,7 +13,7 @@ if "RECORD_OUTPUT" not in os.environ: os.environ["RECORD_OUTPUT"] = os.path.join(DIFF_OUT_DIR, os.environ["RECORD_OUTPUT"]) from openpilot.common.params import Params -from openpilot.system.version import terms_version, training_version +from openpilot.system.version import terms_version, training_version, terms_version_sp, sunnylink_consent_version from openpilot.system.ui.lib.application import gui_app, MousePos, MouseEvent from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.selfdrive.ui.mici.layouts.main import MiciMainLayout @@ -45,6 +45,8 @@ def setup_state(): params.put("CompletedTrainingVersion", training_version) params.put("DongleId", "test123456789") params.put("UpdaterCurrentDescription", "0.10.1 / test-branch / abc1234 / Nov 30") + params.put("HasAcceptedTermsSP", terms_version_sp) + params.put("CompletedSunnylinkConsentVersion", sunnylink_consent_version) return None diff --git a/selfdrive/ui/tests/test_ui/raylib_screenshots.py b/selfdrive/ui/tests/test_ui/raylib_screenshots.py index e209ab8060..cd27b1c12f 100755 --- a/selfdrive/ui/tests/test_ui/raylib_screenshots.py +++ b/selfdrive/ui/tests/test_ui/raylib_screenshots.py @@ -18,7 +18,7 @@ from openpilot.common.prefix import OpenpilotPrefix from openpilot.selfdrive.test.helpers import with_processes from openpilot.selfdrive.selfdrived.alertmanager import set_offroad_alert from openpilot.system.updated.updated import parse_release_notes -from openpilot.system.version import terms_version, training_version +from openpilot.system.version import terms_version, training_version, terms_version_sp, sunnylink_consent_version AlertSize = log.SelfdriveState.AlertSize AlertStatus = log.SelfdriveState.AlertStatus @@ -378,6 +378,8 @@ def create_screenshots(): # Set terms and training version (to skip onboarding) params.put("HasAcceptedTerms", terms_version) params.put("CompletedTrainingVersion", training_version) + params.put("HasAcceptedTermsSP", terms_version_sp) + params.put("CompletedSunnylinkConsentVersion", sunnylink_consent_version) if name == "homescreen_paired": params.put("PrimeType", 0) # NONE diff --git a/sunnypilot/selfdrive/assets/logo.png b/sunnypilot/selfdrive/assets/logo.png new file mode 100644 index 0000000000..690cf7fb70 --- /dev/null +++ b/sunnypilot/selfdrive/assets/logo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:66b3aefa108dd0c7f64205a11e424430c318e6fd06de31b5550d0b9d05616e6a +size 19035 diff --git a/sunnypilot/sunnylink/athena/sunnylinkd.py b/sunnypilot/sunnylink/athena/sunnylinkd.py index d1a03778c6..73fa42e714 100755 --- a/sunnypilot/sunnylink/athena/sunnylinkd.py +++ b/sunnypilot/sunnylink/athena/sunnylinkd.py @@ -42,10 +42,14 @@ METADATA_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__f params = Params() -# Parameters that should never be remotely modified for security reasons +# Parameters that should never be remotely modified BLOCKED_PARAMS = { + "CompletedSunnylinkConsentVersion", + "CompletedTrainingVersion", "GithubUsername", # Could grant SSH access "GithubSshKeys", # Direct SSH key injection + "HasAcceptedTerms", + "HasAcceptedTermsSP", } diff --git a/sunnypilot/sunnylink/params_metadata.json b/sunnypilot/sunnylink/params_metadata.json index f335457fb5..95a143ea66 100644 --- a/sunnypilot/sunnylink/params_metadata.json +++ b/sunnypilot/sunnylink/params_metadata.json @@ -165,6 +165,10 @@ "title": "Chevron Info", "description": "" }, + "CompletedSunnylinkConsentVersion": { + "title": "Completed sunnylink Consent Version", + "description": "" + }, "CompletedTrainingVersion": { "title": "Completed Training Version", "description": "" @@ -349,6 +353,10 @@ "title": "Has Accepted Terms", "description": "" }, + "HasAcceptedTermsSP": { + "title": "Has Accepted sunnypilot Terms", + "description": "" + }, "HideVEgoUI": { "title": "Hide vEgo UI", "description": "" diff --git a/system/hardware/hardwared.py b/system/hardware/hardwared.py index 1d1893a8c5..1179914d0d 100755 --- a/system/hardware/hardwared.py +++ b/system/hardware/hardwared.py @@ -23,7 +23,7 @@ from openpilot.system.statsd import statlog from openpilot.common.swaglog import cloudlog from openpilot.system.hardware.power_monitoring import PowerMonitoring from openpilot.system.hardware.fan_controller import TiciFanController -from openpilot.system.version import terms_version, training_version, get_build_metadata +from openpilot.system.version import terms_version, training_version, get_build_metadata, terms_version_sp ThermalStatus = log.DeviceState.ThermalStatus NetworkType = log.DeviceState.NetworkType @@ -310,6 +310,7 @@ def hardware_thread(end_event, hw_queue) -> None: startup_conditions["no_excessive_actuation"] = params.get("Offroad_ExcessiveActuation") is None startup_conditions["not_uninstalling"] = not params.get_bool("DoUninstall") startup_conditions["accepted_terms"] = params.get("HasAcceptedTerms") == terms_version + startup_conditions["accepted_terms_sp"] = params.get("HasAcceptedTermsSP") == terms_version_sp # with 2% left, we killall, otherwise the phone will take a long time to boot startup_conditions["free_space"] = msg.deviceState.freeSpacePercent > 2 diff --git a/system/version.py b/system/version.py index 8a0e2da3e2..ae6ac1b13a 100755 --- a/system/version.py +++ b/system/version.py @@ -30,6 +30,9 @@ BUILD_METADATA_FILENAME = "build.json" training_version: str = "0.2.0" terms_version: str = "2" +terms_version_sp: str = "1.0" +sunnylink_consent_version: str = "1.0" +sunnylink_consent_declined: str = "-1" def get_version(path: str = BASEDIR) -> str: From 19a7d1d5d71a857d942835e27ee195c09cc54414 Mon Sep 17 00:00:00 2001 From: Kumar <36933347+rav4kumar@users.noreply.github.com> Date: Tue, 30 Dec 2025 22:27:53 -0700 Subject: [PATCH 3/3] [TIZI/TICI] ui: update dmoji position and Developer UI adjustments (#1601) * ui: improve layout and centering of bottom developer UI elements * int * less is more, y'all * always show actual lat for all cars * lint * perfect * cleanup * too long * inherit * remove unused * inir * need to fix * final --------- Co-authored-by: Jason Wen --- selfdrive/ui/onroad/augmented_road_view.py | 1 + .../onroad/developer_ui/__init__.py | 51 +++++++++++++------ .../onroad/developer_ui/elements.py | 21 ++++++++ .../ui/sunnypilot/onroad/driver_state.py | 49 ++++++++++++++++++ 4 files changed, 107 insertions(+), 15 deletions(-) create mode 100644 selfdrive/ui/sunnypilot/onroad/driver_state.py diff --git a/selfdrive/ui/onroad/augmented_road_view.py b/selfdrive/ui/onroad/augmented_road_view.py index 1d66379f93..a1d10193a8 100644 --- a/selfdrive/ui/onroad/augmented_road_view.py +++ b/selfdrive/ui/onroad/augmented_road_view.py @@ -16,6 +16,7 @@ from openpilot.common.transformations.orientation import rot_from_euler if gui_app.sunnypilot_ui(): from openpilot.selfdrive.ui.sunnypilot.onroad.hud_renderer import HudRendererSP as HudRenderer + from openpilot.selfdrive.ui.sunnypilot.onroad.driver_state import DriverStateRendererSP as DriverStateRenderer from openpilot.selfdrive.ui.sunnypilot.onroad.augmented_road_view import BORDER_COLORS_SP diff --git a/selfdrive/ui/sunnypilot/onroad/developer_ui/__init__.py b/selfdrive/ui/sunnypilot/onroad/developer_ui/__init__.py index 14a224ae77..441a169d23 100644 --- a/selfdrive/ui/sunnypilot/onroad/developer_ui/__init__.py +++ b/selfdrive/ui/sunnypilot/onroad/developer_ui/__init__.py @@ -22,6 +22,7 @@ class DeveloperUiRenderer(Widget): DEV_UI_RIGHT = 1 DEV_UI_BOTTOM = 2 DEV_UI_BOTH = 3 + BOTTOM_BAR_HEIGHT = 61 def __init__(self): super().__init__() @@ -43,6 +44,12 @@ class DeveloperUiRenderer(Widget): self.bearing_elem = BearingDegElement() self.altitude_elem = AltitudeElement() + @staticmethod + def get_bottom_dev_ui_offset(): + if ui_state.developer_ui in (DeveloperUiRenderer.DEV_UI_BOTTOM, DeveloperUiRenderer.DEV_UI_BOTH): + return DeveloperUiRenderer.BOTTOM_BAR_HEIGHT + return 0 + def _update_state(self) -> None: self.dev_ui_mode = ui_state.developer_ui @@ -78,10 +85,11 @@ class DeveloperUiRenderer(Widget): ] if controls_state.lateralControlState.which() == 'torqueState': elements.append(self.desired_lat_accel_elem.update(sm, ui_state.is_metric)) - elements.append(self.actual_lat_accel_elem.update(sm, ui_state.is_metric)) else: elements.append(self.desired_steer_elem.update(sm, ui_state.is_metric)) + elements.append(self.actual_lat_accel_elem.update(sm, ui_state.is_metric)) + current_y = y for element in elements: current_y += self._draw_right_dev_ui_element(x, current_y, element) @@ -105,7 +113,7 @@ class DeveloperUiRenderer(Widget): if element.unit: units_height = measure_text_cached(self._font_bold, element.unit, unit_size, 0).x - units_x = x + container_width - 10 + units_x = x + container_width units_y = y + (value_size / 2) + (units_height / 2) rl.draw_text_pro(self._font_bold, element.unit, rl.Vector2(units_x, units_y), rl.Vector2(0, 0), -90.0, unit_size, 0, rl.WHITE) @@ -143,22 +151,35 @@ class DeveloperUiRenderer(Widget): if sm.valid['gpsLocationExternal'] or sm.valid['gpsLocation']: elements.append(self.altitude_elem.update(sm, ui_state.is_metric)) - current_x = int(rect.x + 90) - center_y = y + bar_height // 2 - for element in elements: - current_x += self._draw_bottom_dev_ui_element(current_x, center_y, element) + if not elements: + return - def _draw_bottom_dev_ui_element(self, x: int, y: int, element: UiElement) -> int: font_size = 38 + element_widths = [] + for element in elements: + element.measure(self._font_bold, font_size) + element_widths.append(element.total_width) - label_text = f"{element.label} " - label_width = measure_text_cached(self._font_bold, label_text, font_size, 0).x - rl.draw_text_ex(self._font_bold, label_text, rl.Vector2(x, y - font_size // 2), font_size, 0, rl.WHITE) + total_element_width = sum(element_widths) + num_gaps = len(elements) + 1 + available_width = rect.width + gap_width = (available_width - total_element_width) / num_gaps - value_width = measure_text_cached(self._font_bold, element.value, font_size, 0).x - rl.draw_text_ex(self._font_bold, element.value, rl.Vector2(x + label_width + 10, y - font_size // 2), font_size, 0, element.color) + center_y = y + bar_height // 2 + current_x = rect.x + gap_width + + for i, element in enumerate(elements): + element_center_x = int(current_x + element_widths[i] / 2) + self._draw_bottom_dev_ui_element(element_center_x, center_y, element) + current_x += element_widths[i] + gap_width + + def _draw_bottom_dev_ui_element(self, center_x: int, y: int, element: UiElement) -> None: + font_size = 38 + start_x = center_x - element.total_width / 2 + + rl.draw_text_ex(self._font_bold, element.label_text, rl.Vector2(start_x, y - font_size // 2), font_size, 0, rl.WHITE) + rl.draw_text_ex(self._font_bold, element.val_text, rl.Vector2(start_x + element.label_width, y - font_size // 2), font_size, 0, element.color) if element.unit: - rl.draw_text_ex(self._font_bold, element.unit, rl.Vector2(x + label_width + value_width + 20, y - font_size // 2), font_size, 0, rl.WHITE) - - return 400 + rl.draw_text_ex(self._font_bold, element.unit_text, rl.Vector2(start_x + element.label_width + element.val_width, y - font_size // 2), + font_size, 0, rl.WHITE) diff --git a/selfdrive/ui/sunnypilot/onroad/developer_ui/elements.py b/selfdrive/ui/sunnypilot/onroad/developer_ui/elements.py index e8daca8868..652469bddd 100644 --- a/selfdrive/ui/sunnypilot/onroad/developer_ui/elements.py +++ b/selfdrive/ui/sunnypilot/onroad/developer_ui/elements.py @@ -10,12 +10,33 @@ from dataclasses import dataclass from openpilot.common.constants import CV +from openpilot.system.ui.lib.text_measure import measure_text_cached + + @dataclass class UiElement: value: str label: str unit: str color: rl.Color + val_text: str = "" + label_text: str = "" + unit_text: str = "" + val_width: float = 0.0 + label_width: float = 0.0 + unit_width: float = 0.0 + total_width: float = 0.0 + + def measure(self, font, font_size: int): + self.label_text = f"{self.label} " + self.val_text = self.value + self.unit_text = f" {self.unit}" if self.unit else "" + + self.label_width = measure_text_cached(font, self.label_text, font_size, 0).x + self.val_width = measure_text_cached(font, self.val_text, font_size, 0).x + self.unit_width = measure_text_cached(font, self.unit_text, font_size, 0).x if self.unit else 0 + + self.total_width = self.label_width + self.val_width + self.unit_width class LeadInfoElement: diff --git a/selfdrive/ui/sunnypilot/onroad/driver_state.py b/selfdrive/ui/sunnypilot/onroad/driver_state.py new file mode 100644 index 0000000000..4f2d264ee2 --- /dev/null +++ b/selfdrive/ui/sunnypilot/onroad/driver_state.py @@ -0,0 +1,49 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import numpy as np + +from openpilot.selfdrive.ui import UI_BORDER_SIZE +from openpilot.selfdrive.ui.onroad.driver_state import DriverStateRenderer, BTN_SIZE, ARC_LENGTH +from openpilot.selfdrive.ui.sunnypilot.onroad.developer_ui import DeveloperUiRenderer + + +class DriverStateRendererSP(DriverStateRenderer): + def __init__(self): + super().__init__() + self.dev_ui_offset = DeveloperUiRenderer.get_bottom_dev_ui_offset() + + def _pre_calculate_drawing_elements(self): + """Pre-calculate all drawing elements based on the current rectangle""" + # Calculate icon position (bottom-left or bottom-right) + width, height = self._rect.width, self._rect.height + offset = UI_BORDER_SIZE + BTN_SIZE // 2 + self.position_x = self._rect.x + (width - offset if self.is_rhd else offset) + self.position_y = self._rect.y + height - offset - self.dev_ui_offset + + # Pre-calculate the face lines positions + positioned_keypoints = self.face_keypoints_transformed + np.array([self.position_x, self.position_y]) + for i in range(len(positioned_keypoints)): + self.face_lines[i].x = positioned_keypoints[i][0] + self.face_lines[i].y = positioned_keypoints[i][1] + + # Calculate arc dimensions based on head rotation + delta_x = -self.driver_pose_sins[1] * ARC_LENGTH / 2.0 # Horizontal movement + delta_y = -self.driver_pose_sins[0] * ARC_LENGTH / 2.0 # Vertical movement + + # Horizontal arc + h_width = abs(delta_x) + self.h_arc_data = self._calculate_arc_data( + delta_x, h_width, self.position_x, self.position_y - ARC_LENGTH / 2, + self.driver_pose_sins[1], self.driver_pose_diff[1], is_horizontal=True + ) + + # Vertical arc + v_height = abs(delta_y) + self.v_arc_data = self._calculate_arc_data( + delta_y, v_height, self.position_x - ARC_LENGTH / 2, self.position_y, + self.driver_pose_sins[0], self.driver_pose_diff[0], is_horizontal=False + )