import pyray as rl
from enum import IntEnum
from abc import ABC , abstractmethod
from collections . abc import Callable
from dataclasses import dataclass
from openpilot . common . params import Params
from openpilot . system . hardware import HARDWARE
from openpilot . system . ui . lib . application import gui_app , FontWeight
from openpilot . system . ui . lib . scroll_panel import GuiScrollPanel
from openpilot . system . ui . lib . text_measure import measure_text_cached
from openpilot . system . ui . lib . wrap_text import wrap_text
from openpilot . system . ui . widgets import Widget
from openpilot . system . ui . widgets . html_render import HtmlRenderer
from openpilot . selfdrive . selfdrived . alertmanager import OFFROAD_ALERTS
class AlertColors :
HIGH_SEVERITY = rl . Color ( 226 , 44 , 44 , 255 )
LOW_SEVERITY = rl . Color ( 41 , 41 , 41 , 255 )
BACKGROUND = rl . Color ( 57 , 57 , 57 , 255 )
BUTTON = rl . WHITE
BUTTON_PRESSED = rl . Color ( 200 , 200 , 200 , 255 )
BUTTON_TEXT = rl . BLACK
SNOOZE_BG = rl . Color ( 79 , 79 , 79 , 255 )
SNOOZE_BG_PRESSED = rl . Color ( 100 , 100 , 100 , 255 )
TEXT = rl . WHITE
class AlertConstants :
MIN_BUTTON_WIDTH = 400
BUTTON_HEIGHT = 125
MARGIN = 50
SPACING = 30
FONT_SIZE = 48
BORDER_RADIUS = 30 * 2 # matches Qt's 30px
ALERT_HEIGHT = 120
ALERT_SPACING = 10
ALERT_INSET = 60
@dataclass
class AlertData :
key : str
text : str
severity : int
visible : bool = False
class ButtonStyle ( IntEnum ) :
LIGHT = 0
DARK = 1
class ActionButton ( Widget ) :
def __init__ ( self , text : str , style : ButtonStyle = ButtonStyle . LIGHT ,
min_width : int = AlertConstants . MIN_BUTTON_WIDTH ) :
super ( ) . __init__ ( )
self . _style = style
self . _min_width = min_width
self . _font = gui_app . font ( FontWeight . MEDIUM )
self . set_text ( text )
def set_text ( self , text : str ) :
self . _text = text
self . _text_width = measure_text_cached ( gui_app . font ( FontWeight . MEDIUM ) , self . _text , AlertConstants . FONT_SIZE ) . x
self . _rect . width = max ( self . _text_width + 60 * 2 , self . _min_width )
self . _rect . height = AlertConstants . BUTTON_HEIGHT
def _render ( self , _ ) :
roundness = AlertConstants . BORDER_RADIUS / self . _rect . height
bg_color = AlertColors . BUTTON if self . _style == ButtonStyle . LIGHT else AlertColors . SNOOZE_BG
if self . is_pressed :
bg_color = AlertColors . BUTTON_PRESSED if self . _style == ButtonStyle . LIGHT else AlertColors . SNOOZE_BG_PRESSED
rl . draw_rectangle_rounded ( self . _rect , roundness , 10 , bg_color )
# center text
color = rl . WHITE if self . _style == ButtonStyle . DARK else rl . BLACK
text_x = int ( self . _rect . x + ( self . _rect . width - self . _text_width ) / / 2 )
text_y = int ( self . _rect . y + ( self . _rect . height - AlertConstants . FONT_SIZE ) / / 2 )
rl . draw_text_ex ( self . _font , self . _text , rl . Vector2 ( text_x , text_y ) , AlertConstants . FONT_SIZE , 0 , color )
class AbstractAlert ( Widget , ABC ) :
def __init__ ( self , has_reboot_btn : bool = False ) :
super ( ) . __init__ ( )
self . params = Params ( )
self . has_reboot_btn = has_reboot_btn
self . dismiss_callback : Callable | None = None
def snooze_callback ( ) :
self . params . put_bool ( " SnoozeUpdate " , True )
if self . dismiss_callback :
self . dismiss_callback ( )
def excessive_actuation_callback ( ) :
self . params . remove ( " Offroad_ExcessiveActuation " )
if self . dismiss_callback :
self . dismiss_callback ( )
self . dismiss_btn = ActionButton ( " Close " )
self . snooze_btn = ActionButton ( " Snooze Update " , style = ButtonStyle . DARK )
self . snooze_btn . set_click_callback ( snooze_callback )
self . excessive_actuation_btn = ActionButton ( " Acknowledge Excessive Actuation " , style = ButtonStyle . DARK , min_width = 800 )
self . excessive_actuation_btn . set_click_callback ( excessive_actuation_callback )
self . reboot_btn = ActionButton ( " Reboot and Update " , min_width = 600 )
self . reboot_btn . set_click_callback ( lambda : HARDWARE . reboot ( ) )
# TODO: just use a Scroller?
self . content_rect = rl . Rectangle ( 0 , 0 , 0 , 0 )
self . scroll_panel_rect = rl . Rectangle ( 0 , 0 , 0 , 0 )
self . scroll_panel = GuiScrollPanel ( )
def set_dismiss_callback ( self , callback : Callable ) :
self . dismiss_callback = callback
self . dismiss_btn . set_click_callback ( self . dismiss_callback )
@abstractmethod
def refresh ( self ) - > bool :
pass
@abstractmethod
def get_content_height ( self ) - > float :
pass
def _render ( self , rect : rl . Rectangle ) :
rl . draw_rectangle_rounded ( rect , AlertConstants . BORDER_RADIUS / rect . height , 10 , AlertColors . BACKGROUND )
footer_height = AlertConstants . BUTTON_HEIGHT + AlertConstants . SPACING
content_height = rect . height - 2 * AlertConstants . MARGIN - footer_height
self . content_rect = rl . Rectangle (
rect . x + AlertConstants . MARGIN ,
rect . y + AlertConstants . MARGIN ,
rect . width - 2 * AlertConstants . MARGIN ,
content_height ,
)
self . scroll_panel_rect = rl . Rectangle (
self . content_rect . x , self . content_rect . y , self . content_rect . width , self . content_rect . height
)
self . _render_scrollable_content ( )
self . _render_footer ( rect )
def _render_scrollable_content ( self ) :
content_total_height = self . get_content_height ( )
content_bounds = rl . Rectangle ( 0 , 0 , self . scroll_panel_rect . width , content_total_height )
scroll_offset = self . scroll_panel . update ( self . scroll_panel_rect , content_bounds )
rl . begin_scissor_mode (
int ( self . scroll_panel_rect . x ) ,
int ( self . scroll_panel_rect . y ) ,
int ( self . scroll_panel_rect . width ) ,
int ( self . scroll_panel_rect . height ) ,
)
content_rect_with_scroll = rl . Rectangle (
self . scroll_panel_rect . x ,
self . scroll_panel_rect . y + scroll_offset ,
self . scroll_panel_rect . width ,
content_total_height ,
)
self . _render_content ( content_rect_with_scroll )
rl . end_scissor_mode ( )
@abstractmethod
def _render_content ( self , content_rect : rl . Rectangle ) :
pass
def _render_footer ( self , rect : rl . Rectangle ) :
footer_y = rect . y + rect . height - AlertConstants . MARGIN - AlertConstants . BUTTON_HEIGHT
dismiss_x = rect . x + AlertConstants . MARGIN
self . dismiss_btn . set_position ( dismiss_x , footer_y )
self . dismiss_btn . render ( )
if self . has_reboot_btn :
reboot_x = rect . x + rect . width - AlertConstants . MARGIN - self . reboot_btn . rect . width
self . reboot_btn . set_position ( reboot_x , footer_y )
self . reboot_btn . render ( )
elif self . excessive_actuation_btn . is_visible :
actuation_x = rect . x + rect . width - AlertConstants . MARGIN - self . excessive_actuation_btn . rect . width
self . excessive_actuation_btn . set_position ( actuation_x , footer_y )
self . excessive_actuation_btn . render ( )
elif self . snooze_btn . is_visible :
snooze_x = rect . x + rect . width - AlertConstants . MARGIN - self . snooze_btn . rect . width
self . snooze_btn . set_position ( snooze_x , footer_y )
self . snooze_btn . render ( )
class OffroadAlert ( AbstractAlert ) :
def __init__ ( self ) :
super ( ) . __init__ ( has_reboot_btn = False )
self . sorted_alerts : list [ AlertData ] = [ ]
def refresh ( self ) :
if not self . sorted_alerts :
self . _build_alerts ( )
active_count = 0
connectivity_needed = False
excessive_actuation = False
for alert_data in self . sorted_alerts :
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
if alert_data . key == " Offroad_ConnectivityNeeded " and alert_data . visible :
connectivity_needed = True
if alert_data . key == " Offroad_ExcessiveActuation " and alert_data . visible :
excessive_actuation = True
self . excessive_actuation_btn . set_visible ( excessive_actuation )
self . snooze_btn . set_visible ( connectivity_needed and not excessive_actuation )
return active_count
def get_content_height ( self ) - > float :
if not self . sorted_alerts :
return 0
total_height = 20
font = gui_app . font ( FontWeight . NORMAL )
for alert_data in self . sorted_alerts :
if not alert_data . visible :
continue
text_width = int ( self . content_rect . width - ( AlertConstants . ALERT_INSET * 2 ) )
wrapped_lines = wrap_text ( font , alert_data . text , AlertConstants . FONT_SIZE , text_width )
line_count = len ( wrapped_lines )
text_height = line_count * ( AlertConstants . FONT_SIZE + 5 )
alert_item_height = max ( text_height + ( AlertConstants . ALERT_INSET * 2 ) , AlertConstants . ALERT_HEIGHT )
total_height + = alert_item_height + AlertConstants . ALERT_SPACING
if total_height > 20 :
total_height = total_height - AlertConstants . ALERT_SPACING + 20
return total_height
def _build_alerts ( self ) :
self . sorted_alerts = [ ]
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 )
def _render_content ( self , content_rect : rl . Rectangle ) :
y_offset = AlertConstants . ALERT_SPACING
font = gui_app . font ( FontWeight . NORMAL )
for alert_data in self . sorted_alerts :
if not alert_data . visible :
continue
bg_color = AlertColors . HIGH_SEVERITY if alert_data . severity > 0 else AlertColors . LOW_SEVERITY
text_width = int ( content_rect . width - ( AlertConstants . ALERT_INSET * 2 ) )
wrapped_lines = wrap_text ( font , alert_data . text , AlertConstants . FONT_SIZE , text_width )
line_count = len ( wrapped_lines )
text_height = line_count * ( AlertConstants . FONT_SIZE + 5 )
alert_item_height = max ( text_height + ( AlertConstants . ALERT_INSET * 2 ) , AlertConstants . ALERT_HEIGHT )
alert_rect = rl . Rectangle (
content_rect . x + 10 ,
content_rect . y + y_offset ,
content_rect . width - 30 ,
alert_item_height ,
)
roundness = AlertConstants . BORDER_RADIUS / min ( alert_rect . height , alert_rect . width )
rl . draw_rectangle_rounded ( alert_rect , roundness , 10 , bg_color )
text_x = alert_rect . x + AlertConstants . ALERT_INSET
text_y = alert_rect . y + AlertConstants . ALERT_INSET
for i , line in enumerate ( wrapped_lines ) :
rl . draw_text_ex (
font ,
line ,
rl . Vector2 ( text_x , text_y + i * ( AlertConstants . FONT_SIZE + 5 ) ) ,
AlertConstants . FONT_SIZE ,
0 ,
AlertColors . TEXT ,
)
y_offset + = alert_item_height + AlertConstants . ALERT_SPACING
class UpdateAlert ( AbstractAlert ) :
def __init__ ( self ) :
super ( ) . __init__ ( has_reboot_btn = True )
self . release_notes = " "
self . _wrapped_release_notes = " "
self . _cached_content_height : float = 0.0
self . _html_renderer : HtmlRenderer | None = None
def refresh ( self ) - > bool :
update_available : bool = self . params . get_bool ( " UpdateAvailable " )
if update_available :
self . release_notes = self . params . get ( " UpdaterNewReleaseNotes " )
self . _cached_content_height = 0
return update_available
def get_content_height ( self ) - > float :
if not self . release_notes :
return 100
if self . _cached_content_height == 0 :
self . _wrapped_release_notes = self . release_notes
size = measure_text_cached ( gui_app . font ( FontWeight . NORMAL ) , self . _wrapped_release_notes , AlertConstants . FONT_SIZE )
self . _cached_content_height = max ( size . y + 60 , 100 )
return self . _cached_content_height
def _render_content ( self , content_rect : rl . Rectangle ) :
if self . release_notes :
rl . draw_text_ex (
gui_app . font ( FontWeight . NORMAL ) ,
self . _wrapped_release_notes ,
rl . Vector2 ( content_rect . x + 30 , content_rect . y + 30 ) ,
AlertConstants . FONT_SIZE ,
0.0 ,
AlertColors . TEXT ,
)
else :
no_notes_text = " No release notes available. "
text_width = rl . measure_text ( no_notes_text , AlertConstants . FONT_SIZE )
text_x = content_rect . x + ( content_rect . width - text_width ) / / 2
text_y = content_rect . y + 50
rl . draw_text_ex ( gui_app . font ( FontWeight . NORMAL ) , no_notes_text , ( int ( text_x ) , int ( text_y ) ) , AlertConstants . FONT_SIZE , 0 , AlertColors . TEXT )