Compare commits

...

67 Commits

Author SHA1 Message Date
James Vecellio-Grant
3375277b3c Merge branch 'watcher' into tools 2025-12-11 06:54:46 -08:00
James Vecellio-Grant
6e06bcb14f Merge branch 'paramwatcher' into watcher 2025-12-11 06:54:40 -08:00
discountchubbs
bc07cecfa0 buffer 2025-12-11 06:54:17 -08:00
discountchubbs
de30e9a3cf shebang 2025-12-11 06:51:34 -08:00
discountchubbs
c599542dfa sunnypilot tools: param profiler 2025-12-11 06:50:27 -08:00
discountchubbs
b737989e64 not needed 2025-12-08 05:54:50 -08:00
James Vecellio-Grant
5fbc358fd5 Merge branch 'paramwatcher' into watcher 2025-12-08 05:54:18 -08:00
discountchubbs
ce30d815f7 except "." 2025-12-08 05:53:52 -08:00
James Vecellio
fdde1aa6a1 this is hindsight 2025-12-07 19:26:36 -08:00
discountchubbs
961b2a2d30 drive back the darkness 2025-12-07 18:20:50 -08:00
discountchubbs
f3d39d481a wat 2025-12-07 17:13:58 -08:00
James Vecellio-Grant
6e037d80ff Merge branch 'paramwatcher' into watcher 2025-12-07 17:13:06 -08:00
discountchubbs
907bc5cf06 pyzmq considered... rejected.
I created a service handler in pyzmq and memory tested and it was in the MegaBytes, comparable to direct Params access, fudge that.
2025-12-07 17:12:39 -08:00
James Vecellio-Grant
b3ff268f89 Merge branch 'paramwatcher' into watcher 2025-12-07 14:05:37 -08:00
discountchubbs
42e08515e6 make the link go to LxCx 2025-12-07 12:22:15 -08:00
discountchubbs
d0ec46dc5d cp 2025-12-07 12:14:54 -08:00
discountchubbs
48a8802298 cp analysis needs high level 2025-12-07 12:12:43 -08:00
discountchubbs
79971b9eb2 move limitations to end 2025-12-07 12:11:49 -08:00
discountchubbs
7ba21f9f1b 2025 2025-12-07 11:57:27 -08:00
discountchubbs
6b4118ab27 dates 2025-12-07 11:55:27 -08:00
discountchubbs
0844424ad1 update 2025-12-07 11:47:14 -08:00
James Vecellio-Grant
5901c9b41f Merge branch 'paramwatcher' into watcher 2025-12-07 11:45:20 -08:00
discountchubbs
d52ce19c15 update readme 2025-12-07 11:44:34 -08:00
discountchubbs
05cc9a14e2 reset thread 2025-12-07 11:33:43 -08:00
discountchubbs
18f8956e0e params 2025-12-07 11:23:49 -08:00
discountchubbs
0aa6f22c26 watch 2025-12-07 11:15:58 -08:00
James Vecellio-Grant
c90f262ce7 Merge branch 'paramwatcher' into watcher 2025-12-07 11:08:48 -08:00
discountchubbs
e8ee5a23f0 my little soda pop 2025-12-07 11:08:16 -08:00
discountchubbs
4a189f828a oopsie 2025-12-07 10:13:36 -08:00
James Vecellio-Grant
072e18faef Merge branch 'paramwatcher' into watcher 2025-12-07 10:12:56 -08:00
James Vecellio
3b1fddfde9 60% comparison for slow computers 2025-12-07 10:12:14 -08:00
discountchubbs
bddec6971e debounce 2025-12-07 10:06:23 -08:00
discountchubbs
34e02b6ae5 Revert "Merge remote-tracking branch 'origin/paramwatcher' into watcher"
This reverts commit c98cc5d40a, reversing
changes made to 4a0d8063e5.
2025-12-07 10:03:04 -08:00
discountchubbs
c98cc5d40a Merge remote-tracking branch 'origin/paramwatcher' into watcher 2025-12-07 10:02:55 -08:00
discountchubbs
4a0d8063e5 Merge remote-tracking branch 'origin/paramwatcher' into watcher 2025-12-07 10:02:41 -08:00
discountchubbs
e2e52bcccb Merge remote-tracking branch 'origin/paramwatcher' into paramwatcher 2025-12-07 09:59:57 -08:00
discountchubbs
ccf86b7b72 cb 2025-12-07 09:59:47 -08:00
James Vecellio-Grant
483894cfc8 Merge branch 'master' into watcher 2025-12-07 09:59:04 -08:00
James Vecellio-Grant
a678554122 Merge branch 'master' into paramwatcher 2025-12-07 09:58:35 -08:00
discountchubbs
bfd3eab260 rm 2025-12-07 09:56:45 -08:00
discountchubbs
f5aedbce6e unit/int tests 2025-12-07 09:45:18 -08:00
discountchubbs
4f860dd397 Merge remote-tracking branch 'origin/watcher' into watcher 2025-12-07 08:10:59 -08:00
discountchubbs
f308d9ab17 markdown 2025-12-07 08:10:50 -08:00
James Vecellio-Grant
323b793a83 ui: software panel (#1518)
* param to control stock vs sp ui

* init styles

* SP Toggles

* Lint

* optimizations

* sp raylib preview

* fix callback

* fix ui preview

* dialog txt

* compare vs what used to be done before InputDialog

* introducing ui_state_sp for py

* raylib: input dialog

* raylib: SP Toggles

* raylib: SP Panels

* raylib: Option Control

* init

* param to control stock vs sp ui

* better

* tree dialog, progress bar widget cool stuff

* merge origin raylib toggles

* tree dialog

* less trees for the planet

* the heck

* add ui_update callback

* save the trees we got icons

* Update process.py

* yesssssssssss

* utilize ON_COLOR constant form op system

* Revert "add ui_update callback"

This reverts commit 4da32cc009.

* # Conflicts:
#	system/ui/sunnypilot/widgets/list_view.py
#	system/ui/sunnypilot/widgets/option_control.py

* Merge remote-tracking branch 'openpilot/master' into nov-19-sync

* ui: `GuiApplicationExt`

* add to readme

* scroller_tici :)

* use gui_app.sunnypilot_ui()

* # Conflicts:
#	selfdrive/ui/layouts/main.py
#	selfdrive/ui/sunnypilot/layouts/settings/cruise.py
#	selfdrive/ui/sunnypilot/layouts/settings/display.py
#	selfdrive/ui/sunnypilot/layouts/settings/models.py
#	selfdrive/ui/sunnypilot/layouts/settings/navigation.py
#	selfdrive/ui/sunnypilot/layouts/settings/osm.py
#	selfdrive/ui/sunnypilot/layouts/settings/steering.py
#	selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
#	selfdrive/ui/sunnypilot/layouts/settings/trips.py
#	selfdrive/ui/sunnypilot/layouts/settings/vehicle.py
#	selfdrive/ui/sunnypilot/layouts/settings/visuals.py
#	system/ui/sunnypilot/widgets/option_control.py

* init value

* Remove 'sunnypilot_ui' 

Removed 'sunnypilot_ui' parameter from params_keys.h

* Update raylib_screenshots.py

Removed the parameter setting for 'sunnypilot_ui' in the test.

* easier to see

* Update progress_bar.py

* try something

* adjust placement

* more simple

* smoothing updating components

* ui: fuzzy search helper

* ui_state_sp

* description!

* fuzzy af searching

* better tree. fully dynamic and stuff

* rm

* rearrange

* license

* idk how maybe the merge

* more indent

* more indent

* cleanup

* temporaily revert ui_state_sp

* only show if fav_param is used in the call

* conditional for mypy

* mypy

* conditional for mypy

* str concatenation to reduce line len

* level

* sunny's new x,y makes this even easier!

* refreshing half a second seems legit.

* software stuffs

* rm

* add

* loathing loathing, unadulterated loathing, i loathe it all

* loathing loathing, unadulterated loathing, i loathe it all

* # Conflicts:
#	system/ui/sunnypilot/lib/styles.py
#	system/ui/sunnypilot/widgets/tree_dialog.py

* search

* ds

* hide on advanced controls

* some

* handle toggle confirmation

* sunny, NO

* nayan, NO !!

* easier

* move

* move it!

* add more

* need to show current branch

---------

Co-authored-by: nayan <nayan8teen@gmail.com>
Co-authored-by: Jason Wen <haibin.wen3@gmail.com>
2025-12-07 01:36:02 -05:00
Jason Wen
96c2650ac4 ui: improve TreeOptionDialog node selection and item handling (#1563) 2025-12-07 01:31:00 -05:00
Jason Wen
1807b193fa ui: preserve and update current_ref in TreeOptionDialog init (#1562)
* ui: preserve and update `current_ref` in `TreeOptionDialog` init

* do it in init instead
2025-12-07 00:58:33 -05:00
James Vecellio-Grant
9226222ad4 Merge branch 'master' into watcher 2025-12-06 14:44:23 -08:00
discountchubbs
3e317a8b4d give me ALL the params access. limit sys reading by 500x 2025-12-06 14:43:45 -08:00
Nayan
5007437969 ui: enforce fixed dimensions for ToggleSP (#1558)
fix rect

Co-authored-by: Jason Wen <haibin.wen3@gmail.com>
2025-12-05 22:58:53 -05:00
Nayan
93c1c713a9 ui: toggle should only toggle toggle - don't toggle description with toggle. Yeah, that! (#1559)
@sunnyhaibin, please!

Co-authored-by: Jason Wen <haibin.wen3@gmail.com>
2025-12-05 22:52:41 -05:00
James Vecellio
0eae4e0b3b layout 2025-12-05 19:44:19 -08:00
James Vecellio
37ffa5ed21 layout 2025-12-05 19:44:14 -08:00
James Vecellio
05e3eaf2fc certified freak 2025-12-05 15:59:13 -08:00
Jason Wen
d382cd08e5 ui: adjust Sponsor and Pairing button behaviors in sunnylink panel (#1557)
ui: adjust sponsor and pairing button behaviors in sunnyLink panel
2025-12-05 16:49:29 -05:00
discountchubbs
c8fc344d68 watch this paramwatcher 2025-12-05 13:17:04 -08:00
James Vecellio-Grant
264948e5ff Update param_watcher.py 2025-12-05 08:55:02 -08:00
James Vecellio-Grant
9d87beac8e Merge branch 'master' into watcher 2025-12-05 08:52:18 -08:00
James Vecellio-Grant
2e0bc80f94 Update param_watcher.py 2025-12-05 08:50:31 -08:00
James Vecellio-Grant
4b8781886a Update ui_state.py 2025-12-05 08:43:08 -08:00
James Vecellio-Grant
57eca29970 ui: Models panel (#1495)
* param to control stock vs sp ui

* init styles

* SP Toggles

* Lint

* optimizations

* sp raylib preview

* fix callback

* fix ui preview

* dialog txt

* compare vs what used to be done before InputDialog

* introducing ui_state_sp for py

* raylib: input dialog

* raylib: SP Toggles

* raylib: SP Panels

* raylib: Option Control

* init

* param to control stock vs sp ui

* better

* tree dialog, progress bar widget cool stuff

* merge origin raylib toggles

* tree dialog

* less trees for the planet

* the heck

* add ui_update callback

* save the trees we got icons

* Update process.py

* yesssssssssss

* utilize ON_COLOR constant form op system

* Revert "add ui_update callback"

This reverts commit 4da32cc009.

* # Conflicts:
#	system/ui/sunnypilot/widgets/list_view.py
#	system/ui/sunnypilot/widgets/option_control.py

* Merge remote-tracking branch 'openpilot/master' into nov-19-sync

* ui: `GuiApplicationExt`

* add to readme

* scroller_tici :)

* use gui_app.sunnypilot_ui()

* # Conflicts:
#	selfdrive/ui/layouts/main.py
#	selfdrive/ui/sunnypilot/layouts/settings/cruise.py
#	selfdrive/ui/sunnypilot/layouts/settings/display.py
#	selfdrive/ui/sunnypilot/layouts/settings/models.py
#	selfdrive/ui/sunnypilot/layouts/settings/navigation.py
#	selfdrive/ui/sunnypilot/layouts/settings/osm.py
#	selfdrive/ui/sunnypilot/layouts/settings/steering.py
#	selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
#	selfdrive/ui/sunnypilot/layouts/settings/trips.py
#	selfdrive/ui/sunnypilot/layouts/settings/vehicle.py
#	selfdrive/ui/sunnypilot/layouts/settings/visuals.py
#	system/ui/sunnypilot/widgets/option_control.py

* init value

* Remove 'sunnypilot_ui' 

Removed 'sunnypilot_ui' parameter from params_keys.h

* Update raylib_screenshots.py

Removed the parameter setting for 'sunnypilot_ui' in the test.

* easier to see

* Update progress_bar.py

* try something

* adjust placement

* more simple

* smoothing updating components

* ui: fuzzy search helper

* ui_state_sp

* description!

* fuzzy af searching

* better tree. fully dynamic and stuff

* rm

* rearrange

* license

* idk how maybe the merge

* more indent

* more indent

* cleanup

* temporaily revert ui_state_sp

* only show if fav_param is used in the call

* conditional for mypy

* mypy

* conditional for mypy

* str concatenation to reduce line len

* level

* sunny's new x,y makes this even easier!

* refreshing half a second seems legit.

* loathing loathing, unadulterated loathing, i loathe it all

* loathing loathing, unadulterated loathing, i loathe it all

* # Conflicts:
#	system/ui/sunnypilot/lib/styles.py
#	system/ui/sunnypilot/widgets/tree_dialog.py

* Update models.py

* Change BUTTON_DISABLED_BG_COLOR to a lighter shade

* I think this is it

* Update tree_dialog.py

* Update models.py

* Update models.py

* oops, angry f string

* bool

* Update ui_state.py

* Update ui_state.py

* wtf where'd the end quote go lol

* some

* more

* quick test

* Revert "quick test"

This reverts commit fb97afa54c.

* try this out

* use sp's

* cap

* how weird?

---------

Co-authored-by: nayan <nayan8teen@gmail.com>
Co-authored-by: Jason Wen <haibin.wen3@gmail.com>
2025-12-05 03:14:53 -05:00
Nayan
1d9bda65fe ui: sunnylink panel (#1494)
* param to control stock vs sp ui

* init styles

* SP Toggles

* Lint

* optimizations

* sp raylib preview

* fix callback

* fix ui preview

* sunnylink state

* introducing ui_state_sp for py

* poll from ui_state_sp

* cloudlog & ruff

* param to control stock vs sp ui

* better

* better padding

* this

* listitem -> listitemsp

* add show_description method

* remove padding from line separator.
like, WHY? 😩😩

* ui: `GuiApplicationExt`

* add to readme

* use gui_app.sunnypilot_ui()

* use gui_app.sunnypilot_ui()

* fetch only when connected to network

* sponsor & pairing qr

* init panel elements

* backup & restore

* fruit loops

* update

* enable, disable, enable, disable

* handle layout updates

* not needed

* change it up

* better

* scroller -> scroller_tici

* optimizations

* remove Params

* fix button disablement

* ui_state_sp changes

* keep enabled

* add header text

* dad jokes?

* no

* lint? Lint!

* final touches

* add sp font

* use sp font

* some

* ui: add right-aligned value display support in `ListItem` (in another pr)

* display sunnylink device id

* display sunnylink device id and sponsor tiers

* ui: add right-aligned value display support in `ListItemSP`

* lint

* styles

* lint

* ui: introduce customizable value colors for `ButtonActionSP` and `ListViewSP`

* support

* convert to str

* disable if paired

* colored sponsors

* hide and disable pairing button if paired

* texts

* ui: sunnypilot sponsor tier color mapping

* lint

* dongle id for ui preview

---------

Co-authored-by: Jason Wen <haibin.wen3@gmail.com>
Co-authored-by: James Vecellio-Grant <159560811+Discountchubbs@users.noreply.github.com>
2025-12-05 01:01:55 -05:00
Jason Wen
b5b170b65a ui: sunnypilot sponsor tier color mapping (#1556)
* ui: sunnypilot sponsor tier color mapping

* lint
2025-12-05 00:30:48 -05:00
Jason Wen
c6818bd07f ui: customizable value colors for ButtonActionSP and ListViewSP (#1555)
ui: introduce customizable value colors for `ButtonActionSP` and `ListViewSP`
2025-12-04 21:41:58 -05:00
Jason Wen
d75d80b885 ui: add right-aligned value display support in ListItemSP (#1554)
* ui: add right-aligned value display support in `ListItemSP`

* lint

* styles
2025-12-04 17:53:33 -05:00
James Vecellio-Grant
97edff5e5c Merge branch 'master' into watcher 2025-12-04 06:06:29 -08:00
Nayan
1f967668a5 ui: add sunnypilot font (#1552)
add sp font
2025-12-04 01:51:08 -05:00
discountchubbs
a81570a6c2 file system watcher 👀 2025-12-03 22:23:37 -08:00
33 changed files with 1728 additions and 40 deletions

Binary file not shown.

View File

@@ -1,4 +1,3 @@
from openpilot.common.params import Params
from openpilot.selfdrive.ui.widgets.ssh_key import ssh_key_item
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.system.ui.widgets import Widget
@@ -35,7 +34,7 @@ DESCRIPTIONS = {
class DeveloperLayout(Widget):
def __init__(self):
super().__init__()
self._params = Params()
self._params = ui_state.params
self._is_release = self._params.get_bool("IsReleaseBranch")
# Build items and keep references for callbacks/state updates

View File

@@ -3,7 +3,6 @@ import math
from cereal import messaging, log
from openpilot.common.basedir import BASEDIR
from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog
from openpilot.selfdrive.ui.onroad.driver_camera_dialog import DriverCameraDialog
from openpilot.selfdrive.ui.ui_state import ui_state
@@ -19,6 +18,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."),
@@ -32,7 +34,7 @@ class DeviceLayout(Widget):
def __init__(self):
super().__init__()
self._params = Params()
self._params = ui_state.params
self._select_language_dialog: MultiOptionDialog | None = None
self._driver_camera: DriverCameraDialog | None = None
self._pair_device_dialog: PairingDialog | None = None

View File

@@ -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

View File

@@ -1,5 +1,5 @@
from cereal import log
from openpilot.common.params import Params, UnknownKeyName
from openpilot.common.params import UnknownKeyName
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.list_view import multiple_button_item, toggle_item
from openpilot.system.ui.widgets.scroller_tici import Scroller
@@ -41,7 +41,7 @@ DESCRIPTIONS = {
class TogglesLayout(Widget):
def __init__(self):
super().__init__()
self._params = Params()
self._params = ui_state.params
self._is_release = self._params.get_bool("IsReleaseBranch")
# param, title, desc, icon, needs_restart
@@ -198,11 +198,6 @@ class TogglesLayout(Widget):
self._update_experimental_mode_icon()
# TODO: make a param control list item so we don't need to manage internal state as much here
# refresh toggles from params to mirror external changes
for param in self._toggle_defs:
self._toggles[param].action_item.set_state(self._params.get_bool(param))
# these toggles need restart, block while engaged
for toggle_def in self._toggle_defs:
if self._toggle_defs[toggle_def][3] and toggle_def not in self._locked_toggles:

View File

@@ -4,24 +4,250 @@ 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"<br>{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"<br>{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_value_control.set_visible(turn_desire and advanced_controls)
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)

View File

@@ -12,7 +12,7 @@ from openpilot.selfdrive.ui.layouts.settings import settings as OP
from openpilot.selfdrive.ui.layouts.settings.developer import DeveloperLayout
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.device import DeviceLayoutSP
from openpilot.selfdrive.ui.layouts.settings.firehose import FirehoseLayout
from openpilot.selfdrive.ui.layouts.settings.software import SoftwareLayout
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.software import SoftwareLayoutSP
from openpilot.selfdrive.ui.layouts.settings.toggles import TogglesLayout
from openpilot.system.ui.lib.application import gui_app, MousePos
from openpilot.system.ui.lib.multilang import tr_noop
@@ -31,6 +31,7 @@ from openpilot.selfdrive.ui.sunnypilot.layouts.settings.steering import Steering
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.cruise import CruiseLayout
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.visuals import VisualsLayout
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.display import DisplayLayout
from openpilot.selfdrive.ui.ui_state import ui_state
# from openpilot.selfdrive.ui.sunnypilot.layouts.settings.navigation import NavigationLayout
@@ -112,9 +113,9 @@ class SettingsLayoutSP(OP.SettingsLayout):
self._panels = {
OP.PanelType.DEVICE: PanelInfo(tr_noop("Device"), DeviceLayoutSP(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_home.png"),
OP.PanelType.NETWORK: PanelInfo(tr_noop("Network"), NetworkUISP(wifi_manager), icon="icons/network.png"),
OP.PanelType.SUNNYLINK: PanelInfo(tr_noop("sunnylink"), SunnylinkLayout(), icon="icons/shell.png"),
OP.PanelType.SUNNYLINK: PanelInfo(tr_noop("sunnylink"), SunnylinkLayout(), icon="icons/wifi_strength_full.png"),
OP.PanelType.TOGGLES: PanelInfo(tr_noop("Toggles"), TogglesLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_toggle.png"),
OP.PanelType.SOFTWARE: PanelInfo(tr_noop("Software"), SoftwareLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_software.png"),
OP.PanelType.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"),
@@ -196,6 +197,10 @@ class SettingsLayoutSP(OP.SettingsLayout):
return False
def set_current_panel(self, panel_type: OP.PanelType):
super().set_current_panel(panel_type)
ui_state.set_active_layout(self._panels[self._current_panel].instance)
def show_event(self):
super().show_event()
self._panels[self._current_panel].instance.show_event()

View File

@@ -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.<br><b>This requires a reboot to take effect.</b>"
),
'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)

View File

@@ -4,27 +4,339 @@ 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_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)

View File

@@ -55,5 +55,4 @@ class HyundaiSettings(BrandSettings):
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)

View File

@@ -0,0 +1,41 @@
"""
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.
"""
def update_item_from_param(item, key, params):
if not (action := getattr(item, 'action_item', None)):
return
if hasattr(action, 'set_state'):
action.set_state(params.get_bool(key))
elif hasattr(action, 'set_value'):
action.set_value(params.get(key, return_default=True))
else:
try:
val = int(params.get(key, return_default=True))
if hasattr(action, 'selected_button'):
action.selected_button = val
if hasattr(action, 'current_value'):
action.current_value = val
except (ValueError, TypeError):
pass
def sync_layout_params(layout, param_name, params):
targets = []
if toggles := getattr(layout, '_toggles', None):
targets.extend([(item, k) for k, item in toggles.items()])
items = getattr(layout, 'items', []) or getattr(getattr(layout, '_scroller', None), '_items', [])
for item in items:
action = getattr(item, 'action_item', None)
if key := getattr(action, 'param_key', None) or getattr(getattr(action, 'toggle', None), 'param_key', None):
targets.append((item, key))
for item, key in targets:
if param_name is None or key == param_name:
update_item_from_param(item, key, params)

View File

@@ -5,23 +5,49 @@ 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 cereal import messaging, custom
from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog
from openpilot.sunnypilot.common.params import Params
from openpilot.sunnypilot.sunnylink.sunnylink_state import SunnylinkState
from openpilot.selfdrive.ui.sunnypilot.ui_helpers import sync_layout_params
class UIStateSP:
def __init__(self):
self.params = Params()
self.params.add_watcher(self.on_param_change)
self.params.start()
self.sm_services_ext = [
"modelManagerSP", "selfdriveStateSP", "longitudinalPlanSP", "backupManagerSP",
"gpsLocation", "liveTorqueParameters", "carStateSP", "liveMapDataSP", "carParamsSP"
"gpsLocation", "liveTorqueParameters", "carStateSP", "liveMapDataSP", "carParamsSP", "liveDelay"
]
self.sunnylink_state = SunnylinkState()
self.active_layout = None
self.changed_params = set()
def set_active_layout(self, layout):
self.active_layout = layout
if layout:
sync_layout_params(layout, None, self.params)
def on_param_change(self, param_name):
self.changed_params.add(param_name)
def update(self) -> None:
self.sunnylink_state.start()
if not self.params.is_watching():
cloudlog.warning("ParamWatcher thread died, restarting...")
self.params.start()
if self.changed_params:
while self.changed_params:
self.changed_params.pop()
if self.active_layout:
sync_layout_params(self.active_layout, None, self.params)
def update_params(self) -> None:
CP_SP_bytes = self.params.get("CarParamsSPPersistent")
if CP_SP_bytes is not None:

View File

@@ -369,6 +369,7 @@ def create_screenshots():
with OpenpilotPrefix():
params = Params()
params.put("DongleId", "123456789012345")
params.put("SunnylinkDongleId", "123456789012345")
# Set branch name
params.put("UpdaterCurrentDescription", VERSION)

View File

@@ -6,7 +6,6 @@ from collections.abc import Callable
from enum import Enum
from cereal import messaging, car, log
from openpilot.common.filter_simple import FirstOrderFilter
from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog
from openpilot.selfdrive.ui.lib.prime_state import PrimeState
from openpilot.system.ui.lib.application import gui_app
@@ -34,7 +33,6 @@ class UIState(UIStateSP):
def _initialize(self):
UIStateSP.__init__(self)
self.params = Params()
self.sm = messaging.SubMaster(
[
"modelV2",

133
sunnypilot/common/README.md Normal file
View File

@@ -0,0 +1,133 @@
# Comparative Analysis of Parameter Access Methods: `Params::get` vs. `ParamWatcher`
## Inefficiencies in Standard Parameter Access
The standard `Params::get()` method executes a full file I/O lifecycle—opening, allocating, reading, and closing—for every function call. This approach results in significant CPU overhead and memory churn due to the frequency of these operations in the user interface loop.
### System Overhead Analysis
* **System Call Overhead**: Every read operation requires context switches into kernel mode. The `Params::get` function calls `util::read_file` (sunnypilot, 2025), which subsequently invokes `std::ifstream` (sunnypilot, 2025).
* **Impact**: Frequent context switching degrades performance (Linux man-pages, 2025 -a; Linux man-pages, 2025 -b).
* **C++ Stream Overhead**: The use of `std::ifstream` introduces additional overhead for maintaining stream state and buffering compared to raw file descriptors (cppreference.com, n.d.-a; Codezup, 2025).
* **Memory Churn**: The instantiation of `std::string result(size, '\0');` forces heap allocation and deallocation during every call (sunnypilot, 2025). This stresses the memory allocator and can lead to fragmentation (cppreference.com, n.d.).
## The ParamWatcher Optimization
The `ParamWatcher` implementation utilizes OS-level file system events, such as `inotify` on Linux or `FSEvents` on macOS, to maintain a Random Access Memory (RAM) cache. This architecture eliminates the need for continuous polling.
### Performance Comparison
| Feature | Standard `Params::get` | Optimized `ParamWatcher` |
| :--- | :--- | :--- |
| **Workflow** | `open``malloc``read``close` | `dict.get()` (RAM lookup) |
| **Complexity** | **O(N * F)** (Linear to toggles & FPS) | **O(1)** (Constant time) |
| **Disk I/O** | ~1,000 reads/sec (50 toggles @ 20FPS) | **0 reads/sec** (Steady state) |
| **Memory** | New string object per call (High GC pressure) | Returns reference (Zero GC pressure) |
## Architectural Mismatch of Standard Modules
Standard C++ modules like `std::ifstream` are optimized for **throughput**—reading large files sequentially—rather than **latency** required for polling small files frequently.
* **The I/O Trap**: Even when a file resides in the OS page cache (RAM), invoking `open()` and `read()` forces a CPU mode switch (User → Kernel → User). Executing this sequence 1,000 times per second consumes CPU cycles merely to verify state constancy.
* **The Memory Trap**: The `std::string` class allocates memory on the heap. Repeated allocation creates short-lived objects, which in C++ fragments memory. In Python (which wraps this), it triggers the Garbage Collector, pausing the UI.
* **The Query Mismatch**: `Params::get` queries the current value every frame, whereas `ParamWatcher` waits for a notification of change, serving cached values in the interim.
## Implementation Analysis
The `ParamWatcher` class provides a cross-platform solution for monitoring file system changes, specifically targeting the parameter files used in Openpilot. The implementation leverages the `ctypes` library to interface directly with operating system kernels, bypassing higher-level abstractions for maximum performance.
### Linux Implementation (`_run_linux`)
The Linux implementation interacts directly with the kernel's `inotify` subsystem (Linux man-pages, 2025 -c).
* **Library Loading**: `libc = ctypes.CDLL('libc.so.6')` loads the standard C library to access system calls.
* **Initialization**: `inotify_init()` is called to create a new inotify instance, returning a file descriptor.
* **Watch Setup**: `inotify_add_watch(fd, path, mask)` registers the parameters directory. The mask includes `IN_MODIFY | IN_CREATE | IN_DELETE | IN_MOVED_TO | IN_CLOSE_WRITE` (Linux Kernel Organization, 2005) to capture all relevant file changes.
* **Event Loop**:
* **Polling**: `select.epoll()` is used to efficiently wait for activity on the file descriptor without busy-waiting.
* **Reading**: When events occur, `os.read(fd, 1024)` retrieves the raw binary event data.
* **Parsing**: The code uses Python's `struct` module (`struct.unpack_from("iIII", ...)`) to parse the C-style `inotify_event` structures directly from the buffer, avoiding the overhead of defining `ctypes` structures.
* **Handling**: Extracted filenames are passed to `_trigger_callbacks`, which invalidates the specific cache entry (`self._cache.pop(path, None)`), forcing a fresh read on the next access.
### macOS Implementation (`_run_darwin`)
The macOS implementation uses the `FSEvents` API from the `CoreServices` framework (Apple Inc., n.d.-a), which is more efficient than `kqueue` for directory monitoring.
* **Framework Loading**: `ctypes.cdll.LoadLibrary` loads `CoreServices` and `CoreFoundation`.
* **Callback Definition**: `CFUNCTYPE` is used to define a C-compatible callback function. This function is invoked by the OS whenever a change occurs in the watched directory.
* **Stream Creation**: `FSEventStreamCreate` creates a stream for the target directory. The `kFSEventStreamCreateFlagFileEvents` flag is used to request file-level granularity where available.
* **Event Filtering**: The callback filters events using flags such as `kFSEventStreamEventFlagItemCreated` and `kFSEventStreamEventFlagItemModified` to ensure only relevant file changes trigger updates (Apple Inc., n.d.-c).
* **Scheduling**: `FSEventStreamScheduleWithRunLoop` attaches the stream to the current thread's run loop (Apple Inc., n.d.-b).
* **Execution**: `CFRunLoopRun()` starts the event loop. This passes control to the OS, which wakes the thread only when necessary.
* **Handling**: Inside the callback, the code iterates through the changed paths provided by the OS. It extracts the filename and calls `_trigger_callbacks` to invalidate the cache for that specific parameter.
### Python ctypes Integration
The use of `ctypes` (Python Software Foundation, 2025) is a strategic choice. It allows the Python interpreter to load shared libraries (`libc.so.6` on Linux, `CoreServices` on macOS) and call C functions directly. This approach avoids the overhead of spawning subprocesses or compiling external C extensions, keeping the codebase pure Python while achieving C-level system integration.
### Memory Impact Analysis
With 232 defined parameters in `param_keys.h`, the maximum static RAM footprint of `ParamWatcher` is estimated to be **less than 250 KB**. Even if every single parameter were cached simultaneously, this static usage is negligible. Importantly, this stable footprint is likely more probable to maintain no trend of memory increase, whenc compared to the standard `Params::get` approach, which generates **megabytes** of short-lived "garbage" allocations per second, forcing the Python Garbage Collector to pause execution repeatedly.
## Architectural Integration: The Process-Local Singleton Pattern
To ensure resource efficiency within openpilot's multi-process architecture (e.g., `ui`, `controlsd`, `modeld`), `ParamWatcher` implements the Singleton design pattern (Gamma et al., 1994) using the Python `__new__` allocator.
### Process Isolation and Concurrency
In the context of Python's memory model, a Singleton ensures a single instance exists *per process*. This behavior aligns with openpilot's multiprocess design:
* **Intra-Process Efficiency**: Within a single heavy process like `ui`, multiple sub-components (e.g., `UIState`, `SunnylinkState`) import and use `Params`. The Singleton pattern ensures they share a single `inotify` thread and a unified RAM cache. This prevents the proliferation of redundant watcher threads, which would otherwise compete for the Global Interpreter Lock (GIL).
* **Inter-Process Safety**: Distinct processes (e.g., `modeld` vs. `ui`) maintain completely isolated `ParamWatcher` instances. This isolation eliminates the need for complex Inter-Process Communication (IPC) locking mechanisms for the cache, as each process synchronizes its independent state via the OS file system events.
### Empirical Verification
Runtime analysis demonstrates that multiple instantiation attempts result in a shared object reference, minimizing memory footprint.
* **Test Case**: Instantiating `ParamWatcher` in `UIStateSP` and subsequently in a standalone script within the same process.
* **Result**: Both instances report the exact same memory address (`4814358960`) and share the same background thread ID (`6114635776`).
* **Impact**: The system incurs the overhead of the watcher thread (measured at < 0.1% CPU idle usage) only once per active process, regardless of import frequency. The average CPU usage across one minute was 0.002%.
## Limitations and Trade-offs
While `ParamWatcher` offers superior performance for UI rendering, it presents specific trade-offs:
* **Static RAM Usage**: `ParamWatcher` maintains a persistent dictionary cache of all accessed parameters (~50KB), whereas `Params::get` uses zero static RAM but incurs high dynamic memory access.
* **Event Latency**: In high-load scenarios, `inotify` events may experience slight delays or coalescing compared to direct reads. However, for user interface applications, this latency (<10ms) is imperceptible.
* **Complexity**: The solution (the process singleton approach) requires managing a background thread and OS-specific event loops, increasing code complexity compared to the synchronous `Params::get` function.
## Alternative Architecture Considered: ZeroMQ Service (ZMQ)
During the development of `ParamWatcher`, a Client-Server architecture using ZMQ was evaluated. In this architecture, a single background service process would monitor file system events and publish changes over a ZMQ PUB socket to multiple client processes (SUB).
### Trade-off Analysis
| Metric | In-Process (Current) | ZMQ Service (Rejected) |
| :--- | :--- |:------------------------------------------------------|
| **Memory Usage** | Low (1 thread/process) | High (1 full Python process + ZMQ buffers per client) |
| **CPU Usage** | Low (Direct callback) | High (Serialization + TCP Stack + Deserialization) |
| **Latency** | Instant (<0.1ms) | Variable (TCP Loopback overhead) |
| **Scalability** | Limited by OS file handles | Limited by TCP ports/buffers |
| **Robustness** | Process-isolated failure | Single point of failure (Service crash affects all) |
### Decision Rationale
While the ZMQ approach offers better isolation and reduces the total number of OS file watchers (1 vs N), the overhead of inter-process communication (IPC) proved excessive for this use case.
* **Efficiency**: Even with 50+ processes, the memory footprint of 50 simple threads is significantly lower than the overhead of a dedicated Python service process plus the ZMQ context in every client.
* **Complexity**: The ZMQ architecture introduced synchronization challenges (e.g., service startup race conditions, "Address already in use" errors) that outweighed its benefits.
* **Performance**: The latency of serializing messages and passing them through the TCP stack is orders of magnitude higher than a direct function call within the same process memory space.
## Conclusion
Replacing polling mechanisms with event-driven caching shifts the computational load from kernel space (syscalls) to user space (RAM). This transition eliminates I/O overhead and UI stutters caused by garbage collection, resulting in a more responsive user experience. The In-Process Singleton approach was selected as the optimal balance between performance, complexity, and resource efficiency.
## References
Apple Inc. (n.d.-a). *File System Events*. Retrieved from https://developer.apple.com/documentation/coreservices/file_system_events
Apple Inc. (n.d.-b). *CFRunLoop*. Retrieved from https://developer.apple.com/documentation/corefoundation/cfrunloop
Apple Inc. (n.d.-c). *FSEventStreamEventFlags*. Retrieved from https://developer.apple.com/documentation/coreservices/1455361-fseventstreameventflags
Codezup. (2025). *Efficient File I/O in C++*. Retrieved from https://codezup.com/efficient-file-io-cpp-best-practices/
cppreference.com. (n.d.-a). *std::basic_ifstream*. Retrieved from https://en.cppreference.com/w/cpp/io/basic_ifstream
cppreference.com. (n.d.-b). *std::basic_string*. Retrieved from https://en.cppreference.com/w/cpp/string/basic_string/basic_string
Linux man-pages. (2025 -a). *open(2)*. Retrieved from https://man7.org/linux/man-pages/man2/open.2.html
Linux man-pages. (2025-b). *read(2)*. Retrieved from https://man7.org/linux/man-pages/man2/read.2.html
Linux man-pages. (2025 -c). *inotify(7)*. Retrieved from https://man7.org/linux/man-pages/man7/inotify.7.html
Linux Kernel Organization. (2005). *include/uapi/linux/inotify.h*. Retrieved from https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/include/uapi/linux/inotify.h
Python Software Foundation. (2025). *ctypes — A foreign function library for Python*. Retrieved from https://docs.python.org/3/library/ctypes.html
Gamma, E., Helm, R., Johnson, R., & Vlissides, J. (1994). *Design Patterns: Elements of Reusable Object-Oriented Software*. Addison-Wesley.
sunnypilot. (2025). *common/params.cc* [Source code]. GitHub. https://github.com/sunnypilot/sunnypilot/blob/master/common/params.cc#L180C1-L206C2
sunnypilot. (2025). *common/util.cc* [Source code]. GitHub. https://github.com/sunnypilot/sunnypilot/blob/master/common/util.cc#L79C1-L117C2

View File

View File

@@ -0,0 +1,193 @@
"""
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
import platform
import struct
import select
import threading
import time
import ctypes
import ctypes.util
import traceback
from ctypes import c_void_p, c_size_t, POINTER, c_uint32, c_uint64
from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog
from openpilot.system.hardware.hw import Paths
IN_MODIFY = 0x00000002
IN_CREATE = 0x00000100
IN_DELETE = 0x00000200
IN_MOVED_TO = 0x00000080
IN_CLOSE_WRITE = 0x00000008
class ParamWatcher(Params):
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self):
if self._initialized:
return
super().__init__()
cloudlog.warning("ParamWatcher initialized")
self._cache = {}
self._last_trigger = {}
self._version = {}
self._lock = threading.Lock()
self._callbacks = []
self.last_accessed_param = None
self._initialized = True
self.start()
def start(self):
if getattr(self, '_thread', None) and self._thread.is_alive():
return
self._thread = threading.Thread(target=self._run_watcher, daemon=True)
self._thread.start()
def is_watching(self):
return getattr(self, '_thread', None) and self._thread.is_alive()
def add_watcher(self, callback):
if callback not in self._callbacks:
self._callbacks.append(callback)
def _trigger_callbacks(self, path):
with self._lock:
if (now := time.monotonic()) - self._last_trigger.get(path, 0) < 0.1:
return
self._last_trigger[path] = now
self._version[path] = self._version.get(path, 0) + 1
self._cache.pop(path, None)
for callback in self._callbacks:
try:
callback(path)
except Exception:
cloudlog.exception("Param watcher callback failed")
def _get_cached(self, key, getter, sig):
k = str(key)
with self._lock:
bucket = self._cache.get(k)
if bucket and sig in bucket:
if bucket[sig][0] == self._version.get(k, 0):
return bucket[sig][1]
start_ver = self._version.get(k, 0)
val = getter()
with self._lock:
if self._version.get(k, 0) != start_ver:
val = getter()
self._cache.setdefault(k, {})[sig] = (self._version.get(k, 0), val)
return val
def get(self, key, block=False, return_default=False):
self.last_accessed_param = key
if block:
return super().get(key, block, return_default)
fetcher = super().get
return self._get_cached(key, lambda: fetcher(key, block, return_default), (block, return_default))
def get_bool(self, key, block=False):
self.last_accessed_param = key
if block:
return super().get_bool(key, block)
fetcher = super().get_bool
return self._get_cached(key, lambda: fetcher(key, block), ("bool", block))
def _run_watcher(self):
system = platform.system()
while True:
try:
if system == "Linux":
self._run_linux()
elif system == "Darwin":
self._run_darwin()
except Exception:
cloudlog.exception("Param watcher crashed")
time.sleep(2)
def _run_linux(self):
path = Paths.params_root()
libc = ctypes.CDLL('libc.so.6')
fd = libc.inotify_init()
libc.inotify_add_watch(fd, path.encode(), IN_MODIFY | IN_CREATE | IN_DELETE | IN_MOVED_TO | IN_CLOSE_WRITE)
try:
poll = select.epoll()
poll.register(fd, select.EPOLLIN)
while True:
for fileno, _ in poll.poll():
if fileno == fd:
buffer = os.read(fd, 2048)
i = 0
while i + 16 <= len(buffer):
_, mask, _, name_len = struct.unpack_from("iIII", buffer, i)
if mask & (IN_MODIFY | IN_CREATE | IN_DELETE | IN_MOVED_TO | IN_CLOSE_WRITE):
name = buffer[i+16:i+16+name_len].rstrip(b"\0").decode()
if not name.startswith("."):
self._trigger_callbacks(name)
i += 16 + name_len
finally:
if 'poll' in locals():
poll.unregister(fd)
poll.close()
os.close(fd)
def _run_darwin(self):
CS = ctypes.cdll.LoadLibrary(ctypes.util.find_library("CoreServices"))
CF = ctypes.cdll.LoadLibrary(ctypes.util.find_library("CoreFoundation"))
kCFAllocatorDefault = c_void_p(0)
kCFStringEncodingUTF8 = 0x08000100
kFSEventStreamCreateFlagFileEvents = 0x00000010
kFSEventStreamEventFlagItemCreated = 0x00000100
kFSEventStreamEventFlagItemRemoved = 0x00000200
kFSEventStreamEventFlagItemRenamed = 0x00000800
kFSEventStreamEventFlagItemModified = 0x00001000
CF.CFStringCreateWithCString.restype = c_void_p
CF.CFStringCreateWithCString.argtypes = [c_void_p, ctypes.c_char_p, c_uint32]
CF.CFArrayCreate.restype = c_void_p
CF.CFArrayCreate.argtypes = [c_void_p, POINTER(c_void_p), c_size_t, c_void_p]
CS.FSEventStreamCreate.restype = c_void_p
CS.FSEventStreamCreate.argtypes = [c_void_p, c_void_p, c_void_p, c_void_p, c_uint64, ctypes.c_double, c_uint32]
CS.FSEventStreamScheduleWithRunLoop.argtypes = [c_void_p, c_void_p, c_void_p]
CS.FSEventStreamStart.argtypes = [c_void_p]
CF.CFRunLoopGetCurrent.restype = c_void_p
def _cb(stream, ctx, num, paths, flags, ids):
try:
paths_arr = ctypes.cast(paths, POINTER(c_void_p))
flags_arr = ctypes.cast(flags, POINTER(c_uint32))
for i in range(num):
path = ctypes.cast(paths_arr[i], ctypes.c_char_p).value
if path and (flags_arr[i] & (kFSEventStreamEventFlagItemCreated | kFSEventStreamEventFlagItemRemoved |
kFSEventStreamEventFlagItemRenamed | kFSEventStreamEventFlagItemModified)):
self._trigger_callbacks(os.path.basename(path.decode('utf-8').rstrip('/')))
except Exception:
traceback.print_exc()
self._darwin_cb = ctypes.CFUNCTYPE(None, c_void_p, c_void_p, c_size_t, c_void_p, POINTER(c_uint32), POINTER(c_uint64))(_cb)
path_str = Paths.params_root().encode('utf-8')
cf_path = CF.CFStringCreateWithCString(kCFAllocatorDefault, path_str, kCFStringEncodingUTF8)
cf_paths = CF.CFArrayCreate(kCFAllocatorDefault, (c_void_p * 1)(cf_path), 1, None)
stream = CS.FSEventStreamCreate(kCFAllocatorDefault, self._darwin_cb, None, cf_paths, -1, 0.05, kFSEventStreamCreateFlagFileEvents)
run_loop = CF.CFRunLoopGetCurrent()
kDefaultMode = CF.CFStringCreateWithCString(kCFAllocatorDefault, b"kCFRunLoopDefaultMode", kCFStringEncodingUTF8)
CS.FSEventStreamScheduleWithRunLoop(stream, run_loop, kDefaultMode)
CS.FSEventStreamStart(stream)
CF.CFRunLoopRun()

View File

@@ -0,0 +1,4 @@
from openpilot.common.params_pyx import ParamKeyFlag, ParamKeyType, UnknownKeyName
from openpilot.sunnypilot.common.param_watcher import ParamWatcher as Params
__all__ = ["Params", "ParamKeyFlag", "ParamKeyType", "UnknownKeyName"]

View File

View File

@@ -0,0 +1,94 @@
import time
import pytest
import threading
import tracemalloc
from openpilot.common.params import Params
from openpilot.common.params_pyx import UnknownKeyName
from openpilot.sunnypilot.common.param_watcher import ParamWatcher
class TestParamWatcher:
BYTES_KEYS = ["LocationFilterInitialState", "UpdaterCurrentReleaseNotes", "UpdaterNewReleaseNotes"]
BOOL_KEYS = [
"IsMetric", "AdbEnabled", "AlwaysOnDM", "ExperimentalMode",
"ExperimentalModeConfirmed", "DisengageOnAccelerator",
"OpenpilotEnabledToggle", "RecordAudio", "RecordFront"
]
_key_counter = 0
@pytest.fixture(autouse=True)
def setup_method(self):
self.params = Params()
self.key_index = TestParamWatcher._key_counter
TestParamWatcher._key_counter += 1
self.bytes_key = self.BYTES_KEYS[self.key_index % len(self.BYTES_KEYS)]
self.bool_key = self.BOOL_KEYS[self.key_index % len(self.BOOL_KEYS)]
@pytest.fixture
def param_watcher(self):
ParamWatcher._instance = None
param_watch = ParamWatcher()
param_watch.start()
assert param_watch.is_watching(), "ParamWatcher thread died"
return param_watch
def teardown_method(self):
for key in (self.bytes_key, self.bool_key):
try:
self.params.remove(key)
except UnknownKeyName:
pass
def test_watcher_detects_change(self, param_watcher):
val = b"123"
self.params.put(self.bytes_key, val)
assert param_watcher.get(self.bytes_key) == val
def test_watcher_get_bool(self, param_watcher):
self.params.put_bool(self.bool_key, True)
assert param_watcher.get_bool(self.bool_key) is True # First read should populate internal cache
def test_performance_comparison(self, param_watcher):
plain_params = self.params
for key in self.BYTES_KEYS:
plain_params.put(key, b"x" * 10000)
param_watcher.get(key)
for key in self.BOOL_KEYS:
plain_params.put_bool(key, True)
param_watcher.get_bool(key)
def bench(get_bytes, get_bool):
tracemalloc.start()
start_time = time.process_time()
for _ in range(1000):
for key in self.BYTES_KEYS:
get_bytes(key)
for key in self.BOOL_KEYS:
get_bool(key)
duration = time.process_time() - start_time
_, memory = tracemalloc.get_traced_memory()
tracemalloc.stop()
return duration, memory
plain_cpu, plain_memory = bench(plain_params.get, plain_params.get_bool)
watcher_cpu, watcher_memory = bench(param_watcher.get, param_watcher.get_bool)
# ParamWatcher *should* be significantly faster and use less memory than Params()
assert watcher_cpu < plain_cpu * 0.6, f"PW CPU ({watcher_cpu:.4f}s) should be < 60% of Param call ({plain_cpu:.4f}s)"
assert watcher_memory < plain_memory * 0.5, f"PW Memory ({watcher_memory}B) should be < 50% of Param call ({plain_memory}B)"
def test_cache_invalidation_simulation(self, param_watcher):
self.params.put(self.bytes_key, b"old")
assert param_watcher.get(self.bytes_key) == b"old"
time.sleep(0.2)
event = threading.Event()
param_watcher.add_watcher(lambda key: event.set())
param_watcher._trigger_callbacks(self.bytes_key)
assert event.wait(timeout=2), "Callback not triggered"
self.params.put(self.bytes_key, b"new")
assert param_watcher.get(self.bytes_key) == b"new"

View File

@@ -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()

View File

@@ -8,11 +8,13 @@ from enum import IntEnum
import threading
import time
import json
import pyray as rl
from cereal import messaging
from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog
from openpilot.sunnypilot.sunnylink.api import UNREGISTERED_SUNNYLINK_DONGLE_ID, SunnylinkApi
from openpilot.system.ui.sunnypilot.lib.styles import style
class RoleType(IntEnum):
@@ -201,5 +203,19 @@ class SunnylinkState:
network_type = self._sm["deviceState"].networkType
return bool(network_type != 0)
def get_sponsor_tier_color(self) -> rl.Color:
tier = self.get_sponsor_tier()
if tier == SponsorTier.GUARDIAN:
return rl.Color(255, 215, 0, 255)
elif tier == SponsorTier.BENEFACTOR:
return rl.Color(60, 179, 113, 255)
elif tier == SponsorTier.CONTRIBUTOR:
return rl.Color(70, 130, 180, 255)
elif tier == SponsorTier.SUPPORTER:
return rl.Color(147, 112, 219, 255)
else:
return style.ITEM_TEXT_VALUE_COLOR
def __del__(self):
self.stop()

View File

View File

@@ -0,0 +1,171 @@
#!/usr/bin/env python3
import argparse
import ctypes
import csv
import os
import platform
import random
import select
import struct
import sys
import time
import matplotlib.pyplot as plt
import matplotlib.ticker as ticker
from collections import defaultdict
from openpilot.system.hardware.hw import Paths
from openpilot.sunnypilot.common.param_watcher import ParamWatcher, IN_CLOSE_WRITE, IN_MOVED_TO
IN_ACCESS = 0x00000001
def get_linux_monitor(params_path, reads, writes):
libc = ctypes.CDLL('libc.so.6')
fd = libc.inotify_init()
if fd < 0:
return None
mask = IN_ACCESS | IN_MOVED_TO | IN_CLOSE_WRITE
if libc.inotify_add_watch(fd, params_path.encode(), mask) < 0:
return None
poll_obj = select.epoll()
poll_obj.register(fd, select.EPOLLIN)
def monitor():
for fileno, _ in poll_obj.poll(0.1):
if fileno == fd:
buffer = os.read(fd, 2048)
i = 0
while i + 16 <= len(buffer):
wd, mask, cookie, name_len = struct.unpack_from("iIII", buffer, i)
name = buffer[i+16:i+16+name_len].rstrip(b"\0").decode('utf-8', 'ignore')
if name and not name.startswith("."):
if mask & IN_ACCESS:
reads[name] += 1
elif mask & (IN_MOVED_TO | IN_CLOSE_WRITE):
writes[name] += 1
i += 16 + name_len
def cleanup():
os.close(fd)
return monitor, cleanup
def get_darwin_monitor(params_path, reads, writes):
print("WARNING: macOS only reports WRITES.")
def callback(name):
writes[name] += 1
watcher = ParamWatcher()
watcher.add_watcher(callback)
def monitor():
time.sleep(0.1)
def cleanup():
if callback in watcher._callbacks:
watcher._callbacks.remove(callback)
return monitor, cleanup
def profile_params():
parser = argparse.ArgumentParser(description="Profile Params I/O")
parser.add_argument("--timeout", type=int, default=30, help="Timeout in minutes (default: 30 mins)")
default_out = os.path.join(os.path.dirname(os.path.abspath(__file__)), f"params_profile_{random.randrange(99999)}.csv")
parser.add_argument("--out", type=str, default=default_out, help="Output CSV file")
args = parser.parse_args()
path = Paths.params_root()
if not os.path.exists(path):
return print(f"Error: {path} not found")
print(f"Profiling Params I/O at: {path}\nPress CTRL+C to stop.")
reads, writes = defaultdict(int), defaultdict(int)
setup = get_linux_monitor if platform.system() == "Linux" else \
get_darwin_monitor if platform.system() == "Darwin" else None
if not setup:
return print("Unsupported platform")
monitor, cleanup = setup(path, reads, writes) or (None, None)
if not monitor:
return print("Failed to initialize monitor")
start_time = time.monotonic()
timeout_seconds = args.timeout * 60
last_print = start_time
try:
while True:
monitor()
if time.monotonic() - last_print > 1.0:
sys.stdout.write(".")
sys.stdout.flush()
last_print = time.monotonic()
if args.timeout > 0 and (time.monotonic() - start_time) > timeout_seconds:
print("\nTimeout reached.")
break
except KeyboardInterrupt:
print("\n\nStopping...")
finally:
cleanup()
duration = time.monotonic() - start_time
with open(args.out, 'w', newline='') as csvfile:
writer = csv.writer(csvfile)
writer.writerow(['Param Name', 'Reads/sec', 'Writes/sec', 'Total Reads', 'Total Writes'])
for k in sorted(set(reads) | set(writes), key=lambda k: reads[k] + writes[k], reverse=True):
writer.writerow([k, f"{reads[k]/duration:.1f}", f"{writes[k]/duration:.1f}", reads[k], writes[k]])
print(f"CSV report saved to {args.out}")
data = []
for k in sorted(set(reads) | set(writes), key=lambda k: reads[k] + writes[k], reverse=True):
data.append((k, reads[k]/duration, writes[k]/duration))
if data:
data = data[:10]
names = [x[0] for x in data]
read_rates = [x[1] for x in data]
write_rates = [x[2] for x in data]
bar_height = 0.35
plt.figure(figsize=(12, len(names) * 0.5 + 2), dpi=150)
y_pos = range(len(names))
y_pos_reads = [y - bar_height/2 for y in y_pos]
y_pos_writes = [y + bar_height/2 for y in y_pos]
plt.barh(y_pos_reads, read_rates, height=bar_height, align='center', color='dodgerblue', alpha=0.8, label='Reads/sec')
plt.barh(y_pos_writes, write_rates, height=bar_height, align='center', color='red', alpha=0.8, label='Writes/sec')
for i, (r_rate, w_rate) in enumerate(zip(read_rates, write_rates, strict=False)):
if r_rate > 0:
plt.text(r_rate, y_pos_reads[i], f"{r_rate:.2f}", va='center', fontsize=8, color='#005a9e', fontweight='bold')
if w_rate > 0:
plt.text(w_rate, y_pos_writes[i], f"{w_rate:.2f}", va='center', fontsize=8, color='#a30000', fontweight='bold')
max_val = max(max(read_rates), max(write_rates)) if read_rates else 0
plt.xlim(0, max_val * 1.15)
plt.yticks(y_pos, names)
plt.xlabel('Rate (Hz)')
plt.title('Top 10 Params I/O Profile')
plt.legend()
plt.grid(axis='x', linestyle='--', alpha=0.5)
plt.gca().xaxis.set_major_locator(ticker.MaxNLocator(integer=True, nbins='auto'))
plt.tight_layout()
plt.gca().invert_yaxis()
plot_filename = os.path.splitext(args.out)[0] + ".png"
plt.savefig(plot_filename)
print(f"Plot saved to {plot_filename}")
if __name__ == "__main__":
profile_params()

View File

@@ -95,3 +95,10 @@ class Paths:
return str(Path(Paths.comma_home()) / "media" / "0" / "osm")
else:
return "/data/media/0/osm"
@staticmethod
def params_root() -> str:
if PC:
return str(Path(Paths.comma_home()) / "params" / "d")
else:
return "/data/params/d"

View File

@@ -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"

View File

@@ -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)

View File

@@ -8,12 +8,15 @@ 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
from openpilot.selfdrive.ui.ui_state import ui_state
class ToggleActionSP(ToggleAction):
@@ -23,6 +26,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 +117,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 +159,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 +200,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:
@@ -189,6 +248,9 @@ class ListItemSP(ListItem):
def toggle_item_sp(title: str | Callable[[], str], description: str | Callable[[], str] | None = None, initial_state: bool = False,
callback: Callable | None = None, icon: str = "", enabled: bool | Callable[[], bool] = True, param: str | None = None) -> ListItemSP:
if param is None and hasattr(ui_state.params, 'last_accessed_param') and ui_state.params.last_accessed_param:
param = ui_state.params.last_accessed_param
ui_state.params.last_accessed_param = None
action = ToggleActionSP(initial_state=initial_state, enabled=enabled, callback=callback, param=param)
return ListItemSP(title=title, description=description, action_item=action, icon=icon, callback=callback)
@@ -196,6 +258,9 @@ def toggle_item_sp(title: str | Callable[[], str], description: str | Callable[[
def multiple_button_item_sp(title: str | Callable[[], str], description: str | Callable[[], str], buttons: list[str | Callable[[], str]],
selected_index: int = 0, button_width: int = style.BUTTON_WIDTH, callback: Callable = None,
icon: str = "", param: str | None = None, inline: bool = False) -> ListItemSP:
if param is None and hasattr(ui_state.params, 'last_accessed_param') and ui_state.params.last_accessed_param:
param = ui_state.params.last_accessed_param
ui_state.params.last_accessed_param = None
action = MultipleButtonActionSP(buttons, button_width, selected_index, callback=callback, param=param)
return ListItemSP(title=title, description=description, icon=icon, action_item=action, inline=inline)
@@ -211,3 +276,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)

View File

@@ -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

View File

@@ -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:

View File

@@ -78,7 +78,7 @@ class TreeItemWidget(Button):
class TreeOptionDialog(MultiOptionDialog):
def __init__(self, title, folders, current_ref="", fav_param="", option_font_weight=FontWeight.MEDIUM, search_prompt=None,
get_folders_fn=None, on_exit=None, display_func=None, search_funcs=None, search_title=None, search_subtitle=None):
super().__init__(title, [], "", option_font_weight)
super().__init__(title, [], current_ref, option_font_weight)
self.folders = folders
self.selection_ref = current_ref
self.fav_param = fav_param
@@ -101,6 +101,23 @@ class TreeOptionDialog(MultiOptionDialog):
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()
def _on_search_confirm(self, result, text):
@@ -142,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:
@@ -152,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

View File

@@ -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

109
tools/profile_params.py Executable file
View File

@@ -0,0 +1,109 @@
#!/usr/bin/env python3
import os
import sys
import platform
import struct
import select
import time
import ctypes
from collections import defaultdict
from openpilot.system.hardware.hw import Paths
from sunnypilot.common.param_watcher import ParamWatcher, IN_CLOSE_WRITE, IN_MOVED_TO
IN_ACCESS = 0x00000001
def get_linux_monitor(params_path, reads, writes):
libc = ctypes.CDLL('libc.so.6')
fd = libc.inotify_init()
if fd < 0:
return None
mask = IN_ACCESS | IN_MOVED_TO | IN_CLOSE_WRITE
if libc.inotify_add_watch(fd, params_path.encode(), mask) < 0:
return None
poll_obj = select.epoll()
poll_obj.register(fd, select.EPOLLIN)
def monitor():
for fileno, _ in poll_obj.poll(0.1):
if fileno == fd:
buffer = os.read(fd, 4096)
i = 0
while i + 16 <= len(buffer):
wd, mask, cookie, name_len = struct.unpack_from("iIII", buffer, i)
name = buffer[i+16:i+16+name_len].rstrip(b"\0").decode('utf-8', 'ignore')
if name and not name.startswith("."):
if mask & IN_ACCESS:
reads[name] += 1
elif mask & (IN_MOVED_TO | IN_CLOSE_WRITE):
writes[name] += 1
i += 16 + name_len
def cleanup():
os.close(fd)
return monitor, cleanup
def get_darwin_monitor(params_path, reads, writes):
print("WARNING: macOS only reports WRITES.")
def callback(name):
writes[name] += 1
watcher = ParamWatcher()
watcher.add_watcher(callback)
def monitor():
time.sleep(0.1)
def cleanup():
if callback in watcher._callbacks:
watcher._callbacks.remove(callback)
return monitor, cleanup
def profile_params():
path = Paths.params_root()
if not os.path.exists(path):
return print(f"Error: {path} not found")
print(f"Profiling Params I/O at: {path}\nPress CTRL+C to stop.")
reads, writes = defaultdict(int), defaultdict(int)
setup = get_linux_monitor if platform.system() == "Linux" else \
get_darwin_monitor if platform.system() == "Darwin" else None
if not setup:
return print("Unsupported platform")
monitor, cleanup = setup(path, reads, writes) or (None, None)
if not monitor:
return print("Failed to initialize monitor")
start_time = time.monotonic()
last_print = start_time
try:
while True:
monitor()
if time.monotonic() - last_print > 1.0:
sys.stdout.write(".")
sys.stdout.flush()
last_print = time.monotonic()
except KeyboardInterrupt:
print("\n\nStopping...")
finally:
cleanup()
duration = time.monotonic() - start_time
print(f"\n\n=== Params I/O Profile Report ({duration:.1f}s) ===")
print(f"{'Param Name':<40} | {'Reads/sec':<10} | {'Writes/sec':<10} | {'Total Reads':<12} | {'Total Writes':<12}")
print("-" * 95)
for k in sorted(set(reads) | set(writes), key=lambda k: reads[k] + writes[k], reverse=True):
print(f"{k:<40} | {reads[k]/duration:<10.1f} | {writes[k]/duration:<10.1f} | {reads[k]:<12} | {writes[k]:<12}")
if __name__ == "__main__":
profile_params()