From 29767988525b1e1d3dac3bd573a7328da788c909 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Thu, 9 Oct 2025 19:50:27 -0700 Subject: [PATCH] raylib: implement toggles (#36284) * start on exp mode * more * fmt * rm * 2nd try * almost there * clean up * and this * fmt * more * exp is colored when active * move out, and rm redudnant self.state * revert html changes for now * fix untagged text inheriting previous tag * why would this be unknown * here too * update live with car * clean up + refresh toggles on showEvent + catch from cursor about setting desc if no carparams * not sure why * fix disengaged re-enabling locked toggles --- selfdrive/ui/layouts/settings/toggles.py | 189 ++++++++++++++++++----- selfdrive/ui/ui_state.py | 24 ++- system/ui/widgets/list_view.py | 28 ++-- system/ui/widgets/toggle.py | 8 +- 4 files changed, 195 insertions(+), 54 deletions(-) diff --git a/selfdrive/ui/layouts/settings/toggles.py b/selfdrive/ui/layouts/settings/toggles.py index 1e0e7bfd53..01f663d4b9 100644 --- a/selfdrive/ui/layouts/settings/toggles.py +++ b/selfdrive/ui/layouts/settings/toggles.py @@ -1,7 +1,11 @@ -from openpilot.common.params import Params +from cereal import log +from openpilot.common.params import Params, 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 import Scroller +from openpilot.selfdrive.ui.ui_state import ui_state + +PERSONALITY_TO_INT = log.LongitudinalPersonality.schema.enumerants # Description constants DESCRIPTIONS = { @@ -30,66 +34,179 @@ class TogglesLayout(Widget): def __init__(self): super().__init__() self._params = Params() - items = [ - toggle_item( + + # param, title, desc, icon, needs_restart + self._toggle_defs = { + "OpenpilotEnabledToggle": ( "Enable openpilot", DESCRIPTIONS["OpenpilotEnabledToggle"], - self._params.get_bool("OpenpilotEnabledToggle"), - icon="chffr_wheel.png", + "chffr_wheel.png", + True, ), - toggle_item( + "ExperimentalMode": ( "Experimental Mode", - initial_state=self._params.get_bool("ExperimentalMode"), - icon="experimental_white.png", + "", + "experimental_white.png", + False, ), - toggle_item( + "DisengageOnAccelerator": ( "Disengage on Accelerator Pedal", DESCRIPTIONS["DisengageOnAccelerator"], - self._params.get_bool("DisengageOnAccelerator"), - icon="disengage_on_accelerator.png", + "disengage_on_accelerator.png", + False, ), - multiple_button_item( - "Driving Personality", - DESCRIPTIONS["LongitudinalPersonality"], - buttons=["Aggressive", "Standard", "Relaxed"], - button_width=255, - callback=self._set_longitudinal_personality, - selected_index=self._params.get("LongitudinalPersonality", return_default=True), - icon="speed_limit.png" - ), - toggle_item( + "IsLdwEnabled": ( "Enable Lane Departure Warnings", DESCRIPTIONS["IsLdwEnabled"], - self._params.get_bool("IsLdwEnabled"), - icon="warning.png", + "warning.png", + False, ), - toggle_item( + "AlwaysOnDM": ( "Always-On Driver Monitoring", DESCRIPTIONS["AlwaysOnDM"], - self._params.get_bool("AlwaysOnDM"), - icon="monitoring.png", + "monitoring.png", + False, ), - toggle_item( + "RecordFront": ( "Record and Upload Driver Camera", DESCRIPTIONS["RecordFront"], - self._params.get_bool("RecordFront"), - icon="monitoring.png", + "monitoring.png", + True, ), - toggle_item( + "RecordAudio": ( "Record and Upload Microphone Audio", DESCRIPTIONS["RecordAudio"], - self._params.get_bool("RecordAudio"), - icon="microphone.png", + "microphone.png", + True, ), - toggle_item( - "Use Metric System", DESCRIPTIONS["IsMetric"], self._params.get_bool("IsMetric"), icon="metric.png" + "IsMetric": ( + "Use Metric System", + DESCRIPTIONS["IsMetric"], + "metric.png", + False, ), - ] + } - self._scroller = Scroller(items, line_separator=True, spacing=0) + self._long_personality_setting = multiple_button_item( + "Driving Personality", + DESCRIPTIONS["LongitudinalPersonality"], + buttons=["Aggressive", "Standard", "Relaxed"], + button_width=255, + callback=self._set_longitudinal_personality, + selected_index=self._params.get("LongitudinalPersonality", return_default=True), + icon="speed_limit.png" + ) + + self._toggles = {} + self._locked_toggles = set() + for param, (title, desc, icon, needs_restart) in self._toggle_defs.items(): + toggle = toggle_item( + title, + desc, + self._params.get_bool(param), + callback=lambda state, p=param: self._toggle_callback(state, p), + icon=icon, + ) + + try: + locked = self._params.get_bool(param + "Lock") + except UnknownKeyName: + locked = False + toggle.action_item.set_enabled(not locked) + + if needs_restart and not locked: + toggle.set_description(toggle.description + " Changing this setting will restart openpilot if the car is powered on.") + + # track for engaged state updates + if locked: + self._locked_toggles.add(param) + + self._toggles[param] = toggle + + # insert longitudinal personality after NDOG toggle + if param == "DisengageOnAccelerator": + self._toggles["LongitudinalPersonality"] = self._long_personality_setting + + self._update_experimental_mode_icon() + self._scroller = Scroller(list(self._toggles.values()), line_separator=True, spacing=0) + + def _update_state(self): + if ui_state.sm.updated["selfdriveState"]: + personality = PERSONALITY_TO_INT[ui_state.sm["selfdriveState"].personality] + if personality != ui_state.personality and ui_state.started: + self._long_personality_setting.action_item.set_selected_button(personality) + ui_state.personality = personality + + # 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: + self._toggles[toggle_def].action_item.set_enabled(not ui_state.engaged) + + def show_event(self): + self._update_toggles() + + def _update_toggles(self): + e2e_description = ( + "openpilot defaults to driving in chill mode. Experimental mode enables alpha-level features that aren't ready for chill mode. " + + "Experimental features are listed below:
" + + "

End-to-End Longitudinal Control


" + + "Let the driving model control the gas and brakes. openpilot will drive as it thinks a human would, including stopping for red lights and stop signs. " + + "Since the driving model decides the speed to drive, the set speed will only act as an upper bound. This is an alpha quality feature; " + + "mistakes should be expected.
" + + "

New Driving Visualization


" + + "The driving visualization will transition to the road-facing wide-angle camera at low speeds to better show some turns. " + + "The Experimental mode logo will also be shown in the top right corner." + ) + + is_release = self._params.get_bool("IsReleaseBranch") + + if ui_state.CP is not None: + if ui_state.has_longitudinal_control: + self._toggles["ExperimentalMode"].action_item.set_enabled(True) + self._toggles["ExperimentalMode"].set_description(e2e_description) + self._long_personality_setting.action_item.set_enabled(True) + else: + # no long for now + self._toggles["ExperimentalMode"].action_item.set_enabled(False) + self._toggles["ExperimentalMode"].action_item.set_state(False) + self._long_personality_setting.action_item.set_enabled(False) + self._params.remove("ExperimentalMode") + + unavailable = "Experimental mode is currently unavailable on this car since the car's stock ACC is used for longitudinal control." + + long_desc = unavailable + " openpilot longitudinal control may come in a future update." + if ui_state.CP.getAlphaLongitudinalAvailable(): + if is_release: + long_desc = unavailable + " " + ("An alpha version of openpilot longitudinal control can be tested, along with " + + "Experimental mode, on non-release branches.") + else: + long_desc = "Enable the openpilot longitudinal control (alpha) toggle to allow Experimental mode." + + self._toggles["ExperimentalMode"].set_description("" + long_desc + "

" + e2e_description) + else: + self._toggles["ExperimentalMode"].set_description(e2e_description) + + 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)) def _render(self, rect): self._scroller.render(rect) + def _update_experimental_mode_icon(self): + icon = "experimental.png" if self._toggles["ExperimentalMode"].action_item.get_state() else "experimental_white.png" + self._toggles["ExperimentalMode"].set_icon(icon) + + def _toggle_callback(self, state: bool, param: str): + if param == "ExperimentalMode": + self._update_experimental_mode_icon() + + self._params.put_bool(param, state) + if self._toggle_defs[param][3]: + self._params.put_bool("OnroadCycleRequested", True) + def _set_longitudinal_personality(self, button_index: int): self._params.put("LongitudinalPersonality", button_index) diff --git a/selfdrive/ui/ui_state.py b/selfdrive/ui/ui_state.py index 9c45519b4b..be83517980 100644 --- a/selfdrive/ui/ui_state.py +++ b/selfdrive/ui/ui_state.py @@ -4,9 +4,9 @@ import time import threading from collections.abc import Callable from enum import Enum -from cereal import messaging, log +from cereal import messaging, car, log from openpilot.common.filter_simple import FirstOrderFilter -from openpilot.common.params import Params, UnknownKeyName +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 @@ -69,7 +69,10 @@ class UIState: self.ignition: bool = False self.panda_type: log.PandaState.PandaType = log.PandaState.PandaType.unknown self.personality: log.LongitudinalPersonality = log.LongitudinalPersonality.standard + self.has_longitudinal_control: bool = False + self.CP: car.CarParams | None = None self.light_sensor: float = -1.0 + self._param_update_time: float = 0.0 self._update_params() @@ -87,6 +90,9 @@ class UIState: self.sm.update(0) self._update_state() self._update_status() + if time.monotonic() - self._param_update_time > 5.0: + self._update_params() + self._param_update_time = time.monotonic() device.update() def _update_state(self) -> None: @@ -137,10 +143,16 @@ class UIState: self._started_prev = self.started def _update_params(self) -> None: - try: - self.is_metric = self.params.get_bool("IsMetric") - except UnknownKeyName: - self.is_metric = False + self.is_metric = self.params.get_bool("IsMetric") + + # Update longitudinal control state + CP_bytes = self.params.get("CarParams") + if CP_bytes is not None: + self.CP = messaging.log_from_bytes(CP_bytes, car.CarParams) + if self.CP.alphaLongitudinalAvailable: + self.has_longitudinal_control = self.params.get_bool("AlphaLongitudinalEnabled") + else: + self.has_longitudinal_control = self.CP.openpilotLongitudinalControl class Device: diff --git a/system/ui/widgets/list_view.py b/system/ui/widgets/list_view.py index 509c49be35..bbf66c6555 100644 --- a/system/ui/widgets/list_view.py +++ b/system/ui/widgets/list_view.py @@ -56,10 +56,10 @@ class ItemAction(Widget, ABC): class ToggleAction(ItemAction): - def __init__(self, initial_state: bool = False, width: int = TOGGLE_WIDTH, enabled: bool | Callable[[], bool] = True): + def __init__(self, initial_state: bool = False, width: int = TOGGLE_WIDTH, enabled: bool | Callable[[], bool] = True, + callback: Callable[[bool], None] | None = None): super().__init__(width, enabled) - self.toggle = Toggle(initial_state=initial_state) - self.state = initial_state + self.toggle = Toggle(initial_state=initial_state, callback=callback) def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None: super().set_touch_valid_callback(touch_callback) @@ -68,15 +68,13 @@ class ToggleAction(ItemAction): def _render(self, rect: rl.Rectangle) -> bool: self.toggle.set_enabled(self.enabled) clicked = self.toggle.render(rl.Rectangle(rect.x, rect.y + (rect.height - TOGGLE_HEIGHT) / 2, self._rect.width, TOGGLE_HEIGHT)) - self.state = self.toggle.get_state() return bool(clicked) def set_state(self, state: bool): - self.state = state self.toggle.set_state(state) def get_state(self) -> bool: - return self.state + return self.toggle.get_state() class ButtonAction(ItemAction): @@ -207,6 +205,13 @@ class MultipleButtonAction(ItemAction): self.callback = callback self._font = gui_app.font(FontWeight.MEDIUM) + def set_selected_button(self, index: int): + if 0 <= index < len(self.buttons): + self.selected_button = index + + def get_selected_button(self) -> int: + return self.selected_button + def _render(self, rect: rl.Rectangle): spacing = RIGHT_ITEM_PADDING button_y = rect.y + (rect.height - BUTTON_HEIGHT) / 2 @@ -259,7 +264,7 @@ class ListItem(Widget): action_item: ItemAction | None = None): super().__init__() self.title = title - self.icon = icon + self.set_icon(icon) self._description = description self.description_visible = description_visible self.callback = callback @@ -267,7 +272,6 @@ class ListItem(Widget): self.set_rect(rl.Rectangle(0, 0, ITEM_BASE_WIDTH, ITEM_BASE_HEIGHT)) self._font = gui_app.font(FontWeight.NORMAL) - self._icon_texture = gui_app.texture(os.path.join("icons", self.icon), ICON_SIZE, ICON_SIZE) if self.icon else None self._html_renderer = HtmlRenderer(text="", text_size={ElementType.P: ITEM_DESC_FONT_SIZE}, text_color=ITEM_DESC_TEXT_COLOR) @@ -352,6 +356,10 @@ class ListItem(Widget): if self.callback: self.callback() + def set_icon(self, icon: str | None): + self.icon = icon + self._icon_texture = gui_app.texture(os.path.join("icons", self.icon), ICON_SIZE, ICON_SIZE) if self.icon else None + def set_description(self, description: str | Callable[[], str] | None): self._description = description new_desc = self.description @@ -398,8 +406,8 @@ def simple_item(title: str, callback: Callable | None = None) -> ListItem: def toggle_item(title: str, description: str | Callable[[], str] | None = None, initial_state: bool = False, callback: Callable | None = None, icon: str = "", enabled: bool | Callable[[], bool] = True) -> ListItem: - action = ToggleAction(initial_state=initial_state, enabled=enabled) - return ListItem(title=title, description=description, action_item=action, icon=icon, callback=callback) + action = ToggleAction(initial_state=initial_state, enabled=enabled, callback=callback) + return ListItem(title=title, description=description, action_item=action, icon=icon) def button_item(title: str, button_text: str | Callable[[], str], description: str | Callable[[], str] | None = None, diff --git a/system/ui/widgets/toggle.py b/system/ui/widgets/toggle.py index 968afda9c8..0fbf3c844a 100644 --- a/system/ui/widgets/toggle.py +++ b/system/ui/widgets/toggle.py @@ -1,4 +1,5 @@ import pyray as rl +from collections.abc import Callable from openpilot.system.ui.lib.application import MousePos from openpilot.system.ui.widgets import Widget @@ -14,9 +15,10 @@ ANIMATION_SPEED = 8.0 class Toggle(Widget): - def __init__(self, initial_state=False): + def __init__(self, initial_state: bool = False, callback: Callable[[bool], None] | None = None): super().__init__() self._state = initial_state + self._callback = callback self._enabled = True self._progress = 1.0 if initial_state else 0.0 self._target = self._progress @@ -32,8 +34,10 @@ class Toggle(Widget): self._clicked = True self._state = not self._state self._target = 1.0 if self._state else 0.0 + if self._callback: + self._callback(self._state) - def get_state(self): + def get_state(self) -> bool: return self._state def set_state(self, state: bool):