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
This commit is contained in:
Shane Smiskol
2025-10-09 19:50:27 -07:00
committed by GitHub
parent 1b90b42647
commit 2976798852
4 changed files with 195 additions and 54 deletions
+153 -36
View File
@@ -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 <b>chill mode</b>. Experimental mode enables <b>alpha-level features</b> that aren't ready for chill mode. " +
"Experimental features are listed below:<br>" +
"<h4>End-to-End Longitudinal Control</h4><br>" +
"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.<br>" +
"<h4>New Driving Visualization</h4><br>" +
"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("<b>" + long_desc + "</b><br><br>" + 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)
+18 -6
View File
@@ -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:
+18 -10
View File
@@ -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,
+6 -2
View File
@@ -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):