diff --git a/selfdrive/ui/layouts/home.py b/selfdrive/ui/layouts/home.py index c759346f6..2c06190b3 100644 --- a/selfdrive/ui/layouts/home.py +++ b/selfdrive/ui/layouts/home.py @@ -2,7 +2,6 @@ import time import pyray as rl from collections.abc import Callable from enum import IntEnum -from openpilot.common.params import Params from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.selfdrive.ui.widgets.offroad_alerts import UpdateAlert, OffroadAlert from openpilot.selfdrive.ui.widgets.exp_mode_button import ExperimentalModeButton @@ -32,7 +31,7 @@ class HomeLayoutState(IntEnum): class HomeLayout(Widget): def __init__(self): super().__init__() - self.params = Params() + self.params = ui_state.ui_params self.update_alert = UpdateAlert() self.offroad_alert = OffroadAlert() diff --git a/selfdrive/ui/layouts/main.py b/selfdrive/ui/layouts/main.py index 9aa8e363c..bd24381a4 100644 --- a/selfdrive/ui/layouts/main.py +++ b/selfdrive/ui/layouts/main.py @@ -47,6 +47,11 @@ class MainLayout(Widget): if not self._onboarding_window.completed: gui_app.push_widget(self._onboarding_window) + # TICI/TIZI offroad views are mostly static. Keep onroad and interaction + # at the normal rate, but avoid spending GPU/CPU budget redrawing idle UI. + # MainLayout is only used by BIG UI, so MICI retains its existing scheduler. + gui_app.configure_adaptive_rendering(gui_app.big_ui()) + @staticmethod def _critical_full_alert_active() -> bool: if not ui_state.sm.recv_frame['selfdriveState']: @@ -60,6 +65,7 @@ class MainLayout(Widget): return self._handle_onroad_transition() + gui_app.set_render_mode(self._current_mode == MainState.ONROAD or ui_state.started) self._render_main_content() def _setup_callbacks(self): @@ -105,11 +111,13 @@ class MainLayout(Widget): self._layouts[self._current_mode].hide_event() self._current_mode = layout self._layouts[self._current_mode].show_event() + gui_app.request_high_fps() def open_settings(self, panel_type: PanelType): self._layouts[MainState.SETTINGS].set_current_panel(panel_type) self._set_current_layout(MainState.SETTINGS) self._sidebar.set_visible(False) + gui_app.request_high_fps() def _on_settings_clicked(self): self.open_settings(PanelType.STARPILOT) diff --git a/selfdrive/ui/layouts/settings/common.py b/selfdrive/ui/layouts/settings/common.py index 5e87a6447..2066ec5b7 100644 --- a/selfdrive/ui/layouts/settings/common.py +++ b/selfdrive/ui/layouts/settings/common.py @@ -2,4 +2,4 @@ from openpilot.selfdrive.ui.ui_state import ui_state def restart_needed_callback(_): - ui_state.params.put_bool("OnroadCycleRequested", True) + ui_state.ui_params.put_bool("OnroadCycleRequested", True) diff --git a/selfdrive/ui/layouts/settings/device.py b/selfdrive/ui/layouts/settings/device.py index dc9a6c3fe..96ec6e8df 100644 --- a/selfdrive/ui/layouts/settings/device.py +++ b/selfdrive/ui/layouts/settings/device.py @@ -11,7 +11,6 @@ import pyray as rl import qrcode 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 @@ -112,7 +111,7 @@ class DeviceLayout(Widget): def __init__(self): super().__init__() - self._params = Params() + self._params = ui_state.ui_params self._select_language_dialog: MultiOptionDialog | None = None self._driver_camera: DriverCameraDialog | None = None self._pair_device_dialog: PairingDialog | None = None diff --git a/selfdrive/ui/layouts/settings/software.py b/selfdrive/ui/layouts/settings/software.py index 2e5d3e601..b4705502e 100644 --- a/selfdrive/ui/layouts/settings/software.py +++ b/selfdrive/ui/layouts/settings/software.py @@ -106,11 +106,11 @@ class SoftwareLayout(Widget): super().__init__() self._onroad_label = ListItem(lambda: tr("Updates are only downloaded while the car is off.")) - self._version_item = text_item(lambda: tr("Current Version"), starpilot_display_description(ui_state.params.get("UpdaterCurrentDescription"))) + self._version_item = text_item(lambda: tr("Current Version"), starpilot_display_description(ui_state.ui_params.get("UpdaterCurrentDescription"))) self._auto_updates_toggle = toggle_item( lambda: tr("Automatically Install Updates"), lambda: tr("Automatically install updates when parked with an active internet connection."), - initial_state=ui_state.params.get_bool("AutomaticUpdates"), + initial_state=ui_state.ui_params.get_bool("AutomaticUpdates"), callback=self._on_auto_updates_toggle, ) self._download_btn = button_item( @@ -129,8 +129,8 @@ class SoftwareLayout(Widget): # Branch switcher self._branch_btn = button_item(lambda: tr("Target Branch"), lambda: tr("SELECT"), callback=self._on_select_branch) - self._branch_btn.set_visible(not ui_state.params.get_bool("IsTestedBranch")) - self._branch_btn.action_item.set_value(ui_state.params.get("UpdaterTargetBranch") or "") + self._branch_btn.set_visible(not ui_state.ui_params.get_bool("IsTestedBranch")) + self._branch_btn.action_item.set_value(ui_state.ui_params.get("UpdaterTargetBranch") or "") self._branch_dialog: MultiOptionDialog | None = None self._scroller = Scroller( @@ -159,11 +159,11 @@ class SoftwareLayout(Widget): self._onroad_label.set_visible(ui_state.is_onroad()) # Update current version and release notes - current_desc = starpilot_display_description(ui_state.params.get("UpdaterCurrentDescription")) - current_release_notes = (ui_state.params.get("UpdaterCurrentReleaseNotes") or b"").decode("utf-8", "replace") + current_desc = starpilot_display_description(ui_state.ui_params.get("UpdaterCurrentDescription")) + current_release_notes = (ui_state.ui_params.get("UpdaterCurrentReleaseNotes") or b"").decode("utf-8", "replace") self._version_item.action_item.set_text(current_desc) self._version_item.set_description(current_release_notes) - self._auto_updates_toggle.action_item.set_state(ui_state.params.get_bool("AutomaticUpdates")) + self._auto_updates_toggle.action_item.set_state(ui_state.ui_params.get_bool("AutomaticUpdates")) # Update download button visibility and state self._download_btn.set_visible(ui_state.is_offroad()) @@ -185,10 +185,10 @@ class SoftwareLayout(Widget): # ── Normal updater state (only when fast update NOT active) ─── if state.stage == FastUpdateStage.IDLE: - updater_state = ui_state.params.get("UpdaterState") or "idle" - failed_count = ui_state.params.get("UpdateFailedCount") or 0 - fetch_available = ui_state.params.get_bool("UpdaterFetchAvailable") - update_available = ui_state.params.get_bool("UpdateAvailable") + updater_state = ui_state.ui_params.get("UpdaterState") or "idle" + failed_count = ui_state.ui_params.get("UpdateFailedCount") or 0 + fetch_available = ui_state.ui_params.get_bool("UpdaterFetchAvailable") + update_available = ui_state.ui_params.get_bool("UpdateAvailable") if updater_state != "idle": self._waiting_for_updater = False @@ -203,7 +203,7 @@ class SoftwareLayout(Widget): self._download_btn.action_item.set_value(tr("update available")) self._download_btn.action_item.set_text(tr("DOWNLOAD")) else: - last_update = ui_state.params.get("LastUpdateTime") + last_update = ui_state.ui_params.get("LastUpdateTime") if last_update: formatted = time_ago(last_update) self._download_btn.action_item.set_value(tr("up to date, last checked {}").format(formatted)) @@ -219,14 +219,14 @@ class SoftwareLayout(Widget): update_available = False # Update target branch button value - current_branch = ui_state.params.get("UpdaterTargetBranch") or "" + current_branch = ui_state.ui_params.get("UpdaterTargetBranch") or "" self._branch_btn.action_item.set_value(current_branch) # Update install button self._install_btn.set_visible(ui_state.is_offroad() and update_available and state.stage == FastUpdateStage.IDLE) if update_available and state.stage == FastUpdateStage.IDLE: - new_desc = starpilot_display_description(ui_state.params.get("UpdaterNewDescription")) - new_release_notes = (ui_state.params.get("UpdaterNewReleaseNotes") or b"").decode("utf-8", "replace") + new_desc = starpilot_display_description(ui_state.ui_params.get("UpdaterNewDescription")) + new_release_notes = (ui_state.ui_params.get("UpdaterNewReleaseNotes") or b"").decode("utf-8", "replace") self._install_btn.action_item.set_text(tr("INSTALL")) self._install_btn.action_item.set_value(new_desc) self._install_btn.set_description(new_release_notes) @@ -327,7 +327,7 @@ class SoftwareLayout(Widget): threading.Thread(target=_run_worker, daemon=True).start() def _on_auto_updates_toggle(self, enabled: bool): - ui_state.params.put_bool("AutomaticUpdates", enabled) + ui_state.ui_params.put_bool("AutomaticUpdates", enabled) def _on_uninstall(self): def handle_step1(result): @@ -338,13 +338,13 @@ class SoftwareLayout(Widget): def handle_step3(result3): if result3 == DialogResult.CONFIRM: - ui_state.params.clear_all() - ui_state.params.put_bool("DoUninstall", True) + ui_state.ui_params.clear_all() + ui_state.ui_params.put_bool("DoUninstall", True) dialog = ConfirmDialog(tr("This is a complete factory reset and cannot be undone. Are you absolutely sure?"), tr("Reset"), callback=handle_step3) gui_app.push_widget(dialog) else: - ui_state.params.put_bool("DoUninstall", True) + ui_state.ui_params.put_bool("DoUninstall", True) dialog = ConfirmDialog( tr("Do you want to perform a full factory reset? All saved assets and settings will be permanently deleted!"), tr("Factory Reset"), tr("Skip"), callback=handle_step2 @@ -364,12 +364,12 @@ class SoftwareLayout(Widget): def _on_install_update(self): # Trigger reboot to install update self._install_btn.action_item.set_enabled(False) - ui_state.params.put_bool("DoReboot", True) + ui_state.ui_params.put_bool("DoReboot", True) def _on_select_branch(self): # Get available branches and order - current_git_branch = ui_state.params.get("GitBranch") or "" - branches_str = ui_state.params.get("UpdaterAvailableBranches") or "" + current_git_branch = ui_state.ui_params.get("GitBranch") or "" + branches_str = ui_state.ui_params.get("UpdaterAvailableBranches") or "" branches = [b for b in branches_str.split(",") if b] for b in [current_git_branch, "devel-staging", "devel", "nightly", "nightly-dev", "master"]: @@ -377,13 +377,13 @@ class SoftwareLayout(Widget): branches.remove(b) branches.insert(0, b) - current_target = ui_state.params.get("UpdaterTargetBranch") or "" + current_target = ui_state.ui_params.get("UpdaterTargetBranch") or "" def handle_selection(result): # Confirmed selection if result == DialogResult.CONFIRM and self._branch_dialog is not None and self._branch_dialog.selection: selection = self._branch_dialog.selection - ui_state.params.put("UpdaterTargetBranch", selection) + ui_state.ui_params.put("UpdaterTargetBranch", selection) self._branch_btn.action_item.set_value(selection) os.system("pkill -SIGUSR1 -f system.updated.updated") self._branch_dialog = None diff --git a/selfdrive/ui/layouts/settings/starpilot/aethergrid.py b/selfdrive/ui/layouts/settings/starpilot/aethergrid.py index bb8f0486e..99a4fd46b 100644 --- a/selfdrive/ui/layouts/settings/starpilot/aethergrid.py +++ b/selfdrive/ui/layouts/settings/starpilot/aethergrid.py @@ -13,6 +13,7 @@ from openpilot.system.ui.widgets import Widget, DialogResult from openpilot.system.ui.widgets.label import gui_label from openpilot.selfdrive.ui.layouts.settings.starpilot.scribble import draw_custom_icon +from openpilot.selfdrive.ui.lib.ui_param_cache import shared_ui_params GEOMETRY_OFFSET = 10 @@ -3538,8 +3539,7 @@ class AetherCategoryDrawer(AetherSettingsView): # Read driving side dynamically for ergonomic layout (LHD vs RHD) try: - from openpilot.common.params import Params - self._is_rhd = Params().get_bool("IsRHD") + self._is_rhd = shared_ui_params().get_bool("IsRHD") except Exception: self._is_rhd = False @@ -5476,4 +5476,3 @@ class TileGrid(Widget): - diff --git a/selfdrive/ui/layouts/settings/starpilot/panel.py b/selfdrive/ui/layouts/settings/starpilot/panel.py index e377dc64b..1568ad694 100644 --- a/selfdrive/ui/layouts/settings/starpilot/panel.py +++ b/selfdrive/ui/layouts/settings/starpilot/panel.py @@ -6,6 +6,7 @@ from enum import IntEnum import pyray as rl from openpilot.common.params import Params +from openpilot.selfdrive.ui.lib.ui_param_cache import shared_ui_params from openpilot.starpilot.common.starpilot_variables import update_starpilot_toggles from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.lib.application import gui_app @@ -13,75 +14,50 @@ from openpilot.system.ui.widgets import DialogResult, Widget from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import TileGrid, HubTile, ToggleTile, ValueTile, SliderTile, SPACING, AetherSliderDialog, AetherListColors from openpilot.selfdrive.ui.layouts.settings.starpilot.sectioned_panel import SectionedTileLayout, TileSection -import time class FrameCachedParams: def __init__(self): - self._params = Params() - self._cache = {} - self._last_frame_time = 0.0 - - def _check_clear_cache(self): - now = time.monotonic() - if now != self._last_frame_time: - self._cache.clear() - self._last_frame_time = now + self._cache = shared_ui_params() def get(self, key, **kwargs): - self._check_clear_cache() - cache_key = (key, "get", tuple(kwargs.items())) - if cache_key not in self._cache: - self._cache[cache_key] = self._params.get(key, **kwargs) - return self._cache[cache_key] + return self._cache.get(key, **kwargs) def get_bool(self, key, **kwargs): - self._check_clear_cache() - cache_key = (key, "get_bool", tuple(kwargs.items())) - if cache_key not in self._cache: - self._cache[cache_key] = self._params.get_bool(key, **kwargs) - return self._cache[cache_key] + return self._cache.get_bool(key, **kwargs) def get_int(self, key, **kwargs): - self._check_clear_cache() - cache_key = (key, "get_int", tuple(kwargs.items())) - if cache_key not in self._cache: - self._cache[cache_key] = self._params.get_int(key, **kwargs) - return self._cache[cache_key] + return self._cache.get_int(key, **kwargs) def get_float(self, key, **kwargs): - self._check_clear_cache() - cache_key = (key, "get_float", tuple(kwargs.items())) - if cache_key not in self._cache: - self._cache[cache_key] = self._params.get_float(key, **kwargs) - return self._cache[cache_key] + return self._cache.get_float(key, **kwargs) def _notify_changed(self): - self._cache.clear() + self._cache.invalidate() update_starpilot_toggles() def put(self, key, val, **kwargs): - self._params.put(key, val, **kwargs) + self._cache.put(key, val, **kwargs) self._notify_changed() def put_bool(self, key, val, **kwargs): - self._params.put_bool(key, val, **kwargs) + self._cache.put_bool(key, val, **kwargs) self._notify_changed() def put_int(self, key, val, **kwargs): - self._params.put_int(key, val, **kwargs) + self._cache.put_int(key, val, **kwargs) self._notify_changed() def put_float(self, key, val, **kwargs): - self._params.put_float(key, val, **kwargs) + self._cache.put_float(key, val, **kwargs) self._notify_changed() def remove(self, key): - self._params.remove(key) + self._cache.remove(key) self._notify_changed() def __getattr__(self, name): - return getattr(self._params, name) + return getattr(self._cache, name) class StarPilotPanelType(IntEnum): diff --git a/selfdrive/ui/layouts/settings/starpilot/scribble.py b/selfdrive/ui/layouts/settings/starpilot/scribble.py index aa9e16653..6325297ee 100644 --- a/selfdrive/ui/layouts/settings/starpilot/scribble.py +++ b/selfdrive/ui/layouts/settings/starpilot/scribble.py @@ -1,9 +1,10 @@ from __future__ import annotations import math import pyray as rl +from openpilot.system.ui.lib.application import gui_app -def draw_custom_icon(key: str, x: float, y: float, s: float, color: rl.Color): +def _draw_custom_icon_geometry(key: str, x: float, y: float, s: float, color: rl.Color): # Helper for drawing quadratic Bezier curves def draw_bezier(p0: rl.Vector2, p1: rl.Vector2, p2: rl.Vector2, thick: float): @@ -425,4 +426,43 @@ def draw_custom_icon(key: str, x: float, y: float, s: float, color: rl.Color): rl.draw_line_ex(rl.Vector2(x_c - 6.5 * s, y_c + 2.5 * s), rl.Vector2(x_c + 6.5 * s, y_c + 2.5 * s), 3.5 * s, color) +def draw_custom_icon(key: str, x: float, y: float, s: float, color: rl.Color): + """Draw a custom icon, caching its static vector geometry on the GPU.""" + cache = getattr(gui_app, "cached_render_texture", None) + if hasattr(color, "r"): + color_key = (color.r, color.g, color.b, color.a) + else: + components = tuple(color) + color_key = (*components[:3], components[3] if len(components) > 3 else 255) + if cache is None: + _draw_custom_icon_geometry(key, x, y, s, color) + return + # A few strokes extend beyond the authored 60x60 canvas. Padding prevents + # those edges from being clipped by the render texture. + padding = max(1, int(math.ceil(3.0 * s))) + icon_size = max(1, int(round(60.0 * s))) + width = icon_size + 2 * padding + height = width + cache_key = f"aethergrid-icon:{key}:{round(s, 4)}:{width}:{height}:{color_key}" + texture = cache(cache_key, width, height, + lambda: _draw_custom_icon_geometry(key, padding, padding, s, color)) + if texture is None: + _draw_custom_icon_geometry(key, x, y, s, color) + return + + # Render targets contain premultiplied alpha after drawing onto transparent + # black. Use the matching blend mode to avoid applying translucent icon + # alpha twice when compositing the cached texture. + rl.begin_blend_mode(rl.BlendMode.BLEND_ALPHA_PREMULTIPLY) + try: + rl.draw_texture_pro( + texture, + rl.Rectangle(0, 0, width, -height), + rl.Rectangle(x - padding, y - padding, width, height), + rl.Vector2(0, 0), + 0.0, + rl.WHITE, + ) + finally: + rl.end_blend_mode() diff --git a/selfdrive/ui/layouts/settings/toggles.py b/selfdrive/ui/layouts/settings/toggles.py index 08800c698..5380960a8 100644 --- a/selfdrive/ui/layouts/settings/toggles.py +++ b/selfdrive/ui/layouts/settings/toggles.py @@ -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 @@ -38,7 +38,7 @@ DESCRIPTIONS = { class TogglesLayout(Widget): def __init__(self): super().__init__() - self._params = Params() + self._params = ui_state.ui_params self._sync_rhd_toggle() # param, title, desc, icon, needs_restart diff --git a/selfdrive/ui/lib/ui_param_cache.py b/selfdrive/ui/lib/ui_param_cache.py new file mode 100644 index 000000000..9324fe9ef --- /dev/null +++ b/selfdrive/ui/lib/ui_param_cache.py @@ -0,0 +1,107 @@ +"""Small, shared cache for read-mostly UI parameters. + +Parameter reads are file-backed. The BIG UI asks for the same values from +multiple widgets during a frame, so a short cache avoids repeated open/read/ +close cycles without making settings changes sticky: every write invalidates +the affected key immediately and the short TTL bounds visibility of writes +from other processes. +""" + +from __future__ import annotations + +import time +from collections.abc import Callable +from typing import Any + +from openpilot.common.params import Params + + +class UIParamCache: + def __init__(self, params: Params | Any | None = None, ttl: float = 0.1, + clock: Callable[[], float] = time.monotonic): + self._params = params if params is not None else Params() + self._ttl = max(0.0, ttl) + self._clock = clock + self._cache: dict[tuple[Any, ...], tuple[float, Any]] = {} + + @staticmethod + def _cache_key(method: str, key: str, args: tuple[Any, ...], kwargs: dict[str, Any]) -> tuple[Any, ...]: + # Params arguments are primitive values in UI call sites. repr keeps this + # robust for an occasional list/dict default without requiring hashability. + return (method, key, repr(args), repr(sorted(kwargs.items()))) + + def _read(self, method: str, key: str, *args: Any, **kwargs: Any) -> Any: + cache_key = self._cache_key(method, key, args, kwargs) + now = self._clock() + cached = self._cache.get(cache_key) + if cached is not None and now - cached[0] < self._ttl: + return cached[1] + + value = getattr(self._params, method)(key, *args, **kwargs) + self._cache[cache_key] = (now, value) + return value + + def get(self, key: str, *args: Any, **kwargs: Any) -> Any: + return self._read("get", key, *args, **kwargs) + + def get_bool(self, key: str, *args: Any, **kwargs: Any) -> bool: + return self._read("get_bool", key, *args, **kwargs) + + def get_int(self, key: str, *args: Any, **kwargs: Any) -> int: + return self._read("get_int", key, *args, **kwargs) + + def get_float(self, key: str, *args: Any, **kwargs: Any) -> float: + return self._read("get_float", key, *args, **kwargs) + + def invalidate(self, key: str | None = None) -> None: + if key is None: + self._cache.clear() + return + self._cache = {cache_key: value for cache_key, value in self._cache.items() + if cache_key[1] != key} + + def put(self, key: str, value: Any, *args: Any, **kwargs: Any) -> None: + self._params.put(key, value, *args, **kwargs) + self.invalidate(key) + + def put_bool(self, key: str, value: bool, *args: Any, **kwargs: Any) -> None: + self._params.put_bool(key, value, *args, **kwargs) + self.invalidate(key) + + def put_int(self, key: str, value: int, *args: Any, **kwargs: Any) -> None: + self._params.put_int(key, value, *args, **kwargs) + self.invalidate(key) + + def put_float(self, key: str, value: float, *args: Any, **kwargs: Any) -> None: + self._params.put_float(key, value, *args, **kwargs) + self.invalidate(key) + + def put_nonblocking(self, key: str, value: Any) -> None: + self._params.put_nonblocking(key, value) + self.invalidate(key) + + def put_bool_nonblocking(self, key: str, value: bool) -> None: + self._params.put_bool_nonblocking(key, value) + self.invalidate(key) + + def remove(self, key: str) -> None: + self._params.remove(key) + self.invalidate(key) + + def clear_all(self, *args: Any, **kwargs: Any) -> None: + self._params.clear_all(*args, **kwargs) + self.invalidate() + + def __getattr__(self, name: str) -> Any: + return getattr(self._params, name) + + +_SHARED_UI_PARAMS: UIParamCache | None = None + + +def shared_ui_params() -> UIParamCache: + """Return the cache shared by BIG UI views and settings panels.""" + global _SHARED_UI_PARAMS + if _SHARED_UI_PARAMS is None: + _SHARED_UI_PARAMS = UIParamCache() + return _SHARED_UI_PARAMS diff --git a/selfdrive/ui/onroad/augmented_road_view.py b/selfdrive/ui/onroad/augmented_road_view.py index 48102e402..36c475f8c 100644 --- a/selfdrive/ui/onroad/augmented_road_view.py +++ b/selfdrive/ui/onroad/augmented_road_view.py @@ -145,7 +145,7 @@ class AugmentedRoadView(CameraView): pass def _get_border_width(self) -> int: - return get_border_width(UI_BORDER_SIZE, ui_state.params) + return get_border_width(UI_BORDER_SIZE, ui_state.ui_params) def _draw_border(self, rect: rl.Rectangle): border_width = self._get_border_width() @@ -179,7 +179,8 @@ class AugmentedRoadView(CameraView): return self._is_in_reverse() def _update_reverse_driver_camera_state(self) -> bool: - should_force_driver = ui_state.started and ui_state.params.get_bool("DriverCamera") and self._is_in_reverse() + params = ui_state.ui_params + should_force_driver = ui_state.started and params.get_bool("DriverCamera") and self._is_in_reverse() if not should_force_driver: self._reverse_driver_camera_frames = 0 self._reverse_driver_camera_active = False @@ -191,7 +192,8 @@ class AugmentedRoadView(CameraView): @staticmethod def _camera_view() -> int: - camera_view = ui_state.params.get_int("CameraView", return_default=True, default=CAMERA_VIEW_WIDE) + params = ui_state.ui_params + camera_view = params.get_int("CameraView", return_default=True, default=CAMERA_VIEW_WIDE) if camera_view not in (CAMERA_VIEW_AUTO, CAMERA_VIEW_DRIVER, CAMERA_VIEW_STANDARD, CAMERA_VIEW_WIDE, CAMERA_VIEW_NONE): return CAMERA_VIEW_WIDE return camera_view diff --git a/selfdrive/ui/onroad/driver_camera_dialog.py b/selfdrive/ui/onroad/driver_camera_dialog.py index e66e04b82..405df20d3 100644 --- a/selfdrive/ui/onroad/driver_camera_dialog.py +++ b/selfdrive/ui/onroad/driver_camera_dialog.py @@ -15,11 +15,13 @@ class DriverCameraDialog(CameraView): self.driver_state_renderer = DriverStateRenderer() # TODO: this can grow unbounded, should be given some thought device.add_interactive_timeout_callback(gui_app.pop_widget) - ui_state.params.put_bool("IsDriverViewEnabled", True) + gui_app.set_render_mode(True) + ui_state.ui_params.put_bool("IsDriverViewEnabled", True) def hide_event(self): super().hide_event() - ui_state.params.put_bool("IsDriverViewEnabled", False) + gui_app.set_render_mode(ui_state.started) + ui_state.ui_params.put_bool("IsDriverViewEnabled", False) self.close() def _handle_mouse_release(self, _): diff --git a/selfdrive/ui/onroad/exp_button.py b/selfdrive/ui/onroad/exp_button.py index d2476c2b3..3783b5341 100644 --- a/selfdrive/ui/onroad/exp_button.py +++ b/selfdrive/ui/onroad/exp_button.py @@ -1,6 +1,5 @@ import time import pyray as rl -from openpilot.common.params import Params from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.widgets import Widget @@ -15,7 +14,7 @@ from openpilot.starpilot.common.experimental_state import ( class ExpButton(Widget): def __init__(self, button_size: int, icon_size: int): super().__init__() - self._params = Params() + self._params = ui_state.ui_params self._experimental_mode: bool = False self._engageable: bool = False diff --git a/selfdrive/ui/onroad/model_renderer.py b/selfdrive/ui/onroad/model_renderer.py index 47d437ac7..b53f2cad5 100644 --- a/selfdrive/ui/onroad/model_renderer.py +++ b/selfdrive/ui/onroad/model_renderer.py @@ -4,7 +4,6 @@ import pyray as rl from cereal import messaging, car from dataclasses import dataclass, field from openpilot.common.filter_simple import FirstOrderFilter -from openpilot.common.params import Params from openpilot.common.constants import CV from openpilot.selfdrive.locationd.calibrationd import HEIGHT_INIT from openpilot.selfdrive.ui.lib.starpilot_theme import get_param_color, get_theme_color, get_visual_color, is_stock_color_scheme, with_alpha @@ -88,7 +87,7 @@ class ModelRenderer(Widget): self._rainbow_path = RainbowPath() # Get longitudinal control setting from car parameters - self._params = Params() + self._params = ui_state.ui_params if car_params := self._params.get("CarParams"): cp = messaging.log_from_bytes(car_params, car.CarParams) self._longitudinal_control = cp.openpilotLongitudinalControl diff --git a/selfdrive/ui/onroad/starpilot/compass.py b/selfdrive/ui/onroad/starpilot/compass.py index b52683825..96d8e9991 100644 --- a/selfdrive/ui/onroad/starpilot/compass.py +++ b/selfdrive/ui/onroad/starpilot/compass.py @@ -1,7 +1,8 @@ from openpilot.selfdrive.ui.ui_state import ui_state def get_compass_text() -> str | None: - if not ui_state.params.get_bool("Compass"): + params = ui_state.ui_params + if not params.get_bool("Compass"): return None # Retrieve bearing diff --git a/selfdrive/ui/onroad/starpilot/developer_sidebar.py b/selfdrive/ui/onroad/starpilot/developer_sidebar.py index 724dfce00..d870c1970 100644 --- a/selfdrive/ui/onroad/starpilot/developer_sidebar.py +++ b/selfdrive/ui/onroad/starpilot/developer_sidebar.py @@ -3,7 +3,6 @@ import time import re import json from cereal import car -from openpilot.common.params import Params from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE from openpilot.system.ui.lib.text_measure import measure_text_cached @@ -64,7 +63,7 @@ def _setting_changed(value: float, reference: float) -> bool: class DeveloperSidebar: def __init__(self): - self._params = Params() + self._params = ui_state.ui_params self._font_bold = gui_app.font(FontWeight.SEMI_BOLD) self._last_toggles_check = 0.0 self._cached_metrics = [0] * 7 diff --git a/selfdrive/ui/onroad/starpilot/navigation_card.py b/selfdrive/ui/onroad/starpilot/navigation_card.py index b856126b5..23be70e1c 100644 --- a/selfdrive/ui/onroad/starpilot/navigation_card.py +++ b/selfdrive/ui/onroad/starpilot/navigation_card.py @@ -100,8 +100,9 @@ class NavigationCardRenderer(Widget): self._collapsed = new_state def _cancel_navigation(self) -> None: + params = ui_state.ui_params try: - ui_state.params.remove("NavDestination") + params.remove("NavDestination") except Exception: pass @@ -162,14 +163,15 @@ class NavigationCardRenderer(Widget): return self._icons[icon_name] def _update_state(self) -> None: - self._enabled = ui_state.params.get_bool("NavigationUI") + params = ui_state.ui_params + self._enabled = params.get_bool("NavigationUI") self._valid = False self._interactive_rect = rl.Rectangle(0, 0, 0, 0) if not self._enabled: return - if not (ui_state.params.get("NavDestination") or ""): + if not (params.get("NavDestination") or ""): return raw_state = ui_state.params_memory.get("NavInstructionState") or {} diff --git a/selfdrive/ui/onroad/starpilot/pedal_icons.py b/selfdrive/ui/onroad/starpilot/pedal_icons.py index dae7d9ebc..0a4417a74 100644 --- a/selfdrive/ui/onroad/starpilot/pedal_icons.py +++ b/selfdrive/ui/onroad/starpilot/pedal_icons.py @@ -6,7 +6,7 @@ _RADIUS = 36 _FONT_SIZE = 36 def render_pedal_icons(start_x: float, start_y: float, font): - params = ui_state.params + params = ui_state.ui_params if not params.get_bool("PedalsOnUI"): return diff --git a/selfdrive/ui/onroad/starpilot/slc_speed_limit.py b/selfdrive/ui/onroad/starpilot/slc_speed_limit.py index cf363f15a..958c041b0 100644 --- a/selfdrive/ui/onroad/starpilot/slc_speed_limit.py +++ b/selfdrive/ui/onroad/starpilot/slc_speed_limit.py @@ -123,8 +123,9 @@ def _get_slc_state(): plan = sm["starpilotPlan"] speed_limit_changed = plan.speedLimitChanged - show_slc = ui_state.params.get_bool("ShowSpeedLimits") or ui_state.params.get_bool("SpeedLimitController") - hide_sl = ui_state.params.get_bool("HideSpeedLimit") + params = ui_state.ui_params + show_slc = params.get_bool("ShowSpeedLimits") or params.get_bool("SpeedLimitController") + hide_sl = params.get_bool("HideSpeedLimit") unconfirmed_valid = plan.unconfirmedSlcSpeedLimit > 1 # A pending (unconfirmed) limit overrides HideSpeedLimit so the prompt always shows. hide = not (speed_limit_changed and unconfirmed_valid) and hide_sl @@ -134,10 +135,10 @@ def _get_slc_state(): return None speed_conversion = CV.MS_TO_KPH if ui_state.is_metric else CV.MS_TO_MPH - show_offset = ui_state.params.get_bool("ShowSLCOffset") + show_offset = params.get_bool("ShowSLCOffset") dashboard_sl = sm["starpilotCarState"].dashboardSpeedLimit if sm.valid.get("starpilotCarState", False) else 0.0 - vision_sl = ui_state.params_memory.get_float("VisionSpeedLimit") if ui_state.params.get_bool("VisionSpeedLimitDetection") else 0.0 + vision_sl = ui_state.params_memory.get_float("VisionSpeedLimit") if params.get_bool("VisionSpeedLimitDetection") else 0.0 slc_overridden_speed = plan.slcOverriddenSpeed # Driver override takes precedence over the planner's limit when active. @@ -171,7 +172,7 @@ def _get_slc_state(): 'speed_limit_changed': speed_limit_changed, 'hide': hide, 'show_offset': show_offset, - 'use_vienna': ui_state.params.get_bool("UseVienna"), + 'use_vienna': params.get_bool("UseVienna"), 'offset_str': offset_str, 'speed_conversion': speed_conversion, 'speed_unit': " km/h" if ui_state.is_metric else " mph", @@ -498,4 +499,3 @@ def render_speed_limit_at(state: dict, rect: rl.Rectangle, expanded: bool = Fals _draw_sources_bubble(state, pill_rect, visual_rect) return pill_rect - diff --git a/selfdrive/ui/onroad/starpilot/starpilot_border.py b/selfdrive/ui/onroad/starpilot/starpilot_border.py index 32de7e5d4..b2a6f4dee 100644 --- a/selfdrive/ui/onroad/starpilot/starpilot_border.py +++ b/selfdrive/ui/onroad/starpilot/starpilot_border.py @@ -56,7 +56,8 @@ def _csc_state(): return None plan = sm["starpilotPlan"] - if plan.speedLimitChanged or not ui_state.params.get_bool("ShowCSCStatus"): + params = ui_state.ui_params + if plan.speedLimitChanged or not params.get_bool("ShowCSCStatus"): return None car_state = sm["carState"] @@ -176,8 +177,9 @@ def render_background_effects(rect: rl.Rectangle, border_width: float): # 1. Turn Signal and Blind Spot indicators car_state = sm["carState"] if sm.valid.get("carState", False) else None if car_state: - show_signal = ui_state.params.get_bool("SignalMetrics") - show_blindspot = ui_state.params.get_bool("BlindSpotMetrics") + params = ui_state.ui_params + show_signal = params.get_bool("SignalMetrics") + show_blindspot = params.get_bool("BlindSpotMetrics") if show_signal or show_blindspot: left_blindspot = car_state.leftBlindspot right_blindspot = car_state.rightBlindspot @@ -221,7 +223,7 @@ def render_background_effects(rect: rl.Rectangle, border_width: float): # 2. Steering Torque Border car_control = sm["carControl"] if sm.valid.get("carControl", False) else None if car_control: - show_steering = ui_state.params.get_bool("ShowSteering") + show_steering = ui_state.ui_params.get_bool("ShowSteering") if show_steering: torque = -car_control.actuators.torque abs_torque = abs(torque) diff --git a/selfdrive/ui/onroad/starpilot/starpilot_onroad_view.py b/selfdrive/ui/onroad/starpilot/starpilot_onroad_view.py index 58fbc0e2c..2392a913a 100644 --- a/selfdrive/ui/onroad/starpilot/starpilot_onroad_view.py +++ b/selfdrive/ui/onroad/starpilot/starpilot_onroad_view.py @@ -1,7 +1,6 @@ import pyray as rl import time from msgq.visionipc import VisionStreamType -from openpilot.common.params import Params from openpilot.selfdrive.ui.onroad.augmented_road_view import AugmentedRoadView from openpilot.selfdrive.ui.onroad.starpilot.starpilot_border import render_behind, render_overlay, render_background_effects from openpilot.selfdrive.ui.onroad.starpilot.path import render_adjacent_lanes, render_path_edges @@ -31,7 +30,7 @@ AlertSize = log.SelfdriveState.AlertSize class StarPilotOnroadView(AugmentedRoadView): def __init__(self, stream_type: VisionStreamType = VisionStreamType.VISION_STREAM_ROAD): super().__init__(stream_type) - self._params = Params() + self._params = ui_state.ui_params self._font_bold = gui_app.font(FontWeight.BOLD) self._font_medium = gui_app.font(FontWeight.MEDIUM) diff --git a/selfdrive/ui/onroad/starpilot/stopping_point.py b/selfdrive/ui/onroad/starpilot/stopping_point.py index eb823f01c..4eb067bc6 100644 --- a/selfdrive/ui/onroad/starpilot/stopping_point.py +++ b/selfdrive/ui/onroad/starpilot/stopping_point.py @@ -14,7 +14,7 @@ def _draw_poly_outline(cx: float, cy: float, sides: int, radius: float, rotation rl.draw_line_ex(p1, p2, thickness, color) def render_stopping_point(renderer, font): - params = ui_state.params + params = ui_state.ui_params if not params.get_bool("ShowStoppingPoint"): return diff --git a/selfdrive/ui/onroad/starpilot/widgets/pedal_icons.py b/selfdrive/ui/onroad/starpilot/widgets/pedal_icons.py index 94a6ef06e..bb5967907 100644 --- a/selfdrive/ui/onroad/starpilot/widgets/pedal_icons.py +++ b/selfdrive/ui/onroad/starpilot/widgets/pedal_icons.py @@ -12,7 +12,7 @@ class PedalIconsWidget(LayoutWidget): @property def is_visible(self) -> bool: - params = ui_state.params + params = ui_state.ui_params if not params.get_bool("PedalsOnUI"): return False # Only render when car state is valid diff --git a/selfdrive/ui/onroad/starpilot/widgets/personality_button.py b/selfdrive/ui/onroad/starpilot/widgets/personality_button.py index 05b809536..df7eea759 100644 --- a/selfdrive/ui/onroad/starpilot/widgets/personality_button.py +++ b/selfdrive/ui/onroad/starpilot/widgets/personality_button.py @@ -12,7 +12,8 @@ class PersonalityButtonWidget(LayoutWidget): @property def is_visible(self) -> bool: - toggle_on = ui_state.params.get_bool("OnroadDistanceButton") + params = ui_state.ui_params + toggle_on = params.get_bool("OnroadDistanceButton") return bool(toggle_on and ui_state.started and ui_state.has_longitudinal_control) def get_size(self) -> tuple[float, float]: diff --git a/selfdrive/ui/onroad/starpilot/widgets/speed_limit.py b/selfdrive/ui/onroad/starpilot/widgets/speed_limit.py index da3909a9c..63e28d2b5 100644 --- a/selfdrive/ui/onroad/starpilot/widgets/speed_limit.py +++ b/selfdrive/ui/onroad/starpilot/widgets/speed_limit.py @@ -47,7 +47,8 @@ class SpeedLimitWidget(LayoutWidget): def _render(self, rect: rl.Rectangle) -> None: if self._slc_state is None: return - expanded = ui_state.params.get_bool("SpeedLimitSources") + params = ui_state.ui_params + expanded = params.get_bool("SpeedLimitSources") self._pill_rect = render_speed_limit_at(self._slc_state, rect, expanded) def _handle_mouse_press(self, mouse_pos) -> None: @@ -59,8 +60,9 @@ class SpeedLimitWidget(LayoutWidget): self._pill_rect.height + 40 ) if rl.check_collision_point_rec(mouse_pos, hit_rect): - current = ui_state.params.get_bool("SpeedLimitSources") - Params().put_bool("SpeedLimitSources", not current) + params = ui_state.ui_params + current = params.get_bool("SpeedLimitSources") + params.put_bool("SpeedLimitSources", not current) return state = _get_slc_state() diff --git a/selfdrive/ui/tests/test_aethergrid.py b/selfdrive/ui/tests/test_aethergrid.py index b79337e62..2ffd1898b 100644 --- a/selfdrive/ui/tests/test_aethergrid.py +++ b/selfdrive/ui/tests/test_aethergrid.py @@ -2,7 +2,7 @@ import importlib import sys import types import unittest -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch MODULE_NAME = "openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid" @@ -32,6 +32,7 @@ def _install_aethergrid_stubs(): Texture2D=type("Texture2D", (), {}), Font=type("Font", (), {}), GuiTextAlignment=types.SimpleNamespace(TEXT_ALIGN_CENTER=0), + BlendMode=types.SimpleNamespace(BLEND_ALPHA_PREMULTIPLY=1), WHITE=types.SimpleNamespace(r=255, g=255, b=255, a=255), draw_rectangle_rounded=lambda *a, **k: None, draw_rectangle_rounded_lines_ex=lambda *a, **k: None, @@ -44,6 +45,8 @@ def _install_aethergrid_stubs(): draw_line_ex=lambda *a, **k: None, draw_triangle=lambda *a, **k: None, draw_texture_pro=lambda *a, **k: None, + begin_blend_mode=lambda *a, **k: None, + end_blend_mode=lambda *a, **k: None, draw_text_ex=lambda *a, **k: None, check_collision_point_rec=lambda p, r: (r.x <= p.x <= r.x + r.width) and (r.y <= p.y <= r.y + r.height), get_frame_time=lambda: 0.016, @@ -153,10 +156,13 @@ def _install_panel_stubs(aethergrid): sectioned_mod = types.ModuleType(SECTIONED_PANEL_MODULE_NAME) sectioned_mod.SectionedTileLayout = type("SectionedTileLayout", (), {}) sectioned_mod.TileSection = type("TileSection", (), {}) + starpilot_variables_mod = types.ModuleType("openpilot.starpilot.common.starpilot_variables") + starpilot_variables_mod.update_starpilot_toggles = lambda: None _register_modules({ SECTIONED_PANEL_MODULE_NAME: sectioned_mod, "openpilot.common.params": types.SimpleNamespace(Params=type("Params", (), {}), UnknownKeyName=Exception), + "openpilot.starpilot.common.starpilot_variables": starpilot_variables_mod, MODULE_NAME: aethergrid, }) @@ -251,6 +257,58 @@ class TestAethergridContracts(unittest.TestCase): self.assertEqual(tile.get_status(), "Download 50%") + def test_custom_icon_draws_directly_while_cache_fill_is_pending(self): + mod = _import_aethergrid() + scribble = sys.modules["openpilot.selfdrive.ui.layouts.settings.starpilot.scribble"] + geometry = MagicMock() + cache = MagicMock(return_value=None) + + color = mod.rl.Color(255, 255, 255, 255) + with patch.object(scribble, "_draw_custom_icon_geometry", geometry), \ + patch.object(scribble.gui_app, "cached_render_texture", cache, create=True): + scribble.draw_custom_icon("sound", 10, 20, 1.0, color) + + cache.assert_called_once() + geometry.assert_called_once_with("sound", 10, 20, 1.0, color) + + def test_custom_icon_uses_completed_cache_without_redrawing_geometry(self): + mod = _import_aethergrid() + scribble = sys.modules["openpilot.selfdrive.ui.layouts.settings.starpilot.scribble"] + geometry = MagicMock() + cache = MagicMock(return_value=object()) + draw_texture = MagicMock() + + with patch.object(scribble, "_draw_custom_icon_geometry", geometry), \ + patch.object(scribble.gui_app, "cached_render_texture", cache, create=True), \ + patch.object(scribble.rl, "draw_texture_pro", draw_texture): + scribble.draw_custom_icon("sound", 10, 20, 1.0, mod.rl.Color(255, 255, 255, 255)) + + geometry.assert_not_called() + draw_texture.assert_called_once() + destination = draw_texture.call_args.args[2] + self.assertLess(destination.x, 10) + self.assertLess(destination.y, 20) + + def test_translucent_custom_icon_uses_premultiplied_blending(self): + mod = _import_aethergrid() + scribble = sys.modules["openpilot.selfdrive.ui.layouts.settings.starpilot.scribble"] + geometry = MagicMock() + cache = MagicMock(return_value=object()) + begin_blend = MagicMock() + end_blend = MagicMock() + + color = mod.rl.Color(160, 170, 185, 80) + with patch.object(scribble, "_draw_custom_icon_geometry", geometry), \ + patch.object(scribble.gui_app, "cached_render_texture", cache, create=True), \ + patch.object(scribble.rl, "begin_blend_mode", begin_blend), \ + patch.object(scribble.rl, "end_blend_mode", end_blend): + scribble.draw_custom_icon("first_aid", 10, 20, 0.8, color) + + cache.assert_called_once() + geometry.assert_not_called() + begin_blend.assert_called_once_with(mod.rl.BlendMode.BLEND_ALPHA_PREMULTIPLY) + end_blend.assert_called_once() + def test_tile_grid_reflows_to_wider_tiles_when_width_is_tight(self): mod = _import_aethergrid() grid = mod.TileGrid(columns=None, padding=mod.SPACING.tile_gap) diff --git a/selfdrive/ui/tests/test_ui_param_cache.py b/selfdrive/ui/tests/test_ui_param_cache.py new file mode 100644 index 000000000..badda96ec --- /dev/null +++ b/selfdrive/ui/tests/test_ui_param_cache.py @@ -0,0 +1,121 @@ +import unittest + +from openpilot.selfdrive.ui.lib.ui_param_cache import UIParamCache + + +class FakeParams: + def __init__(self): + self.values = {"enabled": False, "count": 1} + self.calls = [] + + def get(self, key, **_kwargs): + self.calls.append(("get", key)) + return self.values.get(key) + + def get_bool(self, key, **_kwargs): + self.calls.append(("get_bool", key)) + return bool(self.values.get(key, False)) + + def get_int(self, key, **_kwargs): + self.calls.append(("get_int", key)) + return int(self.values.get(key, 0)) + + def get_float(self, key, **_kwargs): + self.calls.append(("get_float", key)) + return float(self.values.get(key, 0.0)) + + def put(self, key, value, **_kwargs): + self.values[key] = value + + def put_bool(self, key, value, **_kwargs): + self.values[key] = value + + def put_int(self, key, value, **_kwargs): + self.values[key] = value + + def put_float(self, key, value, **_kwargs): + self.values[key] = value + + def put_nonblocking(self, key, value): + self.values[key] = value + + def put_bool_nonblocking(self, key, value): + self.values[key] = value + + def remove(self, key): + self.values.pop(key, None) + + def clear_all(self, marker=None): + self.calls.append(("clear_all", marker)) + self.values.clear() + + +class TestUIParamCache(unittest.TestCase): + def test_reads_are_shared_until_ttl(self): + now = [0.0] + params = FakeParams() + cached = UIParamCache(params, ttl=0.1, clock=lambda: now[0]) + + self.assertFalse(cached.get_bool("enabled")) + self.assertFalse(cached.get_bool("enabled")) + self.assertEqual(params.calls, [("get_bool", "enabled")]) + + now[0] = 0.11 + params.values["enabled"] = True + self.assertTrue(cached.get_bool("enabled")) + self.assertEqual(params.calls.count(("get_bool", "enabled")), 2) + + def test_writes_invalidate_immediately(self): + params = FakeParams() + cached = UIParamCache(params, ttl=10.0) + + self.assertFalse(cached.get_bool("enabled")) + cached.put_bool("enabled", True) + self.assertTrue(cached.get_bool("enabled")) + self.assertEqual(params.calls.count(("get_bool", "enabled")), 2) + + def test_zero_ttl_disables_caching(self): + params = FakeParams() + cached = UIParamCache(params, ttl=0.0) + + self.assertFalse(cached.get_bool("enabled")) + params.values["enabled"] = True + self.assertTrue(cached.get_bool("enabled")) + self.assertEqual(params.calls.count(("get_bool", "enabled")), 2) + + def test_all_write_paths_invalidate(self): + operations = ( + lambda cache: cache.put("enabled", True), + lambda cache: cache.put_bool("enabled", True), + lambda cache: cache.put_int("enabled", 1), + lambda cache: cache.put_float("enabled", 1.0), + lambda cache: cache.put_nonblocking("enabled", True), + lambda cache: cache.put_bool_nonblocking("enabled", True), + ) + for operation in operations: + with self.subTest(operation=operation): + params = FakeParams() + cached = UIParamCache(params, ttl=10.0) + self.assertFalse(cached.get_bool("enabled")) + operation(cached) + self.assertTrue(cached.get_bool("enabled")) + self.assertEqual(params.calls.count(("get_bool", "enabled")), 2) + + def test_remove_and_clear_all_invalidate(self): + params = FakeParams() + cached = UIParamCache(params, ttl=10.0) + + self.assertTrue(cached.get_int("count")) + cached.remove("count") + self.assertEqual(cached.get_int("count"), 0) + + self.assertFalse(cached.get_bool("enabled")) + params.values["enabled"] = True + cached.clear_all("flag") + params.values["enabled"] = True + self.assertTrue(cached.get_bool("enabled")) + self.assertIn(("clear_all", "flag"), params.calls) + + +if __name__ == "__main__": + unittest.main() diff --git a/selfdrive/ui/ui_state.py b/selfdrive/ui/ui_state.py index 59393f5aa..74b170914 100644 --- a/selfdrive/ui/ui_state.py +++ b/selfdrive/ui/ui_state.py @@ -10,6 +10,7 @@ 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.selfdrive.ui.lib.ui_param_cache import shared_ui_params from openpilot.system.hardware.usb import chestnut_present from openpilot.system.ui.lib.application import gui_app from openpilot.system.hardware import HARDWARE, PC @@ -35,6 +36,9 @@ class UIState: def _initialize(self): self.params = Params() + # BIG UI views use this read-through cache; keep ``params`` untouched for + # MICI and for non-rendering callers that rely on its exact semantics. + self.ui_params = shared_ui_params() if gui_app.big_ui() else self.params self.params_memory = Params(memory=True) self.sm = messaging.SubMaster( [ @@ -176,24 +180,26 @@ class UIState: self.light_sensor = -1 # Trust hardwared's filtered started state; raw ignition can flap on Toyota. - force_onroad = self.params.get_bool("ForceOnroad") - force_offroad = self.params.get_bool("ForceOffroad") + # Use the BIG-UI cache here as this path runs once per render iteration. + params = self.ui_params + force_onroad = params.get_bool("ForceOnroad") + force_offroad = params.get_bool("ForceOffroad") started = self.sm["deviceState"].started started |= force_onroad started &= not force_offroad self.started = started # Update recording audio state - self.recording_audio = self.params.get_bool("RecordAudio") and self.started + self.recording_audio = params.get_bool("RecordAudio") and self.started - self.is_metric = self.params.get_bool("IsMetric") - self.always_on_dm = self.params.get_bool("AlwaysOnDM") + self.is_metric = params.get_bool("IsMetric") + self.always_on_dm = params.get_bool("AlwaysOnDM") now = time.monotonic() if now - self._usbgpu_update_time >= USBGPU_POLL_INTERVAL: self.usbgpu = chestnut_present() self._usbgpu_update_time = now - self.usbgpu_compiled = self.params.get_bool("UsbGpuCompiled") - self.usbgpu_active = self.params.get_bool("UsbGpuActive") + self.usbgpu_compiled = params.get_bool("UsbGpuCompiled") + self.usbgpu_active = params.get_bool("UsbGpuActive") self.switchback_mode_enabled = self.params_memory.get_bool("SwitchbackModeEnabled") if self.started else False if self.sm.valid.get("starpilotCarState", False): starpilot_car_state = self.sm["starpilotCarState"] @@ -269,7 +275,7 @@ class Device: self._interactive_timeout_callbacks: list[Callable] = [] self._prev_timed_out = False self._awake: bool = True - self._params = Params() + self._params = ui_state.ui_params self._offroad_brightness: int = BACKLIGHT_OFFROAD self._last_brightness: int = 0 diff --git a/selfdrive/ui/widgets/exp_mode_button.py b/selfdrive/ui/widgets/exp_mode_button.py index b1fd40f14..2e578aea0 100644 --- a/selfdrive/ui/widgets/exp_mode_button.py +++ b/selfdrive/ui/widgets/exp_mode_button.py @@ -1,5 +1,4 @@ import pyray as rl -from openpilot.common.params import Params from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.lib.text_measure import measure_text_cached @@ -17,7 +16,7 @@ class ExperimentalModeButton(Widget): self.horizontal_padding = 25 self.button_height = 125 - self.params = Params() + self.params = ui_state.ui_params self.experimental_mode = requested_experimental_mode(self.params, ui_state.params_memory) self.mode_variant = get_mode_banner_variant(self.params, ui_state.params_memory) diff --git a/system/ui/lib/application.py b/system/ui/lib/application.py index 6fb8dd5f1..fd85dd25f 100644 --- a/system/ui/lib/application.py +++ b/system/ui/lib/application.py @@ -30,6 +30,8 @@ FPS_CRITICAL_THRESHOLD = 0.5 # Critical threshold for triggering strict actions MOUSE_THREAD_RATE = 140 # touch controller runs at 140Hz DESKTOP_MOUSE_THREAD_RATE = int(os.getenv("DESKTOP_MOUSE_RATE", "500")) DESKTOP_CLICK_DEBOUNCE = float(os.getenv("DESKTOP_CLICK_DEBOUNCE", "0.2")) +UI_IDLE_FPS = int(os.getenv("UI_IDLE_FPS", "0")) +UI_INTERACTION_FPS_DURATION = 1.25 MAX_TOUCH_SLOTS = 2 TOUCH_HISTORY_TIMEOUT = 3.0 # Seconds before touch points fade out @@ -496,7 +498,14 @@ class GuiApplication: self._ffmpeg_stop_event: threading.Event | None = None self._progress_hook: Callable[[str], None] | None = None self._textures: dict[str, rl.Texture] = {} + self._cached_render_textures: dict[str, rl.RenderTexture] = {} + self._pending_render_textures: dict[str, tuple[int, int, Callable[[], None]]] = {} self._target_fps: int = _DEFAULT_FPS + self._full_target_fps: int = _DEFAULT_FPS + self._idle_target_fps: int = max(10, _DEFAULT_FPS // 4) + self._adaptive_rendering = False + self._full_rate_rendering = False + self._high_fps_until = 0.0 self._last_fps_log_time: float = time.monotonic() self._burn_in_start_time = time.monotonic() self._frame = 0 @@ -538,6 +547,50 @@ class GuiApplication: def target_fps(self): return self._target_fps + def _set_target_fps(self, fps: int) -> None: + fps = max(1, int(fps)) + if fps == self._target_fps: + return + rl.set_target_fps(0 if OFFSCREEN else fps) + self._target_fps = fps + + def configure_adaptive_rendering(self, enabled: bool, idle_fps: int | None = None) -> None: + """Enable low-rate rendering for static BIG-UI offroad screens. + + The normal target remains unchanged unless a caller opts in. This keeps + MICI and all existing non-BIG layouts on their current scheduling path. + """ + # Recording feeds raw frames to ffmpeg at the fixed full FPS, so changing + # the producer rate would make idle portions play back too quickly. + self._adaptive_rendering = bool(enabled and not OFFSCREEN and not RECORD) + if idle_fps is None or idle_fps <= 0: + idle_fps = UI_IDLE_FPS if UI_IDLE_FPS > 0 else max(10, self._full_target_fps // 4) + self._idle_target_fps = min(self._full_target_fps, max(1, int(idle_fps))) + self._full_rate_rendering = False + self._high_fps_until = time.monotonic() + UI_INTERACTION_FPS_DURATION if self._adaptive_rendering else 0.0 + if self._adaptive_rendering: + self._apply_render_mode() + else: + self._set_target_fps(self._full_target_fps) + + def request_high_fps(self, duration: float = UI_INTERACTION_FPS_DURATION) -> None: + if not self._adaptive_rendering: + return + self._high_fps_until = max(self._high_fps_until, time.monotonic() + max(0.0, duration)) + self._apply_render_mode() + + def set_render_mode(self, active: bool) -> None: + if not self._adaptive_rendering: + return + self._full_rate_rendering = active + self._apply_render_mode() + + def _apply_render_mode(self) -> None: + if not self._adaptive_rendering: + return + high_rate = self._full_rate_rendering or time.monotonic() < self._high_fps_until + self._set_target_fps(self._full_target_fps if high_rate else self._idle_target_fps) + def request_close(self): self._window_close_requested = True @@ -607,6 +660,7 @@ class GuiApplication: # OFFSCREEN disables FPS limiting for fast offline rendering (e.g. clips) rl.set_target_fps(0 if OFFSCREEN else fps) + self._full_target_fps = fps self._target_fps = fps self._set_styles() self._load_fonts() @@ -681,6 +735,7 @@ class GuiApplication: prev_widget.set_enabled(False) self._nav_stack.append(widget) + self.request_high_fps() widget.show_event() widget.set_enabled(True) @@ -702,6 +757,7 @@ class GuiApplication: widget = self._nav_stack.pop(idx_to_pop) widget.hide_event() + self.request_high_fps() def pop_widgets_to(self, widget: object, callback: Callable[[], None] | None = None, instant: bool = False): # Pops middle widgets instantly without animation then dismisses top, animated out if NavWidget @@ -750,6 +806,8 @@ class GuiApplication: def set_should_render(self, should_render: bool): self._should_render = should_render + if should_render: + self.request_high_fps() def texture(self, asset_path: str, width: int | None = None, height: int | None = None, alpha_premultiply=False, keep_aspect_ratio=True, flip_x: bool = False) -> rl.Texture: @@ -774,6 +832,62 @@ class GuiApplication: self._textures[cache_key] = texture_obj return texture_obj + def cached_render_texture(self, cache_key: str, width: int, height: int, + render: Callable[[], None]) -> object | None: + """Return a cached texture, scheduling cache misses between frames. + + Raylib render-texture modes are not nestable. Widgets call this while the + main framebuffer (often another render texture) is active, so cache misses + must be populated after the frame has been presented. + """ + cached = self._cached_render_textures.get(cache_key) + if cached is not None: + return cached.texture + + self._pending_render_textures.setdefault( + cache_key, (max(1, int(width)), max(1, int(height)), render) + ) + return None + + def _populate_render_texture_cache(self) -> None: + pending = self._pending_render_textures + self._pending_render_textures = {} + for cache_key, (width, height, render) in pending.items(): + if cache_key in self._cached_render_textures: + continue + + cached = rl.load_render_texture(max(1, int(width)), max(1, int(height))) + began_texture_mode = False + began_blend_mode = False + try: + rl.begin_texture_mode(cached) + began_texture_mode = True + rl.clear_background(rl.Color(0, 0, 0, 0)) + # Preserve straight alpha while RGB is accumulated premultiplied. The + # resulting texture can then be composited with BLEND_ALPHA_PREMULTIPLY + # without squaring translucent vector alpha. + rl.rl_set_blend_factors_separate( + rl.RL_SRC_ALPHA, rl.RL_ONE_MINUS_SRC_ALPHA, + rl.RL_ONE, rl.RL_ONE_MINUS_SRC_ALPHA, + rl.RL_FUNC_ADD, rl.RL_FUNC_ADD, + ) + rl.begin_blend_mode(rl.BlendMode.BLEND_CUSTOM_SEPARATE) + began_blend_mode = True + render() + except Exception: + if began_blend_mode: + rl.end_blend_mode() + if began_texture_mode: + rl.end_texture_mode() + rl.unload_render_texture(cached) + raise + else: + rl.end_blend_mode() + rl.end_texture_mode() + rl.set_texture_filter(cached.texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR) + rl.set_texture_wrap(cached.texture, rl.TextureWrap.TEXTURE_WRAP_CLAMP) + self._cached_render_textures[cache_key] = cached + def _load_image_from_path(self, image_path: str, width: int | None = None, height: int | None = None, alpha_premultiply: bool = False, keep_aspect_ratio: bool = True, flip_x: bool = False) -> rl.Image: """Load and resize an image, storing it for later automatic unloading.""" @@ -850,6 +964,11 @@ class GuiApplication: rl.unload_texture(texture) self._textures = {} + for render_texture in self._cached_render_textures.values(): + rl.unload_render_texture(render_texture) + self._cached_render_textures = {} + self._pending_render_textures = {} + for font in self._fonts.values(): rl.unload_font(font) self._fonts = {} @@ -890,6 +1009,7 @@ class GuiApplication: while not (self._window_close_requested or rl.window_should_close()): self._mark_progress("gui_app.loop_start") + self._apply_render_mode() if PC: # Thread is not used on PC, need to manually add mouse events. self._mouse._handle_mouse_event() @@ -898,6 +1018,7 @@ class GuiApplication: self._mouse_events = self._mouse.get_events() if len(self._mouse_events) > 0: self._last_mouse_event = self._mouse_events[-1] + self.request_high_fps() # Skip rendering when screen is off if not self._should_render: @@ -987,6 +1108,7 @@ class GuiApplication: self._mark_progress("gui_app.before_end_drawing") rl.end_drawing() self._mark_progress("gui_app.after_end_drawing") + self._populate_render_texture_cache() if RECORD: image = rl.load_image_from_texture(self._render_texture.texture)