Files
StarPilot/selfdrive/ui/onroad/alert_renderer.py
T
2026-07-05 00:20:55 -04:00

283 lines
11 KiB
Python

import time
import pyray as rl
from dataclasses import dataclass
from cereal import messaging, log, custom
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.common.filter_simple import BounceFilter, FirstOrderFilter
from openpilot.system.hardware import TICI
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.label import UnifiedLabel
AlertSize = log.SelfdriveState.AlertSize
AlertStatus = log.SelfdriveState.AlertStatus
StarPilotAlertStatus = custom.StarPilotSelfdriveState.AlertStatus
ALERT_MARGIN = 40
ALERT_PADDING = 60
ALERT_HEIGHTS = {
AlertSize.small: 271,
AlertSize.mid: 420,
}
ALERT_SLIDE_DISTANCES = {
AlertSize.small: 40,
AlertSize.mid: 70,
AlertSize.full: 100,
}
SMALL_FONT_SIZE = 60
MID_FONT_SIZE_1 = 88
MID_FONT_SIZE_2 = 56
SELFDRIVE_STATE_TIMEOUT = 5
SELFDRIVE_UNRESPONSIVE_TIMEOUT = 10
ALERT_COLORS = {
AlertStatus.normal: rl.Color(0, 0, 0, 255),
AlertStatus.userPrompt: rl.Color(255, 115, 0, 255),
AlertStatus.critical: rl.Color(255, 0, 21, 255),
StarPilotAlertStatus.starpilot: rl.Color(23, 134, 68, 255),
}
@dataclass
class Alert:
text1: str = ""
text2: str = ""
size: int = 0
status: int = 0
alert_type: str = ""
ALERT_STARTUP_PENDING = Alert(
text1=tr("openpilot Unavailable"),
text2=tr("Waiting to start"),
size=AlertSize.mid,
status=AlertStatus.normal,
)
ALERT_CRITICAL_TIMEOUT = Alert(
text1=tr("TAKE CONTROL IMMEDIATELY"),
text2=tr("System Unresponsive"),
size=AlertSize.full,
status=AlertStatus.critical,
)
ALERT_CRITICAL_REBOOT = Alert(
text1=tr("System Unresponsive"),
text2=tr("Reboot Device"),
size=AlertSize.mid,
status=AlertStatus.normal,
)
class AlertRenderer(Widget):
def __init__(self):
super().__init__()
self._alert_text1_label = UnifiedLabel(text="", font_size=48, font_weight=FontWeight.BOLD,
line_height=0.86, letter_spacing=-0.02)
self._alert_text2_label = UnifiedLabel(text="", font_size=32, font_weight=FontWeight.NORMAL,
line_height=0.86, letter_spacing=0.025)
self._prev_alert: Alert | None = None
self._current_alert: Alert | None = None
self._alert_y_filter = BounceFilter(0, 0.1, 1 / gui_app.target_fps, initialized=False)
self._alpha_filter = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps)
def get_alert(self, sm: messaging.SubMaster) -> Alert | None:
ss = sm['selfdriveState']
if not sm.updated['selfdriveState']:
recv_frame = sm.recv_frame['selfdriveState']
time_since_onroad = time.monotonic() - ui_state.started_time
waiting_for_startup = recv_frame < ui_state.started_frame
if waiting_for_startup and time_since_onroad > 5:
return ALERT_STARTUP_PENDING
if TICI and not waiting_for_startup:
ss_missing = time.monotonic() - sm.recv_time['selfdriveState']
if ss_missing > SELFDRIVE_STATE_TIMEOUT:
if ss.enabled and (ss_missing - SELFDRIVE_STATE_TIMEOUT) < SELFDRIVE_UNRESPONSIVE_TIMEOUT:
return ALERT_CRITICAL_TIMEOUT
return ALERT_CRITICAL_REBOOT
if ss.alertSize != AlertSize.none:
ret = Alert(text1=ss.alertText1, text2=ss.alertText2, size=ss.alertSize.raw, status=ss.alertStatus.raw,
alert_type=ss.alertType)
else:
starpilot_ss = sm["starpilotSelfdriveState"]
if starpilot_ss.alertSize == custom.StarPilotSelfdriveState.AlertSize.none:
return None
ret = Alert(text1=starpilot_ss.alertText1, text2=starpilot_ss.alertText2,
size=starpilot_ss.alertSize.raw, status=starpilot_ss.alertStatus.raw,
alert_type=starpilot_ss.alertType)
if ret.status == AlertStatus.normal and ui_state.starpilot_toggles.get("hide_alerts", False):
return None
if sm.recv_frame['selfdriveState'] < ui_state.started_frame:
return None
self._prev_alert = ret
return ret
def will_render(self) -> tuple[Alert | None, bool]:
"""Return cached alert state without re-polling get_alert(). Safe to call multiple times per frame."""
alert = self._current_alert
return alert or self._prev_alert, alert is None
def _get_alert_rect(self, rect: rl.Rectangle, size: int) -> rl.Rectangle:
if size == AlertSize.full:
return rect
h = ALERT_HEIGHTS.get(size, rect.height)
return rl.Rectangle(rect.x, rect.y + rect.height - h, rect.width, h)
def _render(self, rect: rl.Rectangle) -> bool:
alert = self.get_alert(ui_state.sm)
self._current_alert = alert
self._alpha_filter.update(0 if alert is None else 1)
if alert is not None:
self._rect = self._get_alert_rect(rect, alert.size)
self._alert_y_filter.update(self._rect.y)
elif self._prev_alert is not None:
self._rect = self._get_alert_rect(rect, self._prev_alert.size)
slide = ALERT_SLIDE_DISTANCES.get(self._prev_alert.size, 100)
self._alert_y_filter.update(self._rect.y - slide)
else:
return False
if alert is None:
if self._alpha_filter.x > 0.01 and self._prev_alert is not None:
alert = self._prev_alert
else:
self._prev_alert = None
return False
self._draw_background(alert)
self._draw_text(alert)
return True
def _draw_background(self, alert: Alert) -> None:
color = ALERT_COLORS.get(alert.status, ALERT_COLORS[AlertStatus.normal])
color = rl.Color(color.r, color.g, color.b, int(255 * 0.90 * self._alpha_filter.x))
translucent_color = rl.Color(color.r, color.g, color.b, int(0 * self._alpha_filter.x))
if alert.size == AlertSize.full:
bg_height = int(self._rect.height)
solid_height = round(bg_height * 0.2)
rl.draw_rectangle(int(self._rect.x), int(self._rect.y), int(self._rect.width), solid_height, color)
rl.draw_rectangle_gradient_v(int(self._rect.x), int(self._rect.y + solid_height), int(self._rect.width),
int(bg_height - solid_height), color, translucent_color)
else:
bg_height = int(self._rect.height)
font_size = SMALL_FONT_SIZE if alert.size == AlertSize.small else MID_FONT_SIZE_1
self._alert_text1_label.set_text(alert.text1)
self._alert_text1_label.set_font_size(font_size)
content_h = self._alert_text1_label.get_content_height(int(self._rect.width - ALERT_PADDING * 2))
# Center the plateau on text1's rendered position with a 2px buffer on each
# side to guarantee int-truncated draw calls fully cover text1's float render.
plateau_y = round(self._alert_y_filter.x + (self._rect.height - content_h) / 2) - 2
plateau_h = round(content_h) + 4
top_fade_h = plateau_y - int(self._rect.y)
bottom_fade_h = bg_height - top_fade_h - plateau_h
rl.draw_rectangle_gradient_v(int(self._rect.x), int(self._rect.y),
int(self._rect.width), top_fade_h,
translucent_color, color)
rl.draw_rectangle(int(self._rect.x), plateau_y,
int(self._rect.width), plateau_h, color)
rl.draw_rectangle_gradient_v(int(self._rect.x), plateau_y + plateau_h,
int(self._rect.width), bottom_fade_h,
color, translucent_color)
def _draw_text(self, alert: Alert) -> None:
text_color = rl.Color(255, 255, 255, int(255 * 0.9 * self._alpha_filter.x))
text2_color = rl.Color(255, 255, 255, int(255 * 0.65 * self._alpha_filter.x))
text_rect = rl.Rectangle(
self._rect.x + ALERT_PADDING,
self._alert_y_filter.x,
self._rect.width - ALERT_PADDING * 2,
self._rect.height,
)
if alert.size == AlertSize.small:
self._alert_text1_label.set_text(alert.text1)
self._alert_text1_label.set_text_color(text_color)
self._alert_text1_label.set_font_size(SMALL_FONT_SIZE)
self._alert_text1_label.set_alignment(rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
self._alert_text1_label.set_alignment_vertical(rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
self._alert_text1_label.render(text_rect)
elif alert.size == AlertSize.mid:
self._alert_text1_label.set_text(alert.text1)
self._alert_text1_label.set_text_color(text_color)
self._alert_text1_label.set_font_size(MID_FONT_SIZE_1)
self._alert_text1_label.set_alignment(rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
self._alert_text1_label.set_alignment_vertical(rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
self._alert_text1_label.render(text_rect)
if alert.text2:
content_h = self._alert_text1_label.get_content_height(int(text_rect.width))
center_y = self._alert_y_filter.x + (self._rect.height - content_h) / 2
text2_rect = rl.Rectangle(
text_rect.x,
center_y + content_h,
text_rect.width,
self._rect.height - (center_y + content_h - self._alert_y_filter.x),
)
self._alert_text2_label.set_text(alert.text2)
self._alert_text2_label.set_text_color(text2_color)
self._alert_text2_label.set_font_size(MID_FONT_SIZE_2)
self._alert_text2_label.set_alignment(rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
self._alert_text2_label.set_alignment_vertical(rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP)
self._alert_text2_label.render(text2_rect)
else:
if len(alert.text1) <= 12:
font_size = 120
elif len(alert.text1) <= 16:
font_size = 110
else:
font_size = 100
self._alert_text1_label.set_text(alert.text1)
self._alert_text1_label.set_text_color(text_color)
self._alert_text1_label.set_font_size(font_size)
self._alert_text1_label.set_alignment(rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
self._alert_text1_label.set_alignment_vertical(rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
self._alert_text1_label.render(text_rect)
if alert.text2:
if len(alert.text2) > 24:
t2_size = 48
elif len(alert.text2) > 18:
t2_size = 52
else:
t2_size = 56
content_h = self._alert_text1_label.get_content_height(int(text_rect.width))
center_y = self._alert_y_filter.x + (self._rect.height - content_h) / 2
text2_rect = rl.Rectangle(
text_rect.x,
center_y + content_h,
text_rect.width,
self._rect.height - (center_y + content_h - self._alert_y_filter.x),
)
self._alert_text2_label.set_text(alert.text2)
self._alert_text2_label.set_text_color(text2_color)
self._alert_text2_label.set_font_size(t2_size)
self._alert_text2_label.set_alignment(rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
self._alert_text2_label.set_alignment_vertical(rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP)
self._alert_text2_label.render(text2_rect)