From fb8f46cba9dc90af481719945c21eeecef8d6996 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Wed, 31 Dec 2025 00:08:36 -0500 Subject: [PATCH] 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: