Big UI : Stopped Timer Fix

This commit is contained in:
firestarsdog
2026-08-02 22:53:46 -04:00
parent 22463e8323
commit a6a7475e7a
8 changed files with 257 additions and 75 deletions
@@ -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"),
+2 -1
View File
@@ -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)
@@ -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():
@@ -76,4 +76,3 @@ class WidgetLayoutManager:
for widget in zone:
if widget.is_visible and widget.name not in skip:
widget.render(widget.rect)
@@ -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",
]
@@ -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,
)
@@ -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",
@@ -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