From 2031a3318890ae4959c8925ddf5a71e3b7dac3ba Mon Sep 17 00:00:00 2001 From: Dean Lee Date: Sat, 7 Jun 2025 13:14:18 +0800 Subject: [PATCH] ui: add experimental mode toggle button with visual indicator (#35446) * add experimental mode toggle button with visual indicator * merge master * implement a temporary state hold after mouse click" * move to seperate class --------- Co-authored-by: Shane Smiskol --- selfdrive/ui/layouts/main.py | 13 ++-- selfdrive/ui/onroad/augmented_road_view.py | 11 +++- selfdrive/ui/onroad/exp_button.py | 75 ++++++++++++++++++++++ selfdrive/ui/onroad/hud_renderer.py | 27 ++++---- 4 files changed, 104 insertions(+), 22 deletions(-) create mode 100644 selfdrive/ui/onroad/exp_button.py diff --git a/selfdrive/ui/layouts/main.py b/selfdrive/ui/layouts/main.py index a6beabe671..1f28068010 100644 --- a/selfdrive/ui/layouts/main.py +++ b/selfdrive/ui/layouts/main.py @@ -38,7 +38,6 @@ class MainLayout(Widget): self._update_layout_rects(rect) self._handle_onroad_transition() self._render_main_content() - self._handle_input() if self._current_callback: self._current_callback() @@ -51,6 +50,7 @@ class MainLayout(Widget): self._layouts[MainState.SETTINGS].set_callbacks( on_close=lambda: setattr(self, '_current_callback', self._set_mode_for_state) ) + self._layouts[MainState.ONROAD].on_click = self._on_onrad_clicked def _update_layout_rects(self, rect): self._window_rect = rect @@ -80,6 +80,9 @@ class MainLayout(Widget): def _on_flag_clicked(self): pass + def _on_onrad_clicked(self): + self._sidebar_visible = not self._sidebar_visible + def _render_main_content(self): # Render sidebar if self._sidebar_visible: @@ -87,11 +90,3 @@ class MainLayout(Widget): content_rect = self._content_rect if self._sidebar_visible else self._window_rect self._layouts[self._current_mode].render(content_rect) - - def _handle_input(self): - if self._current_mode != MainState.ONROAD or not rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT): - return - - mouse_pos = rl.get_mouse_position() - if rl.check_collision_point_rec(mouse_pos, self._content_rect): - self._sidebar_visible = not self._sidebar_visible diff --git a/selfdrive/ui/onroad/augmented_road_view.py b/selfdrive/ui/onroad/augmented_road_view.py index 6520c827be..95e0efc6d6 100644 --- a/selfdrive/ui/onroad/augmented_road_view.py +++ b/selfdrive/ui/onroad/augmented_road_view.py @@ -1,6 +1,6 @@ import numpy as np import pyray as rl - +from collections.abc import Callable from cereal import log from msgq.visionipc import VisionStreamType from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus, UI_BORDER_SIZE @@ -49,6 +49,9 @@ class AugmentedRoadView(CameraView): self.alert_renderer = AlertRenderer() self.driver_state_renderer = DriverStateRenderer() + # Callbacks + self.on_click: Callable | None = None + def render(self, rect): # Only render when system is started to avoid invalid data access if not ui_state.started: @@ -94,6 +97,12 @@ class AugmentedRoadView(CameraView): # End clipping region rl.end_scissor_mode() + # Handle click events if no HUD interaction occurred + if not self._hud_renderer.handle_mouse_event(): + if self.on_click and rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT): + if rl.check_collision_point_rec(rl.get_mouse_position(), self._content_rect): + self.on_click() + def _draw_border(self, rect: rl.Rectangle): border_color = BORDER_COLORS.get(ui_state.status, BORDER_COLORS[UIStatus.DISENGAGED]) rl.draw_rectangle_lines_ex(rect, UI_BORDER_SIZE, border_color) diff --git a/selfdrive/ui/onroad/exp_button.py b/selfdrive/ui/onroad/exp_button.py new file mode 100644 index 0000000000..f240b58d84 --- /dev/null +++ b/selfdrive/ui/onroad/exp_button.py @@ -0,0 +1,75 @@ +import time +import pyray as rl +from cereal.messaging import SubMaster +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.ui.lib.application import gui_app +from openpilot.common.params import Params + + +class ExpButton: + def __init__(self, button_size: int, icon_size: int): + self._params = Params() + self._experimental_mode: bool = False + self._engageable: bool = False + + # State hold mechanism + self._hold_duration = 2.0 # seconds + self._held_mode: bool | None = None + self._hold_end_time: float | None = None + + self._white_color: rl.Color = rl.Color(255, 255, 255, 255) + self._black_bg: rl.Color = rl.Color(0, 0, 0, 166) + self._txt_wheel: rl.Texture = gui_app.texture('icons/chffr_wheel.png', icon_size, icon_size) + self._txt_exp: rl.Texture = gui_app.texture('icons/experimental.png', icon_size, icon_size) + self._rect: rl.Rectangle = rl.Rectangle(0, 0, button_size, button_size) + + def update_state(self, sm: SubMaster) -> None: + selfdrive_state = sm["selfdriveState"] + self._experimental_mode = selfdrive_state.experimentalMode + self._engageable = selfdrive_state.engageable or selfdrive_state.enabled + + def handle_mouse_event(self) -> bool: + if rl.check_collision_point_rec(rl.get_mouse_position(), self._rect): + if (rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT) and + self._is_toggle_allowed()): + new_mode = not self._experimental_mode + self._params.put_bool("ExperimentalMode", new_mode) + + # Hold new state temporarily + self._held_mode = new_mode + self._hold_end_time = time.time() + self._hold_duration + return True + return False + + def draw(self, x: int, y: int) -> None: + self._rect.x, self._rect.y = x, y + center_x = int(self._rect.x + self._rect.width // 2) + center_y = int(self._rect.y + self._rect.height // 2) + + mouse_over = rl.check_collision_point_rec(rl.get_mouse_position(), self._rect) + mouse_down = rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT) + self._white_color.a = 180 if (mouse_down and mouse_over) or not self._engageable else 255 + + texture = self._txt_exp if self._held_or_actual_mode() else self._txt_wheel + rl.draw_circle(center_x, center_y, self._rect.width / 2, self._black_bg) + rl.draw_texture(texture, center_x - texture.width // 2, center_y - texture.height // 2, self._white_color) + + def _held_or_actual_mode(self): + now = time.time() + if self._hold_end_time and now < self._hold_end_time: + return self._held_mode + + if self._hold_end_time and now >= self._hold_end_time: + self._hold_end_time = self._held_mode = None + + return self._experimental_mode + + def _is_toggle_allowed(self): + if not self._params.get_bool("ExperimentalModeConfirmed"): + return False + + car_params = ui_state.sm["carParams"] + if car_params.alphaLongitudinalAvailable: + return self._params.get_bool("AlphaLongitudinalEnabled") + else: + return car_params.openpilotLongitudinalControl diff --git a/selfdrive/ui/onroad/hud_renderer.py b/selfdrive/ui/onroad/hud_renderer.py index 6904bf4d3f..3985927060 100644 --- a/selfdrive/ui/onroad/hud_renderer.py +++ b/selfdrive/ui/onroad/hud_renderer.py @@ -2,6 +2,7 @@ import pyray as rl from dataclasses import dataclass from cereal.messaging import SubMaster from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus +from openpilot.selfdrive.ui.onroad.exp_button import ExpButton from openpilot.system.ui.lib.application import gui_app, FontWeight, Widget from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.common.conversions import Conversions as CV @@ -62,11 +63,13 @@ class HudRenderer(Widget): self.set_speed: float = SET_SPEED_NA self.speed: float = 0.0 self.v_ego_cluster_seen: bool = False - self._wheel_texture: rl.Texture = gui_app.texture('icons/chffr_wheel.png', UI_CONFIG.wheel_icon_size, UI_CONFIG.wheel_icon_size) + self._font_semi_bold: rl.Font = gui_app.font(FontWeight.SEMI_BOLD) self._font_bold: rl.Font = gui_app.font(FontWeight.BOLD) self._font_medium: rl.Font = gui_app.font(FontWeight.MEDIUM) + self._exp_button = ExpButton(UI_CONFIG.button_size, UI_CONFIG.wheel_icon_size) + def _update_state(self, sm: SubMaster) -> None: """Update HUD state based on car state and controls state.""" if sm.recv_frame["carState"] < ui_state.started_frame: @@ -94,9 +97,13 @@ class HudRenderer(Widget): speed_conversion = CV.MS_TO_KPH if ui_state.is_metric else CV.MS_TO_MPH self.speed = max(0.0, v_ego * speed_conversion) + self._exp_button.update_state(sm) + def render(self, rect: rl.Rectangle) -> None: """Render HUD elements to the screen.""" self._update_state(ui_state.sm) + + # Draw the header background rl.draw_rectangle_gradient_v( int(rect.x), int(rect.y), @@ -110,7 +117,13 @@ class HudRenderer(Widget): self._draw_set_speed(rect) self._draw_current_speed(rect) - self._draw_wheel_icon(rect) + + button_x = rect.x + rect.width - UI_CONFIG.border_size - UI_CONFIG.button_size + button_y = rect.y + UI_CONFIG.border_size + self._exp_button.draw(button_x, button_y) + + def handle_mouse_event(self) -> bool: + return bool(self._exp_button.handle_mouse_event()) def _draw_set_speed(self, rect: rl.Rectangle) -> None: """Draw the MAX speed indicator box.""" @@ -166,13 +179,3 @@ class HudRenderer(Widget): unit_text_size = measure_text_cached(self._font_medium, unit_text, FONT_SIZES.speed_unit) unit_pos = rl.Vector2(rect.x + rect.width / 2 - unit_text_size.x / 2, 290 - unit_text_size.y / 2) rl.draw_text_ex(self._font_medium, unit_text, unit_pos, FONT_SIZES.speed_unit, 0, COLORS.white_translucent) - - def _draw_wheel_icon(self, rect: rl.Rectangle) -> None: - """Draw the steering wheel icon with status-based opacity.""" - center_x = int(rect.x + rect.width - UI_CONFIG.border_size - UI_CONFIG.button_size / 2) - center_y = int(rect.y + UI_CONFIG.border_size + UI_CONFIG.button_size / 2) - rl.draw_circle(center_x, center_y, UI_CONFIG.button_size / 2, COLORS.black_translucent) - - opacity = 0.7 if ui_state.status == UIStatus.DISENGAGED else 1.0 - img_pos = rl.Vector2(center_x - self._wheel_texture.width / 2, center_y - self._wheel_texture.height / 2) - rl.draw_texture_v(self._wheel_texture, img_pos, rl.Color(255, 255, 255, int(255 * opacity)))