mirror of
https://github.com/dzid26/sunnypilot.git
synced 2026-06-08 07:44:55 +08:00
# 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`
372 lines
13 KiB
Python
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)
|