Files
sunnypilot-tesla/selfdrive/ui/mici/onroad/alert_renderer.py
Jason Wen d5b25e14fd Merge branch 'upstream/openpilot/master' into sync-20260317
# Conflicts:
#	.github/workflows/auto_pr_review.yaml
#	.gitignore
#	opendbc_repo
#	panda
#	selfdrive/ui/mici/layouts/home.py
#	selfdrive/ui/mici/layouts/onboarding.py
#	selfdrive/ui/mici/layouts/settings/device.py
#	selfdrive/ui/tests/diff/replay.py
#	selfdrive/ui/translations/app_fr.po
#	system/ui/mici_setup.py
Sync: `commaai/opendbc:master` → `sunnypilot/opendbc:master`
Sync: `commaai/panda:master` → `sunnypilot/panda:master`
2026-03-17 23:02:10 -04:00

372 lines
13 KiB
Python

import time
from enum import StrEnum
from typing import NamedTuple
import pyray as rl
import random
import string
from dataclasses import dataclass
from cereal import messaging, log, car
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.widgets import Widget
from openpilot.system.ui.widgets.label import UnifiedLabel
from openpilot.selfdrive.ui.sunnypilot.onroad.speed_limit import SpeedLimitAlertRenderer
AlertSize = log.SelfdriveState.AlertSize
AlertStatus = log.SelfdriveState.AlertStatus
ALERT_MARGIN = 18
ALERT_FONT_SMALL = 66 - 50
ALERT_FONT_BIG = 88 - 40
SELFDRIVE_STATE_TIMEOUT = 5 # Seconds
SELFDRIVE_UNRESPONSIVE_TIMEOUT = 10 # Seconds
# Constants
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),
}
TURN_SIGNAL_BLINK_PERIOD = 1 / (80 / 60) # Mazda heartbeat turn signal BPM
DEBUG = False
class IconSide(StrEnum):
left = 'left'
right = 'right'
class IconLayout(NamedTuple):
texture: rl.Texture
side: IconSide
margin_x: int
margin_y: int
alpha: float = 255.0
class AlertLayout(NamedTuple):
text_rect: rl.Rectangle
icon: IconLayout | None
@dataclass
class Alert:
text1: str = ""
text2: str = ""
size: int = 0
status: int = 0
visual_alert: int = car.CarControl.HUDControl.VisualAlert.none
alert_type: str = ""
# Pre-defined alert instances
ALERT_STARTUP_PENDING = Alert(
text1="sunnypilot Unavailable",
text2="Waiting to start",
size=AlertSize.mid,
status=AlertStatus.normal,
)
ALERT_CRITICAL_TIMEOUT = Alert(
text1="TAKE CONTROL IMMEDIATELY",
text2="System Unresponsive",
size=AlertSize.full,
status=AlertStatus.critical,
)
ALERT_CRITICAL_REBOOT = Alert(
text1="System Unresponsive",
text2="Reboot Device",
size=AlertSize.full,
status=AlertStatus.critical,
)
class AlertRenderer(Widget, SpeedLimitAlertRenderer):
def __init__(self):
Widget.__init__(self)
SpeedLimitAlertRenderer.__init__(self)
self._alert_text1_label = UnifiedLabel(text="", font_size=ALERT_FONT_BIG, font_weight=FontWeight.DISPLAY, line_height=0.86,
letter_spacing=-0.02)
self._alert_text2_label = UnifiedLabel(text="", font_size=ALERT_FONT_SMALL, font_weight=FontWeight.ROMAN, line_height=0.86,
letter_spacing=0.025)
self._prev_alert: Alert | None = None
self._text_gen_time = 0
self._alert_text2_gen = ''
# animation filters
# TODO: use 0.1 but with proper alert height calculation
self._alert_y_filter = BounceFilter(0, 0.1, 1 / gui_app.target_fps)
self._alpha_filter = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps)
self._turn_signal_timer = 0.0
self._turn_signal_alpha_filter = FirstOrderFilter(0.0, 0.3, 1 / gui_app.target_fps)
self._last_icon_side: IconSide | None = None
self._load_icons()
def _load_icons(self):
self._txt_turn_signal_left = gui_app.texture('icons_mici/onroad/turn_signal_left.png', 104, 96)
self._txt_turn_signal_right = gui_app.texture('icons_mici/onroad/turn_signal_left.png', 104, 96, flip_x=True)
self._txt_blind_spot_left = gui_app.texture('icons_mici/onroad/blind_spot_left.png', 134, 150)
self._txt_blind_spot_right = gui_app.texture('icons_mici/onroad/blind_spot_left.png', 134, 150, flip_x=True)
def get_alert(self, sm: messaging.SubMaster) -> Alert | None:
"""Generate the current alert based on selfdrive state."""
ss = sm['selfdriveState']
# Check if selfdriveState messages have stopped arriving
if not sm.updated['selfdriveState']:
recv_frame = sm.recv_frame['selfdriveState']
time_since_onroad = time.monotonic() - ui_state.started_time
# 1. Never received selfdriveState since going onroad
waiting_for_startup = recv_frame < ui_state.started_frame
if waiting_for_startup and time_since_onroad > 5:
return ALERT_STARTUP_PENDING
# 2. Lost communication with selfdriveState after receiving it
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
# No alert if size is none
if ss.alertSize == 0:
return None
# Return current alert
ret = Alert(text1=ss.alertText1, text2=ss.alertText2, size=ss.alertSize.raw, status=ss.alertStatus.raw,
visual_alert=ss.alertHudVisual, alert_type=ss.alertType)
self._prev_alert = ret
return ret
def will_render(self) -> tuple[Alert | None, bool]:
alert = self.get_alert(ui_state.sm)
return alert or self._prev_alert, alert is None
def _icon_helper(self, alert: Alert) -> AlertLayout:
icon_side = None
txt_icon = None
icon_alpha = 255.0
icon_margin_x = 20
icon_margin_y = 18
# alert_type format is "EventName/eventType" (e.g., "preLaneChangeLeft/warning")
event_name = alert.alert_type.split('/')[0] if alert.alert_type else ''
if event_name == 'preLaneChangeLeft':
icon_side = IconSide.left
txt_icon = self._txt_turn_signal_left
icon_margin_x = 2
icon_margin_y = 5
elif event_name == 'preLaneChangeRight':
icon_side = IconSide.right
txt_icon = self._txt_turn_signal_right
icon_margin_x = 2
icon_margin_y = 5
elif event_name == 'laneChange':
icon_side = self._last_icon_side
txt_icon = self._txt_turn_signal_left if self._last_icon_side == 'left' else self._txt_turn_signal_right
icon_margin_x = 2
icon_margin_y = 5
elif event_name == 'laneChangeBlocked':
CS = ui_state.sm['carState']
if CS.leftBlinker:
icon_side = IconSide.left
elif CS.rightBlinker:
icon_side = IconSide.right
else:
icon_side = self._last_icon_side
txt_icon = self._txt_blind_spot_left if icon_side == 'left' else self._txt_blind_spot_right
icon_margin_x = 8
icon_margin_y = 0
elif event_name == 'speedLimitPreActive':
icon_side, txt_icon, icon_alpha, icon_margin_x, icon_margin_y = SpeedLimitAlertRenderer.speed_limit_pre_active_icon_helper(self)
else:
self._turn_signal_timer = 0.0
self._last_icon_side = icon_side
# create text rect based on icon presence
text_x = self._rect.x + ALERT_MARGIN
text_width = self._rect.width - ALERT_MARGIN
if icon_side == 'left':
text_x = self._rect.x + self._txt_turn_signal_right.width
text_width = self._rect.width - ALERT_MARGIN - self._txt_turn_signal_right.width
elif icon_side == 'right':
text_x = self._rect.x + ALERT_MARGIN
text_width = self._rect.width - ALERT_MARGIN - self._txt_turn_signal_right.width
text_rect = rl.Rectangle(
text_x,
self._alert_y_filter.x,
text_width,
self._rect.height,
)
icon_layout = IconLayout(txt_icon, icon_side, icon_margin_x, icon_margin_y, icon_alpha) if txt_icon is not None and icon_side is not None else None
return AlertLayout(text_rect, icon_layout)
def _render(self, rect: rl.Rectangle) -> bool:
alert = self.get_alert(ui_state.sm)
# Animate fade and slide in/out
self._alert_y_filter.update(self._rect.y - 50 if alert is None else self._rect.y)
self._alpha_filter.update(0 if alert is None else 1)
if gui_app.sunnypilot_ui():
ui_state.onroad_brightness_handle_alerts(ui_state, alert)
if alert is None:
# If still animating out, keep the previous alert
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)
# update speed limit UI states
SpeedLimitAlertRenderer.update(self)
alert_layout = self._icon_helper(alert)
self._draw_text(alert, alert_layout)
self._draw_icons(alert_layout)
return True
def _draw_icons(self, alert_layout: AlertLayout) -> None:
if alert_layout.icon is None:
return
if time.monotonic() - self._turn_signal_timer > TURN_SIGNAL_BLINK_PERIOD:
self._turn_signal_timer = time.monotonic()
self._turn_signal_alpha_filter.x = 255 * 2
else:
self._turn_signal_alpha_filter.update(255 * 0.2)
if alert_layout.icon.side == 'left':
pos_x = int(self._rect.x + alert_layout.icon.margin_x)
else:
pos_x = int(self._rect.x + self._rect.width - alert_layout.icon.margin_x - alert_layout.icon.texture.width)
if alert_layout.icon.texture not in (self._txt_turn_signal_left, self._txt_turn_signal_right):
icon_alpha = alert_layout.icon.alpha
else:
icon_alpha = int(min(self._turn_signal_alpha_filter.x, 255))
rl.draw_texture_ex(alert_layout.icon.texture, rl.Vector2(pos_x, self._rect.y + alert_layout.icon.margin_y), 0.0, 1.0,
rl.Color(255, 255, 255, int(icon_alpha * self._alpha_filter.x)))
def _draw_background(self, alert: Alert) -> None:
# draw top gradient for alert text at top
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))
small_alert_height = round(self._rect.height * 0.583) # 140px at mici height
medium_alert_height = round(self._rect.height * 0.833) # 200px at mici height
# alert_type format is "EventName/eventType" (e.g., "preLaneChangeLeft/warning")
event_name = alert.alert_type.split('/')[0] if alert.alert_type else ''
if event_name == 'preLaneChangeLeft':
bg_height = small_alert_height
elif event_name == 'preLaneChangeRight':
bg_height = small_alert_height
elif event_name == 'laneChange':
bg_height = small_alert_height
elif event_name == 'laneChangeBlocked':
bg_height = medium_alert_height
else:
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)
def _draw_text(self, alert: Alert, alert_layout: AlertLayout) -> None:
icon_side = alert_layout.icon.side if alert_layout.icon is not None else None
# TODO: hack
alert_text1 = alert.text1.lower().replace('calibrating: ', 'calibrating:\n')
can_draw_second_line = False
# TODO: there should be a common way to determine font size based on text length to maximize rect
if len(alert_text1) <= 12:
can_draw_second_line = True
font_size = 92 - 10
elif len(alert_text1) <= 16:
can_draw_second_line = True
font_size = 70
else:
font_size = 64 - 10
if icon_side is not None:
font_size -= 10
color = rl.Color(255, 255, 255, int(255 * 0.9 * self._alpha_filter.x))
text1_y_offset = 11 if font_size >= 70 else 4
text_rect1 = rl.Rectangle(
alert_layout.text_rect.x,
alert_layout.text_rect.y - text1_y_offset,
alert_layout.text_rect.width,
alert_layout.text_rect.height,
)
self._alert_text1_label.set_text(alert_text1)
self._alert_text1_label.set_text_color(color)
self._alert_text1_label.set_font_size(font_size)
self._alert_text1_label.set_alignment(rl.GuiTextAlignment.TEXT_ALIGN_LEFT if icon_side != 'left' else rl.GuiTextAlignment.TEXT_ALIGN_RIGHT)
self._alert_text1_label.render(text_rect1)
alert_text2 = alert.text2.lower()
# randomize chars and length for testing
if DEBUG:
if time.monotonic() - self._text_gen_time > 0.5:
self._alert_text2_gen = ''.join(random.choices(string.ascii_lowercase + ' ', k=random.randint(0, 40)))
self._text_gen_time = time.monotonic()
alert_text2 = self._alert_text2_gen or alert_text2
if can_draw_second_line and alert_text2:
last_line_h = self._alert_text1_label.rect.y + self._alert_text1_label.get_content_height(int(alert_layout.text_rect.width))
last_line_h -= 4
if len(alert_text2) > 18:
small_font_size = 36
elif len(alert_text2) > 24:
small_font_size = 32
else:
small_font_size = 40
text_rect2 = rl.Rectangle(
alert_layout.text_rect.x,
last_line_h,
alert_layout.text_rect.width,
alert_layout.text_rect.height - last_line_h
)
color = rl.Color(255, 255, 255, int(255 * 0.65 * self._alpha_filter.x))
self._alert_text2_label.set_text(alert_text2)
self._alert_text2_label.set_text_color(color)
self._alert_text2_label.set_font_size(small_font_size)
self._alert_text2_label.set_alignment(rl.GuiTextAlignment.TEXT_ALIGN_LEFT if icon_side != 'left' else rl.GuiTextAlignment.TEXT_ALIGN_RIGHT)
self._alert_text2_label.render(text_rect2)