import json
import pyray as rl
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 . scroll_panel import GuiScrollPanel
from openpilot . system . ui . lib . wrap_text import wrap_text
from openpilot . system . ui . lib . text_measure import measure_text_cached
from openpilot . system . ui . lib . application import gui_app , FontWeight , Widget
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_TEXT = rl . BLACK
SNOOZE_BG = rl . Color ( 79 , 79 , 79 , 255 )
TEXT = rl . WHITE
class AlertConstants :
BUTTON_SIZE = ( 400 , 125 )
SNOOZE_BUTTON_SIZE = ( 550 , 125 )
REBOOT_BUTTON_SIZE = ( 600 , 125 )
MARGIN = 50
SPACING = 30
FONT_SIZE = 48
BORDER_RADIUS = 30
ALERT_HEIGHT = 120
ALERT_SPACING = 20
@dataclass
class AlertData :
key : str
text : str
severity : int
visible : bool = False
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
self . dismiss_btn_rect = rl . Rectangle ( 0 , 0 , * AlertConstants . BUTTON_SIZE )
self . snooze_btn_rect = rl . Rectangle ( 0 , 0 , * AlertConstants . SNOOZE_BUTTON_SIZE )
self . reboot_btn_rect = rl . Rectangle ( 0 , 0 , * AlertConstants . REBOOT_BUTTON_SIZE )
self . snooze_visible = False
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
@abstractmethod
def refresh ( self ) - > bool :
pass
@abstractmethod
def get_content_height ( self ) - > float :
pass
def handle_input ( self , mouse_pos : rl . Vector2 , mouse_clicked : bool ) - > bool :
# TODO: fix scroll_panel.is_click_valid()
if not mouse_clicked :
return False
if rl . check_collision_point_rec ( mouse_pos , self . dismiss_btn_rect ) :
if self . dismiss_callback :
self . dismiss_callback ( )
return True
if self . snooze_visible and rl . check_collision_point_rec ( mouse_pos , self . snooze_btn_rect ) :
self . params . put_bool ( " SnoozeUpdate " , True )
if self . dismiss_callback :
self . dismiss_callback ( )
return True
if self . has_reboot_btn and rl . check_collision_point_rec ( mouse_pos , self . reboot_btn_rect ) :
HARDWARE . reboot ( )
return True
return False
def _render ( self , rect : rl . Rectangle ) :
rl . draw_rectangle_rounded ( rect , AlertConstants . BORDER_RADIUS / rect . width , 10 , AlertColors . BACKGROUND )
footer_height = AlertConstants . BUTTON_SIZE [ 1 ] + 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 . handle_scroll ( 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 . y ,
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_SIZE [ 1 ]
font = gui_app . font ( FontWeight . MEDIUM )
self . dismiss_btn_rect . x = rect . x + AlertConstants . MARGIN
self . dismiss_btn_rect . y = footer_y
rl . draw_rectangle_rounded ( self . dismiss_btn_rect , 0.3 , 10 , AlertColors . BUTTON )
text = " Close "
text_width = measure_text_cached ( font , text , AlertConstants . FONT_SIZE ) . x
text_x = self . dismiss_btn_rect . x + ( AlertConstants . BUTTON_SIZE [ 0 ] - text_width ) / / 2
text_y = self . dismiss_btn_rect . y + ( AlertConstants . BUTTON_SIZE [ 1 ] - AlertConstants . FONT_SIZE ) / / 2
rl . draw_text_ex (
font , text , rl . Vector2 ( int ( text_x ) , int ( text_y ) ) , AlertConstants . FONT_SIZE , 0 , AlertColors . BUTTON_TEXT
)
if self . snooze_visible :
self . snooze_btn_rect . x = rect . x + rect . width - AlertConstants . MARGIN - AlertConstants . SNOOZE_BUTTON_SIZE [ 0 ]
self . snooze_btn_rect . y = footer_y
rl . draw_rectangle_rounded ( self . snooze_btn_rect , 0.3 , 10 , AlertColors . SNOOZE_BG )
text = " Snooze Update "
text_width = measure_text_cached ( font , text , AlertConstants . FONT_SIZE ) . x
text_x = self . snooze_btn_rect . x + ( AlertConstants . SNOOZE_BUTTON_SIZE [ 0 ] - text_width ) / / 2
text_y = self . snooze_btn_rect . y + ( AlertConstants . SNOOZE_BUTTON_SIZE [ 1 ] - AlertConstants . FONT_SIZE ) / / 2
rl . draw_text_ex ( font , text , rl . Vector2 ( int ( text_x ) , int ( text_y ) ) , AlertConstants . FONT_SIZE , 0 , AlertColors . TEXT )
elif self . has_reboot_btn :
self . reboot_btn_rect . x = rect . x + rect . width - AlertConstants . MARGIN - AlertConstants . REBOOT_BUTTON_SIZE [ 0 ]
self . reboot_btn_rect . y = footer_y
rl . draw_rectangle_rounded ( self . reboot_btn_rect , 0.3 , 10 , AlertColors . BUTTON )
text = " Reboot and Update "
text_width = measure_text_cached ( font , text , AlertConstants . FONT_SIZE ) . x
text_x = self . reboot_btn_rect . x + ( AlertConstants . REBOOT_BUTTON_SIZE [ 0 ] - text_width ) / / 2
text_y = self . reboot_btn_rect . y + ( AlertConstants . REBOOT_BUTTON_SIZE [ 1 ] - AlertConstants . FONT_SIZE ) / / 2
rl . draw_text_ex (
font , text , rl . Vector2 ( int ( text_x ) , int ( text_y ) ) , AlertConstants . FONT_SIZE , 0 , AlertColors . BUTTON_TEXT
)
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
for alert_data in self . sorted_alerts :
text = " "
bytes_data = self . params . get ( alert_data . key )
if bytes_data :
try :
alert_json = json . loads ( bytes_data )
text = alert_json . get ( " text " , " " ) . replace ( " {} " , alert_json . get ( " extra " , " " ) )
except json . JSONDecodeError :
text = " "
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
self . snooze_visible = connectivity_needed
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 - 90 )
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 + 40 , 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 = [ ]
try :
with open ( " ../selfdrived/alerts_offroad.json " , " rb " ) as f :
alerts_config = json . load ( f )
for key , config in sorted ( alerts_config . 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 )
except ( FileNotFoundError , json . JSONDecodeError ) :
pass
def _render_content ( self , content_rect : rl . Rectangle ) :
y_offset = 20
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 - 90 )
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 + 40 , AlertConstants . ALERT_HEIGHT )
alert_rect = rl . Rectangle (
content_rect . x + 10 ,
content_rect . y + y_offset ,
content_rect . width - 30 ,
alert_item_height ,
)
rl . draw_rectangle_rounded ( alert_rect , 0.2 , 10 , bg_color )
text_x = alert_rect . x + 30
text_y = alert_rect . y + 20
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
def refresh ( self ) - > bool :
update_available : bool = self . params . get_bool ( " UpdateAvailable " )
if update_available :
self . release_notes = self . params . get ( " UpdaterNewReleaseNotes " , encoding = ' utf-8 ' )
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 ( no_notes_text , int ( text_x ) , int ( text_y ) , AlertConstants . FONT_SIZE , AlertColors . TEXT )