From 138d637bbd1918175c3de28f9670880be8e5a528 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Tue, 2 Dec 2025 13:34:35 -0500 Subject: [PATCH 01/19] ui: couple Platform Selector refresh in Vehicle panel state updates (#1545) --- selfdrive/ui/sunnypilot/layouts/settings/vehicle/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/__init__.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/__init__.py index 23b861f8c3..1b6c35df2c 100644 --- a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/__init__.py +++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/__init__.py @@ -35,6 +35,7 @@ class VehicleLayout(Widget): def _update_state(self): self._update_brand_settings() + self._platform_selector.refresh() def _render(self, rect): self._scroller.render(rect) From 3c5841ff02966f973b7ff0e339efcb1b6af6d446 Mon Sep 17 00:00:00 2001 From: James Vecellio-Grant <159560811+Discountchubbs@users.noreply.github.com> Date: Tue, 2 Dec 2025 11:44:50 -0800 Subject: [PATCH 02/19] ui: vehicle brand settings (#1509) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * commaai/openpilot:d05cb31e2e916fba41ba8167030945f427fd811b * bump opendbc * bump opendbc * bump opendbc * bump opendbc * bump opendbc * sunnypilot: remove Qt * cabana: revert to stock Qt * commaai/openpilot:5198b1b079c37742c1050f02ce0aa6dd42b038b9 * commaai/openpilot:954b567b9ba0f3d1ae57d6aa7797fa86dd92ec6e * commaai/openpilot:7534b2a160faa683412c04c1254440e338931c5e * sum more * bump opendbc * not yet * should've been symlink'ed * raylib says wut * quiet mode back * more fixes * no more * too extra red diff on the side * need to bring this back * too extra * let's update docs here * Revert "let's update docs here" This reverts commit 51fe03cd5121e6fdf14657b2c33852c34922b851. * param to control stock vs sp ui * init styles * SP Toggles * Lint * optimizations * multi-button * Lint * param to control stock vs sp ui * init styles * SP Toggles * Lint * optimizations * Panels. With Icons. And Scroller. * patience, grasshopper * more patience, grasshopper * sp raylib preview * fix callback * fix ui preview * add ui previews * dialog txt * compare vs what used to be done before InputDialog * introducing ui_state_sp for py * param to control stock vs sp ui * better * add ui_update callback * better padding * this * support for next line multi-button * uhh * disabled colors * listitem -> listitemsp * listitem -> listitemsp * Revert "add ui_update callback" This reverts commit 4da32cc0097434aab0aa6a3c35465eabb23c8958. * add show_description method * remove padding from line separator. like, WHY? 😩😩 * scroller -> scroller_tici * scroller -> scroller_tici * ui: `GuiApplicationExt` * add to readme * use gui_app.sunnypilot_ui() * use gui_app.sunnypilot_ui() * use gui_app.sunnypilot_ui() * lint * no fancy toggles :( * match them * mici scroller - no touchy * no * more * size adjustments * fix scroller. yay * ui vehicle panel: platform selector * some brands * platform selector * bruh * ui_state_sp * o * is_offroad() and format * huh * use in toggles panel * ugh. no * better & animated * lint * cleanup * lint. LINT * slight * rebase * no more * rebae * vic * v * # Conflicts: # system/ui/sunnypilot/lib/styles.py # system/ui/sunnypilot/widgets/helpers/fuzzy_search.py * loathing loathing, unadulterated loathing, i loathe it all * more changes! * Update styles.py * set visibility * Update hyundai.py * add padding * use symlink on sp side * use make from json and show all actual makes * all done! * Revert "all done!" This reverts commit 595c45f057d97bd317e4a0518755d393aa289d0c. * reimpl onroad/offroad confirmation * use global offroad directly * ui: highlight on pressed, and less indent * inherit MultiOptionDialog main buttons * align top level folders to the edge properly * lint * lint * handle folder presses too * ui: overridable title and subtitle for `TreeOptionDialog` * override TreeOptionDialog title and subtitle * lint * more * ui: magnifying glass icon and new search bar style in `TreeDialog` * cleanup * ui: recreate search dialog with the latest query * make model year but display as platform * move into settings directory * move into dir * sync * equality * use singleton directly * also use singleton directly * inherit from base class * include all brands * added refresh * always assume it's subaru * slight * split get brand * hyundai changes * tesla changes * do not allow while offroad * fix --------- Co-authored-by: Jason Wen Co-authored-by: nayan --- .../layouts/settings/vehicle/__init__.py | 23 ++++++++ .../settings/vehicle/brands/__init__.py | 0 .../layouts/settings/vehicle/brands/base.py | 16 +++++ .../layouts/settings/vehicle/brands/body.py | 15 +++++ .../settings/vehicle/brands/chrysler.py | 15 +++++ .../settings/vehicle/brands/factory.py | 45 ++++++++++++++ .../layouts/settings/vehicle/brands/ford.py | 15 +++++ .../layouts/settings/vehicle/brands/gm.py | 15 +++++ .../layouts/settings/vehicle/brands/honda.py | 15 +++++ .../settings/vehicle/brands/hyundai.py | 59 +++++++++++++++++++ .../layouts/settings/vehicle/brands/mazda.py | 15 +++++ .../layouts/settings/vehicle/brands/nissan.py | 15 +++++ .../layouts/settings/vehicle/brands/psa.py | 15 +++++ .../layouts/settings/vehicle/brands/rivian.py | 15 +++++ .../layouts/settings/vehicle/brands/subaru.py | 54 +++++++++++++++++ .../layouts/settings/vehicle/brands/tesla.py | 43 ++++++++++++++ .../layouts/settings/vehicle/brands/toyota.py | 15 +++++ .../settings/vehicle/brands/volkswagen.py | 15 +++++ .../settings/vehicle/platform_selector.py | 3 - 19 files changed, 405 insertions(+), 3 deletions(-) create mode 100644 selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/__init__.py create mode 100644 selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/base.py create mode 100644 selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/body.py create mode 100644 selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/chrysler.py create mode 100644 selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/factory.py create mode 100644 selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/ford.py create mode 100644 selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/gm.py create mode 100644 selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/honda.py create mode 100644 selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/hyundai.py create mode 100644 selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/mazda.py create mode 100644 selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/nissan.py create mode 100644 selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/psa.py create mode 100644 selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/rivian.py create mode 100644 selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/subaru.py create mode 100644 selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/tesla.py create mode 100644 selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/toyota.py create mode 100644 selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/volkswagen.py diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/__init__.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/__init__.py index 1b6c35df2c..86d9e61695 100644 --- a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/__init__.py +++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/__init__.py @@ -9,6 +9,7 @@ from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.list_view import ButtonAction from openpilot.system.ui.widgets.scroller_tici import Scroller +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.factory import BrandSettingsFactory from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.platform_selector import PlatformSelector, LegendWidget from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.system.ui.sunnypilot.widgets.list_view import ListItemSP @@ -17,6 +18,9 @@ from openpilot.system.ui.sunnypilot.widgets.list_view import ListItemSP class VehicleLayout(Widget): def __init__(self): super().__init__() + self._brand_settings = None + self._brand_items = [] + self._current_brand = None self._platform_selector = PlatformSelector(self._update_brand_settings) self._vehicle_item = ListItemSP(title=self._platform_selector.text, action_item=ButtonAction(text=tr("Select")), @@ -27,14 +31,33 @@ class VehicleLayout(Widget): self.items = [self._vehicle_item, self._legend_widget] self._scroller = Scroller(self.items, line_separator=True, spacing=0) + @staticmethod + def get_brand(): + if bundle := ui_state.params.get("CarPlatformBundle"): + return bundle.get("brand", "") + elif ui_state.CP and ui_state.CP.carFingerprint != "MOCK": + return ui_state.CP.brand + return "" + def _update_brand_settings(self): self._vehicle_item._title = self._platform_selector.text self._vehicle_item.title_color = self._platform_selector.color vehicle_text = tr("Remove") if ui_state.params.get("CarPlatformBundle") else tr("Select") self._vehicle_item.action_item.set_text(vehicle_text) + brand = self.get_brand() + if brand != self._current_brand: + self._current_brand = brand + self._brand_settings = BrandSettingsFactory.create_brand_settings(brand) + self._brand_items = self._brand_settings.items if self._brand_settings else [] + + self.items = [self._vehicle_item, self._legend_widget] + self._brand_items + self._scroller = Scroller(self.items, line_separator=True, spacing=0) + def _update_state(self): self._update_brand_settings() + if self._brand_settings: + self._brand_settings.update_settings() self._platform_selector.refresh() def _render(self, rect): diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/__init__.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/base.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/base.py new file mode 100644 index 0000000000..8d83fdf916 --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/base.py @@ -0,0 +1,16 @@ +""" +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 abc + + +class BrandSettings(abc.ABC): + def __init__(self): + self.items = [] + + @abc.abstractmethod + def update_settings(self) -> None: + """Update the settings based on the current vehicle brand.""" diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/body.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/body.py new file mode 100644 index 0000000000..d1c9ea5d64 --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/body.py @@ -0,0 +1,15 @@ +""" +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. +""" +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings + + +class BodySettings(BrandSettings): + def __init__(self): + super().__init__() + + def update_settings(self): + pass diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/chrysler.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/chrysler.py new file mode 100644 index 0000000000..ad62dba56f --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/chrysler.py @@ -0,0 +1,15 @@ +""" +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. +""" +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings + + +class ChryslerSettings(BrandSettings): + def __init__(self): + super().__init__() + + def update_settings(self): + pass diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/factory.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/factory.py new file mode 100644 index 0000000000..678732296f --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/factory.py @@ -0,0 +1,45 @@ +""" +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. +""" +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.body import BodySettings +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.chrysler import ChryslerSettings +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.ford import FordSettings +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.gm import GMSettings +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.honda import HondaSettings +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.hyundai import HyundaiSettings +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.mazda import MazdaSettings +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.nissan import NissanSettings +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.psa import PSASettings +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.rivian import RivianSettings +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.subaru import SubaruSettings +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.tesla import TeslaSettings +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.toyota import ToyotaSettings +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.volkswagen import VolkswagenSettings + + +class BrandSettingsFactory: + _BRAND_MAP: dict[str, type[BrandSettings]] = { + "body": BodySettings, + "chrysler": ChryslerSettings, + "ford": FordSettings, + "gm": GMSettings, + "honda": HondaSettings, + "hyundai": HyundaiSettings, + "mazda": MazdaSettings, + "nissan": NissanSettings, + "psa": PSASettings, + "rivian": RivianSettings, + "subaru": SubaruSettings, + "tesla": TeslaSettings, + "toyota": ToyotaSettings, + "volkswagen": VolkswagenSettings, + } + + @staticmethod + def create_brand_settings(brand: str) -> BrandSettings | None: + cls = BrandSettingsFactory._BRAND_MAP.get(brand) + return cls() if cls is not None else None diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/ford.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/ford.py new file mode 100644 index 0000000000..8871087e03 --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/ford.py @@ -0,0 +1,15 @@ +""" +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. +""" +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings + + +class FordSettings(BrandSettings): + def __init__(self): + super().__init__() + + def update_settings(self): + pass diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/gm.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/gm.py new file mode 100644 index 0000000000..edcd17cdb8 --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/gm.py @@ -0,0 +1,15 @@ +""" +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. +""" +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings + + +class GMSettings(BrandSettings): + def __init__(self): + super().__init__() + + def update_settings(self): + pass diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/honda.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/honda.py new file mode 100644 index 0000000000..fec68795a6 --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/honda.py @@ -0,0 +1,15 @@ +""" +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. +""" +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings + + +class HondaSettings(BrandSettings): + def __init__(self): + super().__init__() + + def update_settings(self): + pass diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/hyundai.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/hyundai.py new file mode 100644 index 0000000000..f6849eb201 --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/hyundai.py @@ -0,0 +1,59 @@ +""" +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. +""" +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.sunnypilot.widgets.list_view import multiple_button_item_sp +from opendbc.car.hyundai.values import CAR, CANFD_UNSUPPORTED_LONGITUDINAL_CAR, UNSUPPORTED_LONGITUDINAL_CAR + + +class HyundaiSettings(BrandSettings): + def __init__(self): + super().__init__() + self.alpha_long_available = False + + tuning_texts = [tr("Off"), tr("Dynamic"), tr("Predictive")] + self.longitudinal_tuning_item = multiple_button_item_sp(tr("Custom Longitudinal Tuning"), "", tuning_texts, + button_width=300, callback=self._on_tuning_selected, + param="HyundaiLongitudinalTuning", inline=False) + self.items = [self.longitudinal_tuning_item] + + @staticmethod + def _on_tuning_selected(index): + ui_state.params.put("HyundaiLongitudinalTuning", index) + + def update_settings(self): + self.alpha_long_available = False + bundle = ui_state.params.get("CarPlatformBundle") + if bundle: + platform = bundle.get("platform") + self.alpha_long_available = CAR[platform] not in (UNSUPPORTED_LONGITUDINAL_CAR | CANFD_UNSUPPORTED_LONGITUDINAL_CAR) + elif ui_state.CP: + self.alpha_long_available = ui_state.CP.alphaLongitudinalAvailable + + tuning_param = int(ui_state.params.get("HyundaiLongitudinalTuning") or "0") + long_enabled = ui_state.has_longitudinal_control + + long_tuning_descs = [ + tr("Your vehicle will use the Default longitudinal tuning."), + tr("Your vehicle will use the Dynamic longitudinal tuning."), + tr("Your vehicle will use the Predictive longitudinal tuning."), + ] + long_tuning_desc = long_tuning_descs[tuning_param] if tuning_param < len(long_tuning_descs) else long_tuning_descs[0] + + longitudinal_tuning_disabled = not ui_state.is_offroad() or not long_enabled + if longitudinal_tuning_disabled: + if not ui_state.is_offroad(): + long_tuning_desc = tr("This feature is unavailable while the car is onroad.") + elif not long_enabled: + long_tuning_desc = tr("This feature is unavailable because sunnypilot Longitudinal Control (Alpha) is not enabled.") + + self.longitudinal_tuning_item.action_item.set_enabled(not longitudinal_tuning_disabled) + self.longitudinal_tuning_item.set_description(long_tuning_desc) + self.longitudinal_tuning_item.show_description(True) + self.longitudinal_tuning_item.action_item.set_selected_button(tuning_param) + self.longitudinal_tuning_item.set_visible(self.alpha_long_available) diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/mazda.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/mazda.py new file mode 100644 index 0000000000..d354f0f34b --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/mazda.py @@ -0,0 +1,15 @@ +""" +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. +""" +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings + + +class MazdaSettings(BrandSettings): + def __init__(self): + super().__init__() + + def update_settings(self): + pass diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/nissan.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/nissan.py new file mode 100644 index 0000000000..7b3446a1a7 --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/nissan.py @@ -0,0 +1,15 @@ +""" +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. +""" +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings + + +class NissanSettings(BrandSettings): + def __init__(self): + super().__init__() + + def update_settings(self): + pass diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/psa.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/psa.py new file mode 100644 index 0000000000..6b767d332a --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/psa.py @@ -0,0 +1,15 @@ +""" +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. +""" +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings + + +class PSASettings(BrandSettings): + def __init__(self): + super().__init__() + + def update_settings(self): + pass diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/rivian.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/rivian.py new file mode 100644 index 0000000000..876aa2d2ea --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/rivian.py @@ -0,0 +1,15 @@ +""" +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. +""" +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings + + +class RivianSettings(BrandSettings): + def __init__(self): + super().__init__() + + def update_settings(self): + pass diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/subaru.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/subaru.py new file mode 100644 index 0000000000..66e7ec1d5a --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/subaru.py @@ -0,0 +1,54 @@ +""" +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. +""" +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.sunnypilot.widgets.list_view import toggle_item_sp +from opendbc.car.subaru.values import CAR, SubaruFlags + + +class SubaruSettings(BrandSettings): + def __init__(self): + super().__init__() + self.has_stop_and_go = False + + self.stop_and_go_toggle = toggle_item_sp(tr("Stop and Go (Beta)"), "", param="SubaruStopAndGo", callback=self._on_toggle_changed) + + self.stop_and_go_manual_parking_brake_toggle = toggle_item_sp(tr("Stop and Go for Manual Parking Brake (Beta)"), "", + param="SubaruStopAndGoManualParkingBrake", callback=self._on_toggle_changed) + + self.items = [self.stop_and_go_toggle, self.stop_and_go_manual_parking_brake_toggle] + + def _on_toggle_changed(self, _): + self.update_settings() + + def stop_and_go_disabled_msg(self): + if not self.has_stop_and_go: + return tr("This feature is currently not available on this platform.") + elif not ui_state.is_offroad(): + return tr("Enable \"Always Offroad\" in Device panel, or turn vehicle off to toggle.") + return "" + + def update_settings(self): + bundle = ui_state.params.get("CarPlatformBundle") + if bundle: + platform = bundle.get("platform") + config = CAR[platform].config + self.has_stop_and_go = not (config.flags & (SubaruFlags.GLOBAL_GEN2 | SubaruFlags.HYBRID)) + elif ui_state.CP: + self.has_stop_and_go = not (ui_state.CP.flags & (SubaruFlags.GLOBAL_GEN2 | SubaruFlags.HYBRID)) + + disabled_msg = self.stop_and_go_disabled_msg() + descriptions = [ + tr("Experimental feature to enable auto-resume during stop-and-go for certain supported Subaru platforms."), + tr("Experimental feature to enable stop and go for Subaru Global models with manual handbrake. " + + "Models with electric parking brake should keep this disabled. Thanks to martinl for this implementation!") + ] + + for toggle, desc in zip([self.stop_and_go_toggle, self.stop_and_go_manual_parking_brake_toggle], descriptions, strict=True): + toggle.action_item.set_enabled(self.has_stop_and_go and ui_state.is_offroad()) + toggle.set_description(f"{disabled_msg}

{desc}" if disabled_msg else desc) diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/tesla.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/tesla.py new file mode 100644 index 0000000000..46d536c651 --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/tesla.py @@ -0,0 +1,43 @@ +""" +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. +""" +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.sunnypilot.widgets.list_view import toggle_item_sp + +COOP_STEERING_MIN_KMH = 23 +OEM_STEERING_MIN_KMH = 48 +KM_TO_MILE = 0.621371 + + +class TeslaSettings(BrandSettings): + def __init__(self): + super().__init__() + self.coop_steering_toggle = toggle_item_sp(tr("Cooperative Steering (Beta)"), "", param="TeslaCoopSteering") + self.items = [self.coop_steering_toggle] + + def update_settings(self): + is_metric = ui_state.is_metric + unit = "km/h" if is_metric else "mph" + + display_value_coop = COOP_STEERING_MIN_KMH if is_metric else round(COOP_STEERING_MIN_KMH * KM_TO_MILE) + display_value_oem = OEM_STEERING_MIN_KMH if is_metric else round(OEM_STEERING_MIN_KMH * KM_TO_MILE) + + coop_steering_disabled_msg = tr("Enable \"Always Offroad\" in Device panel, or turn vehicle off to toggle.") + coop_steering_warning = tr(f"Warning: May experience steering oscillations below {display_value_oem} {unit} during turns, " + + "recommend disabling this feature if you experience these.") + coop_steering_desc = ( + f"{coop_steering_warning}

" + + f"{tr('Allows the driver to provide limited steering input while openpilot is engaged.')}
" + + f"{tr(f'Only works above {display_value_coop} {unit}.')}" + ) + + if not ui_state.is_offroad(): + coop_steering_desc = f"{coop_steering_disabled_msg}

{coop_steering_desc}" + + self.coop_steering_toggle.set_description(coop_steering_desc) + self.coop_steering_toggle.action_item.set_enabled(ui_state.is_offroad()) diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/toyota.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/toyota.py new file mode 100644 index 0000000000..e061a8a22a --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/toyota.py @@ -0,0 +1,15 @@ +""" +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. +""" +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings + + +class ToyotaSettings(BrandSettings): + def __init__(self): + super().__init__() + + def update_settings(self): + pass diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/volkswagen.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/volkswagen.py new file mode 100644 index 0000000000..a6d44c5e4d --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/volkswagen.py @@ -0,0 +1,15 @@ +""" +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. +""" +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings + + +class VolkswagenSettings(BrandSettings): + def __init__(self): + super().__init__() + + def update_settings(self): + pass diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/platform_selector.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/platform_selector.py index 60bc335ce4..db90595274 100644 --- a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/platform_selector.py +++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/platform_selector.py @@ -123,19 +123,16 @@ class PlatformSelector(Button): gui_app.set_modal_overlay(dialog, callback=callback) def refresh(self): - self.brand = "" self.color = style.YELLOW self._platform = tr("Unrecognized Vehicle") self.set_text(tr("No vehicle selected")) if bundle := ui_state.params.get("CarPlatformBundle"): self._platform = bundle.get("name", "") - self.brand = bundle.get("brand", "") self.set_text(self._platform) self.color = style.BLUE elif ui_state.CP and ui_state.CP.carFingerprint != "MOCK": self._platform = ui_state.CP.carFingerprint - self.brand = ui_state.CP.brand self.set_text(self._platform) self.color = style.GREEN self.set_enabled(True) From f08dfa1c14f295209d134db0a8ef9b0de3dce1cb Mon Sep 17 00:00:00 2001 From: Nayan Date: Wed, 3 Dec 2025 00:20:40 -0500 Subject: [PATCH 03/19] ui: sunnylink client-side implementation (#1488) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * param to control stock vs sp ui * init styles * SP Toggles * Lint * optimizations * sp raylib preview * fix callback * fix ui preview * sunnylink state * introducing ui_state_sp for py * poll from ui_state_sp * cloudlog & ruff * param to control stock vs sp ui * better * better padding * this * listitem -> listitemsp * add show_description method * remove padding from line separator. like, WHY? 😩😩 * ui: `GuiApplicationExt` * add to readme * use gui_app.sunnypilot_ui() * use gui_app.sunnypilot_ui() * fetch only when connected to network * final --------- Co-authored-by: Jason Wen --- selfdrive/ui/sunnypilot/ui_state.py | 5 +- sunnypilot/sunnylink/sunnylink_state.py | 205 ++++++++++++++++++++++++ 2 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 sunnypilot/sunnylink/sunnylink_state.py diff --git a/selfdrive/ui/sunnypilot/ui_state.py b/selfdrive/ui/sunnypilot/ui_state.py index 18e35508b7..1db5ad613e 100644 --- a/selfdrive/ui/sunnypilot/ui_state.py +++ b/selfdrive/ui/sunnypilot/ui_state.py @@ -6,6 +6,7 @@ See the LICENSE.md file in the root directory for more details. """ from cereal import messaging, custom from openpilot.common.params import Params +from openpilot.sunnypilot.sunnylink.sunnylink_state import SunnylinkState class UIStateSP: @@ -16,8 +17,10 @@ class UIStateSP: "gpsLocation", "liveTorqueParameters", "carStateSP", "liveMapDataSP", "carParamsSP" ] + self.sunnylink_state = SunnylinkState() + def update(self) -> None: - pass + self.sunnylink_state.start() def update_params(self) -> None: CP_SP_bytes = self.params.get("CarParamsSPPersistent") diff --git a/sunnypilot/sunnylink/sunnylink_state.py b/sunnypilot/sunnylink/sunnylink_state.py new file mode 100644 index 0000000000..4d0b397e03 --- /dev/null +++ b/sunnypilot/sunnylink/sunnylink_state.py @@ -0,0 +1,205 @@ +""" +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. +""" +from enum import IntEnum +import threading +import time +import json + +from cereal import messaging +from openpilot.common.params import Params +from openpilot.common.swaglog import cloudlog +from openpilot.sunnypilot.sunnylink.api import UNREGISTERED_SUNNYLINK_DONGLE_ID, SunnylinkApi + + +class RoleType(IntEnum): + READONLY = 0 + SPONSOR = 1 + ADMIN = 2 + + +class SponsorTier(IntEnum): + FREE = 0 + NOVICE = 1 + SUPPORTER = 2 + CONTRIBUTOR = 3 + BENEFACTOR = 4 + GUARDIAN = 5 + + +class User: + device_id: str + user_id: str + created_at: int + updated_at: int + token_hash: str + + def __init__(self, json_data): + self.device_id = json_data.get("device_id") + self.user_id = json_data.get("user_id") + self.created_at = json_data.get("created_at") + self.updated_at = json_data.get("updated_at") + self.token_hash = json_data.get("token_hash") + + +class Role: + role_type: str + role_tier: str + + def __init__(self, json_data): + self.role_type = json_data.get("role_type") + self.role_tier = json_data.get("role_tier") + + +def _parse_roles(roles: str) -> list[Role]: + lst_roles = [] + try: + roles_list = json.loads(roles) + for r in roles_list: + try: + role = Role(r) + lst_roles.append(role) + except Exception as e: + cloudlog.exception(f"Failed to parse role {r}: {e}") + return lst_roles + except Exception as e: + cloudlog.exception(f"Error parsing roles: {e}") + return [] + + +def _parse_users(users: str) -> list[User]: + lst_users = [] + try: + users_list = json.loads(users) + for u in users_list: + try: + user = User(u) + lst_users.append(user) + except Exception as e: + cloudlog.exception(f"Failed to parse user {u}: {e}") + return lst_users + except Exception as e: + cloudlog.exception(f"Error parsing users: {e}") + return [] + + +class SunnylinkState: + FETCH_INTERVAL = 5.0 # seconds between API calls + API_TIMEOUT = 10.0 # seconds for API requests + SLEEP_INTERVAL = 0.5 # seconds to sleep between checks in the worker thread + NOT_PAIRED_USERNAMES = ["unregisteredsponsor", "temporarysponsor"] + + def __init__(self): + self._params = Params() + self._lock = threading.Lock() + self._running = False + self._thread = None + self._sm = messaging.SubMaster(['deviceState']) + + self._roles: list[Role] = [] + self._users: list[User] = [] + self.sponsor_tier: SponsorTier = SponsorTier.FREE + self.sunnylink_dongle_id = self._params.get("SunnylinkDongleId") + self._api = SunnylinkApi(self.sunnylink_dongle_id) + + self._load_initial_state() + + def _load_initial_state(self) -> None: + roles_cache = self._params.get("SunnylinkCache_Roles") + users_cache = self._params.get("SunnylinkCache_Users") + if roles_cache is not None: + self._roles = _parse_roles(roles_cache) + self.sponsor_tier = self._get_highest_tier() + if users_cache is not None: + self._users = _parse_users(users_cache) + + def _get_highest_tier(self) -> SponsorTier: + role_tier = SponsorTier.FREE + for role in self._roles: + try: + if RoleType[role.role_type.upper()] == RoleType.SPONSOR: + role_tier = max(role_tier, SponsorTier[role.role_tier.upper()]) + except Exception as e: + cloudlog.exception(f"Error parsing role {role}: {e} for dongle id {self.sunnylink_dongle_id}") + return role_tier + + def _fetch_roles(self) -> None: + if not self.sunnylink_dongle_id or self.sunnylink_dongle_id == UNREGISTERED_SUNNYLINK_DONGLE_ID: + return + + try: + token = self._api.get_token() + response = self._api.api_get(f"device/{self.sunnylink_dongle_id}/roles", method='GET', access_token=token) + if response.status_code == 200: + self._roles = _parse_roles(response.text) + self._params.put("SunnylinkCache_Roles", response.text) + sponsor_tier = self._get_highest_tier() + with self._lock: + if sponsor_tier != self.sponsor_tier: + self.sponsor_tier = sponsor_tier + cloudlog.info(f"Sunnylink sponsor tier updated to {sponsor_tier.name}") + except Exception as e: + cloudlog.exception(f"Failed to fetch sunnylink roles: {e} for dongle id {self.sunnylink_dongle_id}") + + def _fetch_users(self) -> None: + if not self.sunnylink_dongle_id or self.sunnylink_dongle_id == UNREGISTERED_SUNNYLINK_DONGLE_ID: + return + + try: + token = self._api.get_token() + response = self._api.api_get(f"device/{self.sunnylink_dongle_id}/users", method='GET', access_token=token) + if response.status_code == 200: + users = response.text + self._params.put("SunnylinkCache_Users", users) + with self._lock: + _parse_users(users) + except Exception as e: + cloudlog.exception(f"Failed to fetch sunnylink users: {e} for dongle id {self.sunnylink_dongle_id}") + + def _worker_thread(self) -> None: + while self._running: + if self.is_connected(): + self._fetch_roles() + self._fetch_users() + + for _ in range(int(self.FETCH_INTERVAL / self.SLEEP_INTERVAL)): + if not self._running: + break + time.sleep(self.SLEEP_INTERVAL) + + def start(self) -> None: + if self._thread and self._thread.is_alive(): + return + self._running = True + self._thread = threading.Thread(target=self._worker_thread, daemon=True) + self._thread.start() + + def stop(self) -> None: + self._running = False + if self._thread and self._thread.is_alive(): + self._thread.join(timeout=1.0) + + def get_sponsor_tier(self) -> SponsorTier: + with self._lock: + return self.sponsor_tier + + def is_sponsor(self) -> bool: + with self._lock: + is_sponsor = any(role.role_type.upper() == RoleType.SPONSOR.name and role.role_tier.upper() != SponsorTier.FREE.name + for role in self._roles) + return is_sponsor + + def is_paired(self) -> bool: + with self._lock: + is_paired = any(user.user_id not in self.NOT_PAIRED_USERNAMES for user in self._users) + return is_paired + + def is_connected(self) -> bool: + network_type = self._sm["deviceState"].networkType + return bool(network_type != 0) + + def __del__(self): + self.stop() From a83c64ffbd410e610cbc8be3212fcbd3dad0537f Mon Sep 17 00:00:00 2001 From: David <49467229+TheSecurityDev@users.noreply.github.com> Date: Tue, 2 Dec 2025 23:56:19 -0600 Subject: [PATCH 04/19] ui: fix sidebar scroll in UI screenshots (#1519) * fix: delay between scroll clicks; add larger delay; fix cruise button * scroll more for cruise --------- Co-authored-by: Jason Wen --- .../ui/tests/test_ui/raylib_screenshots.py | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/selfdrive/ui/tests/test_ui/raylib_screenshots.py b/selfdrive/ui/tests/test_ui/raylib_screenshots.py index e64398d227..164358bfce 100755 --- a/selfdrive/ui/tests/test_ui/raylib_screenshots.py +++ b/selfdrive/ui/tests/test_ui/raylib_screenshots.py @@ -105,7 +105,7 @@ def setup_settings_software_branch_switcher(click, pm: PubMaster, scroll=None): def setup_settings_firehose(click, pm: PubMaster, scroll=None): setup_settings(click, pm) - scroll(-1000, 278, 950) + scroll(-20, 278, 950) click(278, 850) @@ -115,7 +115,7 @@ def setup_settings_developer(click, pm: PubMaster, scroll=None): Params().put("CarParamsPersistent", CP.to_bytes()) setup_settings(click, pm) - scroll(-1000, 278, 950) + scroll(-20, 278, 950) click(278, 950) @@ -171,37 +171,37 @@ def setup_settings_steering(click, pm: PubMaster, scroll=None): def setup_settings_cruise(click, pm: PubMaster, scroll=None): setup_settings(click, pm) - click(278, 1017) - scroll(-140, 278, 950) + scroll(-4, 278, 950) + click(278, 860) def setup_settings_visuals(click, pm: PubMaster, scroll=None): setup_settings(click, pm) - scroll(-1000, 278, 950) + scroll(-20, 278, 950) click(278, 330) def setup_settings_display(click, pm: PubMaster, scroll=None): setup_settings(click, pm) - scroll(-1000, 278, 950) + scroll(-20, 278, 950) click(278, 420) def setup_settings_osm(click, pm: PubMaster, scroll=None): setup_settings(click, pm) - scroll(-1000, 278, 950) + scroll(-20, 278, 950) click(278, 520) def setup_settings_trips(click, pm: PubMaster, scroll=None): setup_settings(click, pm) - scroll(-1000, 278, 950) + scroll(-20, 278, 950) click(278, 630) def setup_settings_vehicle(click, pm: PubMaster, scroll=None): setup_settings(click, pm) - scroll(-1000, 278, 950) + scroll(-20, 278, 950) click(278, 750) @@ -342,9 +342,14 @@ class TestUI: time.sleep(0.01) pyautogui.mouseUp(self.ui.left + x, self.ui.top + y, *args, **kwargs) - def scroll(self, clicks, x, y, *args, **kwargs): - pyautogui.scroll(clicks, self.ui.left + x, self.ui.top + y, *args, **kwargs) - time.sleep(UI_DELAY) + def scroll(self, clicks: int, x, y, *args, **kwargs): + if clicks == 0: + return + click = -1 if clicks < 0 else 1 # -1 = down, 1 = up + for _ in range(abs(clicks)): + pyautogui.scroll(click, self.ui.left + x, self.ui.top + y, *args, **kwargs) # scroll for individual clicks since we need to delay between clicks + time.sleep(0.01) # small delay between scroll clicks to work properly + time.sleep(2) # wait for scroll to fully settle @with_processes(["ui"]) def test_ui(self, name, setup_case): From c560ac43aa1531dabad415cd0cfecce9c652d616 Mon Sep 17 00:00:00 2001 From: James Vecellio-Grant <159560811+Discountchubbs@users.noreply.github.com> Date: Tue, 2 Dec 2025 22:08:38 -0800 Subject: [PATCH 05/19] ui: `NetworkUISP` (#1535) * ui: network panel * refactor adding scan button without overriding upstream layout * trigger immediate network refresh instead of waiting --------- Co-authored-by: Jason Wen --- .../ui/sunnypilot/layouts/settings/network.py | 46 +++++++++++++++++++ .../sunnypilot/layouts/settings/settings.py | 4 +- 2 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 selfdrive/ui/sunnypilot/layouts/settings/network.py diff --git a/selfdrive/ui/sunnypilot/layouts/settings/network.py b/selfdrive/ui/sunnypilot/layouts/settings/network.py new file mode 100644 index 0000000000..14f573c628 --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/network.py @@ -0,0 +1,46 @@ +""" +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 threading +import time +import pyray as rl + +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.widgets.button import Button, ButtonStyle +from openpilot.system.ui.widgets.network import NetworkUI, PanelType + + +class NetworkUISP(NetworkUI): + def __init__(self, wifi_manager): + super().__init__(wifi_manager) + + self.scan_button = Button(tr("Scan"), self._scan_clicked, button_style=ButtonStyle.NORMAL, font_size=60, border_radius=30) + self.scan_button.set_rect(rl.Rectangle(0, 0, 400, 100)) + + self._scanning = False + self._wifi_manager.add_callbacks(networks_updated=self._on_networks_updated) + + def _scan_clicked(self): + self._scanning = True + self.scan_button.set_text(tr("Scanning...")) + self.scan_button.set_enabled(False) + + threading.Thread(target=self._wifi_manager._update_networks, daemon=True).start() + self._wifi_manager._request_scan() + self._wifi_manager._last_network_update = time.monotonic() + + def _on_networks_updated(self, networks): + if self._scanning: + self._scanning = False + self.scan_button.set_text(tr("Scan")) + self.scan_button.set_enabled(True) + + def _render(self, rect: rl.Rectangle): + super()._render(rect) + + if self._current_panel == PanelType.WIFI: + self.scan_button.set_position(self._rect.x, self._rect.y + 20) + self.scan_button.render() diff --git a/selfdrive/ui/sunnypilot/layouts/settings/settings.py b/selfdrive/ui/sunnypilot/layouts/settings/settings.py index bf174de90b..905338552f 100644 --- a/selfdrive/ui/sunnypilot/layouts/settings/settings.py +++ b/selfdrive/ui/sunnypilot/layouts/settings/settings.py @@ -19,10 +19,10 @@ from openpilot.system.ui.lib.multilang import tr_noop from openpilot.system.ui.sunnypilot.lib.styles import style from openpilot.system.ui.widgets.scroller_tici import Scroller from openpilot.system.ui.lib.text_measure import measure_text_cached -from openpilot.system.ui.widgets.network import NetworkUI from openpilot.system.ui.lib.wifi_manager import WifiManager from openpilot.system.ui.widgets import Widget from openpilot.selfdrive.ui.sunnypilot.layouts.settings.models import ModelsLayout +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.network import NetworkUISP from openpilot.selfdrive.ui.sunnypilot.layouts.settings.sunnylink import SunnylinkLayout from openpilot.selfdrive.ui.sunnypilot.layouts.settings.osm import OSMLayout from openpilot.selfdrive.ui.sunnypilot.layouts.settings.trips import TripsLayout @@ -111,7 +111,7 @@ class SettingsLayoutSP(OP.SettingsLayout): self._panels = { OP.PanelType.DEVICE: PanelInfo(tr_noop("Device"), DeviceLayoutSP(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_home.png"), - OP.PanelType.NETWORK: PanelInfo(tr_noop("Network"), NetworkUI(wifi_manager), icon="icons/network.png"), + OP.PanelType.NETWORK: PanelInfo(tr_noop("Network"), NetworkUISP(wifi_manager), icon="icons/network.png"), OP.PanelType.SUNNYLINK: PanelInfo(tr_noop("sunnylink"), SunnylinkLayout(), icon="icons/shell.png"), OP.PanelType.TOGGLES: PanelInfo(tr_noop("Toggles"), TogglesLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_toggle.png"), OP.PanelType.SOFTWARE: PanelInfo(tr_noop("Software"), SoftwareLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_software.png"), From b9c54e07fb721bd67bcec3f1ca5c29c70c0198d3 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Wed, 3 Dec 2025 01:18:00 -0500 Subject: [PATCH 06/19] Revert "ci: disable macos builds" (#1529) Revert "ci: disable macos builds (#1514)" This reverts commit 42b2e1534b06a409efdd01a8284d10236e03bdad. --- .github/workflows/tests.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 8d6449cb4b..aab16ffbbe 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -107,7 +107,6 @@ jobs: build_mac: name: build macOS - if: false # temp disable since gcc-arm-embedded install is getting stuck due to checksum mismatch runs-on: ${{ ((github.repository == 'commaai/openpilot') && ((github.event_name != 'pull_request') || (github.event.pull_request.head.repo.full_name == 'commaai/openpilot'))) && 'namespace-profile-macos-8x14' || 'macos-latest' }} steps: - uses: actions/checkout@v4 From bea05d4624671927b7e1a203e4daa0eb6d5b74a3 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Wed, 3 Dec 2025 02:29:33 -0500 Subject: [PATCH 07/19] ui: add pressed state and visual feedback for search button in `TreeOptionDialog` (#1547) * ui: add pressed state and visual feedback for search button in `TreeDialog` * less --- system/ui/sunnypilot/lib/styles.py | 3 +++ system/ui/sunnypilot/widgets/tree_dialog.py | 23 +++++++++++++++++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/system/ui/sunnypilot/lib/styles.py b/system/ui/sunnypilot/lib/styles.py index 68e68d41a9..3597509dc5 100644 --- a/system/ui/sunnypilot/lib/styles.py +++ b/system/ui/sunnypilot/lib/styles.py @@ -69,6 +69,9 @@ class DefaultStyleSP(Base): BUTTON_PRIMARY_COLOR = rl.Color(70, 91, 234, 255) # Royal Blue BUTTON_NEUTRAL_GRAY = rl.Color(51, 51, 51, 255) BUTTON_DISABLED_BG_COLOR = rl.Color(30, 30, 30, 255) # Very Dark Grey + TREE_DIALOG_TRANSPARENT = rl.Color(0, 0, 0, 0) + TREE_DIALOG_SEARCH_BUTTON_PRESSED = rl.Color(0x69, 0x68, 0x68, 0xFF) + TREE_DIALOG_SEARCH_BUTTON_BORDER = rl.Color(150, 150, 150, 200) # Vehicle Description Colors GREEN = rl.Color(0, 241, 0, 255) diff --git a/system/ui/sunnypilot/widgets/tree_dialog.py b/system/ui/sunnypilot/widgets/tree_dialog.py index fd2a576d58..cfc0a5caf1 100644 --- a/system/ui/sunnypilot/widgets/tree_dialog.py +++ b/system/ui/sunnypilot/widgets/tree_dialog.py @@ -99,6 +99,7 @@ class TreeOptionDialog(MultiOptionDialog): self.search_title = search_title or tr("Enter search query") self.search_subtitle = search_subtitle self.search_dialog = None + self._search_pressed = False self._build_visible_items() @@ -183,9 +184,10 @@ class TreeOptionDialog(MultiOptionDialog): input_rect = rl.Rectangle(self._search_rect.x + inset, self._search_rect.y + inset, self._search_rect.width - inset * 2, self._search_rect.height - inset * 2) - # Transparent fill + border - rl.draw_rectangle_rounded(input_rect, roundness, 10, rl.Color(0, 0, 0, 0)) - rl.draw_rectangle_rounded_lines_ex(input_rect, roundness, 10, 3, rl.Color(150, 150, 150, 200)) + # Transparent fill (unpressed), white fill (pressed), border + fill_color = style.TREE_DIALOG_SEARCH_BUTTON_PRESSED if self._search_pressed else style.TREE_DIALOG_TRANSPARENT + rl.draw_rectangle_rounded(input_rect, roundness, 10, fill_color) + rl.draw_rectangle_rounded_lines_ex(input_rect, roundness, 10, 3, style.TREE_DIALOG_SEARCH_BUTTON_BORDER) # Magnifying glass icon icon_color = rl.Color(180, 180, 180, 240) @@ -233,8 +235,21 @@ class TreeOptionDialog(MultiOptionDialog): return self._result - def _handle_mouse_release(self, mouse_pos): + def _handle_mouse_press(self, mouse_pos): if self._search_rect and rl.check_collision_point_rec(mouse_pos, self._search_rect): + self._search_pressed = True + return True + return super()._handle_mouse_press(mouse_pos) + + def _handle_mouse_release(self, mouse_pos): + clicked_search = False + if self._search_rect and rl.check_collision_point_rec(mouse_pos, self._search_rect): + clicked_search = self._search_pressed + + self._search_pressed = False + + if clicked_search: self._on_search_clicked() return True + return super()._handle_mouse_release(mouse_pos) From 1f967668a545b34e2a3ffc85d5e56afdd837efd7 Mon Sep 17 00:00:00 2001 From: Nayan Date: Thu, 4 Dec 2025 01:51:08 -0500 Subject: [PATCH 08/19] ui: add sunnypilot font (#1552) add sp font --- selfdrive/assets/fonts/Audiowide-Regular.ttf | 3 +++ system/ui/lib/application.py | 1 + 2 files changed, 4 insertions(+) create mode 100644 selfdrive/assets/fonts/Audiowide-Regular.ttf diff --git a/selfdrive/assets/fonts/Audiowide-Regular.ttf b/selfdrive/assets/fonts/Audiowide-Regular.ttf new file mode 100644 index 0000000000..1b6913947b --- /dev/null +++ b/selfdrive/assets/fonts/Audiowide-Regular.ttf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:434a720871336d359378beff5ebff3f9fd654d958693d272c7c6f2e271c7e41c +size 47676 diff --git a/system/ui/lib/application.py b/system/ui/lib/application.py index e4850e9cbc..60a110293d 100644 --- a/system/ui/lib/application.py +++ b/system/ui/lib/application.py @@ -94,6 +94,7 @@ class FontWeight(StrEnum): BOLD = "Inter-Bold.fnt" SEMI_BOLD = "Inter-SemiBold.fnt" UNIFONT = "unifont.fnt" + AUDIOWIDE = "Audiowide-Regular.fnt" # Small UI fonts DISPLAY_REGULAR = "Inter-Regular.fnt" From d75d80b885de1e2f7339f29135aa86e444fdbeda Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Thu, 4 Dec 2025 17:53:33 -0500 Subject: [PATCH 09/19] ui: add right-aligned value display support in `ListItemSP` (#1554) * ui: add right-aligned value display support in `ListItemSP` * lint * styles --- system/ui/sunnypilot/lib/styles.py | 1 + system/ui/sunnypilot/widgets/list_view.py | 27 ++++++++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/system/ui/sunnypilot/lib/styles.py b/system/ui/sunnypilot/lib/styles.py index 3597509dc5..c1f8c3926e 100644 --- a/system/ui/sunnypilot/lib/styles.py +++ b/system/ui/sunnypilot/lib/styles.py @@ -17,6 +17,7 @@ class Base: ITEM_TEXT_FONT_SIZE = 50 ITEM_DESC_FONT_SIZE = 40 ITEM_DESC_V_OFFSET = 150 + ITEM_TEXT_VALUE_COLOR = rl.Color(170, 170, 170, 255) CLOSE_BTN_SIZE = 160 # Toggle Control diff --git a/system/ui/sunnypilot/widgets/list_view.py b/system/ui/sunnypilot/widgets/list_view.py index f05de81397..315840f7ea 100644 --- a/system/ui/sunnypilot/widgets/list_view.py +++ b/system/ui/sunnypilot/widgets/list_view.py @@ -8,9 +8,10 @@ from collections.abc import Callable import pyray as rl from openpilot.common.params import Params -from openpilot.system.ui.lib.application import MousePos +from openpilot.system.ui.lib.application import gui_app, MousePos, FontWeight from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.sunnypilot.widgets.toggle import ToggleSP +from openpilot.system.ui.widgets.label import gui_label from openpilot.system.ui.widgets.list_view import ListItem, ToggleAction, ItemAction, MultipleButtonAction, _resolve_value from openpilot.system.ui.sunnypilot.lib.styles import style from openpilot.system.ui.sunnypilot.widgets.option_control import OptionControlSP, LABEL_WIDTH @@ -86,6 +87,17 @@ class ListItemSP(ListItem): self.inline = inline if not self.inline: self._rect.height += style.ITEM_BASE_HEIGHT/1.75 + self._right_value_source: str | Callable[[], str] | None = None + self._right_value_font = gui_app.font(FontWeight.NORMAL) + + def set_right_value(self, value: str | Callable[[], str]): + self._right_value_source = value + + @property + def right_value(self) -> str: + if self._right_value_source is None: + return "" + return str(_resolve_value(self._right_value_source, "")) def get_item_height(self, font: rl.Font, max_width: int) -> float: height = super().get_item_height(font, max_width) @@ -154,6 +166,19 @@ class ListItemSP(ListItem): item_y = self._rect.y + (style.ITEM_BASE_HEIGHT - self._text_size.y) // 2 rl.draw_text_ex(self._font, self.title, rl.Vector2(text_x, item_y), style.ITEM_TEXT_FONT_SIZE, 0, self.title_color) + value_text = self.right_value + if value_text: + # area from after the title to the right edge of the row + value_rect = rl.Rectangle( + text_x, # start at the beginning of the text area + self._rect.y, + self._rect.width - (text_x - self._rect.x) - style.ITEM_PADDING, + style.ITEM_BASE_HEIGHT, + ) + if value_rect.width > 0: + gui_label(value_rect, value_text, font_size=style.ITEM_TEXT_FONT_SIZE, color=style.ITEM_TEXT_VALUE_COLOR, font_weight=FontWeight.NORMAL, + alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) + # Render toggle and handle callback if self.action_item.render(left_rect) and self.action_item.enabled: if self.callback: From c6818bd07f8cadb7a42880f4a814442575875c24 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Thu, 4 Dec 2025 21:41:58 -0500 Subject: [PATCH 10/19] ui: customizable value colors for `ButtonActionSP` and `ListViewSP` (#1555) ui: introduce customizable value colors for `ButtonActionSP` and `ListViewSP` --- selfdrive/ui/layouts/settings/device.py | 3 ++ selfdrive/ui/layouts/settings/software.py | 3 ++ system/ui/sunnypilot/lib/styles.py | 2 + system/ui/sunnypilot/widgets/list_view.py | 57 +++++++++++++++++++---- system/ui/widgets/network.py | 1 + 5 files changed, 56 insertions(+), 10 deletions(-) diff --git a/selfdrive/ui/layouts/settings/device.py b/selfdrive/ui/layouts/settings/device.py index 078623c882..8830ef946f 100644 --- a/selfdrive/ui/layouts/settings/device.py +++ b/selfdrive/ui/layouts/settings/device.py @@ -19,6 +19,9 @@ from openpilot.system.ui.widgets.list_view import text_item, button_item, dual_b from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog from openpilot.system.ui.widgets.scroller_tici import Scroller +if gui_app.sunnypilot_ui(): + from openpilot.system.ui.sunnypilot.widgets.list_view import button_item_sp as button_item + # Description constants DESCRIPTIONS = { 'pair_device': tr_noop("Pair your device with comma connect (connect.comma.ai) and claim your comma prime offer."), diff --git a/selfdrive/ui/layouts/settings/software.py b/selfdrive/ui/layouts/settings/software.py index 4b8b7015f8..c4f899d798 100644 --- a/selfdrive/ui/layouts/settings/software.py +++ b/selfdrive/ui/layouts/settings/software.py @@ -11,6 +11,9 @@ from openpilot.system.ui.widgets.list_view import button_item, text_item, ListIt from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog from openpilot.system.ui.widgets.scroller_tici import Scroller +if gui_app.sunnypilot_ui(): + from openpilot.system.ui.sunnypilot.widgets.list_view import button_item_sp as button_item + # TODO: remove this. updater fails to respond on startup if time is not correct UPDATED_TIMEOUT = 10 # seconds to wait for updated to respond diff --git a/system/ui/sunnypilot/lib/styles.py b/system/ui/sunnypilot/lib/styles.py index c1f8c3926e..3e1d2dc408 100644 --- a/system/ui/sunnypilot/lib/styles.py +++ b/system/ui/sunnypilot/lib/styles.py @@ -20,6 +20,8 @@ class Base: ITEM_TEXT_VALUE_COLOR = rl.Color(170, 170, 170, 255) CLOSE_BTN_SIZE = 160 + TEXT_PADDING = 20 + # Toggle Control TOGGLE_HEIGHT = 120 TOGGLE_WIDTH = int(TOGGLE_HEIGHT * 1.75) diff --git a/system/ui/sunnypilot/widgets/list_view.py b/system/ui/sunnypilot/widgets/list_view.py index 315840f7ea..bd68d35a48 100644 --- a/system/ui/sunnypilot/widgets/list_view.py +++ b/system/ui/sunnypilot/widgets/list_view.py @@ -12,7 +12,8 @@ from openpilot.system.ui.lib.application import gui_app, MousePos, FontWeight from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.sunnypilot.widgets.toggle import ToggleSP from openpilot.system.ui.widgets.label import gui_label -from openpilot.system.ui.widgets.list_view import ListItem, ToggleAction, ItemAction, MultipleButtonAction, _resolve_value +from openpilot.system.ui.widgets.list_view import ListItem, ToggleAction, ItemAction, MultipleButtonAction, ButtonAction, \ + _resolve_value, BUTTON_WIDTH, BUTTON_HEIGHT, TEXT_PADDING from openpilot.system.ui.sunnypilot.lib.styles import style from openpilot.system.ui.sunnypilot.widgets.option_control import OptionControlSP, LABEL_WIDTH @@ -24,6 +25,34 @@ class ToggleActionSP(ToggleAction): self.toggle = ToggleSP(initial_state=initial_state, callback=callback, param=param) +class ButtonActionSP(ButtonAction): + def __init__(self, text: str | Callable[[], str], width: int = style.BUTTON_WIDTH, enabled: bool | Callable[[], bool] = True): + super().__init__(text=text, width=width, enabled=enabled) + self._value_color: rl.Color = style.ITEM_TEXT_VALUE_COLOR + + def set_value(self, value: str | Callable[[], str], color: rl.Color = style.ITEM_TEXT_VALUE_COLOR): + self._value_source = value + self._value_color = color + + def _render(self, rect: rl.Rectangle) -> bool: + """Duplicate of ButtonAction._render, with additional value rendering""" + self._button.set_text(self.text) + self._button.set_enabled(_resolve_value(self.enabled)) + button_rect = rl.Rectangle(rect.x + rect.width - BUTTON_WIDTH, rect.y + (rect.height - BUTTON_HEIGHT) / 2, BUTTON_WIDTH, BUTTON_HEIGHT) + self._button.render(button_rect) + + value_text = self.value + if value_text: + value_rect = rl.Rectangle(rect.x, rect.y, rect.width - BUTTON_WIDTH - TEXT_PADDING, rect.height) + gui_label(value_rect, value_text, font_size=style.ITEM_TEXT_FONT_SIZE, color=self._value_color, + font_weight=FontWeight.NORMAL, alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, + alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) + + pressed = self._pressed + self._pressed = False + return pressed + + class MultipleButtonActionSP(MultipleButtonAction): def __init__(self, buttons: list[str | Callable[[], str]], button_width: int, selected_index: int = 0, callback: Callable = None, param: str | None = None): @@ -89,9 +118,11 @@ class ListItemSP(ListItem): self._rect.height += style.ITEM_BASE_HEIGHT/1.75 self._right_value_source: str | Callable[[], str] | None = None self._right_value_font = gui_app.font(FontWeight.NORMAL) + self._right_value_color: rl.Color = style.ITEM_TEXT_VALUE_COLOR - def set_right_value(self, value: str | Callable[[], str]): + def set_right_value(self, value: str | Callable[[], str], color: rl.Color = style.ITEM_TEXT_VALUE_COLOR): self._right_value_source = value + self._right_value_color = color @property def right_value(self) -> str: @@ -127,17 +158,17 @@ class ListItemSP(ListItem): return rl.Rectangle(item_rect.x + style.ITEM_PADDING, action_y, item_rect.width - (style.ITEM_PADDING * 2), style.BUTTON_HEIGHT) - right_width = self.action_item.rect.width + right_width = self.action_item.get_width_hint() if right_width == 0: return rl.Rectangle(item_rect.x + style.ITEM_PADDING, item_rect.y, item_rect.width - (style.ITEM_PADDING * 2), style.ITEM_BASE_HEIGHT) - action_width = self.action_item.rect.width - if isinstance(self.action_item, ToggleAction): - action_x = item_rect.x - else: - action_x = item_rect.x + item_rect.width - action_width + content_width = item_rect.width - (style.ITEM_PADDING * 2) + title_width = measure_text_cached(self._font, self.title, style.ITEM_TEXT_FONT_SIZE).x + right_width = min(content_width - title_width, right_width) + + action_x = item_rect.x + item_rect.width - right_width action_y = item_rect.y - return rl.Rectangle(action_x, action_y, action_width, style.ITEM_BASE_HEIGHT) + return rl.Rectangle(action_x, action_y, right_width, style.ITEM_BASE_HEIGHT) def _render(self, _): if not self.is_visible: @@ -176,7 +207,7 @@ class ListItemSP(ListItem): style.ITEM_BASE_HEIGHT, ) if value_rect.width > 0: - gui_label(value_rect, value_text, font_size=style.ITEM_TEXT_FONT_SIZE, color=style.ITEM_TEXT_VALUE_COLOR, font_weight=FontWeight.NORMAL, + gui_label(value_rect, value_text, font_size=style.ITEM_TEXT_FONT_SIZE, color=self._right_value_color, font_weight=FontWeight.NORMAL, alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) # Render toggle and handle callback @@ -236,3 +267,9 @@ def option_item_sp(title: str | Callable[[], str], param: str, enabled, on_value_changed, value_map, label_width, use_float_scaling, label_callback ) return ListItemSP(title=title, description=description, action_item=action, icon=icon) + + +def button_item_sp(title: str | Callable[[], str], button_text: str | Callable[[], str], description: str | Callable[[], str] | None = None, + callback: Callable | None = None, enabled: bool | Callable[[], bool] = True) -> ListItemSP: + action = ButtonActionSP(text=button_text, enabled=enabled) + return ListItemSP(title=title, description=description, action_item=action, callback=callback) diff --git a/system/ui/widgets/network.py b/system/ui/widgets/network.py index 71755cc284..2aeefd5444 100644 --- a/system/ui/widgets/network.py +++ b/system/ui/widgets/network.py @@ -16,6 +16,7 @@ from openpilot.system.ui.widgets.scroller_tici import Scroller from openpilot.system.ui.widgets.list_view import ButtonAction, ListItem, MultipleButtonAction, ToggleAction, button_item, text_item if gui_app.sunnypilot_ui(): + from openpilot.system.ui.sunnypilot.widgets.list_view import button_item_sp as button_item from openpilot.system.ui.sunnypilot.widgets.list_view import ListItemSP as ListItem from openpilot.system.ui.sunnypilot.widgets.list_view import ToggleActionSP as ToggleAction from openpilot.system.ui.sunnypilot.widgets.list_view import MultipleButtonActionSP as MultipleButtonAction From b5b170b65a09b0725bca77fa0775e27059c2bc7e Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Fri, 5 Dec 2025 00:30:48 -0500 Subject: [PATCH 11/19] ui: sunnypilot sponsor tier color mapping (#1556) * ui: sunnypilot sponsor tier color mapping * lint --- sunnypilot/sunnylink/sunnylink_state.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/sunnypilot/sunnylink/sunnylink_state.py b/sunnypilot/sunnylink/sunnylink_state.py index 4d0b397e03..13b5ad81ec 100644 --- a/sunnypilot/sunnylink/sunnylink_state.py +++ b/sunnypilot/sunnylink/sunnylink_state.py @@ -8,11 +8,13 @@ from enum import IntEnum import threading import time import json +import pyray as rl from cereal import messaging from openpilot.common.params import Params from openpilot.common.swaglog import cloudlog from openpilot.sunnypilot.sunnylink.api import UNREGISTERED_SUNNYLINK_DONGLE_ID, SunnylinkApi +from openpilot.system.ui.sunnypilot.lib.styles import style class RoleType(IntEnum): @@ -201,5 +203,19 @@ class SunnylinkState: network_type = self._sm["deviceState"].networkType return bool(network_type != 0) + def get_sponsor_tier_color(self) -> rl.Color: + tier = self.get_sponsor_tier() + + if tier == SponsorTier.GUARDIAN: + return rl.Color(255, 215, 0, 255) + elif tier == SponsorTier.BENEFACTOR: + return rl.Color(60, 179, 113, 255) + elif tier == SponsorTier.CONTRIBUTOR: + return rl.Color(70, 130, 180, 255) + elif tier == SponsorTier.SUPPORTER: + return rl.Color(147, 112, 219, 255) + else: + return style.ITEM_TEXT_VALUE_COLOR + def __del__(self): self.stop() From 1d9bda65fea8da24a2804a5a3605fe924eeaed18 Mon Sep 17 00:00:00 2001 From: Nayan Date: Fri, 5 Dec 2025 01:01:55 -0500 Subject: [PATCH 12/19] ui: sunnylink panel (#1494) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * param to control stock vs sp ui * init styles * SP Toggles * Lint * optimizations * sp raylib preview * fix callback * fix ui preview * sunnylink state * introducing ui_state_sp for py * poll from ui_state_sp * cloudlog & ruff * param to control stock vs sp ui * better * better padding * this * listitem -> listitemsp * add show_description method * remove padding from line separator. like, WHY? 😩😩 * ui: `GuiApplicationExt` * add to readme * use gui_app.sunnypilot_ui() * use gui_app.sunnypilot_ui() * fetch only when connected to network * sponsor & pairing qr * init panel elements * backup & restore * fruit loops * update * enable, disable, enable, disable * handle layout updates * not needed * change it up * better * scroller -> scroller_tici * optimizations * remove Params * fix button disablement * ui_state_sp changes * keep enabled * add header text * dad jokes? * no * lint? Lint! * final touches * add sp font * use sp font * some * ui: add right-aligned value display support in `ListItem` (in another pr) * display sunnylink device id * display sunnylink device id and sponsor tiers * ui: add right-aligned value display support in `ListItemSP` * lint * styles * lint * ui: introduce customizable value colors for `ButtonActionSP` and `ListViewSP` * support * convert to str * disable if paired * colored sponsors * hide and disable pairing button if paired * texts * ui: sunnypilot sponsor tier color mapping * lint * dongle id for ui preview --------- Co-authored-by: Jason Wen Co-authored-by: James Vecellio-Grant <159560811+Discountchubbs@users.noreply.github.com> --- .../sunnypilot/layouts/settings/settings.py | 2 +- .../sunnypilot/layouts/settings/sunnylink.py | 326 +++++++++++++++++- .../ui/tests/test_ui/raylib_screenshots.py | 1 + .../widgets/sunnylink_pairing_dialog.py | 137 ++++++++ 4 files changed, 459 insertions(+), 7 deletions(-) create mode 100644 system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py diff --git a/selfdrive/ui/sunnypilot/layouts/settings/settings.py b/selfdrive/ui/sunnypilot/layouts/settings/settings.py index 905338552f..45c9b93483 100644 --- a/selfdrive/ui/sunnypilot/layouts/settings/settings.py +++ b/selfdrive/ui/sunnypilot/layouts/settings/settings.py @@ -112,7 +112,7 @@ class SettingsLayoutSP(OP.SettingsLayout): self._panels = { OP.PanelType.DEVICE: PanelInfo(tr_noop("Device"), DeviceLayoutSP(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_home.png"), OP.PanelType.NETWORK: PanelInfo(tr_noop("Network"), NetworkUISP(wifi_manager), icon="icons/network.png"), - OP.PanelType.SUNNYLINK: PanelInfo(tr_noop("sunnylink"), SunnylinkLayout(), icon="icons/shell.png"), + OP.PanelType.SUNNYLINK: PanelInfo(tr_noop("sunnylink"), SunnylinkLayout(), icon="icons/wifi_strength_full.png"), OP.PanelType.TOGGLES: PanelInfo(tr_noop("Toggles"), TogglesLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_toggle.png"), OP.PanelType.SOFTWARE: PanelInfo(tr_noop("Software"), SoftwareLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_software.png"), OP.PanelType.MODELS: PanelInfo(tr_noop("Models"), ModelsLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_models.png"), diff --git a/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py b/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py index b1e12c17ab..7125fdf3f3 100644 --- a/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py +++ b/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py @@ -4,27 +4,341 @@ 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. """ -from openpilot.common.params import Params -from openpilot.system.ui.widgets.scroller_tici import Scroller -from openpilot.system.ui.widgets import Widget +from cereal import custom +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.sunnylink_pairing_dialog import SunnylinkPairingDialog +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.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 + + +class SunnylinkHeader(Widget): + def __init__(self): + super().__init__() + + self._title = UnifiedLabel( + text="🚀 sunnylink 🚀", + font_size=90, + font_weight=FontWeight.AUDIOWIDE, + text_color=rl.WHITE, + alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, + alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP, + wrap_text=False, + elide=False + ) + + self._description = UnifiedLabel( + text=tr("For secure backup, restore, and remote configuration"), + font_size=40, + font_weight=FontWeight.LIGHT, + text_color=rl.Color(0, 255, 0, 255), # Green + alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, + alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP, + wrap_text=True, + elide=False + ) + + self._sponsor_msg = UnifiedLabel( + text=tr("Sponsorship isn't required for basic backup/restore") + "\n" + + tr("Click the Sponsor button for more details"), + font_size=35, + font_weight=FontWeight.LIGHT, + text_color=rl.Color(255, 165, 0, 255), # Orange + alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, + alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP, + wrap_text=True, + elide=False + ) + + self._padding = 20 + self._spacing = 10 + + def set_parent_rect(self, parent_rect: rl.Rectangle) -> None: + super().set_parent_rect(parent_rect) + + content_width = int(parent_rect.width - (self._padding * 2)) + + title_height = self._title.get_content_height(content_width) + desc_height = self._description.get_content_height(content_width) + sponsor_height = self._sponsor_msg.get_content_height(content_width) + + total_height = (self._padding + title_height + self._spacing + + desc_height + self._spacing + sponsor_height + self._padding) + + self._rect.width = parent_rect.width + self._rect.height = total_height + + def _render(self, rect: rl.Rectangle): + content_width = rect.width - (self._padding * 2) + current_y = rect.y + self._padding + + # Render title + title_height = self._title.get_content_height(int(content_width)) + title_rect = rl.Rectangle(rect.x + self._padding, current_y, content_width, title_height) + self._title.render(title_rect) + current_y += title_height + self._spacing + + # Render description + desc_height = self._description.get_content_height(int(content_width)) + desc_rect = rl.Rectangle(rect.x + self._padding, current_y, content_width, desc_height) + self._description.render(desc_rect) + current_y += desc_height + self._spacing + + # Render sponsor message + sponsor_height = self._sponsor_msg.get_content_height(int(content_width)) + sponsor_rect = rl.Rectangle(rect.x + self._padding, current_y, content_width, sponsor_height) + self._sponsor_msg.render(sponsor_rect) + + +class SunnylinkDescriptionItem(Widget): + def __init__(self): + super().__init__() + self._description = UnifiedLabel( + text="", + font_size=40, + font_weight=FontWeight.LIGHT, + text_color=rl.WHITE, + alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, + alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP, + wrap_text=True, + elide=False, + ) + self._padding = 20 + + def set_parent_rect(self, parent_rect: rl.Rectangle) -> None: + super().set_parent_rect(parent_rect) + desc_height = self._description.get_content_height(int(parent_rect.width)) + self._padding * 2 + + self._rect.width = parent_rect.width + self._rect.height = desc_height + + def set_text(self, text: str): + self._description.set_text(text) + + def set_color(self, color: rl.Color): + self._description.set_text_color(color) + + def _render(self, rect: rl.Rectangle): + content_width = rect.width - (self._padding * 2) + + desc_height = self._description.get_content_height(int(content_width)) + desc_rect = rl.Rectangle(rect.x + self._padding, rect.y, content_width, desc_height) + self._description.render(desc_rect) class SunnylinkLayout(Widget): def __init__(self): super().__init__() - self._params = Params() + self._sunnylink_pairing_dialog: SunnylinkPairingDialog | None = None + self._restore_in_progress = False + self._backup_in_progress = False + self._sunnylink_enabled = ui_state.params.get("SunnylinkEnabled") + items = self._initialize_items() - self._scroller = Scroller(items, line_separator=True, spacing=0) + self._scroller = Scroller(items, line_separator=False, spacing=0) def _initialize_items(self): - items = [ + self._sunnylink_toggle = toggle_item_sp( + title=tr("Enable sunnylink"), + description=tr("This is the master switch, it will allow you to cutoff any sunnylink requests should you want to do that."), + param="SunnylinkEnabled", + callback=self._sunnylink_toggle_callback + ) + self._sunnylink_description = SunnylinkDescriptionItem() + self._sunnylink_description.set_visible(False) + + self._sponsor_btn = button_item( + 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( + title=tr("Pair GitHub Account"), + button_text=tr("Not Paired"), + description=tr( + "Pair your GitHub account to grant your device sponsor benefits, including API access on sunnylink."), + callback=lambda: self._handle_pair_btn(True) + ) + self._sunnylink_uploader_toggle = toggle_item_sp( + title=tr("Enable sunnylink uploader (infrastructure test)"), + description=tr("Enable sunnylink uploader to allow sunnypilot to upload your driving data to sunnypilot servers. ") + + tr("(Only for highest tiers, and does NOT bring ANY benefit to you yet. We are just testing data volume.)"), + param="EnableSunnylinkUploader" + ) + self._sunnylink_backup_restore_buttons = dual_button_item( + description="", + left_text=tr("Backup Settings"), + right_text=tr("Restore Settings"), + left_callback=self._handle_backup_btn, + right_callback=self._handle_restore_btn + ) + self._backup_btn: Button = self._sunnylink_backup_restore_buttons.action_item.left_button # store for easy individual access + self._restore_btn: Button = self._sunnylink_backup_restore_buttons.action_item.right_button + self._backup_btn.set_button_style(ButtonStyle.NORMAL) + self._restore_btn.set_button_style(ButtonStyle.PRIMARY) + + items = [ + SunnylinkHeader(), + LineSeparator(), + self._sunnylink_toggle, + self._sunnylink_description, + LineSeparator(), + self._sponsor_btn, + LineSeparator(), + self._pair_btn, + LineSeparator(), + self._sunnylink_uploader_toggle, + LineSeparator(), + self._sunnylink_backup_restore_buttons ] return items + @staticmethod + def _get_sunnylink_dongle_id() -> str | None: + return str(ui_state.params.get("SunnylinkDongleId") or (lambda: tr("N/A"))) + + def _handle_pair_btn(self, sponsor_pairing: bool = False): + sunnylink_dongle_id = self._get_sunnylink_dongle_id() + if sunnylink_dongle_id == UNREGISTERED_SUNNYLINK_DONGLE_ID: + gui_app.set_modal_overlay(alert_dialog(message=tr("sunnylink Dongle ID not found. ") + + tr("This may be due to weak internet connection or sunnylink registration issue. ") + + tr("Please reboot and try again."))) + elif not self._sunnylink_pairing_dialog: + self._sunnylink_pairing_dialog = SunnylinkPairingDialog(sponsor_pairing) + gui_app.set_modal_overlay(self._sunnylink_pairing_dialog, callback=lambda result: setattr(self, '_sunnylink_pairing_dialog', None)) + + def _handle_backup_btn(self): + backup_dialog = ConfirmDialog(text=tr("Are you sure you want to backup your current sunnypilot settings?"), confirm_text="Backup") + gui_app.set_modal_overlay(backup_dialog, callback=self._backup_handler) + + def _handle_restore_btn(self): + self._restore_btn.set_enabled(False) + restore_dialog = ConfirmDialog(text=tr("Are you sure you want to restore the last backed up sunnypilot settings?"), confirm_text="Restore") + gui_app.set_modal_overlay(restore_dialog, callback=self._restore_handler) + + def _backup_handler(self, dialog_result: int): + if dialog_result == DialogResult.CONFIRM: + self._backup_in_progress = True + self._backup_btn.set_enabled(False) + ui_state.params.put_bool("BackupManager_CreateBackup", True) + + def _restore_handler(self, dialog_result: int): + if dialog_result == DialogResult.CONFIRM: + self._restore_in_progress = True + self._restore_btn.set_enabled(False) + ui_state.params.put("BackupManager_RestoreVersion", "latest") + + def handle_backup_restore_progress(self): + sunnylink_backup_manager = ui_state.sm["backupManagerSP"] + + backup_status = sunnylink_backup_manager.backupStatus + restore_status = sunnylink_backup_manager.restoreStatus + backup_progress = sunnylink_backup_manager.backupProgress + restore_progress = sunnylink_backup_manager.restoreProgress + + if self._backup_in_progress: + self._restore_btn.set_enabled(False) + self._backup_btn.set_enabled(False) + + if backup_status == custom.BackupManagerSP.Status.inProgress: + self._backup_in_progress = True + text = tr(f"Backing up {backup_progress}%") + self._backup_btn.set_text(text) + + elif backup_status == custom.BackupManagerSP.Status.failed: + self._backup_in_progress = False + self._backup_btn.set_enabled(not ui_state.is_onroad()) + self._backup_btn.set_text(tr("Backup Failed")) + + elif (backup_status == custom.BackupManagerSP.Status.completed or + (backup_status == custom.BackupManagerSP.Status.idle and backup_progress == 100.0)): + self._backup_in_progress = False + dialog = alert_dialog(tr("Settings backup completed.")) + gui_app.set_modal_overlay(dialog) + self._backup_btn.set_enabled(not ui_state.is_onroad()) + + elif self._restore_in_progress: + self._restore_btn.set_enabled(False) + self._backup_btn.set_enabled(False) + + if restore_status == custom.BackupManagerSP.Status.inProgress: + self._restore_in_progress = True + text = tr(f"Restoring {restore_progress}%") + self._restore_btn.set_text(text) + + elif restore_status == custom.BackupManagerSP.Status.failed: + self._restore_in_progress = False + self._restore_btn.set_enabled(not ui_state.is_onroad()) + self._restore_btn.set_text(tr("Restore Failed")) + dialog = alert_dialog(tr("Unable to restore the settings, try again later.")) + gui_app.set_modal_overlay(dialog) + + elif (restore_status == custom.BackupManagerSP.Status.completed or + (restore_status == custom.BackupManagerSP.Status.idle and restore_progress == 100.0)): + self._restore_in_progress = False + dialog = alert_dialog(tr("Settings restored. Confirm to restart the interface.")) + gui_app.set_modal_overlay(dialog, callback=lambda: gui_app.request_close()) + + else: + can_enable = self._sunnylink_enabled and not ui_state.is_onroad() + self._backup_btn.set_enabled(can_enable) + self._backup_btn.set_text(tr("Backup Settings")) + self._restore_btn.set_enabled(can_enable) + self._restore_btn.set_text(tr("Restore Settings")) + + def _sunnylink_toggle_callback(self, state: bool): + if state: + description = tr( + "Welcome back!! We're excited to see you've enabled sunnylink again!") + color = rl.Color(0, 255, 0, 255) # Green + else: + description = ("😢 " + tr("Not going to lie, it's sad to see you disabled sunnylink") + + tr(", but we'll be here when you're ready to come back.")) + color = rl.Color(255, 165, 0, 255) # Orange + self._sunnylink_description.set_text(description) + self._sunnylink_description.set_color(color) + self._sunnylink_description.set_visible(True) + self._sunnylink_toggle.show_description(False) + + def _update_state(self): + super()._update_state() + self._sunnylink_enabled = ui_state.params.get_bool("SunnylinkEnabled") + self._sunnylink_toggle.set_right_value(tr("Dongle ID") + ": " + self._get_sunnylink_dongle_id()) + self._sunnylink_toggle.action_item.set_enabled(not ui_state.is_onroad()) + self._sunnylink_toggle.action_item.set_state(self._sunnylink_enabled) + self._sunnylink_uploader_toggle.action_item.set_enabled(self._sunnylink_enabled) + self.handle_backup_restore_progress() + + sponsor_btn_text = tr("THANKS ♥") if ui_state.sunnylink_state.is_sponsor() else tr("SPONSOR") + tier_name = ui_state.sunnylink_state.get_sponsor_tier().name.capitalize() or tr("Not Sponsor") + self._sponsor_btn.action_item.set_text(sponsor_btn_text) + self._sponsor_btn.action_item.set_value(tier_name, ui_state.sunnylink_state.get_sponsor_tier_color()) + self._sponsor_btn.action_item.set_enabled(self._sunnylink_enabled and not ui_state.sunnylink_state.is_sponsor()) + + pair_btn_text = tr("Paired") if ui_state.sunnylink_state.is_paired() else tr("Not Paired") + self._pair_btn.action_item.set_text(pair_btn_text) + self._pair_btn.set_visible(lambda: self._sunnylink_enabled and not ui_state.sunnylink_state.is_paired()) + self._pair_btn.action_item.set_enabled(self._sunnylink_enabled and not ui_state.sunnylink_state.is_paired()) + def _render(self, rect): self._scroller.render(rect) def show_event(self): + super().show_event() self._scroller.show_event() + self._sunnylink_description.set_visible(False) diff --git a/selfdrive/ui/tests/test_ui/raylib_screenshots.py b/selfdrive/ui/tests/test_ui/raylib_screenshots.py index 164358bfce..e209ab8060 100755 --- a/selfdrive/ui/tests/test_ui/raylib_screenshots.py +++ b/selfdrive/ui/tests/test_ui/raylib_screenshots.py @@ -369,6 +369,7 @@ def create_screenshots(): with OpenpilotPrefix(): params = Params() params.put("DongleId", "123456789012345") + params.put("SunnylinkDongleId", "123456789012345") # Set branch name params.put("UpdaterCurrentDescription", VERSION) diff --git a/system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py b/system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py new file mode 100644 index 0000000000..4fab79caf1 --- /dev/null +++ b/system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py @@ -0,0 +1,137 @@ +""" +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 base64 + +import pyray as rl +from openpilot.common.swaglog import cloudlog +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.selfdrive.ui.widgets.pairing_dialog import PairingDialog +from openpilot.sunnypilot.sunnylink.api import SunnylinkApi, UNREGISTERED_SUNNYLINK_DONGLE_ID, API_HOST +from openpilot.system.ui.lib.application import FontWeight, gui_app +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.lib.wrap_text import wrap_text +from openpilot.system.ui.lib.text_measure import measure_text_cached + + +class SunnylinkPairingDialog(PairingDialog): + """Dialog for device pairing with QR code.""" + + QR_REFRESH_INTERVAL = 300 # 5 minutes in seconds + + def __init__(self, sponsor_pairing: bool = False): + PairingDialog.__init__(self) + self._sponsor_pairing = sponsor_pairing + + def _get_pairing_url(self) -> str: + qr_string = "https://github.com/sponsors/sunnyhaibin" + + if self._sponsor_pairing: + try: + sl_dongle_id = self.params.get("SunnylinkDongleId") or UNREGISTERED_SUNNYLINK_DONGLE_ID + token = SunnylinkApi(sl_dongle_id).get_token() + inner_string = f"1|{sl_dongle_id}|{token}" + payload_bytes = base64.b64encode(inner_string.encode('utf-8')).decode('utf-8') + qr_string = f"{API_HOST}/sso?state={payload_bytes}" + except Exception: + cloudlog.exception("Failed to get pairing token") + + return qr_string + + def _update_state(self): + if ui_state.sunnylink_state.is_paired(): + gui_app.set_modal_overlay(None) + + def _render(self, rect: rl.Rectangle) -> int: + rl.clear_background(rl.Color(224, 224, 224, 255)) + + self._check_qr_refresh() + + margin = 70 + content_rect = rl.Rectangle(rect.x + margin, rect.y + margin, rect.width - 2 * margin, rect.height - 2 * margin) + y = content_rect.y + + # Close button + close_size = 80 + pad = 20 + close_rect = rl.Rectangle(content_rect.x - pad, y - pad, close_size + pad * 2, close_size + pad * 2) + self._close_btn.render(close_rect) + + y += close_size + 40 + + # Title + title = tr("Pair your GitHub account") if self._sponsor_pairing else tr("Early Access: Become a sunnypilot Sponsor") + title_font = gui_app.font(FontWeight.NORMAL) + left_width = int(content_rect.width * 0.5 - 15) + + title_wrapped = wrap_text(title_font, title, 75, left_width) + rl.draw_text_ex(title_font, "\n".join(title_wrapped), rl.Vector2(content_rect.x, y), 75, 0.0, rl.BLACK) + y += len(title_wrapped) * 75 + 60 + + # Two columns: instructions and QR code + remaining_height = content_rect.height - (y - content_rect.y) + right_width = content_rect.width // 2 - 20 + + # Instructions + self._render_instructions(rl.Rectangle(content_rect.x, y, left_width, remaining_height)) + + # QR code + qr_size = min(right_width, content_rect.height) - 40 + qr_x = content_rect.x + left_width + 40 + (right_width - qr_size) // 2 + qr_y = content_rect.y + self._render_qr_code(rl.Rectangle(qr_x, qr_y, qr_size, qr_size)) + + return -1 + + def _render_instructions(self, rect: rl.Rectangle) -> None: + if self._sponsor_pairing: + instructions = [ + tr("Scan the QR code to login to your GitHub account"), + tr("Follow the prompts to complete the pairing process"), + tr("Re-enter the \"sunnylink\" panel to verify sponsorship status"), + tr("If sponsorship status was not updated, please contact a moderator on the community forum at https://community.sunnypilot.ai") + ] + else: + instructions = [ + tr("Scan the QR code to visit sunnyhaibin's GitHub Sponsors page"), + tr("Choose your sponsorship tier and confirm your support"), + tr("Join our Community Forum at https://community.sunnypilot.ai and reach out to a moderator if you have issues") + ] + + font = gui_app.font(FontWeight.BOLD) + y = rect.y + + for i, text in enumerate(instructions): + circle_radius = 25 + circle_x = rect.x + circle_radius + 15 + text_x = rect.x + circle_radius * 2 + 40 + text_width = rect.width - (circle_radius * 2 + 40) + + wrapped = wrap_text(font, text, 47, int(text_width)) + text_height = len(wrapped) * 47 + circle_y = y + text_height // 2 + + # Circle and number + rl.draw_circle(int(circle_x), int(circle_y), circle_radius, rl.Color(70, 70, 70, 255)) + number = str(i + 1) + number_size = measure_text_cached(font, number, 30) + rl.draw_text_ex(font, number, (int(circle_x - number_size.x // 2), int(circle_y - number_size.y // 2)), 30, 0, rl.WHITE) + + # Text + rl.draw_text_ex(font, "\n".join(wrapped), rl.Vector2(text_x, y), 47, 0.0, rl.BLACK) + y += text_height + 50 + + +if __name__ == "__main__": + gui_app.init_window("pairing device") + pairing = SunnylinkPairingDialog(sponsor_pairing=True) + try: + for _ in gui_app.render(): + result = pairing.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) + if result != -1: + break + finally: + del pairing From 57eca29970efd01ca17de7a6f7425a1e7ebac6dd Mon Sep 17 00:00:00 2001 From: James Vecellio-Grant <159560811+Discountchubbs@users.noreply.github.com> Date: Fri, 5 Dec 2025 00:14:53 -0800 Subject: [PATCH 13/19] ui: Models panel (#1495) * param to control stock vs sp ui * init styles * SP Toggles * Lint * optimizations * sp raylib preview * fix callback * fix ui preview * dialog txt * compare vs what used to be done before InputDialog * introducing ui_state_sp for py * raylib: input dialog * raylib: SP Toggles * raylib: SP Panels * raylib: Option Control * init * param to control stock vs sp ui * better * tree dialog, progress bar widget cool stuff * merge origin raylib toggles * tree dialog * less trees for the planet * the heck * add ui_update callback * save the trees we got icons * Update process.py * yesssssssssss * utilize ON_COLOR constant form op system * Revert "add ui_update callback" This reverts commit 4da32cc0097434aab0aa6a3c35465eabb23c8958. * # Conflicts: # system/ui/sunnypilot/widgets/list_view.py # system/ui/sunnypilot/widgets/option_control.py * Merge remote-tracking branch 'openpilot/master' into nov-19-sync * ui: `GuiApplicationExt` * add to readme * scroller_tici :) * use gui_app.sunnypilot_ui() * # Conflicts: # selfdrive/ui/layouts/main.py # selfdrive/ui/sunnypilot/layouts/settings/cruise.py # selfdrive/ui/sunnypilot/layouts/settings/display.py # selfdrive/ui/sunnypilot/layouts/settings/models.py # selfdrive/ui/sunnypilot/layouts/settings/navigation.py # selfdrive/ui/sunnypilot/layouts/settings/osm.py # selfdrive/ui/sunnypilot/layouts/settings/steering.py # selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py # selfdrive/ui/sunnypilot/layouts/settings/trips.py # selfdrive/ui/sunnypilot/layouts/settings/vehicle.py # selfdrive/ui/sunnypilot/layouts/settings/visuals.py # system/ui/sunnypilot/widgets/option_control.py * init value * Remove 'sunnypilot_ui' Removed 'sunnypilot_ui' parameter from params_keys.h * Update raylib_screenshots.py Removed the parameter setting for 'sunnypilot_ui' in the test. * easier to see * Update progress_bar.py * try something * adjust placement * more simple * smoothing updating components * ui: fuzzy search helper * ui_state_sp * description! * fuzzy af searching * better tree. fully dynamic and stuff * rm * rearrange * license * idk how maybe the merge * more indent * more indent * cleanup * temporaily revert ui_state_sp * only show if fav_param is used in the call * conditional for mypy * mypy * conditional for mypy * str concatenation to reduce line len * level * sunny's new x,y makes this even easier! * refreshing half a second seems legit. * loathing loathing, unadulterated loathing, i loathe it all * loathing loathing, unadulterated loathing, i loathe it all * # Conflicts: # system/ui/sunnypilot/lib/styles.py # system/ui/sunnypilot/widgets/tree_dialog.py * Update models.py * Change BUTTON_DISABLED_BG_COLOR to a lighter shade * I think this is it * Update tree_dialog.py * Update models.py * Update models.py * oops, angry f string * bool * Update ui_state.py * Update ui_state.py * wtf where'd the end quote go lol * some * more * quick test * Revert "quick test" This reverts commit fb97afa54c2b94129e772a23a789059adf01f609. * try this out * use sp's * cap * how weird? --------- Co-authored-by: nayan Co-authored-by: Jason Wen --- .../ui/sunnypilot/layouts/settings/models.py | 244 +++++++++++++++++- selfdrive/ui/sunnypilot/ui_state.py | 2 +- sunnypilot/models/manager.py | 4 + 3 files changed, 241 insertions(+), 9 deletions(-) diff --git a/selfdrive/ui/sunnypilot/layouts/settings/models.py b/selfdrive/ui/sunnypilot/layouts/settings/models.py index add437b127..43f0d25b47 100644 --- a/selfdrive/ui/sunnypilot/layouts/settings/models.py +++ b/selfdrive/ui/sunnypilot/layouts/settings/models.py @@ -4,24 +4,252 @@ 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. """ -from openpilot.common.params import Params +import os +import re +import time +import pyray as rl + +from cereal import custom +from openpilot.common.constants import CV +from openpilot.selfdrive.ui.ui_state import device, ui_state +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.widgets import DialogResult, Widget +from openpilot.system.ui.widgets.confirm_dialog import alert_dialog, ConfirmDialog from openpilot.system.ui.widgets.scroller_tici import Scroller -from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.toggle import ON_COLOR + +from openpilot.sunnypilot.models.runners.constants import CUSTOM_MODEL_PATH +from openpilot.system.ui.sunnypilot.lib.styles import style +from openpilot.system.ui.sunnypilot.widgets.list_view import ButtonActionSP, ListItemSP, toggle_item_sp, option_item_sp +from openpilot.system.ui.sunnypilot.widgets.progress_bar import progress_item +from openpilot.system.ui.sunnypilot.widgets.tree_dialog import TreeOptionDialog, TreeNode, TreeFolder + +if gui_app.sunnypilot_ui(): + from openpilot.system.ui.sunnypilot.widgets.list_view import button_item_sp as button_item + + +class ModelAction(ButtonActionSP): + def get_width_hint(self): + return super().get_width_hint() + 1 class ModelsLayout(Widget): def __init__(self): super().__init__() + self.model_manager = None + self.download_status = None + self.prev_download_status = None + self.model_dialog = None + self.last_cache_calc_time = 0 - self._params = Params() - items = self._initialize_items() - self._scroller = Scroller(items, line_separator=True, spacing=0) + self._initialize_items() + + self.clear_cache_item.action_item.set_value(f"{self._calculate_cache_size():.2f} MB") + for ctrl, key in [(self.lane_turn_value_control, "LaneTurnValue"), (self.delay_control, "LagdToggleDelay")]: + ctrl.action_item.set_value(int(float(ui_state.params.get(key, return_default=True)) * 100)) + + self._scroller = Scroller(self.items, line_separator=True, spacing=0) def _initialize_items(self): - items = [ + self.current_model_item = ListItemSP( + title=tr("Current Model"), + description="", + action_item=ModelAction(tr("SELECT")), + callback=self._handle_current_model_clicked + ) - ] - return items + self.supercombo_label = progress_item(tr("Driving Model")) + self.vision_label = progress_item(tr("Vision Model")) + self.policy_label = progress_item(tr("Policy Model")) + + self.refresh_item = button_item(tr("Refresh Model List"), tr("REFRESH"), "", + lambda: (ui_state.params.put("ModelManager_LastSyncTime", 0), + gui_app.set_modal_overlay(alert_dialog(tr("Fetching Latest Models"))))) + + self.clear_cache_item = ListItemSP( + title=tr("Clear Model Cache"), + description="", + action_item=ModelAction(tr("CLEAR")), + callback=self._clear_cache + ) + + self.cancel_download_item = button_item(tr("Cancel Download"), tr("Cancel"), "", lambda: ui_state.params.remove("ModelManager_DownloadIndex")) + + self.lane_turn_value_control = option_item_sp(tr("Adjust Lane Turn Speed"), "LaneTurnValue", 500, 2000, + tr("Set the maximum speed for lane turn desires. Default is 19 mph."), + int(round(100 / CV.MPH_TO_KPH)), None, True, "", style.BUTTON_WIDTH, None, True, + lambda v: f"{int(round(v / 100 * (CV.MPH_TO_KPH if ui_state.is_metric else 1)))}" + + f" {'km/h' if ui_state.is_metric else 'mph'}") + + self.lane_turn_desire_toggle = toggle_item_sp(tr("Use Lane Turn Desires"), + tr("If you're driving at 20 mph (32 km/h) or below and have your blinker on," + + " the car will plan a turn in that direction at the nearest drivable path. " + + "This prevents situations (like at red lights) where the car might plan the wrong turn direction."), + param="LaneTurnDesire") + + self.delay_control = option_item_sp(tr("Adjust Software Delay"), "LagdToggleDelay", 5, 50, + tr("Adjust the software delay when Live Learning Steer Delay is toggled off. The default software delay value is 0.2"), + 1, None, True, "", style.BUTTON_WIDTH, None, True, lambda v: f"{v / 100:.2f}s") + + self.lagd_toggle = toggle_item_sp(tr("Live Learning Steer Delay"), "", param="LagdToggle") + + self.items = [self.current_model_item, self.cancel_download_item, self.supercombo_label, self.vision_label, + self.policy_label, self.refresh_item, self.clear_cache_item, self.lane_turn_desire_toggle, + self.lane_turn_value_control, self.lagd_toggle, self.delay_control] + + def _update_lagd_description(self, lagd_toggle: bool): + desc = tr("Enable this for the car to learn and adapt its steering response time. Disable to use a fixed steering response time. " + + "Keeping this on provides the stock openpilot experience.") + if lagd_toggle: + desc += f"
{tr('Live Steer Delay:')} {ui_state.sm['liveDelay'].lateralDelay:.3f} s" + elif ui_state.CP: + sw = float(ui_state.params.get("LagdToggleDelay", "0.2")) + cp = ui_state.CP.steerActuatorDelay + desc += f"
{tr('Actuator Delay:')} {cp:.2f} s + {tr('Software Delay:')} {sw:.2f} s = {tr('Total Delay:')} {cp + sw:.2f} s" + self.lagd_toggle.set_description(desc) + + def _is_downloading(self): + return (self.model_manager and self.model_manager.selectedBundle and + self.model_manager.selectedBundle.status == custom.ModelManagerSP.DownloadStatus.downloading) + + @staticmethod + def _calculate_cache_size(): + cache_size = 0.0 + if os.path.exists(CUSTOM_MODEL_PATH): + cache_size = sum(os.path.getsize(os.path.join(CUSTOM_MODEL_PATH, file)) for file in os.listdir(CUSTOM_MODEL_PATH)) / (1024**2) + return cache_size + + def _clear_cache(self): + def _callback(response): + if response == DialogResult.CONFIRM: + ui_state.params.put_bool("ModelManager_ClearCache", True) + self.clear_cache_item.action_item.set_value(f"{self._calculate_cache_size():.2f} MB") + + gui_app.set_modal_overlay(ConfirmDialog(tr("This will delete ALL downloaded models from the cache except the currently active model. Are you sure?"), + tr("Clear Cache")), callback=_callback) + + def _handle_bundle_download_progress(self): + labels = {custom.ModelManagerSP.Model.Type.supercombo: self.supercombo_label, + custom.ModelManagerSP.Model.Type.vision: self.vision_label, + custom.ModelManagerSP.Model.Type.policy: self.policy_label} + for label in labels.values(): + label.set_visible(False) + self.cancel_download_item.set_visible(False) + + if not self.model_manager or (not self.model_manager.selectedBundle and not self.model_manager.activeBundle): + return + + bundle = self.model_manager.selectedBundle if self._is_downloading() or ( + self.model_manager.selectedBundle and self.model_manager.selectedBundle.status == custom.ModelManagerSP.DownloadStatus.failed + ) else self.model_manager.activeBundle + if not bundle: + return + + self.download_status = bundle.status + status_changed = self.prev_download_status != self.download_status + self.prev_download_status = self.download_status + + self.cancel_download_item.set_visible(bool(self.model_manager.selectedBundle) and bool(ui_state.params.get("ModelManager_DownloadIndex"))) + + if (current_time := time.monotonic()) - self.last_cache_calc_time > 0.5: + self.last_cache_calc_time = current_time + self.clear_cache_item.action_item.set_value(f"{self._calculate_cache_size():.2f} MB") + + if self.download_status == custom.ModelManagerSP.DownloadStatus.downloading: + device.reset_interactive_timeout() + + for model in bundle.models: + if label := labels.get(getattr(model.type, 'raw', model.type)): + label.set_visible(True) + p = model.artifact.downloadProgress + text, show, color = f"pending - {bundle.displayName}", False, rl.GRAY + if p.status == custom.ModelManagerSP.DownloadStatus.downloading: + text, show = f"{int(p.progress)}% - {bundle.displayName}", True + elif p.status in (custom.ModelManagerSP.DownloadStatus.downloaded, custom.ModelManagerSP.DownloadStatus.cached): + status_text = tr("from cache" if p.status == custom.ModelManagerSP.DownloadStatus.cached else "downloaded") + text, color = f"{bundle.displayName} - {status_text if status_changed else tr('ready')}", ON_COLOR + elif p.status == custom.ModelManagerSP.DownloadStatus.failed: + text, color = f"download failed - {bundle.displayName}", rl.RED + label.action_item.update(p.progress, text, show, color) + + @staticmethod + def _show_reset_params_dialog(): + def _callback(response): + if response == DialogResult.CONFIRM: + ui_state.params.remove("CalibrationParams") + ui_state.params.remove("LiveTorqueParameters") + msg = tr("Model download has started in the background. We suggest resetting calibration. Would you like to do that now?") + gui_app.set_modal_overlay(ConfirmDialog(msg, tr("Reset Calibration")), callback=_callback) + + def _on_model_selected(self, result): + if result != DialogResult.CONFIRM: + return + selected_ref = self.model_dialog.selection_ref + if selected_ref == "Default": + ui_state.params.remove("ModelManager_ActiveBundle") + self._show_reset_params_dialog() + elif selected_bundle := next((bundle for bundle in self.model_manager.availableBundles if bundle.ref == selected_ref), None): + ui_state.params.put("ModelManager_DownloadIndex", selected_bundle.index) + if self.model_manager.activeBundle and selected_bundle.generation != self.model_manager.activeBundle.generation: + self._show_reset_params_dialog() + self.model_dialog = None + + @staticmethod + def _bundle_to_node(bundle): + return TreeNode(bundle.ref, {'display_name': bundle.displayName, 'short_name': bundle.internalName}) + + def _get_folders(self, favorites): + bundles = self.model_manager.availableBundles + folders = {} + for bundle in bundles: + folders.setdefault(next((ov_ride.value for ov_ride in bundle.overrides if ov_ride.key == "folder"), ""), []).append(bundle) + + folders_list = [TreeFolder("", [TreeNode("Default", {'display_name': tr("Default Model"), 'short_name': "Default"})])] + for folder, folder_bundles in sorted(folders.items(), key=lambda x: max((bundle.index for bundle in x[1]), default=-1), reverse=True): + folder_bundles.sort(key=lambda bundle: bundle.index, reverse=True) + name = folder + (f" - (Updated: {m.group(1)})" if folder_bundles and (m := re.search(r'\(([^)]*)\)[^(]*$', folder_bundles[0].displayName)) else "") + folders_list.append(TreeFolder(name, [self._bundle_to_node(bundle) for bundle in folder_bundles])) + + if favorites and (fav_bundles := [bundle for bundle in bundles if bundle.ref in favorites]): + folders_list.insert(1, TreeFolder("Favorites", [self._bundle_to_node(bundle) for bundle in fav_bundles])) + return folders_list + + def _handle_current_model_clicked(self): + favs = ui_state.params.get("ModelManager_Favs") + favorites = set(favs.split(';')) if favs else set() + folders_list = self._get_folders(favorites) + + active_ref = self.model_manager.activeBundle.ref if self.model_manager.activeBundle else "Default" + self.model_dialog = TreeOptionDialog(tr("Select a Model"), folders_list, active_ref, "ModelManager_Favs", + get_folders_fn=self._get_folders, on_exit=self._on_model_selected) + gui_app.set_modal_overlay(self.model_dialog, callback=self._on_model_selected) + + def _update_state(self): + advanced_controls: bool = ui_state.params.get_bool("ShowAdvancedControls") + turn_desire: bool = ui_state.params.get_bool("LaneTurnDesire") + live_delay: bool = ui_state.params.get_bool("LagdToggle") + + self.lane_turn_desire_toggle.action_item.set_state(turn_desire) + self.lane_turn_value_control.set_visible(turn_desire and advanced_controls) + self.lagd_toggle.action_item.set_state(live_delay) + self.delay_control.set_visible(not live_delay and advanced_controls) + new_step = int(round(100 / CV.MPH_TO_KPH)) if ui_state.is_metric else 100 + if self.lane_turn_value_control.action_item.value_change_step != new_step: + self.lane_turn_value_control.action_item.value_change_step = new_step + + self._update_lagd_description(live_delay) + self.model_manager = ui_state.sm["modelManagerSP"] + self._handle_bundle_download_progress() + active_name = self.model_manager.activeBundle.internalName if self.model_manager and self.model_manager.activeBundle.ref else tr("Default Model") + self.current_model_item.action_item.set_value(active_name) + + if not ui_state.is_offroad(): + self.current_model_item.action_item.set_enabled(False) + self.current_model_item.set_description(tr("Only available when vehicle is off, or always offroad mode is on")) + else: + self.current_model_item.action_item.set_enabled(True) + self.current_model_item.set_description("") def _render(self, rect): self._scroller.render(rect) diff --git a/selfdrive/ui/sunnypilot/ui_state.py b/selfdrive/ui/sunnypilot/ui_state.py index 1db5ad613e..af625ee619 100644 --- a/selfdrive/ui/sunnypilot/ui_state.py +++ b/selfdrive/ui/sunnypilot/ui_state.py @@ -14,7 +14,7 @@ class UIStateSP: self.params = Params() self.sm_services_ext = [ "modelManagerSP", "selfdriveStateSP", "longitudinalPlanSP", "backupManagerSP", - "gpsLocation", "liveTorqueParameters", "carStateSP", "liveMapDataSP", "carParamsSP" + "gpsLocation", "liveTorqueParameters", "carStateSP", "liveMapDataSP", "carParamsSP", "liveDelay" ] self.sunnylink_state = SunnylinkState() diff --git a/sunnypilot/models/manager.py b/sunnypilot/models/manager.py index c236643a06..2d3d670bfd 100644 --- a/sunnypilot/models/manager.py +++ b/sunnypilot/models/manager.py @@ -63,6 +63,9 @@ class ModelManagerSP: f.write(chunk) bytes_downloaded += len(chunk) + if not self.params.get("ModelManager_DownloadIndex"): + raise Exception("Download cancelled") + if total_size > 0: progress = (bytes_downloaded / total_size) * 100 model.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.downloading @@ -176,6 +179,7 @@ class ModelManagerSP: cloudlog.exception(e) finally: self.params.remove("ModelManager_DownloadIndex") + self.selected_bundle = None if self.params.get("ModelManager_ClearCache"): self.clear_model_cache() From d382cd08e5fd46e63b54400e6f74f284316ee494 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Fri, 5 Dec 2025 16:49:29 -0500 Subject: [PATCH 14/19] ui: adjust Sponsor and Pairing button behaviors in sunnylink panel (#1557) ui: adjust sponsor and pairing button behaviors in sunnyLink panel --- selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py | 5 ++--- system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py | 4 +++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py b/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py index 7125fdf3f3..316d4d2240 100644 --- a/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py +++ b/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py @@ -328,12 +328,11 @@ class SunnylinkLayout(Widget): tier_name = ui_state.sunnylink_state.get_sponsor_tier().name.capitalize() or tr("Not Sponsor") self._sponsor_btn.action_item.set_text(sponsor_btn_text) self._sponsor_btn.action_item.set_value(tier_name, ui_state.sunnylink_state.get_sponsor_tier_color()) - self._sponsor_btn.action_item.set_enabled(self._sunnylink_enabled and not ui_state.sunnylink_state.is_sponsor()) + self._sponsor_btn.action_item.set_enabled(self._sunnylink_enabled) pair_btn_text = tr("Paired") if ui_state.sunnylink_state.is_paired() else tr("Not Paired") self._pair_btn.action_item.set_text(pair_btn_text) - self._pair_btn.set_visible(lambda: self._sunnylink_enabled and not ui_state.sunnylink_state.is_paired()) - self._pair_btn.action_item.set_enabled(self._sunnylink_enabled and not ui_state.sunnylink_state.is_paired()) + self._pair_btn.action_item.set_enabled(self._sunnylink_enabled) def _render(self, rect): self._scroller.render(rect) diff --git a/system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py b/system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py index 4fab79caf1..af6b9cf45d 100644 --- a/system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +++ b/system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py @@ -25,6 +25,7 @@ class SunnylinkPairingDialog(PairingDialog): def __init__(self, sponsor_pairing: bool = False): PairingDialog.__init__(self) self._sponsor_pairing = sponsor_pairing + self._is_paired_prev = ui_state.sunnylink_state.is_paired() def _get_pairing_url(self) -> str: qr_string = "https://github.com/sponsors/sunnyhaibin" @@ -42,7 +43,8 @@ class SunnylinkPairingDialog(PairingDialog): return qr_string def _update_state(self): - if ui_state.sunnylink_state.is_paired(): + is_paired = ui_state.sunnylink_state.is_paired() + if not self._is_paired_prev and is_paired: gui_app.set_modal_overlay(None) def _render(self, rect: rl.Rectangle) -> int: From 93c1c713a9ad4302520829c561698e6c2626254f Mon Sep 17 00:00:00 2001 From: Nayan Date: Fri, 5 Dec 2025 22:52:41 -0500 Subject: [PATCH 15/19] ui: toggle should only toggle toggle - don't toggle description with toggle. Yeah, that! (#1559) @sunnyhaibin, please! Co-authored-by: Jason Wen --- system/ui/sunnypilot/widgets/list_view.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/system/ui/sunnypilot/widgets/list_view.py b/system/ui/sunnypilot/widgets/list_view.py index bd68d35a48..ac646df5bf 100644 --- a/system/ui/sunnypilot/widgets/list_view.py +++ b/system/ui/sunnypilot/widgets/list_view.py @@ -165,8 +165,10 @@ class ListItemSP(ListItem): content_width = item_rect.width - (style.ITEM_PADDING * 2) title_width = measure_text_cached(self._font, self.title, style.ITEM_TEXT_FONT_SIZE).x right_width = min(content_width - title_width, right_width) - - action_x = item_rect.x + item_rect.width - right_width + if isinstance(self.action_item, ToggleAction): + action_x = item_rect.x + else: + action_x = item_rect.x + item_rect.width - right_width action_y = item_rect.y return rl.Rectangle(action_x, action_y, right_width, style.ITEM_BASE_HEIGHT) From 5007437969ca25ea566d390a2853a022f7ac756e Mon Sep 17 00:00:00 2001 From: Nayan Date: Fri, 5 Dec 2025 22:58:53 -0500 Subject: [PATCH 16/19] ui: enforce fixed dimensions for `ToggleSP` (#1558) fix rect Co-authored-by: Jason Wen --- system/ui/sunnypilot/widgets/toggle.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/system/ui/sunnypilot/widgets/toggle.py b/system/ui/sunnypilot/widgets/toggle.py index 46b390f773..2924aec2b4 100644 --- a/system/ui/sunnypilot/widgets/toggle.py +++ b/system/ui/sunnypilot/widgets/toggle.py @@ -24,6 +24,9 @@ class ToggleSP(Toggle): initial_state = self.params.get_bool(self.param_key) Toggle.__init__(self, initial_state, callback) + def set_rect(self, rect: rl.Rectangle): + self._rect = rl.Rectangle(rect.x, rect.y, style.TOGGLE_WIDTH, style.TOGGLE_HEIGHT) + def _handle_mouse_release(self, mouse_pos: MousePos): super()._handle_mouse_release(mouse_pos) if self._enabled and self.param_key: From 1807b193fafeca3c446560a7f9ddf95db66b1f74 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Sun, 7 Dec 2025 00:58:33 -0500 Subject: [PATCH 17/19] ui: preserve and update `current_ref` in `TreeOptionDialog` init (#1562) * ui: preserve and update `current_ref` in `TreeOptionDialog` init * do it in init instead --- system/ui/sunnypilot/widgets/tree_dialog.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/system/ui/sunnypilot/widgets/tree_dialog.py b/system/ui/sunnypilot/widgets/tree_dialog.py index cfc0a5caf1..27a978153e 100644 --- a/system/ui/sunnypilot/widgets/tree_dialog.py +++ b/system/ui/sunnypilot/widgets/tree_dialog.py @@ -78,7 +78,7 @@ class TreeItemWidget(Button): class TreeOptionDialog(MultiOptionDialog): def __init__(self, title, folders, current_ref="", fav_param="", option_font_weight=FontWeight.MEDIUM, search_prompt=None, get_folders_fn=None, on_exit=None, display_func=None, search_funcs=None, search_title=None, search_subtitle=None): - super().__init__(title, [], "", option_font_weight) + super().__init__(title, [], current_ref, option_font_weight) self.folders = folders self.selection_ref = current_ref self.fav_param = fav_param @@ -101,6 +101,19 @@ class TreeOptionDialog(MultiOptionDialog): self.search_dialog = None self._search_pressed = False + if current_ref: + found = False + for folder in folders: + for node in folder.nodes: + if node.ref == current_ref: + display = self.display_func(node) + self.selection = display + self.current = display + found = True + break + if found: + break + self._build_visible_items() def _on_search_confirm(self, result, text): @@ -156,6 +169,7 @@ class TreeOptionDialog(MultiOptionDialog): self.visible_items.append(TreeItemWidget(self.display_func(node), node.ref, False, 1 if folder.folder else 0, lambda node_ref=node: self._select_node(node_ref), favorite_cb, node.ref in self.favorites, is_expanded=expanded)) + self.option_buttons = self.visible_items self.options = [item.text for item in self.visible_items] self.scroller._items = self.visible_items From 96c2650ac4a43175214a6d23739c013b8bb92a89 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Sun, 7 Dec 2025 01:31:00 -0500 Subject: [PATCH 18/19] ui: improve `TreeOptionDialog` node selection and item handling (#1563) --- system/ui/sunnypilot/widgets/tree_dialog.py | 41 +++++++++++++++------ 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/system/ui/sunnypilot/widgets/tree_dialog.py b/system/ui/sunnypilot/widgets/tree_dialog.py index 27a978153e..2dd5e3a2dc 100644 --- a/system/ui/sunnypilot/widgets/tree_dialog.py +++ b/system/ui/sunnypilot/widgets/tree_dialog.py @@ -101,18 +101,22 @@ class TreeOptionDialog(MultiOptionDialog): self.search_dialog = None self._search_pressed = False - if current_ref: - found = False - for folder in folders: - for node in folder.nodes: - if node.ref == current_ref: - display = self.display_func(node) - self.selection = display - self.current = display - found = True - break - if found: + self.selection_node = None + # Try to match by ref, by display text, or fall back to "Default" when no ref is set + for folder in self.folders: + for node in folder.nodes: + display = self.display_func(node) + if ( + node.ref == current_ref or + display == current_ref or + (not current_ref and node.ref == "Default") + ): + self.selection = display + self.current = display + self.selection_node = node break + if self.selection_node is not None: + break self._build_visible_items() @@ -155,6 +159,17 @@ class TreeOptionDialog(MultiOptionDialog): def _build_visible_items(self, reset_scroll=True): self.visible_items = [] + + # Pinned selected item at the very top (if any) + if getattr(self, "selection_node", None) is not None: + node = self.selection_node + display = self.display_func(node) + self.selection = self.current = display + favorite_cb = (lambda node_ref=node: self._toggle_favorite(node_ref)) if self.fav_param and node.ref != "Default" else None + self.visible_items.append(TreeItemWidget(self.display_func(node), node.ref, False, 0, + lambda node_ref=node: self._select_node(node_ref), + favorite_cb, node.ref in self.favorites, is_expanded=True)) + for folder in self.folders: nodes = [node for node in folder.nodes if not self.query or search_from_list(self.query, [search_func(node) for search_func in self.search_funcs])] if not nodes and self.query: @@ -165,6 +180,10 @@ class TreeOptionDialog(MultiOptionDialog): lambda folder_ref=folder: self._toggle_folder(folder_ref))) if expanded: for node in nodes: + # Skip duplicate root-level item for the selected node + if self.selection_node is not None and node.ref == self.selection_node.ref and not folder.folder: + continue + favorite_cb = (lambda node_ref=node: self._toggle_favorite(node_ref)) if self.fav_param and node.ref != "Default" else None self.visible_items.append(TreeItemWidget(self.display_func(node), node.ref, False, 1 if folder.folder else 0, lambda node_ref=node: self._select_node(node_ref), From 323b793a832d605a8e64f51235b713d295ed0c58 Mon Sep 17 00:00:00 2001 From: James Vecellio-Grant <159560811+Discountchubbs@users.noreply.github.com> Date: Sat, 6 Dec 2025 22:36:02 -0800 Subject: [PATCH 19/19] ui: software panel (#1518) * param to control stock vs sp ui * init styles * SP Toggles * Lint * optimizations * sp raylib preview * fix callback * fix ui preview * dialog txt * compare vs what used to be done before InputDialog * introducing ui_state_sp for py * raylib: input dialog * raylib: SP Toggles * raylib: SP Panels * raylib: Option Control * init * param to control stock vs sp ui * better * tree dialog, progress bar widget cool stuff * merge origin raylib toggles * tree dialog * less trees for the planet * the heck * add ui_update callback * save the trees we got icons * Update process.py * yesssssssssss * utilize ON_COLOR constant form op system * Revert "add ui_update callback" This reverts commit 4da32cc0097434aab0aa6a3c35465eabb23c8958. * # Conflicts: # system/ui/sunnypilot/widgets/list_view.py # system/ui/sunnypilot/widgets/option_control.py * Merge remote-tracking branch 'openpilot/master' into nov-19-sync * ui: `GuiApplicationExt` * add to readme * scroller_tici :) * use gui_app.sunnypilot_ui() * # Conflicts: # selfdrive/ui/layouts/main.py # selfdrive/ui/sunnypilot/layouts/settings/cruise.py # selfdrive/ui/sunnypilot/layouts/settings/display.py # selfdrive/ui/sunnypilot/layouts/settings/models.py # selfdrive/ui/sunnypilot/layouts/settings/navigation.py # selfdrive/ui/sunnypilot/layouts/settings/osm.py # selfdrive/ui/sunnypilot/layouts/settings/steering.py # selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py # selfdrive/ui/sunnypilot/layouts/settings/trips.py # selfdrive/ui/sunnypilot/layouts/settings/vehicle.py # selfdrive/ui/sunnypilot/layouts/settings/visuals.py # system/ui/sunnypilot/widgets/option_control.py * init value * Remove 'sunnypilot_ui' Removed 'sunnypilot_ui' parameter from params_keys.h * Update raylib_screenshots.py Removed the parameter setting for 'sunnypilot_ui' in the test. * easier to see * Update progress_bar.py * try something * adjust placement * more simple * smoothing updating components * ui: fuzzy search helper * ui_state_sp * description! * fuzzy af searching * better tree. fully dynamic and stuff * rm * rearrange * license * idk how maybe the merge * more indent * more indent * cleanup * temporaily revert ui_state_sp * only show if fav_param is used in the call * conditional for mypy * mypy * conditional for mypy * str concatenation to reduce line len * level * sunny's new x,y makes this even easier! * refreshing half a second seems legit. * software stuffs * rm * add * loathing loathing, unadulterated loathing, i loathe it all * loathing loathing, unadulterated loathing, i loathe it all * # Conflicts: # system/ui/sunnypilot/lib/styles.py # system/ui/sunnypilot/widgets/tree_dialog.py * search * ds * hide on advanced controls * some * handle toggle confirmation * sunny, NO * nayan, NO !! * easier * move * move it! * add more * need to show current branch --------- Co-authored-by: nayan Co-authored-by: Jason Wen --- .../sunnypilot/layouts/settings/settings.py | 4 +- .../sunnypilot/layouts/settings/software.py | 96 +++++++++++++++++++ 2 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 selfdrive/ui/sunnypilot/layouts/settings/software.py diff --git a/selfdrive/ui/sunnypilot/layouts/settings/settings.py b/selfdrive/ui/sunnypilot/layouts/settings/settings.py index 45c9b93483..4de2cacafd 100644 --- a/selfdrive/ui/sunnypilot/layouts/settings/settings.py +++ b/selfdrive/ui/sunnypilot/layouts/settings/settings.py @@ -12,7 +12,7 @@ from openpilot.selfdrive.ui.layouts.settings import settings as OP from openpilot.selfdrive.ui.layouts.settings.developer import DeveloperLayout from openpilot.selfdrive.ui.sunnypilot.layouts.settings.device import DeviceLayoutSP from openpilot.selfdrive.ui.layouts.settings.firehose import FirehoseLayout -from openpilot.selfdrive.ui.layouts.settings.software import SoftwareLayout +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.software import SoftwareLayoutSP from openpilot.selfdrive.ui.layouts.settings.toggles import TogglesLayout from openpilot.system.ui.lib.application import gui_app, MousePos from openpilot.system.ui.lib.multilang import tr_noop @@ -114,7 +114,7 @@ class SettingsLayoutSP(OP.SettingsLayout): OP.PanelType.NETWORK: PanelInfo(tr_noop("Network"), NetworkUISP(wifi_manager), icon="icons/network.png"), OP.PanelType.SUNNYLINK: PanelInfo(tr_noop("sunnylink"), SunnylinkLayout(), icon="icons/wifi_strength_full.png"), OP.PanelType.TOGGLES: PanelInfo(tr_noop("Toggles"), TogglesLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_toggle.png"), - OP.PanelType.SOFTWARE: PanelInfo(tr_noop("Software"), SoftwareLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_software.png"), + OP.PanelType.SOFTWARE: PanelInfo(tr_noop("Software"), SoftwareLayoutSP(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_software.png"), OP.PanelType.MODELS: PanelInfo(tr_noop("Models"), ModelsLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_models.png"), OP.PanelType.STEERING: PanelInfo(tr_noop("Steering"), SteeringLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_lateral.png"), OP.PanelType.CRUISE: PanelInfo(tr_noop("Cruise"), CruiseLayout(), icon="icons/speed_limit.png"), diff --git a/selfdrive/ui/sunnypilot/layouts/settings/software.py b/selfdrive/ui/sunnypilot/layouts/settings/software.py new file mode 100644 index 0000000000..b890dd5b5b --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/software.py @@ -0,0 +1,96 @@ +""" +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 os + +from openpilot.selfdrive.ui.layouts.settings.software import SoftwareLayout +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.hardware import HARDWARE +from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.lib.multilang import tr, tr_noop +from openpilot.system.ui.widgets import DialogResult +from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog + +from openpilot.system.ui.sunnypilot.widgets.list_view import toggle_item_sp +from openpilot.system.ui.sunnypilot.widgets.tree_dialog import TreeOptionDialog, TreeNode, TreeFolder + + +DESCRIPTIONS = { + 'disable_updates_offroad': tr_noop( + "When enabled, automatic software updates will be off.
This requires a reboot to take effect." + ), + 'disable_updates_onroad': tr_noop( + "Please enable \"Always Offroad\" mode or turn off the vehicle to adjust these toggles." + ) +} + + +class SoftwareLayoutSP(SoftwareLayout): + def __init__(self): + super().__init__() + self.disable_updates_toggle = toggle_item_sp( + lambda: tr("Disable Updates"), + description="", + initial_state=ui_state.params.get_bool("DisableUpdates"), + callback=self._on_disable_updates_toggled, + ) + self._scroller.add_widget(self.disable_updates_toggle) + + def _handle_reboot(self, result): + if result == DialogResult.CONFIRM: + ui_state.params.put_bool("DisableUpdates", self.disable_updates_toggle.action_item.get_state()) + ui_state.params.put_bool("DoReboot", True) + else: + self.disable_updates_toggle.action_item.set_state(ui_state.params.get_bool("DisableUpdates")) + + def _on_disable_updates_toggled(self, enabled): + dialog = ConfirmDialog(tr("System reboot required for changes to take effect. Reboot now?"), tr("Reboot")) + gui_app.set_modal_overlay(dialog, callback=self._handle_reboot) + + def _on_select_branch(self): + current_git_branch = ui_state.params.get("GitBranch") or "" + branches_str = ui_state.params.get("UpdaterAvailableBranches") or "" + branches = [b for b in branches_str.split(",") if b] + current_target = ui_state.params.get("UpdaterTargetBranch") or "" + top_level_branches = [current_git_branch, "release-mici", "release-tizi", "staging", "dev", "master"] + + if HARDWARE.get_device_type() == "tici": + top_level_branches = ["release-tici", "staging-tici"] + branches = [b for b in branches if b.endswith("-tici")] + + top_level_nodes = [TreeNode(b, {'display_name': b}) for b in top_level_branches if b in branches] + remaining_branches = [b for b in branches if b not in top_level_branches] + prebuilt_nodes = [TreeNode(b, {'display_name': b}) for b in remaining_branches if b.endswith("-prebuilt")] + non_prebuilt_nodes = [TreeNode(b, {'display_name': b}) for b in remaining_branches if not b.endswith("-prebuilt")] + + folders = [ + TreeFolder("", top_level_nodes), + TreeFolder("Prebuilt Branches", prebuilt_nodes), + TreeFolder("Non-Prebuilt Branches", non_prebuilt_nodes), + ] + + def _on_branch_selected(result): + if result == DialogResult.CONFIRM and self._branch_dialog is not None: + selection = self._branch_dialog.selection_ref + if selection: + ui_state.params.put("UpdaterTargetBranch", selection) + self._branch_btn.action_item.set_value(selection) + os.system("pkill -SIGUSR1 -f system.updated.updated") + self._branch_dialog = None + + self._branch_dialog = TreeOptionDialog(tr("Select a branch"), folders, current_target, "", + on_exit=_on_branch_selected) + + gui_app.set_modal_overlay(self._branch_dialog, callback=_on_branch_selected) + + def _update_state(self): + super()._update_state() + show_advanced = ui_state.params.get_bool("ShowAdvancedControls") + self.disable_updates_toggle.action_item.set_enabled(ui_state.is_offroad()) + self.disable_updates_toggle.set_visible(show_advanced) + + disable_updates_desc = tr(DESCRIPTIONS["disable_updates_offroad"] if ui_state.is_offroad() else DESCRIPTIONS["disable_updates_onroad"]) + self.disable_updates_toggle.set_description(disable_updates_desc)