Files
StarPilot/selfdrive/ui/onroad/starpilot/widgets/stopped_timer.py
T
2026-08-02 22:53:46 -04:00

132 lines
3.9 KiB
Python

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,
)