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
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/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/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/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..4de2cacafd 100644
--- a/selfdrive/ui/sunnypilot/layouts/settings/settings.py
+++ b/selfdrive/ui/sunnypilot/layouts/settings/settings.py
@@ -12,17 +12,17 @@ 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
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,10 +111,10 @@ 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.SUNNYLINK: PanelInfo(tr_noop("sunnylink"), SunnylinkLayout(), icon="icons/shell.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/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)
diff --git a/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py b/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
index b1e12c17ab..316d4d2240 100644
--- a/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
+++ b/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
@@ -4,27 +4,340 @@ 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)
+
+ 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.action_item.set_enabled(self._sunnylink_enabled)
+
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/sunnypilot/layouts/settings/vehicle/__init__.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/__init__.py
index 23b861f8c3..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,34 @@ 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):
self._scroller.render(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)
diff --git a/selfdrive/ui/sunnypilot/ui_state.py b/selfdrive/ui/sunnypilot/ui_state.py
index 18e35508b7..af625ee619 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:
@@ -13,11 +14,13 @@ 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()
+
def update(self) -> None:
- pass
+ self.sunnylink_state.start()
def update_params(self) -> None:
CP_SP_bytes = self.params.get("CarParamsSPPersistent")
diff --git a/selfdrive/ui/tests/test_ui/raylib_screenshots.py b/selfdrive/ui/tests/test_ui/raylib_screenshots.py
index e64398d227..e209ab8060 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):
@@ -364,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/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()
diff --git a/sunnypilot/sunnylink/sunnylink_state.py b/sunnypilot/sunnylink/sunnylink_state.py
new file mode 100644
index 0000000000..13b5ad81ec
--- /dev/null
+++ b/sunnypilot/sunnylink/sunnylink_state.py
@@ -0,0 +1,221 @@
+"""
+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
+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):
+ 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 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()
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"
diff --git a/system/ui/sunnypilot/lib/styles.py b/system/ui/sunnypilot/lib/styles.py
index 68e68d41a9..3e1d2dc408 100644
--- a/system/ui/sunnypilot/lib/styles.py
+++ b/system/ui/sunnypilot/lib/styles.py
@@ -17,8 +17,11 @@ 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
+ TEXT_PADDING = 20
+
# Toggle Control
TOGGLE_HEIGHT = 120
TOGGLE_WIDTH = int(TOGGLE_HEIGHT * 1.75)
@@ -69,6 +72,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/list_view.py b/system/ui/sunnypilot/widgets/list_view.py
index f05de81397..ac646df5bf 100644
--- a/system/ui/sunnypilot/widgets/list_view.py
+++ b/system/ui/sunnypilot/widgets/list_view.py
@@ -8,10 +8,12 @@ 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.list_view import ListItem, ToggleAction, ItemAction, MultipleButtonAction, _resolve_value
+from openpilot.system.ui.widgets.label import gui_label
+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
@@ -23,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):
@@ -86,6 +116,19 @@ 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)
+ self._right_value_color: rl.Color = style.ITEM_TEXT_VALUE_COLOR
+
+ 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:
+ 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)
@@ -115,17 +158,19 @@ 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
+ 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)
if isinstance(self.action_item, ToggleAction):
action_x = item_rect.x
else:
- action_x = item_rect.x + item_rect.width - action_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:
@@ -154,6 +199,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=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
if self.action_item.render(left_rect) and self.action_item.enabled:
if self.callback:
@@ -211,3 +269,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/sunnypilot/widgets/sunnylink_pairing_dialog.py b/system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py
new file mode 100644
index 0000000000..af6b9cf45d
--- /dev/null
+++ b/system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py
@@ -0,0 +1,139 @@
+"""
+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
+ self._is_paired_prev = ui_state.sunnylink_state.is_paired()
+
+ 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):
+ 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:
+ 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
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:
diff --git a/system/ui/sunnypilot/widgets/tree_dialog.py b/system/ui/sunnypilot/widgets/tree_dialog.py
index fd2a576d58..2dd5e3a2dc 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
@@ -99,6 +99,24 @@ 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.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()
@@ -141,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:
@@ -151,10 +180,15 @@ 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),
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
@@ -183,9 +217,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 +268,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)
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