diff --git a/common/params_keys.h b/common/params_keys.h index 65573bc8b1..950d067551 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"}}, @@ -225,6 +227,7 @@ inline static std::unordered_map keys = { {"LagdValueCache", {PERSISTENT, FLOAT, "0.2"}}, {"LaneTurnDesire", {PERSISTENT | BACKUP, BOOL, "0"}}, {"LaneTurnValue", {PERSISTENT | BACKUP, FLOAT, "19.0"}}, + {"PlanplusControl", {PERSISTENT | BACKUP, FLOAT, "1.0"}}, // mapd {"MapAdvisorySpeedLimit", {CLEAR_ON_ONROAD_TRANSITION, FLOAT}}, 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/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/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/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 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/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 + ) 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/modeld_v2/SConscript b/sunnypilot/modeld_v2/SConscript index 4c04b7382f..750a242ad5 100644 --- a/sunnypilot/modeld_v2/SConscript +++ b/sunnypilot/modeld_v2/SConscript @@ -1,3 +1,4 @@ +import os import glob Import('env', 'envCython', 'arch', 'cereal', 'messaging', 'common', 'visionipc', 'transformations') @@ -28,3 +29,38 @@ for pathdef, fn in {'TRANSFORM': 'transforms/transform.cl', 'LOADYUV': 'transfor cython_libs = envCython["LIBS"] + libs commonmodel_lib = lenv.Library('commonmodel', common_src) lenvCython.Program('models/commonmodel_pyx.so', 'models/commonmodel_pyx.pyx', LIBS=[commonmodel_lib, *cython_libs], FRAMEWORKS=frameworks) +tinygrad_files = ["#"+x for x in glob.glob(env.Dir("#tinygrad_repo").relpath + "/**", recursive=True, root_dir=env.Dir("#").abspath) if 'pycache' not in x] + +# Get model metadata +PC = not os.path.isfile('/TICI') +if PC: + inputs = tinygrad_files + [File(Dir("#sunnypilot/modeld_v2").File("install_models_pc.py").abspath)] + outputs = [] + model_dir = Dir("models").abspath + cmd = f'python3 {Dir("#sunnypilot/modeld_v2").abspath}/install_models_pc.py {model_dir}' + + for model_name in ['supercombo', 'driving_vision', 'driving_policy']: + if File(f"models/{model_name}.onnx").exists(): + inputs.append(File(f"models/{model_name}.onnx")) + inputs.append(File(f"models/{model_name}_tinygrad.pkl")) + outputs.append(File(f"models/{model_name}_metadata.pkl")) + if outputs: + lenv.Command(outputs, inputs, cmd) + +def tg_compile(flags, model_name): + pythonpath_string = 'PYTHONPATH="${PYTHONPATH}:' + env.Dir("#tinygrad_repo").abspath + '"' + fn = File(f"models/{model_name}").abspath + return lenv.Command( + fn + "_tinygrad.pkl", + [fn + ".onnx"] + tinygrad_files, + f'{pythonpath_string} {flags} python3 {Dir("#tinygrad_repo").abspath}/examples/openpilot/compile3.py {fn}.onnx {fn}_tinygrad.pkl' + ) + +# Compile small models +for model_name in ['supercombo', 'driving_vision', 'driving_policy']: + if File(f"models/{model_name}.onnx").exists(): + flags = { + 'larch64': 'DEV=QCOM', + 'Darwin': f'DEV=CPU HOME={os.path.expanduser("~")} IMAGE=0', # tinygrad calls brew which needs a $HOME in the env + }.get(arch, 'DEV=CPU CPU_LLVM=1 IMAGE=0') + tg_compile(flags, model_name) diff --git a/sunnypilot/modeld_v2/install_models_pc.py b/sunnypilot/modeld_v2/install_models_pc.py new file mode 100755 index 0000000000..3f964dc285 --- /dev/null +++ b/sunnypilot/modeld_v2/install_models_pc.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +import sys +import shutil +import pickle +import codecs +import onnx +from pathlib import Path + +from openpilot.system.hardware.hw import Paths + + +def get_name_and_shape(value_info): + shape = tuple([int(dim.dim_value) for dim in value_info.type.tensor_type.shape.dim]) + return value_info.name, shape + + +def get_metadata_value_by_name(model, name): + for prop in model.metadata_props: + if prop.key == name: + return prop.value + return None + + +def generate_metadata_pkl(model_path, output_path): + try: + model = onnx.load(str(model_path)) + output_slices = get_metadata_value_by_name(model, 'output_slices') + + if output_slices: + metadata = { + 'model_checkpoint': get_metadata_value_by_name(model, 'model_checkpoint'), + 'output_slices': pickle.loads(codecs.decode(output_slices.encode(), "base64")), + 'input_shapes': dict([get_name_and_shape(x) for x in model.graph.input]), + 'output_shapes': dict([get_name_and_shape(x) for x in model.graph.output]) + } + with open(output_path, 'wb') as f: + pickle.dump(metadata, f) + return True + else: + return False + except Exception: + return False + + +def install_models(model_dir): + model_dir = Path(model_dir) + models = ["driving_policy", "driving_vision"] + found_models = [] + + for model in models: + if (model_dir / f"{model}.onnx").exists(): + found_models.append(model) + + if not found_models: + return + + try: + custom_name = input(f"Found models ({', '.join(found_models)}). Enter model short name (e.g. wmiv4): ").strip() + except EOFError: + return + + if not custom_name: + print("No name provided, skipping installation.") + return + + dest_dir = Path(Paths.model_root()) + dest_dir.mkdir(parents=True, exist_ok=True) + + for model in found_models: + onnx_path = model_dir / f"{model}.onnx" + tinygrad_pkl = model_dir / f"{model}_tinygrad.pkl" + metadata_pkl = model_dir / f"{model}_metadata.pkl" + + if not metadata_pkl.exists(): + generate_metadata_pkl(onnx_path, metadata_pkl) + + dest_tinygrad = dest_dir / f"{model}_{custom_name}_tinygrad.pkl" + dest_metadata = dest_dir / f"{model}_{custom_name}_metadata.pkl" + + if tinygrad_pkl.exists(): + shutil.move(str(tinygrad_pkl), str(dest_tinygrad)) + if metadata_pkl.exists(): + shutil.move(str(metadata_pkl), str(dest_metadata)) + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Usage: install_models_pc.py ") + sys.exit(1) + install_models(sys.argv[1]) diff --git a/sunnypilot/modeld_v2/modeld.py b/sunnypilot/modeld_v2/modeld.py index 82eb099e7e..b3b1d35fd9 100755 --- a/sunnypilot/modeld_v2/modeld.py +++ b/sunnypilot/modeld_v2/modeld.py @@ -28,7 +28,6 @@ from openpilot.sunnypilot.models.helpers import get_active_bundle from openpilot.sunnypilot.models.runners.helpers import get_model_runner PROCESS_NAME = "selfdrive.modeld.modeld_tinygrad" -RECOVERY_POWER = 1.0 # The higher this number the more aggressively the model will recover to lanecenter, too high and it will ping-pong class FrameMeta: @@ -63,6 +62,7 @@ class ModelState(ModelStateBase): self.LAT_SMOOTH_SECONDS = float(overrides.get('lat', ".0")) self.LONG_SMOOTH_SECONDS = float(overrides.get('long', ".0")) self.MIN_LAT_CONTROL_SPEED = 0.3 + self.PLANPLUS_CONTROL: float = 1.0 buffer_length = 5 if self.model_runner.is_20hz else 2 self.frames = {name: DrivingModelFrame(context, buffer_length) for name in self.model_runner.vision_input_names} @@ -158,7 +158,8 @@ class ModelState(ModelStateBase): lat_action_t: float, long_action_t: float, v_ego: float) -> log.ModelDataV2.Action: plan = model_output['plan'][0] if 'planplus' in model_output: - plan = plan + RECOVERY_POWER*model_output['planplus'][0] + recovery_power = self.PLANPLUS_CONTROL * (0.75 if v_ego > 20.0 else 1.0) + plan = plan + recovery_power * model_output['planplus'][0] desired_accel, should_stop = get_accel_from_plan(plan[:, Plan.VELOCITY][:, 0], plan[:, Plan.ACCELERATION][:, 0], self.constants.T_IDXS, action_t=long_action_t) desired_accel = smooth_value(desired_accel, prev_action.desiredAcceleration, self.LONG_SMOOTH_SECONDS) @@ -283,6 +284,7 @@ def main(demo=False): v_ego = max(sm["carState"].vEgo, 0.) if sm.frame % 60 == 0: model.lat_delay = get_lat_delay(params, sm["liveDelay"].lateralDelay) + model.PLANPLUS_CONTROL = params.get("PlanplusControl", return_default=True) lat_delay = model.lat_delay + model.LAT_SMOOTH_SECONDS if sm.updated["liveCalibration"] and sm.seen['roadCameraState'] and sm.seen['deviceState']: device_from_calib_euler = np.array(sm["liveCalibration"].rpyCalib, dtype=np.float32) diff --git a/sunnypilot/modeld_v2/tests/test_modeld.py b/sunnypilot/modeld_v2/tests/test_modeld.py deleted file mode 100644 index 6927c9e473..0000000000 --- a/sunnypilot/modeld_v2/tests/test_modeld.py +++ /dev/null @@ -1,102 +0,0 @@ -import numpy as np -import random - -import cereal.messaging as messaging -from msgq.visionipc import VisionIpcServer, VisionStreamType -from opendbc.car.car_helpers import get_demo_car_params -from openpilot.common.params import Params -from openpilot.common.transformations.camera import DEVICE_CAMERAS -from openpilot.common.realtime import DT_MDL -from openpilot.system.manager.process_config import managed_processes -from openpilot.selfdrive.test.process_replay.vision_meta import meta_from_camera_state - -CAM = DEVICE_CAMERAS[("tici", "ar0231")].fcam -IMG = np.zeros(int(CAM.width*CAM.height*(3/2)), dtype=np.uint8) -IMG_BYTES = IMG.flatten().tobytes() - - -class TestModeld: - - def setup_method(self): - self.vipc_server = VisionIpcServer("camerad") - self.vipc_server.create_buffers(VisionStreamType.VISION_STREAM_ROAD, 40, CAM.width, CAM.height) - self.vipc_server.create_buffers(VisionStreamType.VISION_STREAM_DRIVER, 40, CAM.width, CAM.height) - self.vipc_server.create_buffers(VisionStreamType.VISION_STREAM_WIDE_ROAD, 40, CAM.width, CAM.height) - self.vipc_server.start_listener() - Params().put("CarParams", get_demo_car_params().to_bytes()) - - self.sm = messaging.SubMaster(['modelV2', 'cameraOdometry']) - self.pm = messaging.PubMaster(['roadCameraState', 'wideRoadCameraState', 'liveCalibration']) - - managed_processes['modeld'].start() - self.pm.wait_for_readers_to_update("roadCameraState", 10) - - def teardown_method(self): - managed_processes['modeld'].stop() - del self.vipc_server - - def _send_frames(self, frame_id, cams=None): - if cams is None: - cams = ('roadCameraState', 'wideRoadCameraState') - - cs = None - for cam in cams: - msg = messaging.new_message(cam) - cs = getattr(msg, cam) - cs.frameId = frame_id - cs.timestampSof = int((frame_id * DT_MDL) * 1e9) - cs.timestampEof = int(cs.timestampSof + (DT_MDL * 1e9)) - cam_meta = meta_from_camera_state(cam) - - self.pm.send(msg.which(), msg) - self.vipc_server.send(cam_meta.stream, IMG_BYTES, cs.frameId, - cs.timestampSof, cs.timestampEof) - return cs - - def _wait(self): - self.sm.update(5000) - if self.sm['modelV2'].frameId != self.sm['cameraOdometry'].frameId: - self.sm.update(1000) - - def test_modeld(self): - for n in range(1, 500): - cs = self._send_frames(n) - self._wait() - - mdl = self.sm['modelV2'] - assert mdl.frameId == n - assert mdl.frameIdExtra == n - assert mdl.timestampEof == cs.timestampEof - assert mdl.frameAge == 0 - assert mdl.frameDropPerc == 0 - - odo = self.sm['cameraOdometry'] - assert odo.frameId == n - assert odo.timestampEof == cs.timestampEof - - def test_dropped_frames(self): - """ - modeld should only run on consecutive road frames - """ - frame_id = -1 - road_frames = list() - for n in range(1, 50): - if (random.random() < 0.1) and n > 3: - cams = random.choice([(), ('wideRoadCameraState', )]) - self._send_frames(n, cams) - else: - self._send_frames(n) - road_frames.append(n) - self._wait() - - if len(road_frames) < 3 or road_frames[-1] - road_frames[-2] == 1: - frame_id = road_frames[-1] - - mdl = self.sm['modelV2'] - odo = self.sm['cameraOdometry'] - assert mdl.frameId == frame_id - assert mdl.frameIdExtra == frame_id - assert odo.frameId == frame_id - if n != frame_id: - assert not self.sm.updated['modelV2'] - assert not self.sm.updated['cameraOdometry'] diff --git a/sunnypilot/modeld_v2/tests/test_recovery_power.py b/sunnypilot/modeld_v2/tests/test_recovery_power.py new file mode 100644 index 0000000000..e38b2c86dc --- /dev/null +++ b/sunnypilot/modeld_v2/tests/test_recovery_power.py @@ -0,0 +1,61 @@ +import numpy as np + +from cereal import log + +from openpilot.sunnypilot.modeld_v2.constants import Plan +from openpilot.sunnypilot.modeld_v2.modeld import ModelState +import openpilot.sunnypilot.modeld_v2.modeld as modeld + + +class MockStruct: + def __init__(self, **kwargs): + for k, v in kwargs.items(): + setattr(self, k, v) + + +def test_recovery_power_scaling(): + state = MockStruct( + PLANPLUS_CONTROL=1.0, + LONG_SMOOTH_SECONDS=0.3, + LAT_SMOOTH_SECONDS=0.1, + MIN_LAT_CONTROL_SPEED=0.3, + mlsim=True, + generation=12, + constants=MockStruct(T_IDXS=np.arange(100), DESIRE_LEN=8) + ) + prev_action = log.ModelDataV2.Action() + recorded_vel: list = [] + + def mock_accel(plan_vel, plan_accel, t_idxs, action_t=0.0): + recorded_vel.append(plan_vel.copy()) + return 0.0, False + + modeld.get_accel_from_plan = mock_accel + modeld.get_curvature_from_output = lambda *args: 0.0 + plan = np.random.rand(1, 100, 15).astype(np.float32) + planplus = np.random.rand(1, 100, 15).astype(np.float32) + + model_output: dict = { + 'plan': plan.copy(), + 'planplus': planplus.copy() + } + + test_cases: list = [ + # (control, v_ego, expected_factor) + (0.55, 20.0, 1.0), + (1.0, 25.0, .75), + (1.5, 25.1, 0.75), + (2.0, 20.0, 1.0), + (0.75, 19.0, 1.0), + (0.8, 25.1, 0.75), + ] + + for control, v_ego, factor in test_cases: + state.PLANPLUS_CONTROL = control + recorded_vel.clear() + ModelState.get_action_from_model(state, model_output, prev_action, 0.0, 0.0, v_ego) + + expected_recovery_power = control * factor + expected_plan_vel = plan[0, :, Plan.VELOCITY][:, 0] + expected_recovery_power * planplus[0, :, Plan.VELOCITY][:, 0] + + np.testing.assert_allclose(recorded_vel[0], expected_plan_vel, rtol=1e-5, atol=1e-6) diff --git a/sunnypilot/models/runners/helpers.py b/sunnypilot/models/runners/helpers.py index 6a128b340e..8f9d8fc2f5 100644 --- a/sunnypilot/models/runners/helpers.py +++ b/sunnypilot/models/runners/helpers.py @@ -2,25 +2,17 @@ from openpilot.sunnypilot.models.helpers import get_active_bundle from openpilot.sunnypilot.models.runners.model_runner import ModelRunner from openpilot.sunnypilot.models.runners.tinygrad.tinygrad_runner import TinygradRunner, TinygradSplitRunner from openpilot.sunnypilot.models.runners.constants import ModelType -from openpilot.system.hardware import TICI -if not TICI: - from openpilot.sunnypilot.models.runners.onnx.onnx_runner import ONNXRunner def get_model_runner() -> ModelRunner: """ Factory function to create and return the appropriate ModelRunner instance. - Selects between ONNXRunner (for non-TICI platforms) and TinygradRunner - (for TICI platforms), choosing TinygradSplitRunner if separate vision/policy + Selects TinygradRunner, choosing TinygradSplitRunner if separate vision/policy models are detected in the active bundle. :return: An instance of a ModelRunner subclass (ONNXRunner, TinygradRunner, or TinygradSplitRunner). """ - if not TICI: - return ONNXRunner() - - # On TICI platforms, use Tinygrad runners bundle = get_active_bundle() if bundle and bundle.models: model_types = {m.type.raw for m in bundle.models} 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 f5f378e4ce..be67d35fd1 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": "" @@ -826,6 +834,13 @@ "title": "Panda Som Reset Triggered", "description": "" }, + "PlanplusControl": { + "title": "Plan Plus Controls", + "description": "Adjust planplus model recentering strength. The higher this number the more aggressively the model will recover to lanecenter, too high and it will ping-pong", + "min": 0.0, + "max": 2.0, + "step": 0.1 + }, "PrimeType": { "title": "Prime Type", "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: