From 63adbdd9c6aa3fcc925532ef4be530410cbf9d15 Mon Sep 17 00:00:00 2001 From: stefpi <19478336+stefpi@users.noreply.github.com> Date: Wed, 11 Mar 2026 11:49:08 -0700 Subject: [PATCH 1/9] feat: forward joystick mode sdp offer through athena --- system/athena/athenad.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/system/athena/athenad.py b/system/athena/athenad.py index b52ef21ba63702..3e527f50daac44 100755 --- a/system/athena/athenad.py +++ b/system/athena/athenad.py @@ -44,6 +44,7 @@ ATHENA_HOST = os.getenv('ATHENA_HOST', 'wss://athena.comma.ai') HANDLER_THREADS = int(os.getenv('HANDLER_THREADS', "4")) LOCAL_PORT_WHITELIST = {22, } # SSH +WEBRTCD_PORT = 5001 LOG_ATTR_NAME = 'user.upload' LOG_ATTR_VALUE_MAX_UNIX_TIME = int.to_bytes(2147483647, 4, sys.byteorder) @@ -557,6 +558,17 @@ def getNetworks(): return HARDWARE.get_networks() +@dispatcher.add_method +def startJoystickStream(sdp: str) -> dict: + from openpilot.system.webrtc.webrtcd import StreamRequestBody + Params().put_bool("JoystickDebugMode", True) + body = StreamRequestBody(sdp, ["driver"], ["testJoystick"], ["carState"]) + resp = requests.post(f"http://localhost:{WEBRTCD_PORT}/stream", + json=asdict(body), timeout=10) + resp.raise_for_status() + return resp.json() + + @dispatcher.add_method def takeSnapshot() -> str | dict[str, str] | None: from openpilot.system.camerad.snapshot import jpeg_write, snapshot From 16a2fbfebe2d91a92b23984353f6e9325cdf375b Mon Sep 17 00:00:00 2001 From: stefpi <19478336+stefpi@users.noreply.github.com> Date: Wed, 11 Mar 2026 14:36:17 -0700 Subject: [PATCH 2/9] feat: add getNotCar dispatcher --- system/athena/athenad.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/system/athena/athenad.py b/system/athena/athenad.py index 3e527f50daac44..accbe3c17ff464 100755 --- a/system/athena/athenad.py +++ b/system/athena/athenad.py @@ -28,7 +28,7 @@ create_connection) import cereal.messaging as messaging -from cereal import log +from cereal import car, log from cereal.services import SERVICE_LIST from openpilot.common.api import Api, get_key_pair from openpilot.common.utils import CallbackReader, get_upload_stream @@ -537,6 +537,14 @@ def getSshAuthorizedKeys() -> str: def getGithubUsername() -> str: return cast(str, Params().get("GithubUsername") or "") +@dispatcher.add_method +def getNotCar() -> bool: + CP = Params().get("CarParams") + if CP is not None: + return car.CarParams.from_bytes(CP).notCar + return False + + @dispatcher.add_method def getSimInfo(): return HARDWARE.get_sim_info() From b346efbb85c44445bcac16ec8d15adb0bdf91bfe Mon Sep 17 00:00:00 2001 From: stefpi <19478336+stefpi@users.noreply.github.com> Date: Wed, 11 Mar 2026 14:39:41 -0700 Subject: [PATCH 3/9] fix: context manager with statement --- system/athena/athenad.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/system/athena/athenad.py b/system/athena/athenad.py index accbe3c17ff464..76600073356b0d 100755 --- a/system/athena/athenad.py +++ b/system/athena/athenad.py @@ -537,11 +537,13 @@ def getSshAuthorizedKeys() -> str: def getGithubUsername() -> str: return cast(str, Params().get("GithubUsername") or "") + @dispatcher.add_method def getNotCar() -> bool: - CP = Params().get("CarParams") - if CP is not None: - return car.CarParams.from_bytes(CP).notCar + cp_bytes = Params().get("CarParams") + if cp_bytes is not None: + with car.CarParams.from_bytes(cp_bytes) as CP: + return CP.notCar return False From 8cb630ae9228725440a048eee415081cff69b7ce Mon Sep 17 00:00:00 2001 From: stefpi <19478336+stefpi@users.noreply.github.com> Date: Wed, 11 Mar 2026 17:54:17 -0700 Subject: [PATCH 4/9] track fix/datachannel-double-counting --- .gitmodules | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitmodules b/.gitmodules index ad6530de9ac910..b01ab88806ef28 100644 --- a/.gitmodules +++ b/.gitmodules @@ -13,6 +13,7 @@ [submodule "teleoprtc_repo"] path = teleoprtc_repo url = ../../commaai/teleoprtc + branch = fix/datachannel-double-counting [submodule "tinygrad"] path = tinygrad_repo url = https://github.com/tinygrad/tinygrad.git From d7883cd0388d5e8cade1c90d95ad36662d4c17d4 Mon Sep 17 00:00:00 2001 From: stefpi <19478336+stefpi@users.noreply.github.com> Date: Thu, 12 Mar 2026 17:12:35 -0700 Subject: [PATCH 5/9] feat: (v1) bring back comma body face --- selfdrive/debug/bodyview.py | 65 ++++++ selfdrive/debug/uiview.py | 2 +- selfdrive/ui/layouts/body/__init__.py | 0 selfdrive/ui/layouts/body/body.py | 85 +++++++ selfdrive/ui/layouts/body/body_sidebar.py | 267 ++++++++++++++++++++++ selfdrive/ui/layouts/main.py | 45 +++- 6 files changed, 451 insertions(+), 13 deletions(-) create mode 100755 selfdrive/debug/bodyview.py create mode 100644 selfdrive/ui/layouts/body/__init__.py create mode 100644 selfdrive/ui/layouts/body/body.py create mode 100644 selfdrive/ui/layouts/body/body_sidebar.py diff --git a/selfdrive/debug/bodyview.py b/selfdrive/debug/bodyview.py new file mode 100755 index 00000000000000..baece307ab500c --- /dev/null +++ b/selfdrive/debug/bodyview.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Launch the big UI (comma 3X) simulating a comma body.""" +import os +import time +import threading + +os.environ["BIG"] = "1" + +import pyray as rl +from cereal import car, log, messaging +from openpilot.common.params import Params +from openpilot.system.ui.lib.application import gui_app + +# Pin window to monitor 0 after init +_orig_init_window = gui_app.init_window +def _init_window_on_monitor0(*args, **kwargs): + _orig_init_window(*args, **kwargs) + pos = rl.get_monitor_position(1) + rl.set_window_position(int(pos.x), int(pos.y)) +gui_app.init_window = _init_window_on_monitor0 + + +def send_messages(): + pm = messaging.PubMaster(['deviceState', 'pandaStates', 'carParams', 'carState']) + + car_params_msg = messaging.new_message('carParams') + car_params_msg.carParams.brand = "body" + car_params_msg.carParams.notCar = True + + device_state_msg = messaging.new_message('deviceState') + device_state_msg.deviceState.started = True + + panda_msg = messaging.new_message('pandaStates', 1) + panda_msg.pandaStates[0].ignitionLine = True + panda_msg.pandaStates[0].pandaType = log.PandaState.PandaType.uno + + car_state_msg = messaging.new_message('carState') + car_state_msg.carState.charging = True + car_state_msg.carState.fuelGauge = 0.80 + + while True: + pm.send('carParams', car_params_msg) + pm.send('deviceState', device_state_msg) + pm.send('pandaStates', panda_msg) + pm.send('carState', car_state_msg) + time.sleep(0.01) + + +def main(): + # Set CarParamsPersistent so ui_state.CP.notCar is True on startup + params = Params() + CP = car.CarParams.new_message(notCar=True, brand="body", wheelbase=1, steerRatio=10) + params.put("CarParamsPersistent", CP.to_bytes()) + + # Start message sender in background + t = threading.Thread(target=send_messages, daemon=True) + t.start() + + # Import after env is set so BIG_UI picks it up + from openpilot.selfdrive.ui.ui import main as ui_main + ui_main() + + +if __name__ == "__main__": + main() diff --git a/selfdrive/debug/uiview.py b/selfdrive/debug/uiview.py index 8e75769a85ec64..07fac0e3f47047 100755 --- a/selfdrive/debug/uiview.py +++ b/selfdrive/debug/uiview.py @@ -19,7 +19,7 @@ msgs = {s: messaging.new_message(s) for s in ['controlsState', 'deviceState', 'carParams']} msgs['deviceState'].deviceState.started = True msgs['deviceState'].deviceState.deviceType = HARDWARE.get_device_type() - msgs['carParams'].carParams.openpilotLongitudinalControl = True + # msgs['carParams'].carParams.openpilotLongitudinalControl = True msgs['pandaStates'] = messaging.new_message('pandaStates', 1) msgs['pandaStates'].pandaStates[0].ignitionLine = True diff --git a/selfdrive/ui/layouts/body/__init__.py b/selfdrive/ui/layouts/body/__init__.py new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/selfdrive/ui/layouts/body/body.py b/selfdrive/ui/layouts/body/body.py new file mode 100644 index 00000000000000..c7b40c24e273ce --- /dev/null +++ b/selfdrive/ui/layouts/body/body.py @@ -0,0 +1,85 @@ +import os +import time +import pyray as rl +from enum import Enum +from openpilot.common.basedir import BASEDIR +from openpilot.system.ui.widgets import Widget + +BODY_ASSETS = os.path.join(BASEDIR, "selfdrive/assets/body") +FRAME_DELAY = 0.1 # seconds between frames +CYCLE_INTERVAL = 10.0 # seconds between animation cycles + +class BodyAnim(Enum): + AWAKE = "awake.gif" + SLEEP = "sleep.gif" + +class BodyLayout(Widget): + def __init__(self, anim: BodyAnim): + super().__init__() + self._setup_widget = type('', (), {'set_open_settings_callback': lambda self, cb: None})() + self._frame_count = 0 + self._current_frame = 0 + self._last_frame_time = 0.0 + self._next_cycle_time = 0.0 + self._animating = True + self._texture = None + self._image = None + self._frame_size = 0 + + self._load_gif(os.path.join(BODY_ASSETS, anim.value)) + + def set_settings_callback(self, callback): + pass + + + def _load_gif(self, gif_path: str): + frames_ptr = rl.ffi.new('int *') + self._image = rl.load_image_anim(gif_path, frames_ptr) + self._frame_count = frames_ptr[0] + + # Each frame: width * height * 4 bytes (RGBA) + self._frame_size = self._image.width * self._image.height * 4 + + self._texture = rl.load_texture_from_image(self._image) + rl.set_texture_filter(self._texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR) + self._last_frame_time = time.monotonic() + + def _render(self, rect: rl.Rectangle): + rl.clear_background(rl.BLACK) + + now = time.monotonic() + + # Start a new animation cycle every CYCLE_INTERVAL + if not self._animating and now >= self._next_cycle_time: + self._animating = True + self._current_frame = 0 + self._last_frame_time = now + # Update texture to first frame + rl.update_texture(self._texture, self._image.data) + + # Advance frames while animating + if self._animating and now - self._last_frame_time >= FRAME_DELAY: + self._current_frame += 1 + self._last_frame_time = now + + if self._current_frame >= self._frame_count: + # Animation complete, wait for next cycle + self._current_frame = 0 + self._animating = False + self._next_cycle_time = now + CYCLE_INTERVAL + rl.update_texture(self._texture, self._image.data) + else: + offset = self._current_frame * self._frame_size + frame_data = rl.ffi.cast("unsigned char *", self._image.data) + offset + rl.update_texture(self._texture, frame_data) + + # Draw centered and scaled to fit + scale = min(rect.width / self._texture.width, rect.height / self._texture.height) + draw_w = self._texture.width * scale + draw_h = self._texture.height * scale + x = rect.x + (rect.width - draw_w) / 2 + y = rect.y + (rect.height - draw_h) / 2 + + source = rl.Rectangle(0, 0, self._texture.width, self._texture.height) + dest = rl.Rectangle(x, y, draw_w, draw_h) + rl.draw_texture_pro(self._texture, source, dest, rl.Vector2(0, 0), 0, rl.WHITE) diff --git a/selfdrive/ui/layouts/body/body_sidebar.py b/selfdrive/ui/layouts/body/body_sidebar.py new file mode 100644 index 00000000000000..066145251c60d6 --- /dev/null +++ b/selfdrive/ui/layouts/body/body_sidebar.py @@ -0,0 +1,267 @@ +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, MousePos, FONT_SCALE +from openpilot.system.ui.lib.multilang import tr, tr_noop +from openpilot.system.ui.lib.text_measure import measure_text_cached +from openpilot.system.ui.widgets import Widget + +BODY_SIDEBAR_HEIGHT = 200 +METRIC_HEIGHT = 117 +METRIC_WIDTH = 220 +METRIC_MARGIN = 20 +FONT_SIZE = 30 + +ThermalStatus = log.DeviceState.ThermalStatus +NetworkType = log.DeviceState.NetworkType + + +class Colors: + WHITE = rl.WHITE + WHITE_DIM = rl.Color(255, 255, 255, 85) + GRAY = rl.Color(84, 84, 84, 255) + GOOD = rl.WHITE + WARNING = rl.Color(218, 202, 37, 255) + DANGER = rl.Color(201, 34, 49, 255) + METRIC_BORDER = rl.Color(255, 255, 255, 85) + BUTTON_NORMAL = rl.WHITE + BUTTON_PRESSED = rl.Color(255, 255, 255, 166) + + +NETWORK_TYPES = { + NetworkType.none: tr_noop("--"), + NetworkType.wifi: tr_noop("Wi-Fi"), + NetworkType.ethernet: tr_noop("ETH"), + NetworkType.cell2G: tr_noop("2G"), + NetworkType.cell3G: tr_noop("3G"), + NetworkType.cell4G: tr_noop("LTE"), + NetworkType.cell5G: tr_noop("5G"), +} + + +@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 BodySidebar(Widget): + """A top-dropping sidebar for the comma body, containing the same info as the regular sidebar.""" + + def __init__(self): + super().__init__() + self._net_type = NETWORK_TYPES.get(NetworkType.none) + self._net_strength = 0 + + self._temp_status = MetricData(tr_noop("TEMP"), tr_noop("GOOD"), Colors.GOOD) + self._panda_status = MetricData(tr_noop("VEHICLE"), tr_noop("ONLINE"), Colors.GOOD) + self._connect_status = MetricData(tr_noop("CONNECT"), tr_noop("OFFLINE"), Colors.WARNING) + self._recording_audio = False + + self._settings_img = gui_app.texture("images/button_settings.png", 200, 117) + self._flag_img = gui_app.texture("images/button_flag.png", 180, 180) + self._mic_img = gui_app.texture("icons/microphone.png", 30, 30) + self._mic_indicator_rect = rl.Rectangle(0, 0, 0, 0) + self._font_regular = gui_app.font(FontWeight.NORMAL) + self._font_bold = gui_app.font(FontWeight.SEMI_BOLD) + self._font_extra_bold = gui_app.font(FontWeight.BOLD) + + # Callbacks + self._on_settings_click: Callable | None = None + self._on_flag_click: Callable | None = None + self._open_settings_callback: Callable | None = None + + def set_callbacks(self, on_settings: Callable | None = None, on_flag: Callable | None = None, + open_settings: Callable | None = None): + self._on_settings_click = on_settings + self._on_flag_click = on_flag + self._open_settings_callback = open_settings + + def _render(self, rect: rl.Rectangle): + rl.draw_rectangle_rec(rect, rl.Color(30, 30, 30, 0)) + + self._draw_settings_button(rect) + self._draw_network_indicator(rect) + self._draw_metrics(rect) + self._draw_pair_button(rect) + self._draw_mic_indicator(rect) + + def _update_state(self): + sm = ui_state.sm + if not sm.updated['deviceState']: + return + + device_state = sm['deviceState'] + self._recording_audio = ui_state.recording_audio + 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, tr_noop("Unknown")) + strength = device_state.networkStrength + self._net_strength = max(0, min(5, strength.raw + 1)) if strength.raw > 0 else 0 + + def _update_temperature_status(self, device_state): + thermal_status = device_state.thermalStatus + if thermal_status == ThermalStatus.green: + self._temp_status.update(tr_noop("TEMP"), tr_noop("GOOD"), Colors.GOOD) + elif thermal_status == ThermalStatus.yellow: + self._temp_status.update(tr_noop("TEMP"), tr_noop("OK"), Colors.WARNING) + else: + self._temp_status.update(tr_noop("TEMP"), tr_noop("HIGH"), Colors.DANGER) + + def _update_connection_status(self, device_state): + last_ping = device_state.lastAthenaPingTime + if last_ping == 0: + self._connect_status.update(tr_noop("CONNECT"), tr_noop("OFFLINE"), Colors.WARNING) + elif time.monotonic_ns() - last_ping < 80_000_000_000: + self._connect_status.update(tr_noop("CONNECT"), tr_noop("ONLINE"), Colors.GOOD) + else: + self._connect_status.update(tr_noop("CONNECT"), tr_noop("ERROR"), Colors.DANGER) + + def _update_panda_status(self): + if ui_state.panda_type == log.PandaState.PandaType.unknown: + self._panda_status.update(tr_noop("NO"), tr_noop("PANDA"), Colors.DANGER) + else: + self._panda_status.update(tr_noop("VEHICLE"), tr_noop("ONLINE"), Colors.GOOD) + + def _handle_mouse_release(self, mouse_pos: MousePos): + # Settings button (top-left) + settings_rect = rl.Rectangle(self._rect.x + 30, self._rect.y + 30, 200, 117) + if rl.check_collision_point_rec(mouse_pos, settings_rect): + if self._on_settings_click: + self._on_settings_click() + return + + # Flag button (top-right) + flag_rect = rl.Rectangle(self._rect.x + self._rect.width - 100, self._rect.y + 30, 60, 60) + if rl.check_collision_point_rec(mouse_pos, flag_rect) and ui_state.started: + if self._on_flag_click: + self._on_flag_click() + return + + # Mic indicator + if self._recording_audio and rl.check_collision_point_rec(mouse_pos, self._mic_indicator_rect): + if self._open_settings_callback: + self._open_settings_callback() + + def _draw_settings_button(self, rect: rl.Rectangle): + mouse_pos = rl.get_mouse_position() + mouse_down = self.is_pressed and rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT) + + btn_x = int(rect.x + 30) + btn_y = int(rect.y + 30) + settings_rect = rl.Rectangle(btn_x, btn_y, 200, 117) + settings_down = mouse_down and rl.check_collision_point_rec(mouse_pos, settings_rect) + tint = Colors.BUTTON_PRESSED if settings_down else Colors.BUTTON_NORMAL + rl.draw_texture(self._settings_img, btn_x, btn_y, tint) + + def _draw_pair_button(self, rect: rl.Rectangle): + mouse_pos = rl.get_mouse_position() + mouse_down = self.is_pressed and rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT) + + text = tr(tr_noop("PAIR")) + text_size = measure_text_cached(self._font_extra_bold, text, FONT_SIZE) + btn_w = int(text_size.x + 60) + btn_h = 117 + btn_x = int(rect.x + rect.width - btn_w - 30) + btn_y = int(rect.y + 30) + + pair_rect = rl.Rectangle(btn_x, btn_y, btn_w, btn_h) + pair_pressed = mouse_down and rl.check_collision_point_rec(mouse_pos, pair_rect) + bg_color = Colors.BUTTON_PRESSED if pair_pressed else Colors.BUTTON_NORMAL + + rl.draw_rectangle_rounded(pair_rect, 0.3, 10, bg_color) + text_pos = rl.Vector2(btn_x + (btn_w - text_size.x) / 2, btn_y + (btn_h - text_size.y) / 2) + rl.draw_text_ex(self._font_extra_bold, text, text_pos, FONT_SIZE, 0, rl.BLACK) + + # def _draw_flag_button(self, rect: rl.Rectangle): + # if not ui_state.started: + # return + + # mouse_pos = rl.get_mouse_position() + # mouse_down = self.is_pressed and rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT) + + # btn_x = int(rect.x + rect.width - 100) + # btn_y = int(rect.y + 30) + # flag_rect = rl.Rectangle(btn_x, btn_y, 60, 60) + # flag_pressed = mouse_down and rl.check_collision_point_rec(mouse_pos, flag_rect) + # tint = Colors.BUTTON_PRESSED if flag_pressed else Colors.BUTTON_NORMAL + # rl.draw_texture(self._flag_img, btn_x, btn_y, tint) + + def _draw_network_indicator(self, rect: rl.Rectangle): + # Draw network dots horizontally, positioned after the settings button + x_start = rect.x + 260 + y_pos = rect.y + 40 + dot_size = 20 + dot_spacing = 28 + + 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 below dots + text_pos = rl.Vector2(x_start, y_pos + dot_size + 8) + rl.draw_text_ex(self._font_regular, tr(self._net_type), text_pos, FONT_SIZE, 0, Colors.WHITE) + + def _draw_metrics(self, rect: rl.Rectangle): + metrics = [self._temp_status, self._panda_status, self._connect_status] + # Center the 3 metrics in the middle of the bar + total_width = len(metrics) * METRIC_WIDTH + (len(metrics) - 1) * METRIC_MARGIN + start_x = rect.x + (rect.width - total_width) / 2 + + y = rect.y + 30 + + for i, metric in enumerate(metrics): + x = start_x + i * (METRIC_WIDTH + METRIC_MARGIN) + self._draw_metric(metric, x, y) + + def _draw_metric(self, metric: MetricData, x: float, y: float): + r = rl.Rectangle(x, y, METRIC_WIDTH, METRIC_HEIGHT) + + # Colored top edge (clipped rounded rect) + rl.begin_scissor_mode(int(x), int(y + 4), int(METRIC_WIDTH), 18) + rl.draw_rectangle_rounded(rl.Rectangle(x + 4, y + 4, METRIC_WIDTH - 8, 100), 0.3, 10, metric.color) + rl.end_scissor_mode() + + rl.draw_rectangle_rounded_lines_ex(r, 0.3, 10, 2, Colors.METRIC_BORDER) + + # Center label and value below the top edge + text_y = y + 22 + ((METRIC_HEIGHT - 22) / 2 - 2 * FONT_SIZE * FONT_SCALE) + for label in (metric.label, metric.value): + text = tr(label) + size = measure_text_cached(self._font_bold, text, FONT_SIZE) + text_y += size.y + text_pos = rl.Vector2( + x + (METRIC_WIDTH - size.x) / 2, + text_y + ) + rl.draw_text_ex(self._font_bold, text, text_pos, FONT_SIZE, 0, Colors.WHITE) + + def _draw_mic_indicator(self, rect: rl.Rectangle): + if not self._recording_audio: + return + + mouse_pos = rl.get_mouse_position() + mouse_down = self.is_pressed and rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT) + + self._mic_indicator_rect = rl.Rectangle(rect.x + rect.width - 180, rect.y + rect.height - 50, 75, 40) + mic_pressed = mouse_down and rl.check_collision_point_rec(mouse_pos, self._mic_indicator_rect) + bg_color = rl.Color(Colors.DANGER.r, Colors.DANGER.g, Colors.DANGER.b, int(255 * 0.65)) if mic_pressed else Colors.DANGER + + rl.draw_rectangle_rounded(self._mic_indicator_rect, 1, 10, bg_color) + rl.draw_texture(self._mic_img, int(self._mic_indicator_rect.x + (self._mic_indicator_rect.width - self._mic_img.width) / 2), + int(self._mic_indicator_rect.y + (self._mic_indicator_rect.height - self._mic_img.height) / 2), Colors.WHITE) diff --git a/selfdrive/ui/layouts/main.py b/selfdrive/ui/layouts/main.py index 15d44e24da5b37..11fd220f735779 100644 --- a/selfdrive/ui/layouts/main.py +++ b/selfdrive/ui/layouts/main.py @@ -3,6 +3,8 @@ import cereal.messaging as messaging from openpilot.system.ui.lib.application import gui_app from openpilot.selfdrive.ui.layouts.sidebar import Sidebar, SIDEBAR_WIDTH +from openpilot.selfdrive.ui.layouts.body.body import BodyAnim, BodyLayout +from openpilot.selfdrive.ui.layouts.body.body_sidebar import BodySidebar, BODY_SIDEBAR_HEIGHT from openpilot.selfdrive.ui.layouts.home import HomeLayout from openpilot.selfdrive.ui.layouts.settings.settings import SettingsLayout, PanelType from openpilot.selfdrive.ui.onroad.augmented_road_view import AugmentedRoadView @@ -23,12 +25,17 @@ def __init__(self): self._pm = messaging.PubMaster(['bookmarkButton']) - self._sidebar = Sidebar() + self._is_body = self._check_not_car() + self._sidebar = BodySidebar() if self._is_body else Sidebar() self._current_mode = MainState.HOME self._prev_onroad = False # Initialize layouts - self._layouts = {MainState.HOME: HomeLayout(), MainState.SETTINGS: SettingsLayout(), MainState.ONROAD: AugmentedRoadView()} + if self._is_body: + self._layouts = {MainState.HOME: BodyLayout(BodyAnim.SLEEP), MainState.SETTINGS: SettingsLayout(), MainState.ONROAD: BodyLayout(BodyAnim.AWAKE)} + self._sidebar.set_visible(False) + else: + self._layouts = {MainState.HOME: HomeLayout(), MainState.SETTINGS: SettingsLayout(), MainState.ONROAD: AugmentedRoadView()} self._sidebar_rect = rl.Rectangle(0, 0, 0, 0) self._content_rect = rl.Rectangle(0, 0, 0, 0) @@ -58,10 +65,17 @@ def _setup_callbacks(self): device.add_interactive_timeout_callback(self._set_mode_for_state) def _update_layout_rects(self): - self._sidebar_rect = rl.Rectangle(self._rect.x, self._rect.y, SIDEBAR_WIDTH, self._rect.height) + if self._is_body: + self._sidebar_rect = rl.Rectangle(self._rect.x, self._rect.y, self._rect.width, BODY_SIDEBAR_HEIGHT) + y_offset = BODY_SIDEBAR_HEIGHT if self._sidebar.is_visible else 0 + self._content_rect = rl.Rectangle(self._rect.x, self._rect.y + y_offset, self._rect.width, self._rect.height - y_offset) + else: + self._sidebar_rect = rl.Rectangle(self._rect.x, self._rect.y, SIDEBAR_WIDTH, self._rect.height) + x_offset = SIDEBAR_WIDTH if self._sidebar.is_visible else 0 + self._content_rect = rl.Rectangle(self._rect.y + x_offset, self._rect.y, self._rect.width - x_offset, self._rect.height) - x_offset = SIDEBAR_WIDTH if self._sidebar.is_visible else 0 - self._content_rect = rl.Rectangle(self._rect.y + x_offset, self._rect.y, self._rect.width - x_offset, self._rect.height) + def _check_not_car(self): + return ui_state.CP is not None and ui_state.CP.notCar def _handle_onroad_transition(self): if ui_state.started != self._prev_onroad: @@ -77,7 +91,9 @@ def _set_mode_for_state(self): self._set_current_layout(MainState.ONROAD) else: self._set_current_layout(MainState.HOME) - self._sidebar.set_visible(True) + # Body sidebar always starts closed; regular sidebar starts open + if not self._is_body: + self._sidebar.set_visible(True) def _set_current_layout(self, layout: MainState): if layout != self._current_mode: @@ -102,9 +118,14 @@ def _on_onroad_clicked(self): self._sidebar.set_visible(not self._sidebar.is_visible) def _render_main_content(self): - # Render sidebar - if self._sidebar.is_visible: - self._sidebar.render(self._sidebar_rect) - - content_rect = self._content_rect if self._sidebar.is_visible else self._rect - self._layouts[self._current_mode].render(content_rect) + if self._is_body: + # Render body content first so sidebar draws on top (body clears background) + content_rect = self._content_rect if self._sidebar.is_visible else self._rect + self._layouts[self._current_mode].render(content_rect) + if self._sidebar.is_visible: + self._sidebar.render(self._sidebar_rect) + else: + if self._sidebar.is_visible: + self._sidebar.render(self._sidebar_rect) + content_rect = self._content_rect if self._sidebar.is_visible else self._rect + self._layouts[self._current_mode].render(content_rect) From 5299b704b12cf788216184600a1477d1384c472d Mon Sep 17 00:00:00 2001 From: stefpi <19478336+stefpi@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:57:37 -0700 Subject: [PATCH 6/9] feat: raylib face instead of gif, animation harness and barebones pair screen --- selfdrive/ui/layouts/body/animations.py | 69 ++++++ selfdrive/ui/layouts/body/body.py | 142 ++++++------ selfdrive/ui/layouts/body/body_pairing.py | 263 ++++++++++++++++++++++ selfdrive/ui/layouts/body/body_sidebar.py | 20 +- selfdrive/ui/layouts/main.py | 16 +- 5 files changed, 425 insertions(+), 85 deletions(-) create mode 100644 selfdrive/ui/layouts/body/animations.py create mode 100644 selfdrive/ui/layouts/body/body_pairing.py diff --git a/selfdrive/ui/layouts/body/animations.py b/selfdrive/ui/layouts/body/animations.py new file mode 100644 index 00000000000000..ba0d935c8b92b7 --- /dev/null +++ b/selfdrive/ui/layouts/body/animations.py @@ -0,0 +1,69 @@ +from dataclasses import dataclass + + +@dataclass +class Animation: + frames: list[list[tuple[int, int]]] + frame_duration: float = 0.15 # seconds each frame is shown + animation_frequency: float = 5 # seconds between animation restarts (0 = play once then hold last frame) + hold_end: float = 0.0 # seconds to hold the last frame before playing backward (-1 = no backward) + + +NORMAL = Animation( + frames=[ + [ + # Left eye + (3, 1), (4, 1), + (2, 2), (3, 2), (4, 2), (5, 2), + (2, 3), (3, 3), (4, 3), (5, 3), + (3, 4), (4, 4), + # Left eye brow + (1, 0), (0, 1), (0, 2), + # Right eye + (3, 14), (4, 14), + (2, 13), (3, 13), (4, 13), (5, 13), + (2, 12), (3, 12), (4, 12), (5, 12), + (3, 11), (4, 11), + # Right eye brow + (1, 15), (0, 14), (0, 13), + # Mouth (4 circles at bottom middle) + (6, 6), (7, 7), (7, 8), (6, 9) + ], + [ + # Left eye + (4, 1), + (4, 2), (5, 2), + (4, 3), (5, 3), + (4, 4), + # Left eye brow + (1, 0), (0, 1), (0, 2), + # Right eye + (4, 14), + (4, 13), (5, 13), + (4, 12), (5, 12), + (4, 11), + # Right eye brow + (1, 15), (0, 14), (0, 13), + # Mouth (4 circles at bottom middle) + (6, 6), (7, 7), (7, 8), (6, 9) + ], + [ + # Left eye + (4, 1), + (5, 2), + (5, 3), + (4, 4), + # Left eye brow + (2, 0), (1, 1), (1, 2), + # Right eye + (4, 14), + (5, 13), + (5, 12), + (4, 11), + # Right eye brow + (2, 15), (1, 14), (1, 13), + # Mouth (4 circles at bottom middle) + (6, 6), (7, 7), (7, 8), (6, 9) + ], + ], +) diff --git a/selfdrive/ui/layouts/body/body.py b/selfdrive/ui/layouts/body/body.py index c7b40c24e273ce..13a09af63ae9de 100644 --- a/selfdrive/ui/layouts/body/body.py +++ b/selfdrive/ui/layouts/body/body.py @@ -1,85 +1,83 @@ -import os import time + import pyray as rl -from enum import Enum -from openpilot.common.basedir import BASEDIR from openpilot.system.ui.widgets import Widget - -BODY_ASSETS = os.path.join(BASEDIR, "selfdrive/assets/body") -FRAME_DELAY = 0.1 # seconds between frames -CYCLE_INTERVAL = 10.0 # seconds between animation cycles - -class BodyAnim(Enum): - AWAKE = "awake.gif" - SLEEP = "sleep.gif" +from .animations import Animation, NORMAL + +GRID_COLS = 16 +GRID_ROWS = 8 +RADIUS = 50 + +ALL_DOTS = [(col, row) for row in range(GRID_ROWS) for col in range(GRID_COLS)] + +def draw_dot_grid(rect: rl.Rectangle, animation: Animation, color: rl.Color = None): + if color is None: + color = rl.WHITE + + now = time.monotonic() + num_frames = len(animation.frames) + + if num_frames == 1: + frame_index = 0 + else: + forward_duration = num_frames * animation.frame_duration + no_backward = animation.hold_end < 0 + + if no_backward: + cycle_duration = forward_duration + else: + backward_frames = max(num_frames - 2, 0) + backward_duration = backward_frames * animation.frame_duration + cycle_duration = forward_duration + animation.hold_end + backward_duration + + if animation.animation_frequency > 0: + elapsed = now % animation.animation_frequency + else: + elapsed = now % cycle_duration + + if elapsed < forward_duration: + # Playing forward + frame_index = min(int(elapsed / animation.frame_duration), num_frames - 1) + elif no_backward: + # No backward, hold last frame + frame_index = num_frames - 1 + elif elapsed < forward_duration + animation.hold_end: + # Holding last frame + frame_index = num_frames - 1 + elif elapsed < forward_duration + animation.hold_end + backward_duration: + # Playing backward (excluding first and last frame) + backward_elapsed = elapsed - forward_duration - animation.hold_end + backward_index = min(int(backward_elapsed / animation.frame_duration), backward_frames - 1) + frame_index = num_frames - 2 - backward_index + else: + # Hold first frame for remainder + frame_index = 0 + + dots = animation.frames[frame_index] + + spacing = (rect.height) / (GRID_ROWS) + + # Total size of the grid from first to last dot center + grid_w = (GRID_COLS - 1) * spacing + grid_h = (GRID_ROWS - 1) * spacing + + # Center horizontally, keep vertical centering + offset_x = rect.x + (rect.width - grid_w) / 2 + offset_y = rect.y + (rect.height - grid_h) / 2 + + for row, col in dots: + x = int(offset_x + col * spacing) + y = int(offset_y + row * spacing) + rl.draw_circle(x, y, RADIUS, color) class BodyLayout(Widget): - def __init__(self, anim: BodyAnim): + def __init__(self): super().__init__() self._setup_widget = type('', (), {'set_open_settings_callback': lambda self, cb: None})() - self._frame_count = 0 - self._current_frame = 0 - self._last_frame_time = 0.0 - self._next_cycle_time = 0.0 - self._animating = True - self._texture = None - self._image = None - self._frame_size = 0 - - self._load_gif(os.path.join(BODY_ASSETS, anim.value)) def set_settings_callback(self, callback): pass - - def _load_gif(self, gif_path: str): - frames_ptr = rl.ffi.new('int *') - self._image = rl.load_image_anim(gif_path, frames_ptr) - self._frame_count = frames_ptr[0] - - # Each frame: width * height * 4 bytes (RGBA) - self._frame_size = self._image.width * self._image.height * 4 - - self._texture = rl.load_texture_from_image(self._image) - rl.set_texture_filter(self._texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR) - self._last_frame_time = time.monotonic() - def _render(self, rect: rl.Rectangle): rl.clear_background(rl.BLACK) - - now = time.monotonic() - - # Start a new animation cycle every CYCLE_INTERVAL - if not self._animating and now >= self._next_cycle_time: - self._animating = True - self._current_frame = 0 - self._last_frame_time = now - # Update texture to first frame - rl.update_texture(self._texture, self._image.data) - - # Advance frames while animating - if self._animating and now - self._last_frame_time >= FRAME_DELAY: - self._current_frame += 1 - self._last_frame_time = now - - if self._current_frame >= self._frame_count: - # Animation complete, wait for next cycle - self._current_frame = 0 - self._animating = False - self._next_cycle_time = now + CYCLE_INTERVAL - rl.update_texture(self._texture, self._image.data) - else: - offset = self._current_frame * self._frame_size - frame_data = rl.ffi.cast("unsigned char *", self._image.data) + offset - rl.update_texture(self._texture, frame_data) - - # Draw centered and scaled to fit - scale = min(rect.width / self._texture.width, rect.height / self._texture.height) - draw_w = self._texture.width * scale - draw_h = self._texture.height * scale - x = rect.x + (rect.width - draw_w) / 2 - y = rect.y + (rect.height - draw_h) / 2 - - source = rl.Rectangle(0, 0, self._texture.width, self._texture.height) - dest = rl.Rectangle(x, y, draw_w, draw_h) - rl.draw_texture_pro(self._texture, source, dest, rl.Vector2(0, 0), 0, rl.WHITE) + draw_dot_grid(rect, NORMAL) diff --git a/selfdrive/ui/layouts/body/body_pairing.py b/selfdrive/ui/layouts/body/body_pairing.py new file mode 100644 index 00000000000000..f723d9289236f0 --- /dev/null +++ b/selfdrive/ui/layouts/body/body_pairing.py @@ -0,0 +1,263 @@ +from __future__ import annotations + +import socket +import time + +import numpy as np +import pyray as rl +import qrcode + +from openpilot.common.api import Api +from openpilot.common.params import Params +from openpilot.common.swaglog import cloudlog +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.ui.lib.application import FontWeight, gui_app +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.button import IconButton + +WEBRTC_PORT = 5001 +CARD_BG = rl.Color(40, 40, 40, 255) +CARD_RADIUS = 0.05 +TEXT_COLOR = rl.WHITE +TEXT_DIM = rl.Color(255, 255, 255, 150) +SCREEN_BG = rl.Color(20, 20, 20, 255) + + +def _get_local_ip() -> str: + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: + s.connect(("8.8.8.8", 80)) + return s.getsockname()[0] + except Exception: + return "" + + +class BodyPairingScreen(Widget): + """Two-panel pairing screen for comma body: account pairing (left) and one-time connection (right).""" + + QR_REFRESH_INTERVAL = 300 # seconds + + def __init__(self): + super().__init__() + self._params = Params() + self._close_btn = self._child(IconButton(gui_app.texture("icons/close.png", 80, 80))) + self._close_btn.set_click_callback(gui_app.pop_widget) + + # QR code state for account pairing (left card) + self._pair_qr_texture: rl.Texture | None = None + self._pair_qr_last_gen = float('-inf') + + # QR code state for one-time connection (right card) + self._connect_qr_texture: rl.Texture | None = None + self._connect_qr_last_gen = float('-inf') + + # Cached IP + self._ip_address = "" + self._last_ip_check = float('-inf') + + self._font = gui_app.font(FontWeight.NORMAL) + self._font_bold = gui_app.font(FontWeight.BOLD) + self._font_semi = gui_app.font(FontWeight.SEMI_BOLD) + + def _update_state(self): + # Refresh IP periodically + now = time.monotonic() + if now - self._last_ip_check > 10: + self._ip_address = _get_local_ip() + self._last_ip_check = now + + def _get_pairing_url(self) -> str: + try: + dongle_id = self._params.get("DongleId") or "" + token = Api(dongle_id).get_token({'pair': True}) + except Exception: + cloudlog.exception("Failed to get pairing token") + token = "" + return f"https://connect.comma.ai/?pair={token}" + + def _get_connect_url(self) -> str: + ip = self._ip_address or "unknown" + return f"http://{ip}:{WEBRTC_PORT}" + + def _generate_qr(self, data: str, invert: bool = False) -> rl.Texture | None: + try: + qr = qrcode.QRCode(version=1, error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=10, border=4) + qr.add_data(data) + qr.make(fit=True) + + fill = "white" if invert else "black" + bg = "black" if invert else "white" + pil_img = qr.make_image(fill_color=fill, back_color=bg).convert('RGBA') + img_array = np.array(pil_img, dtype=np.uint8) + + rl_image = rl.Image() + rl_image.data = rl.ffi.cast("void *", img_array.ctypes.data) + rl_image.width = pil_img.width + rl_image.height = pil_img.height + rl_image.mipmaps = 1 + rl_image.format = rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_R8G8B8A8 + return rl.load_texture_from_image(rl_image) + except Exception: + cloudlog.exception("QR code generation failed") + return None + + def _refresh_pair_qr(self): + now = time.monotonic() + if now - self._pair_qr_last_gen >= self.QR_REFRESH_INTERVAL: + if self._pair_qr_texture and self._pair_qr_texture.id != 0: + rl.unload_texture(self._pair_qr_texture) + self._pair_qr_texture = self._generate_qr(self._get_pairing_url(), invert=True) + self._pair_qr_last_gen = now + + def _refresh_connect_qr(self): + now = time.monotonic() + if now - self._connect_qr_last_gen >= self.QR_REFRESH_INTERVAL: + if self._connect_qr_texture and self._connect_qr_texture.id != 0: + rl.unload_texture(self._connect_qr_texture) + self._connect_qr_texture = self._generate_qr(self._get_connect_url(), invert=True) + self._connect_qr_last_gen = now + + def _render(self, rect: rl.Rectangle): + rl.clear_background(SCREEN_BG) + + margin = 40 + gap = 30 + close_size = 60 + + # Close button top-left + close_rect = rl.Rectangle(rect.x + margin, rect.y + margin, close_size, close_size) + self._close_btn.render(close_rect) + + # Cards area below close button + cards_y = rect.y + margin + close_size + 20 + cards_h = rect.height - margin - close_size - 20 - margin + card_w = (rect.width - 2 * margin - gap) / 2 + + left_rect = rl.Rectangle(rect.x + margin, cards_y, card_w, cards_h) + right_rect = rl.Rectangle(rect.x + margin + card_w + gap, cards_y, card_w, cards_h) + + is_paired = ui_state.prime_state.is_paired() + + self._render_pair_card(left_rect, is_paired) + self._render_connect_card(right_rect) + + def _render_pair_card(self, rect: rl.Rectangle, is_paired: bool): + rl.draw_rectangle_rounded(rect, CARD_RADIUS, 20, CARD_BG) + + pad = 40 + x = rect.x + pad + y = rect.y + pad + w = rect.width - 2 * pad + + # Title + title = "PAIR with COMMA BODY" + rl.draw_text_ex(self._font_bold, title, rl.Vector2(x, y), 48, 0, TEXT_COLOR) + y += 70 + + if is_paired: + self._render_paired_state(x, y, w) + else: + self._render_unpaired_state(x, y, w, rect) + + def _render_paired_state(self, x: float, y: float, w: float): + # Checkmark circle + circle_x = x + w / 2 + circle_y = y + 80 + rl.draw_circle(int(circle_x), int(circle_y), 40, rl.Color(75, 180, 75, 255)) + check_font = gui_app.font(FontWeight.BOLD) + check_size = measure_text_cached(check_font, "✓", 50) + rl.draw_text_ex(check_font, "✓", rl.Vector2(circle_x - check_size.x / 2, circle_y - check_size.y / 2), 50, 0, rl.WHITE) + + y = circle_y + 70 + + paired_text = "This comma body is paired" + text_size = measure_text_cached(self._font_semi, paired_text, 38) + rl.draw_text_ex(self._font_semi, paired_text, rl.Vector2(x + (w - text_size.x) / 2, y), 38, 0, TEXT_COLOR) + y += 50 + + sub_text = "Manage your device at connect.comma.ai" + sub_size = measure_text_cached(self._font, sub_text, 30) + rl.draw_text_ex(self._font, sub_text, rl.Vector2(x + (w - sub_size.x) / 2, y), 30, 0, TEXT_DIM) + + def _render_unpaired_state(self, x: float, y: float, w: float, card_rect: rl.Rectangle): + # Description + desc = "With connect prime, you can control your comma body from anywhere in the world!" + wrapped = wrap_text(self._font, desc, 34, int(w)) + for line in wrapped: + rl.draw_text_ex(self._font, line, rl.Vector2(x, y), 34, 0, TEXT_DIM) + y += 42 + + y += 20 + + # QR code centered in remaining space + self._refresh_pair_qr() + remaining_h = card_rect.y + card_rect.height - y - 40 + qr_size = min(int(w * 0.6), int(remaining_h)) + if qr_size > 0 and self._pair_qr_texture: + qr_x = x + (w - qr_size) / 2 + source = rl.Rectangle(0, 0, self._pair_qr_texture.width, self._pair_qr_texture.height) + dest = rl.Rectangle(qr_x, y, qr_size, qr_size) + rl.draw_texture_pro(self._pair_qr_texture, source, dest, rl.Vector2(0, 0), 0, rl.WHITE) + + def _render_connect_card(self, rect: rl.Rectangle): + rl.draw_rectangle_rounded(rect, CARD_RADIUS, 20, CARD_BG) + + pad = 40 + x = rect.x + pad + y = rect.y + pad + w = rect.width - 2 * pad + + # Title + title = "Connect to COMMA BODY" + rl.draw_text_ex(self._font_bold, title, rl.Vector2(x, y), 48, 0, TEXT_COLOR) + y += 70 + + # Description + desc = "You can connect to this comma one time by scanning the QR code on your connect app" + wrapped = wrap_text(self._font, desc, 34, int(w)) + for line in wrapped: + rl.draw_text_ex(self._font, line, rl.Vector2(x, y), 34, 0, TEXT_DIM) + y += 42 + + y += 20 + + # QR code + self._refresh_connect_qr() + qr_avail_h = rect.y + rect.height - y - 180 # leave room for manual info + qr_size = min(int(w * 0.5), int(qr_avail_h)) + if qr_size > 0 and self._connect_qr_texture: + qr_x = x + (w - qr_size) / 2 + source = rl.Rectangle(0, 0, self._connect_qr_texture.width, self._connect_qr_texture.height) + dest = rl.Rectangle(qr_x, y, qr_size, qr_size) + rl.draw_texture_pro(self._connect_qr_texture, source, dest, rl.Vector2(0, 0), 0, rl.WHITE) + y += qr_size + 20 + + # Manual connection info at bottom + bottom_y = rect.y + rect.height - pad - 120 + y = max(y, bottom_y) + + rl.draw_text_ex(self._font, "Can't use the QR code? Input the following manually:", rl.Vector2(x, y), 30, 0, TEXT_DIM) + y += 40 + + ip_text = f"IP: {self._ip_address}" if self._ip_address else "IP: not connected" + rl.draw_text_ex(self._font_semi, ip_text, rl.Vector2(x, y), 34, 0, TEXT_COLOR) + y += 42 + rl.draw_text_ex(self._font_semi, f"Port: {WEBRTC_PORT}", rl.Vector2(x, y), 34, 0, TEXT_COLOR) + + def __del__(self): + for tex in (self._pair_qr_texture, self._connect_qr_texture): + if tex and tex.id != 0: + rl.unload_texture(tex) + + +if __name__ == "__main__": + gui_app.init_window("Body Pairing") + screen = BodyPairingScreen() + gui_app.push_widget(screen) + try: + for _ in gui_app.render(): + pass + finally: + del screen diff --git a/selfdrive/ui/layouts/body/body_sidebar.py b/selfdrive/ui/layouts/body/body_sidebar.py index 066145251c60d6..01a198dfac0441 100644 --- a/selfdrive/ui/layouts/body/body_sidebar.py +++ b/selfdrive/ui/layouts/body/body_sidebar.py @@ -4,6 +4,7 @@ from collections.abc import Callable from cereal import log from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.selfdrive.ui.layouts.body.body_pairing import BodyPairingScreen from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos, FONT_SCALE from openpilot.system.ui.lib.multilang import tr, tr_noop from openpilot.system.ui.lib.text_measure import measure_text_cached @@ -87,7 +88,7 @@ def set_callbacks(self, on_settings: Callable | None = None, on_flag: Callable | self._open_settings_callback = open_settings def _render(self, rect: rl.Rectangle): - rl.draw_rectangle_rec(rect, rl.Color(30, 30, 30, 0)) + rl.draw_rectangle_rec(rect, rl.BLACK) self._draw_settings_button(rect) self._draw_network_indicator(rect) @@ -144,11 +145,16 @@ def _handle_mouse_release(self, mouse_pos: MousePos): self._on_settings_click() return - # Flag button (top-right) - flag_rect = rl.Rectangle(self._rect.x + self._rect.width - 100, self._rect.y + 30, 60, 60) - if rl.check_collision_point_rec(mouse_pos, flag_rect) and ui_state.started: - if self._on_flag_click: - self._on_flag_click() + # Pair button + text = tr(tr_noop("PAIR")) + text_size = measure_text_cached(self._font_extra_bold, text, FONT_SIZE) + btn_w = int(text_size.x + 60) + btn_h = 117 + btn_x = int(self._rect.x + self._rect.width - btn_w - 30) + btn_y = int(self._rect.y + 30) + pair_rect = rl.Rectangle(btn_x, btn_y, btn_w, btn_h) + if rl.check_collision_point_rec(mouse_pos, pair_rect): + gui_app.push_widget(BodyPairingScreen()) return # Mic indicator @@ -182,7 +188,7 @@ def _draw_pair_button(self, rect: rl.Rectangle): pair_pressed = mouse_down and rl.check_collision_point_rec(mouse_pos, pair_rect) bg_color = Colors.BUTTON_PRESSED if pair_pressed else Colors.BUTTON_NORMAL - rl.draw_rectangle_rounded(pair_rect, 0.3, 10, bg_color) + rl.draw_rectangle_rounded(pair_rect, 0.5, 10, bg_color) text_pos = rl.Vector2(btn_x + (btn_w - text_size.x) / 2, btn_y + (btn_h - text_size.y) / 2) rl.draw_text_ex(self._font_extra_bold, text, text_pos, FONT_SIZE, 0, rl.BLACK) diff --git a/selfdrive/ui/layouts/main.py b/selfdrive/ui/layouts/main.py index 11fd220f735779..d25587907fae4c 100644 --- a/selfdrive/ui/layouts/main.py +++ b/selfdrive/ui/layouts/main.py @@ -3,7 +3,7 @@ import cereal.messaging as messaging from openpilot.system.ui.lib.application import gui_app from openpilot.selfdrive.ui.layouts.sidebar import Sidebar, SIDEBAR_WIDTH -from openpilot.selfdrive.ui.layouts.body.body import BodyAnim, BodyLayout +from openpilot.selfdrive.ui.layouts.body.body import BodyLayout from openpilot.selfdrive.ui.layouts.body.body_sidebar import BodySidebar, BODY_SIDEBAR_HEIGHT from openpilot.selfdrive.ui.layouts.home import HomeLayout from openpilot.selfdrive.ui.layouts.settings.settings import SettingsLayout, PanelType @@ -32,7 +32,7 @@ def __init__(self): # Initialize layouts if self._is_body: - self._layouts = {MainState.HOME: BodyLayout(BodyAnim.SLEEP), MainState.SETTINGS: SettingsLayout(), MainState.ONROAD: BodyLayout(BodyAnim.AWAKE)} + self._layouts = {MainState.HOME: BodyLayout(), MainState.SETTINGS: SettingsLayout(), MainState.ONROAD: BodyLayout()} self._sidebar.set_visible(False) else: self._layouts = {MainState.HOME: HomeLayout(), MainState.SETTINGS: SettingsLayout(), MainState.ONROAD: AugmentedRoadView()} @@ -118,10 +118,14 @@ def _on_onroad_clicked(self): self._sidebar.set_visible(not self._sidebar.is_visible) def _render_main_content(self): - if self._is_body: - # Render body content first so sidebar draws on top (body clears background) - content_rect = self._content_rect if self._sidebar.is_visible else self._rect - self._layouts[self._current_mode].render(content_rect) + if self._is_body: # overlay sidebar but recompute boundaries for proper click events + if self._sidebar.is_visible: + self._layouts[self._current_mode].set_parent_rect( + rl.Rectangle(self._rect.x, self._rect.y + BODY_SIDEBAR_HEIGHT, + self._rect.width, self._rect.height - BODY_SIDEBAR_HEIGHT)) + else: + self._layouts[self._current_mode].set_parent_rect(None) + self._layouts[self._current_mode].render(self._rect) if self._sidebar.is_visible: self._sidebar.render(self._sidebar_rect) else: From b591fb6c4d4781adae1b509243411a35538bb85d Mon Sep 17 00:00:00 2001 From: stefpi <19478336+stefpi@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:58:15 -0700 Subject: [PATCH 7/9] feat: 10mbps bitrate for connect webrtc livestream --- system/loggerd/loggerd.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/system/loggerd/loggerd.h b/system/loggerd/loggerd.h index 6aa0c8be40b96f..1bc4668b6fc756 100644 --- a/system/loggerd/loggerd.h +++ b/system/loggerd/loggerd.h @@ -47,7 +47,7 @@ struct EncoderSettings { } static EncoderSettings StreamEncoderSettings() { - int _stream_bitrate = getenv("STREAM_BITRATE") ? atoi(getenv("STREAM_BITRATE")) : 1'000'000; + int _stream_bitrate = getenv("STREAM_BITRATE") ? atoi(getenv("STREAM_BITRATE")) : 10'000'000; return EncoderSettings{.encode_type = cereal::EncodeIndex::Type::QCAMERA_H264, .bitrate = _stream_bitrate , .gop_size = 15}; } }; From b83e43617be4429402843360d1b6d4b0c3e5135f Mon Sep 17 00:00:00 2001 From: stefpi <19478336+stefpi@users.noreply.github.com> Date: Fri, 13 Mar 2026 11:35:30 -0700 Subject: [PATCH 8/9] feat: FaceAnimations, new faces and reactivity, battery indicator --- selfdrive/ui/layouts/body/animations.py | 191 +++++++++++++++------- selfdrive/ui/layouts/body/body.py | 189 ++++++++++++++++----- selfdrive/ui/layouts/body/body_sidebar.py | 64 ++++++++ 3 files changed, 344 insertions(+), 100 deletions(-) diff --git a/selfdrive/ui/layouts/body/animations.py b/selfdrive/ui/layouts/body/animations.py index ba0d935c8b92b7..0ef7a29ab420e3 100644 --- a/selfdrive/ui/layouts/body/animations.py +++ b/selfdrive/ui/layouts/body/animations.py @@ -1,69 +1,148 @@ from dataclasses import dataclass +from enum import Enum + + +class AnimationMode(Enum): + ONCE_FORWARD = 1 + ONCE_FORWARD_BACKWARD = 2 + REPEAT_FORWARD = 3 + REPEAT_FORWARD_BACKWARD = 4 @dataclass class Animation: frames: list[list[tuple[int, int]]] + starting_frames: list[list[tuple[int, int]]] | None = None # played once before the main loop frame_duration: float = 0.15 # seconds each frame is shown - animation_frequency: float = 5 # seconds between animation restarts (0 = play once then hold last frame) - hold_end: float = 0.0 # seconds to hold the last frame before playing backward (-1 = no backward) + mode: AnimationMode = AnimationMode.REPEAT_FORWARD_BACKWARD + repeat_interval: float = 5.0 # seconds between animation restarts (only for REPEAT modes) + hold_end: float = 0.0 # seconds to hold the last frame before playing backward (only for *_BACKWARD modes) + + +def _mirror(dots: list[tuple[int, int]]) -> list[tuple[int, int]]: + """Mirror a component from the left side of the face to the right""" + return [(r, 15 - c) for r, c in dots] + + +def _mirror_no_flip(dots: list[tuple[int, int]], ref: list[tuple[int, int]] | None = None) -> list[tuple[int, int]]: + """Move a component to the symmetric position without flipping the shape. + ref: reference component defining the full bounding box (e.g. EYE_OPEN for any eye variant)""" + ref = ref if ref is not None else dots + shift = min(c for _, c in _mirror(ref)) - min(c for _, c in ref) + return [(r, c + shift) for r, c in dots] + + +def _shift_up(dots: list[tuple[int, int]], n: int = 1) -> list[tuple[int, int]]: + return [(r - n, c) for r, c in dots] + + +def _shift_down(dots: list[tuple[int, int]], n: int = 1) -> list[tuple[int, int]]: + return [(r + n, c) for r, c in dots] + + +def _shift_left(dots: list[tuple[int, int]], n: int = 1) -> list[tuple[int, int]]: + return [(r, c - n) for r, c in dots] + + +def _shift_right(dots: list[tuple[int, int]], n: int = 1) -> list[tuple[int, int]]: + return [(r, c + n) for r, c in dots] + + +def _make_frame(left_eye: list[tuple[int, int]], right_eye: list[tuple[int, int]], + left_brow: list[tuple[int, int]], right_brow: list[tuple[int, int]], + mouth: list[tuple[int, int]]) -> list[tuple[int, int]]: + return left_eye + left_brow + right_eye + right_brow + mouth + + +# Eyes (left side) +EYE_OPEN = [ + (2, 2), (2, 3), +(3, 1), (3, 2), (3, 3), (3, 4), +(4, 1), (4, 2), (4, 3), (4, 4), + (5, 2), (5, 3) +] +EYE_HALF = [ +(4, 1), (4, 2), (4, 3), (4, 4), + (5, 2), (5, 3) +] +EYE_CLOSED = [ +(4, 1), (4, 4), + (5, 2), (5, 3), +] +EYE_LEFT_LOOK = [ + (2, 2), (2, 3), +(3, 1), (3, 2), +(4, 1), (4, 2), + (5, 2), (5, 3), +] +EYE_RIGHT_LOOK = [ + (2, 2), (2, 3), + (3, 3), (3, 4), + (4, 3), (4, 4), + (5, 2), (5, 3), +] + +# Eyebrows (left side) +BROW_HIGH = [(1, 0), (0, 1), (0, 2)] +BROW_LOWERED = [(2, 0), (1, 1), (1, 2)] +BROW_STRAIGHT = [(2, 0), (2, 1), (2, 2)] +# Mouths (centered, not mirrored) +MOUTH_SMILE = [(6, 6), (7, 7), (7, 8), (6, 9)] +MOUTH_NORMAL = [(7, 7), (7, 8)] +MOUTH_SAD = [(7, 6), (6, 7), (6, 8), (7, 9)] + + +# --- Animations --- NORMAL = Animation( frames=[ - [ - # Left eye - (3, 1), (4, 1), - (2, 2), (3, 2), (4, 2), (5, 2), - (2, 3), (3, 3), (4, 3), (5, 3), - (3, 4), (4, 4), - # Left eye brow - (1, 0), (0, 1), (0, 2), - # Right eye - (3, 14), (4, 14), - (2, 13), (3, 13), (4, 13), (5, 13), - (2, 12), (3, 12), (4, 12), (5, 12), - (3, 11), (4, 11), - # Right eye brow - (1, 15), (0, 14), (0, 13), - # Mouth (4 circles at bottom middle) - (6, 6), (7, 7), (7, 8), (6, 9) - ], - [ - # Left eye - (4, 1), - (4, 2), (5, 2), - (4, 3), (5, 3), - (4, 4), - # Left eye brow - (1, 0), (0, 1), (0, 2), - # Right eye - (4, 14), - (4, 13), (5, 13), - (4, 12), (5, 12), - (4, 11), - # Right eye brow - (1, 15), (0, 14), (0, 13), - # Mouth (4 circles at bottom middle) - (6, 6), (7, 7), (7, 8), (6, 9) - ], - [ - # Left eye - (4, 1), - (5, 2), - (5, 3), - (4, 4), - # Left eye brow - (2, 0), (1, 1), (1, 2), - # Right eye - (4, 14), - (5, 13), - (5, 12), - (4, 11), - # Right eye brow - (2, 15), (1, 14), (1, 13), - # Mouth (4 circles at bottom middle) - (6, 6), (7, 7), (7, 8), (6, 9) - ], + _make_frame(EYE_OPEN, _mirror(EYE_OPEN), BROW_HIGH, _mirror(BROW_HIGH), MOUTH_SMILE), + _make_frame(EYE_HALF, _mirror(EYE_HALF), BROW_HIGH, _mirror(BROW_HIGH), MOUTH_SMILE), + _make_frame(EYE_CLOSED, _mirror(EYE_CLOSED), BROW_LOWERED, _mirror(BROW_LOWERED), MOUTH_SMILE), + ], +) + +ASLEEP = Animation( + frames=[ + _make_frame(EYE_CLOSED, _mirror(EYE_CLOSED), BROW_STRAIGHT, _mirror(BROW_STRAIGHT), MOUTH_NORMAL), + ], + # frame_duration=0.25, +) + +SLEEPY = Animation( + frames=[ + _make_frame(EYE_CLOSED, _mirror(EYE_CLOSED), BROW_STRAIGHT, _mirror(BROW_STRAIGHT), MOUTH_NORMAL), + _make_frame(EYE_CLOSED, _mirror(EYE_HALF), BROW_STRAIGHT, _mirror(BROW_LOWERED), MOUTH_NORMAL), + _make_frame(EYE_CLOSED, _mirror(EYE_OPEN), BROW_STRAIGHT, _mirror(BROW_HIGH), MOUTH_NORMAL) + ], + frame_duration=0.25, + mode=AnimationMode.ONCE_FORWARD_BACKWARD, + repeat_interval=10, + hold_end=1.5, +) + +INQUISITIVE = Animation( + frames=[ + _make_frame(EYE_OPEN, _mirror(EYE_OPEN), BROW_HIGH, _mirror(BROW_HIGH), MOUTH_SMILE), + + _make_frame(EYE_LEFT_LOOK, _mirror_no_flip(EYE_LEFT_LOOK, EYE_OPEN), BROW_HIGH, _mirror(BROW_HIGH), MOUTH_SMILE), + _make_frame(_shift_left(EYE_LEFT_LOOK, 1), _shift_left(_mirror_no_flip(EYE_LEFT_LOOK, EYE_OPEN), 1), BROW_HIGH, _mirror(BROW_HIGH), MOUTH_SMILE), + _make_frame(_shift_left(EYE_LEFT_LOOK, 1), _shift_left(_mirror_no_flip(EYE_LEFT_LOOK, EYE_OPEN), 1), BROW_HIGH, _mirror(BROW_HIGH), MOUTH_SMILE), + _make_frame(_shift_left(EYE_LEFT_LOOK, 1), _shift_left(_mirror_no_flip(EYE_LEFT_LOOK, EYE_OPEN), 1), BROW_HIGH, _mirror(BROW_HIGH), MOUTH_SMILE), + _make_frame(EYE_LEFT_LOOK, _mirror_no_flip(EYE_LEFT_LOOK, EYE_OPEN), BROW_HIGH, _mirror(BROW_HIGH), MOUTH_SMILE), + + # _make_frame(EYE_OPEN, _mirror(EYE_OPEN), BROW_HIGH, _mirror(BROW_HIGH), MOUTH_SMILE), + + _make_frame(EYE_RIGHT_LOOK, _mirror_no_flip(EYE_RIGHT_LOOK, EYE_OPEN), BROW_HIGH, _mirror(BROW_HIGH), MOUTH_SMILE), + _make_frame(_shift_right(EYE_RIGHT_LOOK, 1), _shift_right(_mirror_no_flip(EYE_RIGHT_LOOK, EYE_OPEN), 1), BROW_HIGH, _mirror(BROW_HIGH), MOUTH_SMILE), + _make_frame(_shift_right(EYE_RIGHT_LOOK, 1), _shift_right(_mirror_no_flip(EYE_RIGHT_LOOK, EYE_OPEN), 1), BROW_HIGH, _mirror(BROW_HIGH), MOUTH_SMILE), + _make_frame(_shift_right(EYE_RIGHT_LOOK, 1), _shift_right(_mirror_no_flip(EYE_RIGHT_LOOK, EYE_OPEN), 1), BROW_HIGH, _mirror(BROW_HIGH), MOUTH_SMILE), + _make_frame(EYE_RIGHT_LOOK, _mirror_no_flip(EYE_RIGHT_LOOK, EYE_OPEN), BROW_HIGH, _mirror(BROW_HIGH), MOUTH_SMILE), + + _make_frame(EYE_OPEN, _mirror(EYE_OPEN), BROW_HIGH, _mirror(BROW_HIGH), MOUTH_SMILE), ], + mode=AnimationMode.REPEAT_FORWARD, + frame_duration=0.2, + repeat_interval=10 ) diff --git a/selfdrive/ui/layouts/body/body.py b/selfdrive/ui/layouts/body/body.py index 13a09af63ae9de..75477d0c1f08c1 100644 --- a/selfdrive/ui/layouts/body/body.py +++ b/selfdrive/ui/layouts/body/body.py @@ -1,83 +1,184 @@ +import math import time import pyray as rl +from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.system.ui.widgets import Widget -from .animations import Animation, NORMAL +from .animations import Animation, AnimationMode, NORMAL, SLEEPY, ASLEEP, INQUISITIVE GRID_COLS = 16 GRID_ROWS = 8 RADIUS = 50 -ALL_DOTS = [(col, row) for row in range(GRID_ROWS) for col in range(GRID_COLS)] +# Gaze tracking — horizontal pixel offset driven by steering input +GAZE_MAX_OFFSET = 30.0 # max pixel shift in either direction +GAZE_SMOOTHING_TAU = 0.15 # exponential smoothing time constant (seconds) +STEER_SENSITIVITY = 0.6 # pixels of offset per degree of steering angle +IDLE_TIMEOUT = 15.0 # seconds of no joystick input before playing INQUISITIVE +IDLE_STEER_THRESH = 0.5 # degrees — below this counts as no input +IDLE_SPEED_THRESH = 0.01 # m/s — below this counts as no input -def draw_dot_grid(rect: rl.Rectangle, animation: Animation, color: rl.Color = None): - if color is None: - color = rl.WHITE - now = time.monotonic() +def _get_frame_index(animation: Animation, elapsed: float, gap_first: bool = False) -> int: + """Get the current frame index given elapsed time and animation mode.""" num_frames = len(animation.frames) - if num_frames == 1: - frame_index = 0 - else: - forward_duration = num_frames * animation.frame_duration - no_backward = animation.hold_end < 0 + return 0 - if no_backward: - cycle_duration = forward_duration - else: - backward_frames = max(num_frames - 2, 0) - backward_duration = backward_frames * animation.frame_duration - cycle_duration = forward_duration + animation.hold_end + backward_duration + forward_duration = num_frames * animation.frame_duration + has_backward = animation.mode in (AnimationMode.ONCE_FORWARD_BACKWARD, AnimationMode.REPEAT_FORWARD_BACKWARD) + repeats = animation.mode in (AnimationMode.REPEAT_FORWARD, AnimationMode.REPEAT_FORWARD_BACKWARD) - if animation.animation_frequency > 0: - elapsed = now % animation.animation_frequency - else: - elapsed = now % cycle_duration - - if elapsed < forward_duration: - # Playing forward - frame_index = min(int(elapsed / animation.frame_duration), num_frames - 1) - elif no_backward: - # No backward, hold last frame - frame_index = num_frames - 1 - elif elapsed < forward_duration + animation.hold_end: - # Holding last frame - frame_index = num_frames - 1 - elif elapsed < forward_duration + animation.hold_end + backward_duration: - # Playing backward (excluding first and last frame) - backward_elapsed = elapsed - forward_duration - animation.hold_end - backward_index = min(int(backward_elapsed / animation.frame_duration), backward_frames - 1) - frame_index = num_frames - 2 - backward_index - else: - # Hold first frame for remainder - frame_index = 0 + if has_backward: + backward_frames = max(num_frames - 2, 0) + backward_duration = backward_frames * animation.frame_duration + cycle_duration = forward_duration + animation.hold_end + backward_duration + else: + backward_frames = 0 + backward_duration = 0 + cycle_duration = forward_duration - dots = animation.frames[frame_index] + if not repeats: + # Play once — clamp elapsed to one cycle + t = min(elapsed, cycle_duration) + else: + adj_elapsed = elapsed + cycle_duration if gap_first else elapsed + t = adj_elapsed % animation.repeat_interval + + if t < forward_duration: + return min(int(t / animation.frame_duration), num_frames - 1) + elif not has_backward: + return num_frames - 1 + elif t < forward_duration + animation.hold_end: + return num_frames - 1 + elif t < forward_duration + animation.hold_end + backward_duration: + backward_elapsed = t - forward_duration - animation.hold_end + backward_index = min(int(backward_elapsed / animation.frame_duration), backward_frames - 1) + return num_frames - 2 - backward_index + else: + return 0 + + +class FaceAnimator: + def __init__(self, animation: Animation): + self._animation = animation + self._next: Animation | None = None + self._start_time = time.monotonic() + self._rewinding = False + self._rewind_start: float = 0.0 + self._rewind_from: int = 0 + self._seen_nonzero = False + + def set_animation(self, animation: Animation): + if animation is not self._animation: + self._next = animation + + def get_dots(self) -> list[tuple[int, int]]: + now = time.monotonic() + elapsed = now - self._start_time + + # Handle rewind for forward-only animations + if self._rewinding: + rewind_elapsed = now - self._rewind_start + frames_back = round(rewind_elapsed / self._animation.frame_duration) + frame_index = self._rewind_from - frames_back + if frame_index <= 0: + return self._switch_to_next(now) + return self._animation.frames[frame_index] + + # Play starting frames first (once) + starting = self._animation.starting_frames or [] + starting_duration = len(starting) * self._animation.frame_duration + if starting and elapsed < starting_duration: + frame_index = min(int(elapsed / self._animation.frame_duration), len(starting) - 1) + return starting[frame_index] + + # Main loop + loop_elapsed = elapsed - starting_duration if starting else elapsed + frame_index = _get_frame_index(self._animation, loop_elapsed, gap_first=bool(starting)) + + if frame_index != 0: + self._seen_nonzero = True + + if self._next is not None: + if frame_index == 0 and (len(self._animation.frames) == 1 or self._seen_nonzero): + return self._switch_to_next(now) + # No natural return to frame 0 — start rewinding + if self._animation.mode in (AnimationMode.ONCE_FORWARD, AnimationMode.REPEAT_FORWARD): + self._rewinding = True + self._rewind_start = now + self._rewind_from = frame_index + + return self._animation.frames[frame_index] + + def _switch_to_next(self, now: float) -> list[tuple[int, int]]: + self._animation = self._next + self._next = None + self._rewinding = False + self._seen_nonzero = False + self._start_time = now + return self._animation.frames[0] + + +def draw_dot_grid(rect: rl.Rectangle, dots: list[tuple[int, int]], color: rl.Color = None, gaze_offset: float = 0.0): + if color is None: + color = rl.WHITE spacing = (rect.height) / (GRID_ROWS) - # Total size of the grid from first to last dot center grid_w = (GRID_COLS - 1) * spacing grid_h = (GRID_ROWS - 1) * spacing - # Center horizontally, keep vertical centering offset_x = rect.x + (rect.width - grid_w) / 2 offset_y = rect.y + (rect.height - grid_h) / 2 for row, col in dots: - x = int(offset_x + col * spacing) + x = int(offset_x + col * spacing + gaze_offset) y = int(offset_y + row * spacing) rl.draw_circle(x, y, RADIUS, color) + class BodyLayout(Widget): def __init__(self): super().__init__() self._setup_widget = type('', (), {'set_open_settings_callback': lambda self, cb: None})() + self._animator = FaceAnimator(ASLEEP) + self._gaze_offset = 0.0 + self._last_input_time = time.monotonic() def set_settings_callback(self, callback): pass + def _update_state(self): + sm = ui_state.sm + + joystick_mode = ui_state.params.get_bool("JoystickDebugMode") + if joystick_mode and sm['selfdriveState'].enabled: + cs = sm['carState'] + has_input = abs(cs.steeringAngleDeg) > IDLE_STEER_THRESH or abs(cs.vEgo) > IDLE_SPEED_THRESH + if has_input: + self._last_input_time = time.monotonic() + + if time.monotonic() - self._last_input_time > IDLE_TIMEOUT: + self._animator.set_animation(INQUISITIVE) + else: + self._animator.set_animation(NORMAL) + else: + if rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT): + self._animator.set_animation(SLEEPY) + else: + self._animator.set_animation(ASLEEP) + + if not sm.updated['carState']: + return + + steer = sm['carState'].steeringAngleDeg + target = max(-GAZE_MAX_OFFSET, min(GAZE_MAX_OFFSET, steer * STEER_SENSITIVITY)) + dt = rl.get_frame_time() + if dt > 0: + alpha = 1.0 - math.exp(-dt / GAZE_SMOOTHING_TAU) + self._gaze_offset += (target - self._gaze_offset) * alpha + def _render(self, rect: rl.Rectangle): rl.clear_background(rl.BLACK) - draw_dot_grid(rect, NORMAL) + draw_dot_grid(rect, self._animator.get_dots(), gaze_offset=self._gaze_offset) diff --git a/selfdrive/ui/layouts/body/body_sidebar.py b/selfdrive/ui/layouts/body/body_sidebar.py index 01a198dfac0441..37448349775f56 100644 --- a/selfdrive/ui/layouts/body/body_sidebar.py +++ b/selfdrive/ui/layouts/body/body_sidebar.py @@ -15,6 +15,7 @@ METRIC_WIDTH = 220 METRIC_MARGIN = 20 FONT_SIZE = 30 +BATTERY_FONT_SIZE = 26 ThermalStatus = log.DeviceState.ThermalStatus NetworkType = log.DeviceState.NetworkType @@ -30,6 +31,8 @@ class Colors: METRIC_BORDER = rl.Color(255, 255, 255, 85) BUTTON_NORMAL = rl.WHITE BUTTON_PRESSED = rl.Color(255, 255, 255, 166) + BATTERY_GREEN = rl.Color(0, 200, 0, 255) + BATTERY_LOW = rl.Color(201, 34, 49, 255) NETWORK_TYPES = { @@ -66,6 +69,8 @@ def __init__(self): self._temp_status = MetricData(tr_noop("TEMP"), tr_noop("GOOD"), Colors.GOOD) self._panda_status = MetricData(tr_noop("VEHICLE"), tr_noop("ONLINE"), Colors.GOOD) self._connect_status = MetricData(tr_noop("CONNECT"), tr_noop("OFFLINE"), Colors.WARNING) + self._battery_percent = 0.0 + self._battery_charging = False self._recording_audio = False self._settings_img = gui_app.texture("images/button_settings.png", 200, 117) @@ -93,6 +98,7 @@ def _render(self, rect: rl.Rectangle): self._draw_settings_button(rect) self._draw_network_indicator(rect) self._draw_metrics(rect) + self._draw_battery_indicator(rect) self._draw_pair_button(rect) self._draw_mic_indicator(rect) @@ -107,6 +113,7 @@ def _update_state(self): self._update_temperature_status(device_state) self._update_connection_status(device_state) self._update_panda_status() + self._update_battery_status() def _update_network_status(self, device_state): self._net_type = NETWORK_TYPES.get(device_state.networkType.raw, tr_noop("Unknown")) @@ -137,6 +144,13 @@ def _update_panda_status(self): else: self._panda_status.update(tr_noop("VEHICLE"), tr_noop("ONLINE"), Colors.GOOD) + def _update_battery_status(self): + sm = ui_state.sm + if sm.updated['carState']: + car_state = sm['carState'] + self._battery_percent = max(0.0, min(1.0, car_state.fuelGauge)) + self._battery_charging = car_state.charging + def _handle_mouse_release(self, mouse_pos: MousePos): # Settings button (top-left) settings_rect = rl.Rectangle(self._rect.x + 30, self._rect.y + 30, 200, 117) @@ -192,6 +206,56 @@ def _draw_pair_button(self, rect: rl.Rectangle): text_pos = rl.Vector2(btn_x + (btn_w - text_size.x) / 2, btn_y + (btn_h - text_size.y) / 2) rl.draw_text_ex(self._font_extra_bold, text, text_pos, FONT_SIZE, 0, rl.BLACK) + def _draw_battery_indicator(self, rect: rl.Rectangle): + # Position to the left of the PAIR button + text = tr(tr_noop("PAIR")) + text_size = measure_text_cached(self._font_extra_bold, text, FONT_SIZE) + pair_btn_w = int(text_size.x + 60) + pair_btn_x = int(rect.x + rect.width - pair_btn_w - 30) + + # Battery icon dimensions + batt_w = 50 + batt_h = 28 + tip_w = 5 + tip_h = 12 + batt_x = pair_btn_x - batt_w - 80 + batt_y = int(rect.y + 30 + (METRIC_HEIGHT - batt_h) / 2) + + # Choose fill color based on level + pct = self._battery_percent + if pct <= 0.2: + fill_color = Colors.BATTERY_LOW + elif self._battery_charging: + fill_color = Colors.BATTERY_GREEN + else: + fill_color = Colors.WHITE + + # Battery outline + rl.draw_rectangle_rounded_lines_ex(rl.Rectangle(batt_x, batt_y, batt_w, batt_h), 0.2, 6, 2, Colors.WHITE) + + # Battery tip (positive terminal) + tip_x = batt_x + batt_w + tip_y = batt_y + (batt_h - tip_h) / 2 + rl.draw_rectangle_rounded(rl.Rectangle(tip_x, tip_y, tip_w, tip_h), 0.3, 4, Colors.WHITE) + + # Fill level + fill_margin = 4 + fill_max_w = batt_w - 2 * fill_margin + fill_w = max(0, int(fill_max_w * pct)) + if fill_w > 0: + rl.draw_rectangle_rounded( + rl.Rectangle(batt_x + fill_margin, batt_y + fill_margin, fill_w, batt_h - 2 * fill_margin), + 0.15, 4, fill_color + ) + + # Percentage text + pct_text = f"{int(pct * 100)}%" + if self._battery_charging: + pct_text = pct_text + pct_size = measure_text_cached(self._font_bold, pct_text, BATTERY_FONT_SIZE) + pct_pos = rl.Vector2(batt_x + (batt_w - pct_size.x) / 2, batt_y + batt_h + 6) + rl.draw_text_ex(self._font_bold, pct_text, pct_pos, BATTERY_FONT_SIZE, 0, Colors.WHITE) + # def _draw_flag_button(self, rect: rl.Rectangle): # if not ui_state.started: # return From 6bb0a225fdd1ac90c258076f77805e88984359e8 Mon Sep 17 00:00:00 2001 From: stefpi <19478336+stefpi@users.noreply.github.com> Date: Fri, 13 Mar 2026 11:42:56 -0700 Subject: [PATCH 9/9] fix: asleep animation, body_sidebar --- selfdrive/ui/layouts/body/animations.py | 9 +++++---- selfdrive/ui/layouts/main.py | 2 ++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/selfdrive/ui/layouts/body/animations.py b/selfdrive/ui/layouts/body/animations.py index 0ef7a29ab420e3..e921738872a38d 100644 --- a/selfdrive/ui/layouts/body/animations.py +++ b/selfdrive/ui/layouts/body/animations.py @@ -86,6 +86,7 @@ def _make_frame(left_eye: list[tuple[int, int]], right_eye: list[tuple[int, int] BROW_HIGH = [(1, 0), (0, 1), (0, 2)] BROW_LOWERED = [(2, 0), (1, 1), (1, 2)] BROW_STRAIGHT = [(2, 0), (2, 1), (2, 2)] +NO_BROW = [] # Mouths (centered, not mirrored) MOUTH_SMILE = [(6, 6), (7, 7), (7, 8), (6, 9)] @@ -105,16 +106,16 @@ def _make_frame(left_eye: list[tuple[int, int]], right_eye: list[tuple[int, int] ASLEEP = Animation( frames=[ - _make_frame(EYE_CLOSED, _mirror(EYE_CLOSED), BROW_STRAIGHT, _mirror(BROW_STRAIGHT), MOUTH_NORMAL), + _make_frame(EYE_CLOSED, _mirror(EYE_CLOSED), NO_BROW, NO_BROW, MOUTH_NORMAL), ], # frame_duration=0.25, ) SLEEPY = Animation( frames=[ - _make_frame(EYE_CLOSED, _mirror(EYE_CLOSED), BROW_STRAIGHT, _mirror(BROW_STRAIGHT), MOUTH_NORMAL), - _make_frame(EYE_CLOSED, _mirror(EYE_HALF), BROW_STRAIGHT, _mirror(BROW_LOWERED), MOUTH_NORMAL), - _make_frame(EYE_CLOSED, _mirror(EYE_OPEN), BROW_STRAIGHT, _mirror(BROW_HIGH), MOUTH_NORMAL) + _make_frame(EYE_CLOSED, _mirror(EYE_CLOSED), NO_BROW, _mirror(BROW_STRAIGHT), MOUTH_NORMAL), + _make_frame(EYE_CLOSED, _mirror(EYE_HALF), NO_BROW, _mirror(BROW_LOWERED), MOUTH_NORMAL), + _make_frame(EYE_CLOSED, _mirror(EYE_OPEN), NO_BROW, _mirror(BROW_HIGH), MOUTH_NORMAL) ], frame_duration=0.25, mode=AnimationMode.ONCE_FORWARD_BACKWARD, diff --git a/selfdrive/ui/layouts/main.py b/selfdrive/ui/layouts/main.py index d25587907fae4c..48e7d07a44dadb 100644 --- a/selfdrive/ui/layouts/main.py +++ b/selfdrive/ui/layouts/main.py @@ -62,6 +62,8 @@ def _setup_callbacks(self): self._layouts[MainState.HOME].set_settings_callback(lambda: self.open_settings(PanelType.TOGGLES)) self._layouts[MainState.SETTINGS].set_callbacks(on_close=self._set_mode_for_state) self._layouts[MainState.ONROAD].set_click_callback(self._on_onroad_clicked) + if self._is_body: + self._layouts[MainState.HOME].set_click_callback(self._on_onroad_clicked) device.add_interactive_timeout_callback(self._set_mode_for_state) def _update_layout_rects(self):