import time import pyray as rl from dataclasses import dataclass from cereal import messaging, log from openpilot.system.hardware import TICI from openpilot.system.ui.lib.application import gui_app, FontWeight, DEBUG_FPS from openpilot.system.ui.lib.label import gui_text_box from openpilot.selfdrive.ui.ui_state import ui_state ALERT_MARGIN = 40 ALERT_PADDING = 60 ALERT_LINE_SPACING = 45 ALERT_BORDER_RADIUS = 30 ALERT_FONT_SMALL = 66 ALERT_FONT_MEDIUM = 74 ALERT_FONT_BIG = 88 SELFDRIVE_STATE_TIMEOUT = 5 # Seconds SELFDRIVE_UNRESPONSIVE_TIMEOUT = 10 # Seconds # Constants ALERT_COLORS = { log.SelfdriveState.AlertStatus.normal: rl.Color(0, 0, 0, 235), # Black log.SelfdriveState.AlertStatus.userPrompt: rl.Color(0xFE, 0x8C, 0x34, 235), # Orange log.SelfdriveState.AlertStatus.critical: rl.Color(0xC9, 0x22, 0x31, 235), # Red } @dataclass class Alert: text1: str = "" text2: str = "" size: int = 0 status: int = 0 # Pre-defined alert instances ALERT_STARTUP_PENDING = Alert( text1="openpilot Unavailable", text2="Waiting to start", size=log.SelfdriveState.AlertSize.mid, status=log.SelfdriveState.AlertStatus.normal, ) ALERT_CRITICAL_TIMEOUT = Alert( text1="TAKE CONTROL IMMEDIATELY", text2="System Unresponsive", size=log.SelfdriveState.AlertSize.full, status=log.SelfdriveState.AlertStatus.critical, ) ALERT_CRITICAL_REBOOT = Alert( text1="System Unresponsive", text2="Reboot Device", size=log.SelfdriveState.AlertSize.full, status=log.SelfdriveState.AlertStatus.critical, ) class AlertRenderer: def __init__(self): self.font_regular: rl.Font = gui_app.font(FontWeight.NORMAL) self.font_bold: rl.Font = gui_app.font(FontWeight.BOLD) self.font_metrics_cache: dict[tuple[str, int, str], rl.Vector2] = {} 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'] if (sm.frame - recv_frame) > 5 * DEBUG_FPS: # Check if waiting to start if recv_frame < ui_state.started_frame: return ALERT_STARTUP_PENDING # Handle selfdrive timeout if TICI: 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 return Alert(text1=ss.alertText1, text2=ss.alertText2, size=ss.alertSize, status=ss.alertStatus) def draw(self, rect: rl.Rectangle, sm: messaging.SubMaster) -> None: alert = self.get_alert(sm) if not alert: return alert_rect = self._get_alert_rect(rect, alert.size) self._draw_background(alert_rect, alert) text_rect = rl.Rectangle( alert_rect.x + ALERT_PADDING, alert_rect.y + ALERT_PADDING, alert_rect.width - 2 * ALERT_PADDING, alert_rect.height - 2 * ALERT_PADDING ) self._draw_text(text_rect, alert) def _get_alert_rect(self, rect: rl.Rectangle, size: int) -> rl.Rectangle: if size == log.SelfdriveState.AlertSize.full: return rect height = (ALERT_FONT_MEDIUM + 2 * ALERT_PADDING if size == log.SelfdriveState.AlertSize.small else ALERT_FONT_BIG + ALERT_LINE_SPACING + ALERT_FONT_SMALL + 2 * ALERT_PADDING) return rl.Rectangle( rect.x + ALERT_MARGIN, rect.y + rect.height - ALERT_MARGIN - height, rect.width - 2 * ALERT_MARGIN, height ) def _draw_background(self, rect: rl.Rectangle, alert: Alert) -> None: color = ALERT_COLORS.get(alert.status, ALERT_COLORS[log.SelfdriveState.AlertStatus.normal]) if alert.size != log.SelfdriveState.AlertSize.full: roundness = ALERT_BORDER_RADIUS / (min(rect.width, rect.height) / 2) rl.draw_rectangle_rounded(rect, roundness, 10, color) else: rl.draw_rectangle_rec(rect, color) def _draw_text(self, rect: rl.Rectangle, alert: Alert) -> None: if alert.size == log.SelfdriveState.AlertSize.small: self._draw_centered(alert.text1, rect, self.font_bold, ALERT_FONT_MEDIUM) elif alert.size == log.SelfdriveState.AlertSize.mid: self._draw_centered(alert.text1, rect, self.font_bold, ALERT_FONT_BIG, center_y=False) rect.y += ALERT_FONT_BIG + ALERT_LINE_SPACING self._draw_centered(alert.text2, rect, self.font_regular, ALERT_FONT_SMALL, center_y=False) else: is_long = len(alert.text1) > 15 font_size1 = 132 if is_long else 177 align_ment = rl.GuiTextAlignment.TEXT_ALIGN_CENTER vertical_align = rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE text_rect = rl.Rectangle(rect.x, rect.y, rect.width, rect.height // 2) gui_text_box(text_rect, alert.text1, font_size1, alignment=align_ment, alignment_vertical=vertical_align, font_weight=FontWeight.BOLD) text_rect.y = rect.y + rect.height // 2 gui_text_box(text_rect, alert.text2, ALERT_FONT_BIG, alignment=align_ment) def _measure_text(self, font: rl.Font, text: str, font_size: int, font_type: str) -> rl.Vector2: key = (text, font_size, font_type) if key not in self.font_metrics_cache: self.font_metrics_cache[key] = rl.measure_text_ex(font, text, font_size, 0) return self.font_metrics_cache[key] def _draw_centered(self, text, rect, font, font_size, center_y=True, color=rl.WHITE) -> None: text_size = self._measure_text(font, text, font_size, 'bold' if font == self.font_bold else 'regular') x = rect.x + (rect.width - text_size.x) / 2 y = rect.y + ((rect.height - text_size.y) / 2 if center_y else 0) rl.draw_text_ex(font, text, rl.Vector2(x, y), font_size, 0, color)