From a6a7475e7a38bd349c7683b857262e3f0064b49a Mon Sep 17 00:00:00 2001 From: firestarsdog <229254897+firestarsdog@users.noreply.github.com> Date: Sun, 2 Aug 2026 22:53:46 -0400 Subject: [PATCH] Big UI : Stopped Timer Fix --- .../layouts/settings/starpilot/appearance.py | 3 +- selfdrive/ui/onroad/hud_renderer.py | 3 +- .../onroad/starpilot/starpilot_onroad_view.py | 83 ++--------- .../onroad/starpilot/widget_layout_manager.py | 1 - .../ui/onroad/starpilot/widgets/__init__.py | 2 + .../onroad/starpilot/widgets/stopped_timer.py | 131 ++++++++++++++++++ .../ui/tests/test_onroad_render_layers.py | 1 + .../ui/tests/test_stopped_timer_widget.py | 108 +++++++++++++++ 8 files changed, 257 insertions(+), 75 deletions(-) create mode 100644 selfdrive/ui/onroad/starpilot/widgets/stopped_timer.py create mode 100644 selfdrive/ui/tests/test_stopped_timer_widget.py diff --git a/selfdrive/ui/layouts/settings/starpilot/appearance.py b/selfdrive/ui/layouts/settings/starpilot/appearance.py index cdd7473bb..04b7b94c8 100644 --- a/selfdrive/ui/layouts/settings/starpilot/appearance.py +++ b/selfdrive/ui/layouts/settings/starpilot/appearance.py @@ -316,8 +316,7 @@ class StarPilotAppearanceLayout(_SettingsPage): SettingRow("StoppedTimer", "toggle", tr_noop("Stopped Timer"), subtitle="", get_state=lambda: self._params.get_bool("StoppedTimer"), - set_state=lambda s: self._params.put_bool("StoppedTimer", s), - visible=hud_on), + set_state=lambda s: self._params.put_bool("StoppedTimer", s)), SettingRow("ShowCSCStatus", "toggle", tr_noop("CSC Status Widget"), subtitle=tr_noop("Show the Curve Speed Controller target speed and ambient border glow."), get_state=lambda: self._params.get_bool("ShowCSCStatus"), diff --git a/selfdrive/ui/onroad/hud_renderer.py b/selfdrive/ui/onroad/hud_renderer.py index 597aa85bf..94d0523cd 100644 --- a/selfdrive/ui/onroad/hud_renderer.py +++ b/selfdrive/ui/onroad/hud_renderer.py @@ -76,6 +76,7 @@ class HudRenderer(Widget): self._navigation_card = NavigationCardRenderer() self.draw_set_speed = True + self.draw_current_speed = True self.draw_exp_button = True def _update_state(self) -> None: @@ -136,7 +137,7 @@ class HudRenderer(Widget): if self.draw_set_speed and self.is_cruise_available and not ui_state.starpilot_toggles.get("hide_max_speed", False): self._draw_set_speed(rect) - if not ui_state.starpilot_toggles.get("hide_speed", False): + if self.draw_current_speed and not ui_state.starpilot_toggles.get("hide_speed", False): self._draw_current_speed(rect) self._navigation_card.render(rect) diff --git a/selfdrive/ui/onroad/starpilot/starpilot_onroad_view.py b/selfdrive/ui/onroad/starpilot/starpilot_onroad_view.py index 5d8accc93..7104866d0 100644 --- a/selfdrive/ui/onroad/starpilot/starpilot_onroad_view.py +++ b/selfdrive/ui/onroad/starpilot/starpilot_onroad_view.py @@ -1,5 +1,4 @@ import pyray as rl -import time from msgq.visionipc import VisionStreamType 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 @@ -10,14 +9,13 @@ from openpilot.selfdrive.ui.onroad.starpilot.widget_layout_manager import Widget from openpilot.selfdrive.ui.onroad.starpilot.widgets import ( SetSpeedWidget, SpeedLimitWidget, PedalIconsWidget, AetherGaugeWidget, PersonalityButtonWidget, DriverMonitorWidget, - SteeringWheelWidget + SteeringWheelWidget, StoppedTimerWidget ) from openpilot.selfdrive.ui.onroad.starpilot.stopping_point import render_stopping_point from openpilot.selfdrive.ui.onroad.starpilot.pause_indicators import render_lateral_paused, render_longitudinal_paused from openpilot.selfdrive.ui.onroad.starpilot.weather_icon import render_weather_icon from openpilot.selfdrive.ui.lib.starpilot_status import ( - get_screen_edge_color, ENGAGED_COLOR, - EXPERIMENTAL_COLOR, TRAFFIC_COLOR, + get_screen_edge_color, ) from openpilot.system.ui.lib.application import MousePos, gui_app, FontWeight @@ -34,7 +32,6 @@ class StarPilotOnroadView(AugmentedRoadView): self._font_bold = gui_app.font(FontWeight.BOLD) self._font_medium = gui_app.font(FontWeight.MEDIUM) - self._standstill_started_at = 0.0 self._torque_bar = TorqueBar() self._min_fps = 99.9 self._max_fps = 0.0 @@ -55,6 +52,7 @@ class StarPilotOnroadView(AugmentedRoadView): self._pedals_widget = PedalIconsWidget() self._personality_button_widget = PersonalityButtonWidget() self._driver_monitor_widget = DriverMonitorWidget(self.driver_state_renderer) + self._stopped_timer_widget = StoppedTimerWidget(self.is_in_reverse) # Register to layout zones self.layout_manager.register_widget("left", self._set_speed_widget) @@ -73,12 +71,17 @@ class StarPilotOnroadView(AugmentedRoadView): self._child(self._pedals_widget) self._child(self._personality_button_widget) self._child(self._driver_monitor_widget) + self._child(self._stopped_timer_widget) def _render(self, rect: rl.Rectangle): border_width = self._get_border_width() border_color = get_screen_edge_color(ui_state) rl.draw_rectangle_rounded(rect, 0.12, 10, border_color) render_background_effects(rect, border_width) + + self._hud_renderer.draw_current_speed = ( + ui_state.started and not self._stopped_timer_widget.replaces_current_speed + ) super()._render(rect) if not ui_state.started: @@ -109,10 +112,13 @@ class StarPilotOnroadView(AugmentedRoadView): def _render_overlays(self): alert_showing, _ = self.alert_renderer.will_render() + if alert_showing is not None and alert_showing.size == AlertSize.full: + return + + self._stopped_timer_widget.render(self._content_rect) if alert_showing is not None: return - self._render_standstill_timer() self._render_developer_metrics() self.layout_manager.render_widgets(exclude={"speed_limit", "set_speed"}) @@ -155,71 +161,6 @@ class StarPilotOnroadView(AugmentedRoadView): alert_showing, _ = self.alert_renderer.will_render() return alert_showing is not None and alert_showing.size == AlertSize.full - def _render_standstill_timer(self): - if not self._params.get_bool("stopped_timer"): - self._standstill_started_at = 0.0 - return - if not ui_state.sm.valid.get("carState", False): - return - - car_state = ui_state.sm["carState"] - if getattr(car_state, "standstill", False): - if self._standstill_started_at == 0.0: - self._standstill_started_at = time.monotonic() - else: - self._standstill_started_at = 0.0 - return - - if self._standstill_started_at == 0.0: - return - - duration = int(time.monotonic() - self._standstill_started_at) - if duration < 60: - return - - minutes = duration // 60 - seconds = duration % 60 - minute_text = f"{minutes} minute{'s' if minutes != 1 else ''}" - second_text = f"{seconds} second{'s' if seconds != 1 else ''}" - minute_size = measure_text_cached(self._font_bold, minute_text, 176) - second_size = measure_text_cached(self._font_medium, second_text, 66) - - def blend_colors(start: rl.Color, end: rl.Color, transition: float) -> rl.Color: - transition = float(min(max(transition, 0.0), 1.0)) - return rl.Color( - int(start.r + transition * (end.r - start.r)), - int(start.g + transition * (end.g - start.g)), - int(start.b + transition * (end.b - start.b)), - 255, - ) - - if duration < 150: - transition = (duration - 60) / 90.0 - duration_color = blend_colors(ENGAGED_COLOR, EXPERIMENTAL_COLOR, transition) - elif duration < 300: - transition = (duration - 150) / 150.0 - duration_color = blend_colors(EXPERIMENTAL_COLOR, TRAFFIC_COLOR, transition) - else: - duration_color = TRAFFIC_COLOR - - x = gui_app.width / 2 - rl.draw_text_ex( - self._font_bold, - minute_text, - rl.Vector2(x - minute_size.x / 2, 210 - minute_size.y / 2), - 176, - 0, - duration_color, - ) - rl.draw_text_ex( - self._font_medium, - second_text, - rl.Vector2(x - second_size.x / 2, 290 - second_size.y / 2), - 66, - 0, - rl.Color(255, 255, 255, 242), - ) - def _handle_mouse_press(self, mouse_pos: MousePos): # Check if click maps to any of the layout widgets for zone in self.layout_manager.zones.values(): diff --git a/selfdrive/ui/onroad/starpilot/widget_layout_manager.py b/selfdrive/ui/onroad/starpilot/widget_layout_manager.py index 7d1513fde..7536801cc 100644 --- a/selfdrive/ui/onroad/starpilot/widget_layout_manager.py +++ b/selfdrive/ui/onroad/starpilot/widget_layout_manager.py @@ -76,4 +76,3 @@ class WidgetLayoutManager: for widget in zone: if widget.is_visible and widget.name not in skip: widget.render(widget.rect) - diff --git a/selfdrive/ui/onroad/starpilot/widgets/__init__.py b/selfdrive/ui/onroad/starpilot/widgets/__init__.py index 1f1518689..f90b67e01 100644 --- a/selfdrive/ui/onroad/starpilot/widgets/__init__.py +++ b/selfdrive/ui/onroad/starpilot/widgets/__init__.py @@ -6,6 +6,7 @@ from openpilot.selfdrive.ui.onroad.starpilot.widgets.aethergauge import AetherGa from openpilot.selfdrive.ui.onroad.starpilot.widgets.personality_button import PersonalityButtonWidget from openpilot.selfdrive.ui.onroad.starpilot.widgets.driver_monitor import DriverMonitorWidget from openpilot.selfdrive.ui.onroad.starpilot.widgets.steering_wheel import SteeringWheelWidget +from openpilot.selfdrive.ui.onroad.starpilot.widgets.stopped_timer import StoppedTimerWidget __all__ = [ "LayoutWidget", @@ -16,4 +17,5 @@ __all__ = [ "PersonalityButtonWidget", "DriverMonitorWidget", "SteeringWheelWidget", + "StoppedTimerWidget", ] diff --git a/selfdrive/ui/onroad/starpilot/widgets/stopped_timer.py b/selfdrive/ui/onroad/starpilot/widgets/stopped_timer.py new file mode 100644 index 000000000..e730d500b --- /dev/null +++ b/selfdrive/ui/onroad/starpilot/widgets/stopped_timer.py @@ -0,0 +1,131 @@ +import time +from collections.abc import Callable + +import pyray as rl + +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.selfdrive.ui.lib.starpilot_status import ( + ENGAGED_COLOR, EXPERIMENTAL_COLOR, TRAFFIC_COLOR, +) +from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.text_measure import measure_text_cached +from openpilot.system.ui.widgets import Widget + + +class StoppedTimerWidget(Widget): + SHOW_AFTER_SECONDS = 60 + + def __init__(self, in_reverse: Callable[[], bool] | None = None): + super().__init__() + self.set_enabled(False) + self._in_reverse = in_reverse or (lambda: False) + self._font_bold = gui_app.font(FontWeight.BOLD) + self._font_normal = gui_app.font(FontWeight.NORMAL) + self._standstill_started_at: float | None = None + self._started_frame = -1 + self._duration = 0 + + @property + def is_visible(self) -> bool: + return self._update_timer() > 0 + + @property + def replaces_current_speed(self) -> bool: + return self.is_visible + + def _update_timer(self) -> int: + started_frame = getattr(ui_state, "started_frame", 0) + if started_frame != self._started_frame: + self._started_frame = started_frame + self._reset_timer() + + params = ui_state.ui_params + if (not ui_state.started or + not params.get_bool("QOLVisuals") or + not params.get_bool("StoppedTimer") or + not ui_state.sm.valid.get("carState", False)): + self._reset_timer() + return 0 + + if self._in_reverse(): + self._reset_timer() + return 0 + + try: + if ui_state.sm.recv_frame["carState"] < started_frame: + self._reset_timer() + return 0 + except (AttributeError, KeyError, TypeError): + pass + + if not getattr(ui_state.sm["carState"], "standstill", False): + self._reset_timer() + return 0 + + now = time.monotonic() + if self._standstill_started_at is None: + self._standstill_started_at = now + + if now - getattr(ui_state, "started_time", 0.0) < self.SHOW_AFTER_SECONDS: + self._duration = 0 + return 0 + + self._duration = max(0, int(now - self._standstill_started_at)) + return self._duration + + def _reset_timer(self) -> None: + self._standstill_started_at = None + self._duration = 0 + + @staticmethod + def _format_duration_text(duration: int) -> tuple[str, str]: + minutes = duration // 60 + seconds = duration % 60 + return ( + f"{minutes} minute{'s' if minutes != 1 else ''}", + f"{seconds} second{'s' if seconds != 1 else ''}", + ) + + def _render(self, rect: rl.Rectangle) -> None: + duration = self._duration + minute_text, second_text = self._format_duration_text(duration) + + minute_size = measure_text_cached(self._font_bold, minute_text, 176) + second_size = measure_text_cached(self._font_normal, second_text, 66) + + if duration < 150: + transition = (duration - 60) / 90.0 + duration_color = self._blend_colors(ENGAGED_COLOR, EXPERIMENTAL_COLOR, transition) + elif duration < 300: + transition = (duration - 150) / 150.0 + duration_color = self._blend_colors(EXPERIMENTAL_COLOR, TRAFFIC_COLOR, transition) + else: + duration_color = TRAFFIC_COLOR + + center_x = rect.x + rect.width / 2 + rl.draw_text_ex( + self._font_bold, + minute_text, + rl.Vector2(center_x - minute_size.x / 2, rect.y + 210 - minute_size.y), + 176, + 0, + duration_color, + ) + rl.draw_text_ex( + self._font_normal, + second_text, + rl.Vector2(center_x - second_size.x / 2, rect.y + 290 - second_size.y), + 66, + 0, + rl.Color(255, 255, 255, 255), + ) + + @staticmethod + def _blend_colors(start: rl.Color, end: rl.Color, transition: float) -> rl.Color: + transition = min(max(transition, 0.0), 1.0) + return rl.Color( + int(start.r + transition * (end.r - start.r)), + int(start.g + transition * (end.g - start.g)), + int(start.b + transition * (end.b - start.b)), + 255, + ) diff --git a/selfdrive/ui/tests/test_onroad_render_layers.py b/selfdrive/ui/tests/test_onroad_render_layers.py index 3638f3034..5ef7d15bc 100644 --- a/selfdrive/ui/tests/test_onroad_render_layers.py +++ b/selfdrive/ui/tests/test_onroad_render_layers.py @@ -75,6 +75,7 @@ def _load_starpilot_onroad_view(monkeypatch): PersonalityButtonWidget=dummy_widget, DriverMonitorWidget=dummy_widget, SteeringWheelWidget=dummy_widget, + StoppedTimerWidget=dummy_widget, ) stub_module( "openpilot.selfdrive.ui.onroad.starpilot.stopping_point", diff --git a/selfdrive/ui/tests/test_stopped_timer_widget.py b/selfdrive/ui/tests/test_stopped_timer_widget.py new file mode 100644 index 000000000..5bcd6271b --- /dev/null +++ b/selfdrive/ui/tests/test_stopped_timer_widget.py @@ -0,0 +1,108 @@ +import importlib.util +import sys +from pathlib import Path +from types import ModuleType, SimpleNamespace + + +def _load_stopped_timer(monkeypatch): + rl = SimpleNamespace( + Color=lambda r, g, b, a=255: SimpleNamespace(r=r, g=g, b=b, a=a), + Rectangle=lambda x=0, y=0, width=0, height=0: SimpleNamespace(x=x, y=y, width=width, height=height), + Vector2=lambda x, y: SimpleNamespace(x=x, y=y), + WHITE=SimpleNamespace(r=255, g=255, b=255, a=255), + draw_text_ex=lambda *_args: None, + ) + monkeypatch.setitem(sys.modules, "pyray", rl) + + def module(name, **attributes): + result = ModuleType(name) + for key, value in attributes.items(): + setattr(result, key, value) + monkeypatch.setitem(sys.modules, name, result) + + class Widget: + def __init__(self): + pass + + def set_enabled(self, _enabled): + pass + + module("openpilot.system.ui.widgets", Widget=Widget) + module( + "openpilot.system.ui.lib.application", + FontWeight=SimpleNamespace(BOLD=1, NORMAL=2), + gui_app=SimpleNamespace(font=lambda *_args: None), + ) + module( + "openpilot.system.ui.lib.text_measure", + measure_text_cached=lambda *_args: SimpleNamespace(x=100, y=20), + ) + module( + "openpilot.selfdrive.ui.lib.starpilot_status", + ENGAGED_COLOR=rl.Color(22, 127, 64), + EXPERIMENTAL_COLOR=rl.Color(218, 111, 37), + TRAFFIC_COLOR=rl.Color(201, 34, 49), + ) + module("openpilot.selfdrive.ui.ui_state", ui_state=SimpleNamespace()) + + module_path = Path(__file__).parents[1] / "onroad/starpilot/widgets/stopped_timer.py" + spec = importlib.util.spec_from_file_location("stopped_timer_under_test", module_path) + stopped_timer = importlib.util.module_from_spec(spec) + spec.loader.exec_module(stopped_timer) + return stopped_timer + + +def test_stopped_timer_visibility_waits_for_onroad_grace_period(monkeypatch): + stopped_timer = _load_stopped_timer(monkeypatch) + params = SimpleNamespace(get_bool=lambda key: key in {"QOLVisuals", "StoppedTimer"}) + car_state = SimpleNamespace(standstill=True) + + class SubMaster: + valid = {"carState": True} + recv_frame = {"carState": 1} + + def __getitem__(self, _key): + return car_state + + ui_state = SimpleNamespace( + started=True, + started_frame=1, + started_time=100.0, + ui_params=params, + sm=SubMaster(), + ) + monkeypatch.setattr(stopped_timer, "ui_state", ui_state) + + now = iter((100.0, 159.9, 160.0)) + monkeypatch.setattr(stopped_timer.time, "monotonic", lambda: next(now)) + + widget = stopped_timer.StoppedTimerWidget() + + assert not widget.is_visible + assert not widget.is_visible + assert widget.is_visible + + +def test_stopped_timer_uses_qt_text_contract(monkeypatch): + stopped_timer = _load_stopped_timer(monkeypatch) + + assert stopped_timer.StoppedTimerWidget._format_duration_text(61) == ("1 minute", "1 second") + assert stopped_timer.StoppedTimerWidget._format_duration_text(121) == ("2 minutes", "1 second") + + +def test_stopped_timer_draws_qt_positions_and_opaque_seconds(monkeypatch): + stopped_timer = _load_stopped_timer(monkeypatch) + widget = stopped_timer.StoppedTimerWidget() + widget._duration = 61 + draws = [] + monkeypatch.setattr(stopped_timer.rl, "draw_text_ex", lambda *args: draws.append(args)) + + widget._render(stopped_timer.rl.Rectangle(0, 0, 2160, 1080)) + + assert draws[0][2].x == 1030 + assert draws[0][2].y == 190 + assert draws[0][3] == 176 + assert draws[1][2].x == 1030 + assert draws[1][2].y == 270 + assert draws[1][3] == 66 + assert draws[1][5].a == 255