mirror of
https://github.com/sunnypilot/sunnypilot.git
synced 2026-06-09 04:54:23 +08:00
Compare commits
67 Commits
ui-scroll-
...
tools
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3375277b3c | ||
|
|
6e06bcb14f | ||
|
|
bc07cecfa0 | ||
|
|
de30e9a3cf | ||
|
|
c599542dfa | ||
|
|
b737989e64 | ||
|
|
5fbc358fd5 | ||
|
|
ce30d815f7 | ||
|
|
fdde1aa6a1 | ||
|
|
961b2a2d30 | ||
|
|
f3d39d481a | ||
|
|
6e037d80ff | ||
|
|
907bc5cf06 | ||
|
|
b3ff268f89 | ||
|
|
42e08515e6 | ||
|
|
d0ec46dc5d | ||
|
|
48a8802298 | ||
|
|
79971b9eb2 | ||
|
|
7ba21f9f1b | ||
|
|
6b4118ab27 | ||
|
|
0844424ad1 | ||
|
|
5901c9b41f | ||
|
|
d52ce19c15 | ||
|
|
05cc9a14e2 | ||
|
|
18f8956e0e | ||
|
|
0aa6f22c26 | ||
|
|
c90f262ce7 | ||
|
|
e8ee5a23f0 | ||
|
|
4a189f828a | ||
|
|
072e18faef | ||
|
|
3b1fddfde9 | ||
|
|
bddec6971e | ||
|
|
34e02b6ae5 | ||
|
|
c98cc5d40a | ||
|
|
4a0d8063e5 | ||
|
|
e2e52bcccb | ||
|
|
ccf86b7b72 | ||
|
|
483894cfc8 | ||
|
|
a678554122 | ||
|
|
bfd3eab260 | ||
|
|
f5aedbce6e | ||
|
|
4f860dd397 | ||
|
|
f308d9ab17 | ||
|
|
323b793a83 | ||
|
|
96c2650ac4 | ||
|
|
1807b193fa | ||
|
|
9226222ad4 | ||
|
|
3e317a8b4d | ||
|
|
5007437969 | ||
|
|
93c1c713a9 | ||
|
|
0eae4e0b3b | ||
|
|
37ffa5ed21 | ||
|
|
05e3eaf2fc | ||
|
|
d382cd08e5 | ||
|
|
c8fc344d68 | ||
|
|
264948e5ff | ||
|
|
9d87beac8e | ||
|
|
2e0bc80f94 | ||
|
|
4b8781886a | ||
|
|
57eca29970 | ||
|
|
1d9bda65fe | ||
|
|
b5b170b65a | ||
|
|
c6818bd07f | ||
|
|
d75d80b885 | ||
|
|
97edff5e5c | ||
|
|
1f967668a5 | ||
|
|
a81570a6c2 |
BIN
selfdrive/assets/fonts/Audiowide-Regular.ttf
LFS
Normal file
BIN
selfdrive/assets/fonts/Audiowide-Regular.ttf
LFS
Normal file
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
96
selfdrive/ui/sunnypilot/layouts/settings/software.py
Normal file
96
selfdrive/ui/sunnypilot/layouts/settings/software.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
41
selfdrive/ui/sunnypilot/ui_helpers.py
Normal file
41
selfdrive/ui/sunnypilot/ui_helpers.py
Normal 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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
133
sunnypilot/common/README.md
Normal 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
|
||||
0
sunnypilot/common/__init__.py
Normal file
0
sunnypilot/common/__init__.py
Normal file
193
sunnypilot/common/param_watcher.py
Normal file
193
sunnypilot/common/param_watcher.py
Normal 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()
|
||||
4
sunnypilot/common/params.py
Normal file
4
sunnypilot/common/params.py
Normal 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"]
|
||||
0
sunnypilot/common/tests/__init__.py
Normal file
0
sunnypilot/common/tests/__init__.py
Normal file
94
sunnypilot/common/tests/test_param_watcher.py
Normal file
94
sunnypilot/common/tests/test_param_watcher.py
Normal 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"
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
0
sunnypilot/tools/__init__.py
Normal file
0
sunnypilot/tools/__init__.py
Normal file
171
sunnypilot/tools/profile_params.py
Executable file
171
sunnypilot/tools/profile_params.py
Executable 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()
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
139
system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py
Normal file
139
system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py
Normal 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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
109
tools/profile_params.py
Executable 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()
|
||||
Reference in New Issue
Block a user