mirror of
https://github.com/ajouatom/openpilot.git
synced 2026-06-08 11:04:57 +08:00
308 lines
12 KiB
Python
308 lines
12 KiB
Python
import pyray as rl
|
|
import re
|
|
import time
|
|
from dataclasses import dataclass
|
|
from enum import IntEnum
|
|
from openpilot.common.params import Params
|
|
from openpilot.selfdrive.selfdrived.alertmanager import OFFROAD_ALERTS
|
|
from openpilot.system.hardware import HARDWARE
|
|
from openpilot.system.ui.widgets import Widget
|
|
from openpilot.system.ui.widgets.label import UnifiedLabel
|
|
from openpilot.system.ui.widgets.scroller import Scroller
|
|
from openpilot.system.ui.lib.application import gui_app, FontWeight
|
|
from openpilot.system.ui.lib.multilang import tr
|
|
|
|
REFRESH_INTERVAL = 5.0 # seconds
|
|
|
|
|
|
class AlertSize(IntEnum):
|
|
SMALL = 0
|
|
MEDIUM = 1
|
|
BIG = 2
|
|
|
|
|
|
@dataclass
|
|
class AlertData:
|
|
key: str
|
|
text: str
|
|
severity: int
|
|
visible: bool = False
|
|
|
|
|
|
class AlertItem(Widget):
|
|
# TODO: click should always go somewhere: home or specific settings pane
|
|
"""Individual alert item widget with background image and text."""
|
|
ALERT_WIDTH = 520
|
|
ALERT_HEIGHT_SMALL = 212
|
|
ALERT_HEIGHT_MED = 240
|
|
ALERT_HEIGHT_BIG = 324
|
|
ALERT_PADDING = 28
|
|
ICON_SIZE = 64
|
|
ICON_MARGIN = 12
|
|
TEXT_COLOR = rl.Color(255, 255, 255, int(255 * 0.9))
|
|
TITLE_BODY_SPACING = 24
|
|
|
|
def __init__(self, alert_data: AlertData):
|
|
super().__init__()
|
|
self.alert_data = alert_data
|
|
|
|
# Load background textures
|
|
self._bg_small = gui_app.texture("icons_mici/offroad_alerts/small_alert.png", self.ALERT_WIDTH, self.ALERT_HEIGHT_SMALL)
|
|
self._bg_small_pressed = gui_app.texture("icons_mici/offroad_alerts/small_alert_pressed.png", self.ALERT_WIDTH, self.ALERT_HEIGHT_SMALL)
|
|
self._bg_medium = gui_app.texture("icons_mici/offroad_alerts/medium_alert.png", self.ALERT_WIDTH, self.ALERT_HEIGHT_MED)
|
|
self._bg_medium_pressed = gui_app.texture("icons_mici/offroad_alerts/medium_alert_pressed.png", self.ALERT_WIDTH, self.ALERT_HEIGHT_MED)
|
|
self._bg_big = gui_app.texture("icons_mici/offroad_alerts/big_alert.png", self.ALERT_WIDTH, self.ALERT_HEIGHT_BIG)
|
|
self._bg_big_pressed = gui_app.texture("icons_mici/offroad_alerts/big_alert_pressed.png", self.ALERT_WIDTH, self.ALERT_HEIGHT_BIG)
|
|
|
|
# Load warning icons
|
|
self._icon_orange = gui_app.texture("icons_mici/offroad_alerts/orange_warning.png", self.ICON_SIZE, self.ICON_SIZE)
|
|
self._icon_red = gui_app.texture("icons_mici/offroad_alerts/red_warning.png", self.ICON_SIZE, self.ICON_SIZE)
|
|
self._icon_green = gui_app.texture("icons_mici/offroad_alerts/green_wheel.png", self.ICON_SIZE, self.ICON_SIZE)
|
|
|
|
self._title_label = UnifiedLabel(text="", font_size=32, font_weight=FontWeight.SEMI_BOLD, text_color=self.TEXT_COLOR,
|
|
alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
|
|
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP, line_height=0.95)
|
|
|
|
self._body_label = UnifiedLabel(text="", font_size=28, font_weight=FontWeight.ROMAN, text_color=self.TEXT_COLOR,
|
|
alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
|
|
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM, line_height=0.95)
|
|
|
|
self._title_text = ""
|
|
self._body_text = ""
|
|
self._alert_size = AlertSize.SMALL
|
|
|
|
self._update_content()
|
|
|
|
def _split_text(self, text: str) -> tuple[str, str]:
|
|
"""Split text into title (first sentence) and body (remaining text)."""
|
|
# Find the end of the first sentence (period, exclamation, or question mark followed by space or end)
|
|
match = re.search(r'[.!?](?:\s+|$)', text)
|
|
if match:
|
|
# Found a sentence boundary - split at the end of the sentence
|
|
title = text[:match.start()].strip()
|
|
body = text[match.end():].strip()
|
|
return title, body
|
|
else:
|
|
# No sentence boundary found, return full text as title
|
|
return "", text
|
|
|
|
def _update_content(self):
|
|
"""Update text and calculate height."""
|
|
if not self.alert_data.visible or not self.alert_data.text:
|
|
self.set_visible(False)
|
|
return
|
|
|
|
self.set_visible(True)
|
|
|
|
# Split text into title and body
|
|
self._title_text, self._body_text = self._split_text(self.alert_data.text)
|
|
|
|
# Calculate text width (alert width minus padding and icon space on right)
|
|
title_width = self.ALERT_WIDTH - (self.ALERT_PADDING * 2) - self.ICON_SIZE - self.ICON_MARGIN
|
|
body_width = self.ALERT_WIDTH - (self.ALERT_PADDING * 2)
|
|
|
|
# Update labels
|
|
self._title_label.set_text(self._title_text)
|
|
self._body_label.set_text(self._body_text)
|
|
|
|
# Calculate content height
|
|
title_height = self._title_label.get_content_height(title_width) if self._title_text else 0
|
|
body_height = self._body_label.get_content_height(body_width) if self._body_text else 0
|
|
spacing = self.TITLE_BODY_SPACING if (self._title_text and self._body_text) else 0
|
|
total_text_height = title_height + spacing + body_height
|
|
|
|
# Determine which background size to use based on content height
|
|
min_height_with_padding = total_text_height + (self.ALERT_PADDING * 2)
|
|
if min_height_with_padding > self.ALERT_HEIGHT_MED:
|
|
self._alert_size = AlertSize.BIG
|
|
height = self.ALERT_HEIGHT_BIG
|
|
elif min_height_with_padding > self.ALERT_HEIGHT_SMALL:
|
|
self._alert_size = AlertSize.MEDIUM
|
|
height = self.ALERT_HEIGHT_MED
|
|
else:
|
|
self._alert_size = AlertSize.SMALL
|
|
height = self.ALERT_HEIGHT_SMALL
|
|
|
|
# Set rect size
|
|
self.set_rect(rl.Rectangle(0, 0, self.ALERT_WIDTH, height))
|
|
|
|
def update_alert_data(self, alert_data: AlertData):
|
|
"""Update alert data and refresh display."""
|
|
self.alert_data = alert_data
|
|
self._update_content()
|
|
|
|
def _render(self, _):
|
|
if not self.alert_data.visible or not self.alert_data.text:
|
|
return
|
|
|
|
# Choose background based on size
|
|
if self._alert_size == AlertSize.BIG:
|
|
bg_texture = self._bg_big_pressed if self.is_pressed else self._bg_big
|
|
elif self._alert_size == AlertSize.MEDIUM:
|
|
bg_texture = self._bg_medium_pressed if self.is_pressed else self._bg_medium
|
|
else: # AlertSize.SMALL
|
|
bg_texture = self._bg_small_pressed if self.is_pressed else self._bg_small
|
|
|
|
# Draw background
|
|
rl.draw_texture_ex(bg_texture, rl.Vector2(self._rect.x, self._rect.y), 0.0, 1.0, rl.WHITE)
|
|
|
|
# Calculate text area (left side, avoiding icon on right)
|
|
title_width = self.ALERT_WIDTH - (self.ALERT_PADDING * 2) - self.ICON_SIZE - self.ICON_MARGIN
|
|
body_width = self.ALERT_WIDTH - (self.ALERT_PADDING * 2)
|
|
text_x = self._rect.x + self.ALERT_PADDING
|
|
text_y = self._rect.y + self.ALERT_PADDING
|
|
|
|
# Draw title label
|
|
if self._title_text:
|
|
title_rect = rl.Rectangle(
|
|
text_x,
|
|
text_y,
|
|
title_width,
|
|
self._title_label.get_content_height(title_width),
|
|
)
|
|
self._title_label.render(title_rect)
|
|
text_y += title_rect.height + self.TITLE_BODY_SPACING
|
|
|
|
# Draw body label
|
|
if self._body_text:
|
|
body_rect = rl.Rectangle(
|
|
text_x,
|
|
text_y,
|
|
body_width,
|
|
self._rect.height - text_y + self._rect.y - self.ALERT_PADDING,
|
|
)
|
|
self._body_label.render(body_rect)
|
|
|
|
# Draw warning icon on the right side
|
|
# Use green icon for update alerts (severity = -1), red for high severity, orange for low severity
|
|
if self.alert_data.severity == -1:
|
|
icon_texture = self._icon_green
|
|
elif self.alert_data.severity > 0:
|
|
icon_texture = self._icon_red
|
|
else:
|
|
icon_texture = self._icon_orange
|
|
icon_x = self._rect.x + self.ALERT_WIDTH - self.ALERT_PADDING - self.ICON_SIZE
|
|
icon_y = self._rect.y + self.ALERT_PADDING
|
|
rl.draw_texture_ex(icon_texture, rl.Vector2(icon_x, icon_y), 0.0, 1.0, rl.WHITE)
|
|
|
|
|
|
class MiciOffroadAlerts(Scroller):
|
|
"""Offroad alerts layout with vertical scrolling."""
|
|
|
|
def __init__(self):
|
|
# Create vertical scroller
|
|
super().__init__(horizontal=False, spacing=12, pad=0)
|
|
self.params = Params()
|
|
self.sorted_alerts: list[AlertData] = []
|
|
self.alert_items: list[AlertItem] = []
|
|
self._last_refresh = 0.0
|
|
|
|
# Create empty state label
|
|
self._empty_label = UnifiedLabel(tr("no alerts"), 65, FontWeight.DISPLAY, rl.WHITE,
|
|
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
|
|
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
|
|
|
|
# Build initial alert list
|
|
self._build_alerts()
|
|
|
|
def active_alerts(self) -> int:
|
|
return sum(alert.visible for alert in self.sorted_alerts)
|
|
|
|
def scrolling(self):
|
|
return self._scroller.scroll_panel.is_touch_valid()
|
|
|
|
def _build_alerts(self):
|
|
"""Build sorted list of alerts from OFFROAD_ALERTS."""
|
|
self.sorted_alerts = []
|
|
|
|
# Add UpdateAvailable alert at the top (severity = -1 to indicate special handling)
|
|
update_alert_data = AlertData(key="UpdateAvailable", text="", severity=-1)
|
|
self.sorted_alerts.append(update_alert_data)
|
|
update_alert_item = AlertItem(update_alert_data)
|
|
update_alert_item.set_click_callback(lambda: HARDWARE.reboot())
|
|
self.alert_items.append(update_alert_item)
|
|
self._scroller.add_widget(update_alert_item)
|
|
|
|
# Add regular alerts sorted by severity
|
|
for key, config in sorted(OFFROAD_ALERTS.items(), key=lambda x: x[1].get("severity", 0), reverse=True):
|
|
severity = config.get("severity", 0)
|
|
alert_data = AlertData(key=key, text="", severity=severity)
|
|
self.sorted_alerts.append(alert_data)
|
|
|
|
# Create alert item widget
|
|
alert_item = AlertItem(alert_data)
|
|
self.alert_items.append(alert_item)
|
|
self._scroller.add_widget(alert_item)
|
|
|
|
def refresh(self) -> int:
|
|
"""Refresh alerts from params and return active count."""
|
|
active_count = 0
|
|
|
|
# Handle UpdateAvailable alert specially
|
|
update_available = self.params.get_bool("UpdateAvailable")
|
|
update_alert_data = next((alert_data for alert_data in self.sorted_alerts if alert_data.key == "UpdateAvailable"), None)
|
|
|
|
if update_alert_data:
|
|
if update_available:
|
|
version_string = ""
|
|
|
|
# Get new version description and parse version and date
|
|
new_desc = self.params.get("UpdaterNewDescription") or ""
|
|
if new_desc:
|
|
# format: "version / branch / commit / date"
|
|
parts = new_desc.split(" / ")
|
|
if len(parts) > 3:
|
|
version, date = parts[0], parts[3]
|
|
version_string = f"\nopenpilot {version}, {date}\n"
|
|
|
|
update_alert_data.text = f"Update available {version_string}. Click to update. Read the release notes at blog.comma.ai."
|
|
update_alert_data.visible = True
|
|
active_count += 1
|
|
else:
|
|
update_alert_data.text = ""
|
|
update_alert_data.visible = False
|
|
|
|
# Handle regular alerts
|
|
for alert_data in self.sorted_alerts:
|
|
if alert_data.key == "UpdateAvailable":
|
|
continue # Skip, already handled above
|
|
|
|
text = ""
|
|
alert_json = self.params.get(alert_data.key)
|
|
|
|
if alert_json:
|
|
text = alert_json.get("text", "").replace("%1", alert_json.get("extra", ""))
|
|
|
|
alert_data.text = text
|
|
alert_data.visible = bool(text)
|
|
|
|
if alert_data.visible:
|
|
active_count += 1
|
|
|
|
# Update alert items (they reference the same alert_data objects)
|
|
for alert_item in self.alert_items:
|
|
alert_item.update_alert_data(alert_item.alert_data)
|
|
|
|
return active_count
|
|
|
|
def show_event(self):
|
|
"""Reset scroll position when shown and refresh alerts."""
|
|
super().show_event()
|
|
self._last_refresh = time.monotonic()
|
|
self.refresh()
|
|
|
|
def _update_state(self):
|
|
"""Periodically refresh alerts."""
|
|
# Refresh alerts periodically, not every frame
|
|
current_time = time.monotonic()
|
|
if current_time - self._last_refresh >= REFRESH_INTERVAL:
|
|
self.refresh()
|
|
self._last_refresh = current_time
|
|
|
|
def _render(self, rect: rl.Rectangle):
|
|
"""Render the alerts scroller or empty state."""
|
|
if self.active_alerts() == 0:
|
|
self._empty_label.render(rect)
|
|
else:
|
|
self._scroller.render(rect)
|