import pyray as rl import time from dataclasses import dataclass from collections.abc import Callable from cereal import log from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.lib.text_measure import measure_text_cached SIDEBAR_WIDTH = 300 METRIC_HEIGHT = 126 METRIC_WIDTH = 240 METRIC_MARGIN = 30 SETTINGS_BTN = rl.Rectangle(50, 35, 200, 117) HOME_BTN = rl.Rectangle(60, 860, 180, 180) ThermalStatus = log.DeviceState.ThermalStatus NetworkType = log.DeviceState.NetworkType # Color scheme class Colors: SIDEBAR_BG = rl.Color(57, 57, 57, 255) WHITE = rl.Color(255, 255, 255, 255) WHITE_DIM = rl.Color(255, 255, 255, 85) GRAY = rl.Color(84, 84, 84, 255) # Status colors GOOD = rl.Color(255, 255, 255, 255) WARNING = rl.Color(218, 202, 37, 255) DANGER = rl.Color(201, 34, 49, 255) # UI elements METRIC_BORDER = rl.Color(255, 255, 255, 85) BUTTON_NORMAL = rl.Color(255, 255, 255, 255) BUTTON_PRESSED = rl.Color(255, 255, 255, 166) NETWORK_TYPES = { NetworkType.none: "Offline", NetworkType.wifi: "WiFi", NetworkType.cell2G: "2G", NetworkType.cell3G: "3G", NetworkType.cell4G: "LTE", NetworkType.cell5G: "5G", NetworkType.ethernet: "Ethernet", } @dataclass(slots=True) class MetricData: label: str value: str color: rl.Color def update(self, label: str, value: str, color: rl.Color): self.label = label self.value = value self.color = color class Sidebar: def __init__(self): self._net_type = NETWORK_TYPES.get(NetworkType.none) self._net_strength = 0 self._temp_status = MetricData("TEMP", "GOOD", Colors.GOOD) self._panda_status = MetricData("VEHICLE", "ONLINE", Colors.GOOD) self._connect_status = MetricData("CONNECT", "OFFLINE", Colors.WARNING) self._home_img = gui_app.texture("images/button_home.png", HOME_BTN.width, HOME_BTN.height) self._flag_img = gui_app.texture("images/button_flag.png", HOME_BTN.width, HOME_BTN.height) self._settings_img = gui_app.texture("images/button_settings.png", SETTINGS_BTN.width, SETTINGS_BTN.height) self._font_regular = gui_app.font(FontWeight.NORMAL) self._font_bold = gui_app.font(FontWeight.SEMI_BOLD) # Callbacks self._on_settings_click: Callable | None = None self._on_flag_click: Callable | None = None def set_callbacks(self, on_settings: Callable | None = None, on_flag: Callable | None = None): self._on_settings_click = on_settings self._on_flag_click = on_flag def render(self, rect: rl.Rectangle): self.update_state() # Background rl.draw_rectangle_rec(rect, Colors.SIDEBAR_BG) self._draw_buttons(rect) self._draw_network_indicator(rect) self._draw_metrics(rect) self._handle_mouse_release() def update_state(self): sm = ui_state.sm if not sm.updated['deviceState']: return device_state = sm['deviceState'] self._update_network_status(device_state) self._update_temperature_status(device_state) self._update_connection_status(device_state) self._update_panda_status() def _update_network_status(self, device_state): self._net_type = NETWORK_TYPES.get(device_state.networkType.raw, "Unknown") strength = device_state.networkStrength self._net_strength = max(0, min(5, strength.raw + 1)) if strength > 0 else 0 def _update_temperature_status(self, device_state): thermal_status = device_state.thermalStatus if thermal_status == ThermalStatus.green: self._temp_status.update("TEMP", "GOOD", Colors.GOOD) elif thermal_status == ThermalStatus.yellow: self._temp_status.update("TEMP", "OK", Colors.WARNING) else: self._temp_status.update("TEMP", "HIGH", Colors.DANGER) def _update_connection_status(self, device_state): last_ping = device_state.lastAthenaPingTime if last_ping == 0: self._connect_status.update("CONNECT", "OFFLINE", Colors.WARNING) elif time.monotonic_ns() - last_ping < 80_000_000_000: # 80 seconds in nanoseconds self._connect_status.update("CONNECT", "ONLINE", Colors.GOOD) else: self._connect_status.update("CONNECT", "ERROR", Colors.DANGER) def _update_panda_status(self): if ui_state.panda_type == log.PandaState.PandaType.unknown: self._panda_status.update("NO", "PANDA", Colors.DANGER) else: self._panda_status.update("VEHICLE", "ONLINE", Colors.GOOD) def _handle_mouse_release(self): if not rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT): return mouse_pos = rl.get_mouse_position() if rl.check_collision_point_rec(mouse_pos, SETTINGS_BTN): if self._on_settings_click: self._on_settings_click() elif rl.check_collision_point_rec(mouse_pos, HOME_BTN) and ui_state.started: if self._on_flag_click: self._on_flag_click() def _draw_buttons(self, rect: rl.Rectangle): mouse_pos = rl.get_mouse_position() mouse_down = rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT) # Settings button settings_down = mouse_down and rl.check_collision_point_rec(mouse_pos, SETTINGS_BTN) tint = Colors.BUTTON_PRESSED if settings_down else Colors.BUTTON_NORMAL rl.draw_texture(self._settings_img, int(SETTINGS_BTN.x), int(SETTINGS_BTN.y), tint) # Home/Flag button flag_pressed = mouse_down and rl.check_collision_point_rec(mouse_pos, HOME_BTN) button_img = self._flag_img if ui_state.started else self._home_img tint = Colors.BUTTON_PRESSED if (ui_state.started and flag_pressed) else Colors.BUTTON_NORMAL rl.draw_texture(button_img, int(HOME_BTN.x), int(HOME_BTN.y), tint) def _draw_network_indicator(self, rect: rl.Rectangle): # Signal strength dots x_start = rect.x + 58 y_pos = rect.y + 196 dot_size = 27 dot_spacing = 37 for i in range(5): color = Colors.WHITE if i < self._net_strength else Colors.GRAY x = int(x_start + i * dot_spacing + dot_size // 2) y = int(y_pos + dot_size // 2) rl.draw_circle(x, y, dot_size // 2, color) # Network type text text_y = rect.y + 247 text_pos = rl.Vector2(rect.x + 58, text_y) rl.draw_text_ex(self._font_regular, self._net_type, text_pos, 35, 0, Colors.WHITE) def _draw_metrics(self, rect: rl.Rectangle): metrics = [(self._temp_status, 338), (self._panda_status, 496), (self._connect_status, 654)] for metric, y_offset in metrics: self._draw_metric(rect, metric, rect.y + y_offset) def _draw_metric(self, rect: rl.Rectangle, metric: MetricData, y: float): metric_rect = rl.Rectangle(rect.x + METRIC_MARGIN, y, METRIC_WIDTH, METRIC_HEIGHT) # Draw colored left edge (clipped rounded rectangle) edge_rect = rl.Rectangle(metric_rect.x + 4, metric_rect.y + 4, 100, 118) rl.begin_scissor_mode(int(metric_rect.x + 4), int(metric_rect.y), 18, int(metric_rect.height)) rl.draw_rectangle_rounded(edge_rect, 0.18, 10, metric.color) rl.end_scissor_mode() # Draw border rl.draw_rectangle_rounded_lines_ex(metric_rect, 0.15, 10, 2, Colors.METRIC_BORDER) # Draw text text = f"{metric.label}\n{metric.value}" text_size = measure_text_cached(self._font_bold, text, 35) text_pos = rl.Vector2( metric_rect.x + 22 + (metric_rect.width - 22 - text_size.x) / 2, metric_rect.y + (metric_rect.height - text_size.y) / 2 ) rl.draw_text_ex(self._font_bold, text, text_pos, 35, 0, Colors.WHITE)