From 14d0e9a2c39bac1260f80d064cf6ad3f013b69bf Mon Sep 17 00:00:00 2001 From: firestarsdog <229254897+firestarsdog@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:39:26 -0400 Subject: [PATCH] Rays --- selfdrive/ui/layouts/home.py | 126 +- selfdrive/ui/layouts/settings/software.py | 55 +- .../ui/layouts/settings/starpilot/data.py | 238 ++- .../layouts/settings/starpilot/developer.py | 446 ++++++ .../ui/layouts/settings/starpilot/device.py | 280 +++- .../settings/starpilot/driving_model.py | 365 ++--- .../ui/layouts/settings/starpilot/lateral.py | 401 +---- .../settings/starpilot/longitudinal.py | 1143 +++++++++---- .../layouts/settings/starpilot/main_panel.py | 288 ++-- .../ui/layouts/settings/starpilot/maps.py | 167 +- .../ui/layouts/settings/starpilot/metro.py | 412 +++++ .../layouts/settings/starpilot/navigation.py | 141 +- .../ui/layouts/settings/starpilot/panel.py | 52 +- .../ui/layouts/settings/starpilot/sounds.py | 279 ++-- .../ui/layouts/settings/starpilot/themes.py | 170 +- .../layouts/settings/starpilot/utilities.py | 175 +- .../ui/layouts/settings/starpilot/vehicle.py | 514 +++++- .../ui/layouts/settings/starpilot/visuals.py | 531 +++++- .../ui/layouts/settings/starpilot/wheel.py | 109 +- selfdrive/ui/layouts/sidebar.py | 218 ++- selfdrive/ui/onroad/alert_renderer.py | 44 +- selfdrive/ui/onroad/augmented_road_view.py | 46 +- selfdrive/ui/onroad/driver_state.py | 72 +- selfdrive/ui/onroad/exp_button.py | 78 +- selfdrive/ui/onroad/frogpilot_overlay.py | 1425 +++++++++++++++++ selfdrive/ui/onroad/gif_player.py | 170 ++ selfdrive/ui/onroad/hud_renderer.py | 32 +- selfdrive/ui/onroad/model_renderer.py | 427 ++++- selfdrive/ui/ui_state.py | 29 +- selfdrive/ui/widgets/developer_sidebar.py | 186 +++ selfdrive/ui/widgets/drive_summary.py | 183 +++ system/ui/lib/application.py | 40 +- system/ui/widgets/__init__.py | 17 + system/ui/widgets/input_dialog.py | 113 +- system/ui/widgets/keyboard.py | 59 +- 35 files changed, 7405 insertions(+), 1626 deletions(-) create mode 100644 selfdrive/ui/layouts/settings/starpilot/developer.py create mode 100644 selfdrive/ui/layouts/settings/starpilot/metro.py create mode 100644 selfdrive/ui/onroad/frogpilot_overlay.py create mode 100644 selfdrive/ui/onroad/gif_player.py create mode 100644 selfdrive/ui/widgets/developer_sidebar.py create mode 100644 selfdrive/ui/widgets/drive_summary.py diff --git a/selfdrive/ui/layouts/home.py b/selfdrive/ui/layouts/home.py index cd6ae600e..15e177b1d 100644 --- a/selfdrive/ui/layouts/home.py +++ b/selfdrive/ui/layouts/home.py @@ -1,4 +1,6 @@ +import json import time +import datetime import pyray as rl from collections.abc import Callable from enum import IntEnum @@ -7,6 +9,8 @@ from openpilot.selfdrive.ui.widgets.offroad_alerts import UpdateAlert, OffroadAl from openpilot.selfdrive.ui.widgets.exp_mode_button import ExperimentalModeButton from openpilot.selfdrive.ui.widgets.prime import PrimeWidget from openpilot.selfdrive.ui.widgets.setup import SetupWidget +from openpilot.selfdrive.ui.widgets.drive_summary import DriveSummary, RANDOM_EVENTS +from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos from openpilot.system.ui.lib.multilang import tr, trn @@ -58,6 +62,14 @@ class HomeLayout(Widget): self._prime_widget = PrimeWidget() self._setup_widget = SetupWidget() + self._drive_summary = DriveSummary(show_random_events=False) + self._random_events_summary = DriveSummary(show_random_events=True) + self._drive_summary_active = False + self._random_events_active = False + self._last_onroad_stats: dict = {} + + ui_state.add_offroad_transition_callback(self._on_offroad_transition) + self._exp_mode_button = ExperimentalModeButton() self._setup_callbacks() @@ -71,6 +83,34 @@ class HomeLayout(Widget): self.offroad_alert.set_dismiss_callback(lambda: self._set_state(HomeLayoutState.HOME)) self._exp_mode_button.set_click_callback(lambda: self.settings_callback() if self.settings_callback else None) + def _on_offroad_transition(self): + """Called when transitioning from onroad to offroad.""" + raw = self.params.get("FrogPilotStats") + if raw: + try: + current_stats = json.loads(raw) + prev = self._last_onroad_stats or {} + # Show drive summary if there's new drive data + if current_stats.get("TrackedTime", 0) > prev.get("TrackedTime", 0): + self._drive_summary.set_previous_stats(prev) + self._drive_summary.show_event() + self._drive_summary_active = True + # Random events + if ui_state.frogpilot_toggles.get("random_events", False): + cur_ev = current_stats.get("RandomEvents", {}) + prev_ev = prev.get("RandomEvents", {}) + if any(cur_ev.get(k, 0) > prev_ev.get(k, 0) for k, _ in RANDOM_EVENTS): + self._random_events_summary.set_previous_stats(prev) + self._random_events_summary.show_event() + self._random_events_active = True + self._last_onroad_stats = dict(current_stats) + except (json.JSONDecodeError, TypeError): + pass + + def _dismiss_drive_summary(self): + self._drive_summary_active = False + self._random_events_active = False + def set_settings_callback(self, callback: Callable): self.settings_callback = callback @@ -104,24 +144,18 @@ class HomeLayout(Widget): self._render_alerts_view() def _update_state(self): - self.header_rect = rl.Rectangle( - self._rect.x + CONTENT_MARGIN, self._rect.y + CONTENT_MARGIN, self._rect.width - 2 * CONTENT_MARGIN, HEADER_HEIGHT - ) + self.header_rect = rl.Rectangle(self._rect.x + CONTENT_MARGIN, self._rect.y + CONTENT_MARGIN, self._rect.width - 2 * CONTENT_MARGIN, HEADER_HEIGHT) content_y = self._rect.y + CONTENT_MARGIN + HEADER_HEIGHT + SPACING content_height = self._rect.height - CONTENT_MARGIN - HEADER_HEIGHT - SPACING - CONTENT_MARGIN - self.content_rect = rl.Rectangle( - self._rect.x + CONTENT_MARGIN, content_y, self._rect.width - 2 * CONTENT_MARGIN, content_height - ) + self.content_rect = rl.Rectangle(self._rect.x + CONTENT_MARGIN, content_y, self._rect.width - 2 * CONTENT_MARGIN, content_height) left_width = self.content_rect.width - RIGHT_COLUMN_WIDTH - SPACING self.left_column_rect = rl.Rectangle(self.content_rect.x, self.content_rect.y, left_width, self.content_rect.height) - self.right_column_rect = rl.Rectangle( - self.content_rect.x + left_width + SPACING, self.content_rect.y, RIGHT_COLUMN_WIDTH, self.content_rect.height - ) + self.right_column_rect = rl.Rectangle(self.content_rect.x + left_width + SPACING, self.content_rect.y, RIGHT_COLUMN_WIDTH, self.content_rect.height) self.update_notif_rect.x = self.header_rect.x self.update_notif_rect.y = self.header_rect.y + (self.header_rect.height - 60) // 2 @@ -133,6 +167,12 @@ class HomeLayout(Widget): def _handle_mouse_release(self, mouse_pos: MousePos): super()._handle_mouse_release(mouse_pos) + # Dismiss drive summary on click anywhere in content area + if self._drive_summary_active: + if rl.check_collision_point_rec(mouse_pos, self.left_column_rect) or rl.check_collision_point_rec(mouse_pos, self.right_column_rect): + self._dismiss_drive_summary() + return + if self.update_available and rl.check_collision_point_rec(mouse_pos, self.update_notif_rect): self._set_state(HomeLayoutState.UPDATE) elif self.alert_count > 0 and rl.check_collision_point_rec(mouse_pos, self.alert_notif_rect): @@ -143,6 +183,15 @@ class HomeLayout(Widget): version_text_width = self.header_rect.width + # FrogPilot: date display (left-aligned) + date_text = datetime.datetime.now().strftime("%A, %B %-d") + date_font = gui_app.font(FontWeight.NORMAL) + date_size = 32 + date_ts = measure_text_cached(date_font, date_text, date_size) + date_x = self.header_rect.x + date_y = self.header_rect.y + (self.header_rect.height - date_ts.y) // 2 + rl.draw_text_ex(date_font, date_text, rl.Vector2(int(date_x), int(date_y)), date_size, 0, rl.Color(255, 255, 255, 150)) + # Update notification button if self.update_available: version_text_width -= self.update_notif_rect.width @@ -175,8 +224,9 @@ class HomeLayout(Widget): if self.update_available or self.alert_count > 0: version_text_width -= SPACING * 1.5 - version_rect = rl.Rectangle(self.header_rect.x + self.header_rect.width - version_text_width, self.header_rect.y, - version_text_width, self.header_rect.height) + version_rect = rl.Rectangle( + self.header_rect.x + self.header_rect.width - version_text_width, self.header_rect.y, version_text_width, self.header_rect.height + ) gui_label(version_rect, self._version_text, 48, rl.WHITE, alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT) def _render_home_content(self): @@ -190,13 +240,19 @@ class HomeLayout(Widget): self.offroad_alert.render(self.content_rect) def _render_left_column(self): - self._prime_widget.render(self.left_column_rect) + if self._drive_summary_active: + self._drive_summary.render(self.left_column_rect) + else: + self._prime_widget.render(self.left_column_rect) + self._render_frogpilot_stats() def _render_right_column(self): + if self._random_events_active: + self._random_events_summary.render(self.right_column_rect) + return + exp_height = 125 - exp_rect = rl.Rectangle( - self.right_column_rect.x, self.right_column_rect.y, self.right_column_rect.width, exp_height - ) + exp_rect = rl.Rectangle(self.right_column_rect.x, self.right_column_rect.y, self.right_column_rect.width, exp_height) self._exp_mode_button.render(exp_rect) setup_rect = rl.Rectangle( @@ -207,6 +263,46 @@ class HomeLayout(Widget): ) self._setup_widget.render(setup_rect) + def _render_frogpilot_stats(self): + """Render FrogPilot lifetime stats (Drives/Distance/Hours) at bottom of left column.""" + raw = self.params.get("FrogPilotStats") + if not raw: + return + try: + stats = json.loads(raw) + except (json.JSONDecodeError, TypeError): + return + + drives = stats.get("FrogPilotDrives", 0) + meters = stats.get("FrogPilotMeters", 0) + hours = int(stats.get("FrogPilotSeconds", 0) / 3600) + + if ui_state.is_metric: + dist_str = f"{meters / 1000:.0f} km" + else: + dist_str = f"{meters * 0.000621371:.0f} mi" + + font = gui_app.font(FontWeight.NORMAL) + label_size = 24 + val_size = 36 + green = rl.Color(0x17, 0x86, 0x44, 255) + + # Position at bottom of left column + y = self.left_column_rect.y + self.left_column_rect.height - 100 + x = self.left_column_rect.x + col_w = self.left_column_rect.width / 3 + + stats_data = [("Drives", str(drives)), ("Distance", dist_str), ("Hours", str(hours))] + + for i, (label, value) in enumerate(stats_data): + cx = x + col_w * i + col_w / 2 + # Value + vs = measure_text_cached(font, value, val_size) + rl.draw_text_ex(font, value, rl.Vector2(cx - vs.x / 2, y), val_size, 0, rl.WHITE) + # Label + ls = measure_text_cached(font, label, label_size) + rl.draw_text_ex(font, label, rl.Vector2(cx - ls.x / 2, y + val_size + 5), label_size, 0, green) + def _refresh(self): self._version_text = self._get_version_text() update_available = self.update_alert.refresh() diff --git a/selfdrive/ui/layouts/settings/software.py b/selfdrive/ui/layouts/settings/software.py index b3aad97e8..4251f2f59 100644 --- a/selfdrive/ui/layouts/settings/software.py +++ b/selfdrive/ui/layouts/settings/software.py @@ -1,6 +1,7 @@ import os import time import datetime +from pathlib import Path from openpilot.common.time_helpers import system_time_valid from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.system.ui.lib.application import gui_app @@ -76,15 +77,20 @@ class SoftwareLayout(Widget): self._branch_btn.action_item.set_value(ui_state.params.get("UpdaterTargetBranch") or "") self._branch_dialog: MultiOptionDialog | None = None - self._scroller = Scroller([ - self._onroad_label, - self._version_item, - self._auto_updates_toggle, - self._download_btn, - self._install_btn, - self._branch_btn, - button_item(lambda: tr("Uninstall"), lambda: tr("UNINSTALL"), callback=self._on_uninstall), - ], line_separator=True, spacing=0) + self._scroller = Scroller( + [ + self._onroad_label, + self._version_item, + self._auto_updates_toggle, + self._download_btn, + self._install_btn, + self._branch_btn, + button_item(lambda: tr("Uninstall"), lambda: tr("UNINSTALL"), callback=self._on_uninstall), + button_item(lambda: tr("Error Log"), lambda: tr("VIEW"), callback=self._on_error_log), + ], + line_separator=True, + spacing=0, + ) def show_event(self): self._scroller.show_event() @@ -170,18 +176,43 @@ class SoftwareLayout(Widget): # Start downloading self._waiting_for_updater = True self._waiting_start_ts = time.monotonic() + ui_state.params_memory.put_bool("ManualUpdateInitiated", True) os.system("pkill -SIGHUP -f system.updated.updated") def _on_auto_updates_toggle(self, enabled: bool): ui_state.params.put_bool("AutomaticUpdates", enabled) def _on_uninstall(self): - def handle_uninstall_confirmation(result): + def handle_step1(result): if result == DialogResult.CONFIRM: - ui_state.params.put_bool("DoUninstall", True) + + def handle_step2(result2): + if result2 == DialogResult.CONFIRM: + + def handle_step3(result3): + if result3 == DialogResult.CONFIRM: + ui_state.params.clear_all() + ui_state.params.put_bool("DoUninstall", True) + + dialog = ConfirmDialog(tr("This is a complete factory reset and cannot be undone. Are you absolutely sure?"), tr("Reset")) + gui_app.set_modal_overlay(dialog, callback=handle_step3) + else: + ui_state.params.put_bool("DoUninstall", True) + + dialog = ConfirmDialog( + tr("Do you want to perform a full factory reset? All saved assets and settings will be permanently deleted!"), tr("Factory Reset"), tr("Skip") + ) + gui_app.set_modal_overlay(dialog, callback=handle_step2) dialog = ConfirmDialog(tr("Are you sure you want to uninstall?"), tr("Uninstall")) - gui_app.set_modal_overlay(dialog, callback=handle_uninstall_confirmation) + gui_app.set_modal_overlay(dialog, callback=handle_step1) + + def _on_error_log(self): + try: + txt = Path("/data/error_logs/error.txt").read_text(encoding='utf-8', errors='replace') + except Exception: + txt = tr("No error log found.") + gui_app.set_modal_overlay(ConfirmDialog(txt, tr("OK"), on_close=lambda r: None, rich=True)) def _on_install_update(self): # Trigger reboot to install update diff --git a/selfdrive/ui/layouts/settings/starpilot/data.py b/selfdrive/ui/layouts/settings/starpilot/data.py index 10ce50337..965bcd461 100644 --- a/selfdrive/ui/layouts/settings/starpilot/data.py +++ b/selfdrive/ui/layouts/settings/starpilot/data.py @@ -1,25 +1,233 @@ from __future__ import annotations +import os +import shutil +import threading +import subprocess +from datetime import datetime +from pathlib import Path +from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.lib.multilang import tr, tr_noop -from openpilot.system.ui.widgets.list_view import button_item -from openpilot.system.ui.widgets.scroller_tici import Scroller +from openpilot.system.ui.widgets import DialogResult +from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog, alert_dialog +from openpilot.system.ui.widgets.selection_dialog import SelectionDialog +from openpilot.system.ui.widgets.input_dialog import InputDialog from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import StarPilotPanel + class StarPilotDataLayout(StarPilotPanel): def __init__(self): super().__init__() - - items = [ - button_item( - tr_noop("Manage Backups"), - lambda: tr("MANAGE"), - tr_noop("Create, restore, or delete backups of your StarPilot settings."), - ), - button_item( - tr_noop("Manage Storage"), - lambda: tr("MANAGE"), - tr_noop("View and manage storage usage for models, maps, and other data."), - ), + self.CATEGORIES = [ + {"title": tr_noop("Manage Backups"), "panel": "backups", "icon": "toggle_icons/icon_system.png", "color": "#FA6800"}, + {"title": tr_noop("Toggle Backups"), "panel": "toggle_backups", "icon": "toggle_icons/icon_system.png", "color": "#FA6800"}, + {"title": tr_noop("Manage Storage"), "panel": "storage", "icon": "toggle_icons/icon_system.png", "color": "#FA6800"}, + { + "title": tr_noop("Delete Driving Data"), + "type": "hub", + "on_click": self._on_delete_driving_data, + "icon": "toggle_icons/icon_system.png", + "color": "#FA6800", + }, + { + "title": tr_noop("Delete Error Logs"), + "type": "hub", + "on_click": self._on_delete_error_logs, + "icon": "toggle_icons/icon_system.png", + "color": "#FA6800", + }, ] - self._scroller = Scroller(items, line_separator=True, spacing=0) + self._sub_panels = { + "backups": StarPilotBackupsLayout(), + "toggle_backups": StarPilotToggleBackupsLayout(), + "storage": StarPilotStorageLayout(), + } + + for name, panel in self._sub_panels.items(): + if hasattr(panel, 'set_navigate_callback'): + panel.set_navigate_callback(self._navigate_to) + if hasattr(panel, 'set_back_callback'): + panel.set_back_callback(self._go_back) + + self._rebuild_grid() + + def _on_delete_driving_data(self): + def _do_delete(res): + if res == DialogResult.CONFIRM: + + def _task(): + drive_paths = ["/data/media/0/realdata/", "/data/media/0/realdata_HD/", "/data/media/0/realdata_konik/"] + for path in drive_paths: + p = Path(path) + if p.exists(): + for entry in p.iterdir(): + if entry.is_dir(): + shutil.rmtree(entry, ignore_errors=True) + + threading.Thread(target=_task, daemon=True).start() + gui_app.set_modal_overlay(alert_dialog(tr("Driving data deletion started."))) + + gui_app.set_modal_overlay(ConfirmDialog(tr("Delete all driving data and footage?"), tr("Delete"), on_close=_do_delete)) + + def _on_delete_error_logs(self): + def _do_delete(res): + if res == DialogResult.CONFIRM: + shutil.rmtree("/data/error_logs", ignore_errors=True) + os.makedirs("/data/error_logs", exist_ok=True) + gui_app.set_modal_overlay(alert_dialog(tr("Error logs deleted."))) + + gui_app.set_modal_overlay(ConfirmDialog(tr("Delete all error logs?"), tr("Delete"), on_close=_do_delete)) + + +class StarPilotBackupsLayout(StarPilotPanel): + def __init__(self): + super().__init__() + self.CATEGORIES = [ + {"title": tr_noop("Create Backup"), "type": "hub", "on_click": self._on_create_backup, "color": "#FA6800"}, + {"title": tr_noop("Restore Backup"), "type": "hub", "on_click": self._on_restore_backup, "color": "#FA6800"}, + {"title": tr_noop("Delete Backup"), "type": "hub", "on_click": self._on_delete_backup, "color": "#FA6800"}, + ] + self._rebuild_grid() + + def _get_backups(self): + b_dir = Path("/data/backups") + if not b_dir.exists(): + return [] + return [f.name for f in b_dir.glob("*.tar.zst") if "in_progress" not in f.name] + + def _on_create_backup(self): + def on_name(res, name): + if res == DialogResult.CONFIRM: + safe_name = name.replace(" ", "_") if name else f"backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}" + backup_path = f"/data/backups/{safe_name}.tar.zst" + if Path(backup_path).exists(): + gui_app.set_modal_overlay(alert_dialog(tr("A backup with this name already exists."))) + return + gui_app.set_modal_overlay(alert_dialog(tr("Backup creation started."))) + + def _task(): + os.makedirs("/data/backups", exist_ok=True) + subprocess.run(["tar", "--use-compress-program=zstd", "-cf", backup_path, "/data/openpilot"]) + + threading.Thread(target=_task, daemon=True).start() + + gui_app.set_modal_overlay(InputDialog(tr("Name your backup"), "", on_close=on_name)) + + def _on_restore_backup(self): + backups = self._get_backups() + if not backups: + gui_app.set_modal_overlay(alert_dialog(tr("No backups found."))) + return + + def _on_select(res, val): + if res == DialogResult.CONFIRM: + gui_app.set_modal_overlay(alert_dialog(tr("Restoring... device will reboot."))) + + def _task(): + subprocess.run(["rm", "-rf", "/data/openpilot/*"]) + subprocess.run(["tar", "--use-compress-program=zstd", "-xf", f"/data/backups/{val}", "-C", "/"]) + os.system("reboot") + + threading.Thread(target=_task, daemon=True).start() + + gui_app.set_modal_overlay(SelectionDialog(tr("Select Backup"), backups, on_close=_on_select)) + + def _on_delete_backup(self): + backups = self._get_backups() + if not backups: + gui_app.set_modal_overlay(alert_dialog(tr("No backups found."))) + return + + def _on_select(res, val): + if res == DialogResult.CONFIRM: + os.remove(f"/data/backups/{val}") + self._rebuild_grid() + + gui_app.set_modal_overlay(SelectionDialog(tr("Delete Backup"), backups, on_close=_on_select)) + + +class StarPilotToggleBackupsLayout(StarPilotPanel): + def __init__(self): + super().__init__() + self.CATEGORIES = [ + {"title": tr_noop("Create Toggle Backup"), "type": "hub", "on_click": self._on_create, "color": "#FA6800"}, + {"title": tr_noop("Restore Toggle Backup"), "type": "hub", "on_click": self._on_restore, "color": "#FA6800"}, + {"title": tr_noop("Delete Toggle Backup"), "type": "hub", "on_click": self._on_delete, "color": "#FA6800"}, + ] + self._rebuild_grid() + + def _get_backups(self): + b_dir = Path("/data/toggle_backups") + if not b_dir.exists(): + return [] + return [d.name for d in b_dir.iterdir() if d.is_dir() and "in_progress" not in d.name] + + def _on_create(self): + def on_name(res, name): + if res == DialogResult.CONFIRM: + safe_name = name.replace(" ", "_") if name else f"toggle_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}" + backup_path = Path(f"/data/toggle_backups/{safe_name}") + if backup_path.exists(): + gui_app.set_modal_overlay(alert_dialog(tr("A toggle backup with this name already exists."))) + return + os.makedirs(backup_path, exist_ok=True) + shutil.copytree("/data/params/d", str(backup_path), dirs_exist_ok=True) + gui_app.set_modal_overlay(alert_dialog(tr("Toggle backup created."))) + self._rebuild_grid() + + gui_app.set_modal_overlay(InputDialog(tr("Name your toggle backup"), "", on_close=on_name)) + + def _on_restore(self): + backups = self._get_backups() + if not backups: + gui_app.set_modal_overlay(alert_dialog(tr("No toggle backups found."))) + return + + def _on_select(res, val): + if res == DialogResult.CONFIRM: + + def on_confirm(r2): + if r2 == DialogResult.CONFIRM: + src = Path(f"/data/toggle_backups/{val}") + shutil.copytree(str(src), "/data/params/d", dirs_exist_ok=True) + gui_app.set_modal_overlay(alert_dialog(tr("Toggles restored."))) + self._rebuild_grid() + + gui_app.set_modal_overlay(ConfirmDialog(tr("This will overwrite your current toggles."), tr("Restore"), on_close=on_confirm)) + + gui_app.set_modal_overlay(SelectionDialog(tr("Select Toggle Backup"), backups, on_close=_on_select)) + + def _on_delete(self): + backups = self._get_backups() + if not backups: + gui_app.set_modal_overlay(alert_dialog(tr("No toggle backups found."))) + return + + def _on_select(res, val): + if res == DialogResult.CONFIRM: + shutil.rmtree(f"/data/toggle_backups/{val}", ignore_errors=True) + self._rebuild_grid() + + gui_app.set_modal_overlay(SelectionDialog(tr("Delete Toggle Backup"), backups, on_close=_on_select)) + + +class StarPilotStorageLayout(StarPilotPanel): + def __init__(self): + super().__init__() + self.CATEGORIES = [ + {"title": tr_noop("Driving Data"), "type": "value", "get_value": self._get_storage, "on_click": lambda: None, "color": "#FA6800"}, + ] + self._rebuild_grid() + + def _get_storage(self): + paths = ["/data/media/0/osm/offline", "/data/media/0/realdata", "/data/backups"] + total = 0 + for p in paths: + pp = Path(p) + if pp.exists(): + total += sum(f.stat().st_size for f in pp.rglob('*') if f.is_file()) + mb = total / (1024 * 1024) + if mb > 1024: + return f"{(mb / 1024):.2f} GB" + return f"{mb:.2f} MB" diff --git a/selfdrive/ui/layouts/settings/starpilot/developer.py b/selfdrive/ui/layouts/settings/starpilot/developer.py new file mode 100644 index 000000000..516daccdb --- /dev/null +++ b/selfdrive/ui/layouts/settings/starpilot/developer.py @@ -0,0 +1,446 @@ +from __future__ import annotations +from openpilot.selfdrive.ui.lib.starpilot_state import starpilot_state +from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.lib.multilang import tr, tr_noop +from openpilot.system.ui.widgets import DialogResult +from openpilot.system.ui.widgets.selection_dialog import SelectionDialog +from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import StarPilotPanel +from openpilot.selfdrive.ui.layouts.settings.starpilot.metro import TileGrid, ToggleTile + + +SIDEBAR_METRIC_OPTIONS = [ + "None", + "Acceleration: Current", + "Acceleration: Max", + "Auto Tune: Actuator Delay", + "Auto Tune: Friction", + "Auto Tune: Lateral Acceleration", + "Auto Tune: Steer Ratio", + "Auto Tune: Stiffness Factor", + "Engagement %: Lateral", + "Engagement %: Longitudinal", + "Lateral Control: Steering Angle", + "Lateral Control: Torque % Used", + "Longitudinal Control: Actuator Acceleration Output", + "Longitudinal MPC: Danger Factor", + "Longitudinal MPC Jerk: Acceleration", + "Longitudinal MPC Jerk: Danger Zone", + "Longitudinal MPC Jerk: Speed Control", +] + +# Exclusive groups for sidebar metrics (only one in each group can be active) +_SIDEBAR_CPU_GPU = {"ShowCPU", "ShowGPU"} +_SIDEBAR_STORAGE = {"ShowMemoryUsage", "ShowStorageLeft", "ShowStorageUsed"} + + +class StarPilotDeveloperLayout(StarPilotPanel): + def __init__(self): + super().__init__() + self._sub_panels = { + "metrics": StarPilotDevMetricsLayout(), + "sidebar": StarPilotDevSidebarLayout(), + "widgets": StarPilotDevWidgetsLayout(), + } + self.CATEGORIES = [ + {"title": tr_noop("Developer Metrics"), "panel": "metrics", "icon": "toggle_icons/icon_display.png", "color": "#364DEF"}, + {"title": tr_noop("Developer Sidebar"), "panel": "sidebar", "icon": "toggle_icons/icon_device.png", "color": "#364DEF"}, + {"title": tr_noop("Developer Widgets"), "panel": "widgets", "icon": "toggle_icons/icon_road.png", "color": "#364DEF"}, + ] + for _name, panel in self._sub_panels.items(): + if hasattr(panel, 'set_navigate_callback'): + panel.set_navigate_callback(self._navigate_to) + if hasattr(panel, 'set_back_callback'): + panel.set_back_callback(self._go_back) + self._rebuild_grid() + + +class StarPilotDevMetricsLayout(StarPilotPanel): + def __init__(self): + super().__init__() + self.CATEGORIES = [ + { + "title": tr_noop("Adjacent Lane Metrics"), + "type": "toggle", + "key": "AdjacentPathMetrics", + "get_state": lambda: self._params.get_bool("AdjacentPathMetrics"), + "set_state": lambda s: self._params.put_bool("AdjacentPathMetrics", s), + "icon": "toggle_icons/icon_road.png", + "color": "#364DEF", + }, + { + "title": tr_noop("Border Metrics"), + "type": "toggle", + "key": "BorderMetrics", + "get_state": lambda: self._params.get_bool("BorderMetrics"), + "set_state": lambda s: self._params.put_bool("BorderMetrics", s), + "icon": "toggle_icons/icon_display.png", + "color": "#364DEF", + }, + { + "title": tr_noop("Blind Spot"), + "type": "toggle", + "key": "BlindSpotMetrics", + "parent": "BorderMetrics", + "get_state": lambda: self._params.get_bool("BlindSpotMetrics"), + "set_state": lambda s: self._params.put_bool("BlindSpotMetrics", s), + "icon": "toggle_icons/icon_display.png", + "color": "#364DEF", + }, + { + "title": tr_noop("Steering Torque"), + "type": "toggle", + "key": "ShowSteering", + "parent": "BorderMetrics", + "get_state": lambda: self._params.get_bool("ShowSteering"), + "set_state": lambda s: self._params.put_bool("ShowSteering", s), + "icon": "toggle_icons/icon_display.png", + "color": "#364DEF", + }, + { + "title": tr_noop("Turn Signal"), + "type": "toggle", + "key": "SignalMetrics", + "parent": "BorderMetrics", + "get_state": lambda: self._params.get_bool("SignalMetrics"), + "set_state": lambda s: self._params.put_bool("SignalMetrics", s), + "icon": "toggle_icons/icon_display.png", + "color": "#364DEF", + }, + { + "title": tr_noop("FPS Display"), + "type": "toggle", + "key": "FPSCounter", + "get_state": lambda: self._params.get_bool("FPSCounter"), + "set_state": lambda s: self._params.put_bool("FPSCounter", s), + "icon": "toggle_icons/icon_display.png", + "color": "#364DEF", + }, + { + "title": tr_noop("Lead Info"), + "type": "toggle", + "key": "LeadInfo", + "get_state": lambda: self._params.get_bool("LeadInfo"), + "set_state": lambda s: self._params.put_bool("LeadInfo", s), + "icon": "toggle_icons/icon_display.png", + "color": "#364DEF", + }, + { + "title": tr_noop("Numerical Temp"), + "type": "toggle", + "key": "NumericalTemp", + "get_state": lambda: self._params.get_bool("NumericalTemp"), + "set_state": lambda s: self._params.put_bool("NumericalTemp", s), + "icon": "toggle_icons/icon_display.png", + "color": "#364DEF", + }, + { + "title": tr_noop("Fahrenheit"), + "type": "toggle", + "key": "Fahrenheit", + "parent": "NumericalTemp", + "get_state": lambda: self._params.get_bool("Fahrenheit"), + "set_state": lambda s: self._params.put_bool("Fahrenheit", s), + "icon": "toggle_icons/icon_display.png", + "color": "#364DEF", + }, + { + "title": tr_noop("Sidebar Metrics"), + "type": "toggle", + "key": "SidebarMetrics", + "get_state": lambda: self._params.get_bool("SidebarMetrics"), + "set_state": lambda s: self._params.put_bool("SidebarMetrics", s), + "icon": "toggle_icons/icon_display.png", + "color": "#364DEF", + }, + { + "title": tr_noop("CPU"), + "type": "toggle", + "key": "ShowCPU", + "parent": "SidebarMetrics", + "get_state": lambda: self._params.get_bool("ShowCPU"), + "set_state": lambda s: self._set_sidebar_exclusive("ShowCPU", s), + "icon": "toggle_icons/icon_display.png", + "color": "#364DEF", + }, + { + "title": tr_noop("GPU"), + "type": "toggle", + "key": "ShowGPU", + "parent": "SidebarMetrics", + "get_state": lambda: self._params.get_bool("ShowGPU"), + "set_state": lambda s: self._set_sidebar_exclusive("ShowGPU", s), + "icon": "toggle_icons/icon_display.png", + "color": "#364DEF", + }, + { + "title": tr_noop("IP Address"), + "type": "toggle", + "key": "ShowIP", + "parent": "SidebarMetrics", + "get_state": lambda: self._params.get_bool("ShowIP"), + "set_state": lambda s: self._params.put_bool("ShowIP", s), + "icon": "toggle_icons/icon_display.png", + "color": "#364DEF", + }, + { + "title": tr_noop("RAM Usage"), + "type": "toggle", + "key": "ShowMemoryUsage", + "parent": "SidebarMetrics", + "get_state": lambda: self._params.get_bool("ShowMemoryUsage"), + "set_state": lambda s: self._set_sidebar_exclusive("ShowMemoryUsage", s), + "icon": "toggle_icons/icon_display.png", + "color": "#364DEF", + }, + { + "title": tr_noop("SSD Left"), + "type": "toggle", + "key": "ShowStorageLeft", + "parent": "SidebarMetrics", + "get_state": lambda: self._params.get_bool("ShowStorageLeft"), + "set_state": lambda s: self._set_sidebar_exclusive("ShowStorageLeft", s), + "icon": "toggle_icons/icon_display.png", + "color": "#364DEF", + }, + { + "title": tr_noop("SSD Used"), + "type": "toggle", + "key": "ShowStorageUsed", + "parent": "SidebarMetrics", + "get_state": lambda: self._params.get_bool("ShowStorageUsed"), + "set_state": lambda s: self._set_sidebar_exclusive("ShowStorageUsed", s), + "icon": "toggle_icons/icon_display.png", + "color": "#364DEF", + }, + { + "title": tr_noop("Use SI Units"), + "type": "toggle", + "key": "UseSI", + "get_state": lambda: self._params.get_bool("UseSI"), + "set_state": lambda s: self._params.put_bool("UseSI", s), + "icon": "toggle_icons/icon_display.png", + "color": "#364DEF", + }, + ] + self._rebuild_grid() + + def _set_sidebar_exclusive(self, key: str, state: bool): + self._params.put_bool(key, state) + if state: + if key in _SIDEBAR_CPU_GPU: + for k in _SIDEBAR_CPU_GPU: + if k != key: + self._params.put_bool(k, False) + elif key in _SIDEBAR_STORAGE: + for k in _SIDEBAR_STORAGE: + if k != key: + self._params.put_bool(k, False) + self._rebuild_grid() + + def _rebuild_grid(self): + if not self.CATEGORIES: + return + if self._tile_grid is None: + self._tile_grid = TileGrid(columns=None, padding=20) + self._tile_grid.clear() + + border_on = self._params.get_bool("BorderMetrics") + temp_on = self._params.get_bool("NumericalTemp") + sidebar_on = self._params.get_bool("SidebarMetrics") + + for cat in self.CATEGORIES: + key = cat.get("key") + parent = cat.get("parent") + + # Conditional visibility + if parent == "BorderMetrics" and not border_on: + if key == "BlindSpotMetrics" and not starpilot_state.car_state.hasBSM: + continue + if not border_on: + continue + elif parent == "NumericalTemp" and not temp_on: + continue + elif parent == "SidebarMetrics" and not sidebar_on: + continue + + # BlindSpotMetrics extra gate + if key == "BlindSpotMetrics" and not starpilot_state.car_state.hasBSM: + continue + + tile = ToggleTile( + title=tr(cat["title"]), + get_state=cat["get_state"], + set_state=cat["set_state"], + icon_path=cat.get("icon"), + bg_color=cat.get("color"), + ) + self._tile_grid.add_tile(tile) + + +class StarPilotDevSidebarLayout(StarPilotPanel): + def __init__(self): + super().__init__() + self.CATEGORIES = [ + { + "title": tr_noop("Metric #1"), + "type": "value", + "key": "DeveloperSidebarMetric1", + "get_value": lambda: tr(self._get_metric_name("DeveloperSidebarMetric1")), + "on_click": lambda: self._show_metric_selector("DeveloperSidebarMetric1"), + "icon": "toggle_icons/icon_device.png", + "color": "#364DEF", + }, + { + "title": tr_noop("Metric #2"), + "type": "value", + "key": "DeveloperSidebarMetric2", + "get_value": lambda: tr(self._get_metric_name("DeveloperSidebarMetric2")), + "on_click": lambda: self._show_metric_selector("DeveloperSidebarMetric2"), + "icon": "toggle_icons/icon_device.png", + "color": "#364DEF", + }, + { + "title": tr_noop("Metric #3"), + "type": "value", + "key": "DeveloperSidebarMetric3", + "get_value": lambda: tr(self._get_metric_name("DeveloperSidebarMetric3")), + "on_click": lambda: self._show_metric_selector("DeveloperSidebarMetric3"), + "icon": "toggle_icons/icon_device.png", + "color": "#364DEF", + }, + { + "title": tr_noop("Metric #4"), + "type": "value", + "key": "DeveloperSidebarMetric4", + "get_value": lambda: tr(self._get_metric_name("DeveloperSidebarMetric4")), + "on_click": lambda: self._show_metric_selector("DeveloperSidebarMetric4"), + "icon": "toggle_icons/icon_device.png", + "color": "#364DEF", + }, + { + "title": tr_noop("Metric #5"), + "type": "value", + "key": "DeveloperSidebarMetric5", + "get_value": lambda: tr(self._get_metric_name("DeveloperSidebarMetric5")), + "on_click": lambda: self._show_metric_selector("DeveloperSidebarMetric5"), + "icon": "toggle_icons/icon_device.png", + "color": "#364DEF", + }, + { + "title": tr_noop("Metric #6"), + "type": "value", + "key": "DeveloperSidebarMetric6", + "get_value": lambda: tr(self._get_metric_name("DeveloperSidebarMetric6")), + "on_click": lambda: self._show_metric_selector("DeveloperSidebarMetric6"), + "icon": "toggle_icons/icon_device.png", + "color": "#364DEF", + }, + { + "title": tr_noop("Metric #7"), + "type": "value", + "key": "DeveloperSidebarMetric7", + "get_value": lambda: tr(self._get_metric_name("DeveloperSidebarMetric7")), + "on_click": lambda: self._show_metric_selector("DeveloperSidebarMetric7"), + "icon": "toggle_icons/icon_device.png", + "color": "#364DEF", + }, + ] + self._rebuild_grid() + + def _get_metric_name(self, key: str) -> str: + idx = self._params.get_int(key) + if 0 <= idx < len(SIDEBAR_METRIC_OPTIONS): + return SIDEBAR_METRIC_OPTIONS[idx] + return "None" + + def _show_metric_selector(self, key: str): + current_idx = self._params.get_int(key) + current = SIDEBAR_METRIC_OPTIONS[current_idx] if 0 <= current_idx < len(SIDEBAR_METRIC_OPTIONS) else "None" + + def on_close(res, val): + if res == DialogResult.CONFIRM: + try: + selected_idx = SIDEBAR_METRIC_OPTIONS.index(val) + self._params.put_int(key, selected_idx) + self._rebuild_grid() + except ValueError: + pass + + gui_app.set_modal_overlay(SelectionDialog(tr("Select a Metric"), SIDEBAR_METRIC_OPTIONS, current, on_close=on_close)) + + +class StarPilotDevWidgetsLayout(StarPilotPanel): + def __init__(self): + super().__init__() + self.CATEGORIES = [ + { + "title": tr_noop("Adjacent Leads Tracking"), + "type": "toggle", + "key": "AdjacentLeadsUI", + "get_state": lambda: self._params.get_bool("AdjacentLeadsUI"), + "set_state": lambda s: self._params.put_bool("AdjacentLeadsUI", s), + "icon": "toggle_icons/icon_display.png", + "color": "#364DEF", + }, + { + "title": tr_noop("Model Stopping Point"), + "type": "toggle", + "key": "ShowStoppingPoint", + "get_state": lambda: self._params.get_bool("ShowStoppingPoint"), + "set_state": lambda s: self._params.put_bool("ShowStoppingPoint", s), + "icon": "toggle_icons/icon_road.png", + "color": "#364DEF", + }, + { + "title": tr_noop("Show Distance"), + "type": "toggle", + "key": "ShowStoppingPointMetrics", + "parent": "ShowStoppingPoint", + "get_state": lambda: self._params.get_bool("ShowStoppingPointMetrics"), + "set_state": lambda s: self._params.put_bool("ShowStoppingPointMetrics", s), + "icon": "toggle_icons/icon_road.png", + "color": "#364DEF", + }, + { + "title": tr_noop("Radar Tracks"), + "type": "toggle", + "key": "RadarTracksUI", + "get_state": lambda: self._params.get_bool("RadarTracksUI"), + "set_state": lambda s: self._params.put_bool("RadarTracksUI", s), + "icon": "toggle_icons/icon_display.png", + "color": "#364DEF", + }, + ] + self._rebuild_grid() + + def _rebuild_grid(self): + if not self.CATEGORIES: + return + if self._tile_grid is None: + self._tile_grid = TileGrid(columns=None, padding=20) + self._tile_grid.clear() + + stopping_on = self._params.get_bool("ShowStoppingPoint") + + for cat in self.CATEGORIES: + key = cat.get("key") + parent = cat.get("parent") + + # Conditional visibility + if key == "AdjacentLeadsUI" and not starpilot_state.car_state.hasRadar: + continue + elif key == "RadarTracksUI" and not starpilot_state.car_state.hasRadar: + continue + elif key == "ShowStoppingPoint" and not starpilot_state.car_state.hasOpenpilotLongitudinal: + continue + elif parent == "ShowStoppingPoint" and not stopping_on: + continue + + tile = ToggleTile( + title=tr(cat["title"]), + get_state=cat["get_state"], + set_state=cat["set_state"], + icon_path=cat.get("icon"), + bg_color=cat.get("color"), + ) + self._tile_grid.add_tile(tile) diff --git a/selfdrive/ui/layouts/settings/starpilot/device.py b/selfdrive/ui/layouts/settings/starpilot/device.py index 89da6aa38..c69eeee94 100644 --- a/selfdrive/ui/layouts/settings/starpilot/device.py +++ b/selfdrive/ui/layouts/settings/starpilot/device.py @@ -1,25 +1,275 @@ from __future__ import annotations +import subprocess +from pathlib import Path +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.hardware import HARDWARE +from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.lib.multilang import tr, tr_noop -from openpilot.system.ui.widgets.list_view import button_item -from openpilot.system.ui.widgets.scroller_tici import Scroller +from openpilot.system.ui.widgets import DialogResult +from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog +from openpilot.system.ui.widgets.selection_dialog import SelectionDialog from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import StarPilotPanel +from openpilot.selfdrive.ui.layouts.settings.starpilot.metro import SliderDialog + class StarPilotDeviceLayout(StarPilotPanel): def __init__(self): super().__init__() - - items = [ - button_item( - tr_noop("Screen Controls"), - lambda: tr("MANAGE"), - tr_noop("Adjust screen brightness, timeout, and other display settings."), - ), - button_item( - tr_noop("Device Settings"), - lambda: tr("MANAGE"), - tr_noop("Configure device-specific options like orientation and controls."), - ), + self.CATEGORIES = [ + {"title": tr_noop("Screen Settings"), "panel": "screen", "icon": "toggle_icons/icon_light.png", "color": "#FA6800"}, + {"title": tr_noop("Device Settings"), "panel": "device_settings", "icon": "toggle_icons/icon_device.png", "color": "#FA6800"}, + { + "title": tr_noop("Device Shutdown"), + "type": "value", + "get_value": self._get_shutdown_timer, + "on_click": self._show_shutdown_selector, + "color": "#FA6800", + }, + { + "title": tr_noop("Disable Logging"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("NoLogging"), + "set_state": lambda s: self._params.put_bool("NoLogging", s), + "color": "#FA6800", + }, + { + "title": tr_noop("Disable Uploads"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("NoUploads"), + "set_state": lambda s: self._params.put_bool("NoUploads", s), + "color": "#FA6800", + }, + { + "title": tr_noop("Disable Onroad Uploads"), + "type": "toggle", + "param": "DisableOnroadUploads", + "get_state": lambda: self._params.get_bool("DisableOnroadUploads"), + "set_state": lambda s: self._params.put_bool("DisableOnroadUploads", s), + "color": "#FA6800", + }, + { + "title": tr_noop("High-Quality Recording"), + "type": "toggle", + "param": "HigherBitrate", + "get_state": lambda: self._params.get_bool("HigherBitrate"), + "set_state": lambda s: self._on_higher_bitrate_toggle(s), + "color": "#FA6800", + }, + { + "title": tr_noop("Screen Recorder"), + "type": "hub", + "on_click": self._on_screen_recorder_clicked, + "color": "#FA6800", + }, ] - self._scroller = Scroller(items, line_separator=True, spacing=0) + self._sub_panels = { + "screen": StarPilotScreenLayout(), + "device_settings": StarPilotDeviceManagementLayout(), + } + + for name, panel in self._sub_panels.items(): + if hasattr(panel, 'set_navigate_callback'): + panel.set_navigate_callback(self._navigate_to) + if hasattr(panel, 'set_back_callback'): + panel.set_back_callback(self._go_back) + + self._rebuild_grid() + + def _rebuild_grid(self): + no_uploads = self._params.get_bool("NoUploads") + disable_onroad = self._params.get_bool("DisableOnroadUploads") + filtered = [] + for cat in self.CATEGORIES: + param = cat.get("param") + if param == "DisableOnroadUploads" and not no_uploads: + continue + if param == "HigherBitrate" and (not no_uploads or disable_onroad): + continue + filtered.append(cat) + original = self.CATEGORIES + self.CATEGORIES = filtered + super()._rebuild_grid() + self.CATEGORIES = original + + def _on_higher_bitrate_toggle(self, state): + self._params.put_bool("HigherBitrate", state) + cache_path = Path("/cache/use_HD") + if state: + cache_path.parent.mkdir(parents=True, exist_ok=True) + cache_path.touch() + else: + if cache_path.exists(): + cache_path.unlink() + if ui_state.started: + gui_app.set_modal_overlay( + ConfirmDialog( + tr("Reboot required. Reboot now?"), tr("Reboot"), tr("Cancel"), on_close=lambda res: HARDWARE.reboot() if res == DialogResult.CONFIRM else None + ) + ) + + def _get_shutdown_timer(self): + v = self._params.get_int("DeviceShutdown") + if v == 0: + return tr("5 mins") + if v <= 3: + return f"{v * 15} mins" + return f"{v - 3} " + (tr("hour") if v == 4 else tr("hours")) + + def _show_shutdown_selector(self): + def on_close(res, val): + if res == DialogResult.CONFIRM: + self._params.put_int("DeviceShutdown", int(val)) + self._rebuild_grid() + + labels = {0: tr("5 mins")} + for i in range(1, 4): + labels[i] = f"{i * 15} mins" + for i in range(4, 34): + labels[i] = f"{i - 3} " + (tr("hour") if i == 4 else tr("hours")) + + gui_app.set_modal_overlay(SliderDialog(tr("Device Shutdown"), 0, 33, 1, self._params.get_int("DeviceShutdown"), on_close, labels=labels, color="#FA6800")) + + def _on_screen_recorder_clicked(self): + is_recording = self._params_memory.get_bool("ScreenRecording") + if is_recording: + # Stop recording + subprocess.run(["killall", "-INT", "screenrecord"], capture_output=True) + self._params_memory.put_bool("ScreenRecording", False) + else: + # Start recording + output = "/tmp/screen_recording.mp4" + subprocess.Popen(["screenrecord", "--time-limit", "180", output], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + self._params_memory.put_bool("ScreenRecording", True) + + +class StarPilotScreenLayout(StarPilotPanel): + def __init__(self): + super().__init__() + self.CATEGORIES = [ + { + "title": tr_noop("Brightness (Offroad)"), + "type": "value", + "get_value": lambda: self._get_brightness("ScreenBrightness"), + "on_click": lambda: self._show_brightness_selector("ScreenBrightness"), + "color": "#FA6800", + }, + { + "title": tr_noop("Brightness (Onroad)"), + "type": "value", + "get_value": lambda: self._get_brightness("ScreenBrightnessOnroad"), + "on_click": lambda: self._show_brightness_selector("ScreenBrightnessOnroad"), + "color": "#FA6800", + }, + { + "title": tr_noop("Timeout (Offroad)"), + "type": "value", + "get_value": lambda: f"{self._params.get_int('ScreenTimeout')}s", + "on_click": lambda: self._show_timeout_selector("ScreenTimeout"), + "color": "#FA6800", + }, + { + "title": tr_noop("Timeout (Onroad)"), + "type": "value", + "get_value": lambda: f"{self._params.get_int('ScreenTimeoutOnroad')}s", + "on_click": lambda: self._show_timeout_selector("ScreenTimeoutOnroad"), + "color": "#FA6800", + }, + { + "title": tr_noop("Standby Mode"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("StandbyMode"), + "set_state": lambda s: self._params.put_bool("StandbyMode", s), + "color": "#FA6800", + }, + ] + self._rebuild_grid() + + def _get_brightness(self, key): + v = self._params.get_int(key) + if v == 0: + return tr("Off") + if v == 101: + return tr("Auto") + return f"{v}%" + + def _show_brightness_selector(self, key): + def on_close(res, val): + if res == DialogResult.CONFIRM: + new_v = int(val) + self._params.put_int(key, new_v) + HARDWARE.set_brightness(new_v) + self._rebuild_grid() + + gui_app.set_modal_overlay( + SliderDialog(tr(key), 0, 101, 1, self._params.get_int(key), on_close, unit="%", labels={0: tr("Off"), 101: tr("Auto")}, color="#FA6800") + ) + + def _show_timeout_selector(self, key): + def on_close(res, val): + if res == DialogResult.CONFIRM: + self._params.put_int(key, int(val)) + self._rebuild_grid() + + gui_app.set_modal_overlay(SliderDialog(tr(key), 5, 60, 5, self._params.get_int(key), on_close, unit="s", color="#FA6800")) + + +class StarPilotDeviceManagementLayout(StarPilotPanel): + def __init__(self): + super().__init__() + self.CATEGORIES = [ + { + "title": tr_noop("Low-Voltage Cutoff"), + "type": "value", + "get_value": lambda: f"{self._params.get_float('LowVoltageShutdown'):.1f}V", + "on_click": self._show_voltage_selector, + "color": "#FA6800", + }, + { + "title": tr_noop("Raise Temp Limits"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("IncreaseThermalLimits"), + "set_state": lambda s: self._params.put_bool("IncreaseThermalLimits", s), + "color": "#FA6800", + }, + { + "title": tr_noop("Use Konik Server"), + "type": "toggle", + "get_state": lambda: self._get_konik_state(), + "set_state": lambda s: self._on_konik_toggle(s), + "color": "#FA6800", + }, + ] + self._rebuild_grid() + + def _get_konik_state(self): + if Path("/data/not_vetted").exists(): + return True + return self._params.get_bool("UseKonikServer") + + def _on_konik_toggle(self, state): + self._params.put_bool("UseKonikServer", state) + cache_path = Path("/cache/use_konik") + if state: + cache_path.parent.mkdir(parents=True, exist_ok=True) + cache_path.touch() + else: + if cache_path.exists(): + cache_path.unlink() + if ui_state.started: + gui_app.set_modal_overlay( + ConfirmDialog( + tr("Reboot required. Reboot now?"), tr("Reboot"), tr("Cancel"), on_close=lambda res: HARDWARE.reboot() if res == DialogResult.CONFIRM else None + ) + ) + + def _show_voltage_selector(self): + def on_close(res, val): + if res == DialogResult.CONFIRM: + self._params.put_float("LowVoltageShutdown", float(val)) + self._rebuild_grid() + + gui_app.set_modal_overlay( + SliderDialog(tr("Low-Voltage Cutoff"), 11.8, 12.5, 0.1, self._params.get_float("LowVoltageShutdown"), on_close, unit="V", color="#FA6800") + ) diff --git a/selfdrive/ui/layouts/settings/starpilot/driving_model.py b/selfdrive/ui/layouts/settings/starpilot/driving_model.py index 6e326f522..88c7d2500 100644 --- a/selfdrive/ui/layouts/settings/starpilot/driving_model.py +++ b/selfdrive/ui/layouts/settings/starpilot/driving_model.py @@ -12,14 +12,13 @@ from openpilot.system.ui.lib.multilang import tr, tr_noop from openpilot.system.ui.widgets import DialogResult from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog, alert_dialog from openpilot.system.ui.widgets.selection_dialog import SelectionDialog -from openpilot.system.ui.widgets.list_view import toggle_item, button_item -from openpilot.system.ui.widgets.scroller_tici import Scroller from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import StarPilotPanel +from openpilot.selfdrive.ui.layouts.settings.starpilot.metro import SliderDialog + class StarPilotDrivingModelLayout(StarPilotPanel): def __init__(self): super().__init__() - self._toggle_items = {} if PC: self._model_dir = Path(os.path.expanduser("~/.comma/frogpilot/data/models")) @@ -35,66 +34,35 @@ class StarPilotDrivingModelLayout(StarPilotPanel): self._model_file_to_name = {} self._model_series_map = {} self._model_version_map = {} - self._current_model_name = "" + self._current_model_name = tr("Default") - self._toggle_items["ModelRandomizer"] = toggle_item( - tr_noop("Model Randomizer"), - tr_noop("Driving models are chosen at random each drive and feedback prompts are used to find the model that best suits your needs."), - self._params.get_bool("ModelRandomizer"), - callback=self._on_model_randomizer_toggled, - ) - - self._toggle_items["AutomaticallyDownloadModels"] = toggle_item( - tr_noop("Automatically Download New Models"), - tr_noop("Automatically download new driving models as they become available."), - self._params.get_bool("AutomaticallyDownloadModels"), - callback=self._on_auto_download_toggled, - ) - - self._select_model_btn = button_item( - tr_noop("Select Driving Model"), - lambda: tr("SELECT"), - tr_noop("Select the active driving model."), - callback=self._on_select_model_clicked, - ) - self._download_model_btn = button_item( - tr_noop("Download Driving Models"), - lambda: tr("DOWNLOAD"), - tr_noop("Download driving models to the device."), - callback=self._on_download_clicked, - ) - self._delete_model_btn = button_item( - tr_noop("Delete Driving Models"), - lambda: tr("DELETE"), - tr_noop("Delete driving models from the device."), - callback=self._on_delete_clicked, - ) - - items = [ - self._select_model_btn, - self._download_model_btn, - self._delete_model_btn, - self._toggle_items["ModelRandomizer"], - self._toggle_items["AutomaticallyDownloadModels"], - button_item( - tr_noop("Manage Model Blacklist"), - lambda: tr("MANAGE"), - tr_noop("Add or remove models from the Model Randomizer's blacklist."), - callback=self._on_blacklist_clicked, - ), - button_item( - tr_noop("Manage Model Ratings"), - lambda: tr("MANAGE"), - tr_noop("Reset or view the saved ratings for the driving models."), - callback=self._on_scores_clicked, - ), + self.CATEGORIES = [ + {"title": tr_noop("Ratings"), "type": "hub", "icon": "toggle_icons/icon_system.png", "on_click": self._on_scores_clicked, "color": "#1BA1E2"}, + { + "title": tr_noop("Recovery Power"), + "type": "value", + "icon": "toggle_icons/icon_longitudinal_tune.png", + "on_click": self._on_recovery_power_clicked, + "get_value": lambda: f"{self._params.get_float('RecoveryPower', default=1.0):.1f}", + "visible": lambda: self._params.get_int("TuningLevel", default=1) >= 3, + "color": "#1BA1E2", + }, + { + "title": tr_noop("Stop Distance"), + "type": "value", + "icon": "toggle_icons/icon_longitudinal_tune.png", + "on_click": self._on_stop_distance_clicked, + "get_value": lambda: f"{self._params.get_float('StopDistance', default=6.0):.1f}", + "visible": lambda: self._params.get_int("TuningLevel", default=1) >= 3, + "color": "#1BA1E2", + }, ] - + self._model_manager = ModelManager(self._params, self._params_memory) self._download_thread = None - self._scroller = Scroller(items, line_separator=True, spacing=0) self._update_model_metadata() + self._rebuild_grid() def _render(self, rect: rl.Rectangle): self._update_state() @@ -104,50 +72,13 @@ class StarPilotDrivingModelLayout(StarPilotPanel): super().show_event() self._update_model_metadata() - def refresh_visibility(self): - current_level = int(self._params.get("TuningLevel", return_default=True, default="1") or "1") - for key, item in self._toggle_items.items(): - min_level = self._tuning_levels.get(key, 0) - item.set_visible(current_level >= min_level) - - def _on_auto_download_toggled(self, state: bool): - self._params.put_bool("AutomaticallyDownloadModels", state) - def _is_model_installed(self, key: str) -> bool: if not key: return False - - has_thneed = False - has_policy_meta = False - has_policy_tg = False - has_vision_meta = False - has_vision_tg = False - found_any = False - - for file in self._model_dir.iterdir(): - if not (file.name.startswith(key) or file.name.startswith(key + "_")): - continue - - found_any = True - ext = file.suffix.lower() - base = file.stem - - if ext == ".thneed": - has_thneed = True - elif ext == ".pkl": - if "_driving_policy_metadata" in base: - has_policy_meta = True - elif "_driving_policy_tinygrad" in base: - has_policy_tg = True - elif "_driving_vision_metadata" in base: - has_vision_meta = True - elif "_driving_vision_tinygrad" in base: - has_vision_tg = True - + has_thneed = (self._model_dir / f"{key}.thneed").exists() if has_thneed: return True - - return has_policy_meta and has_policy_tg and has_vision_meta and has_vision_tg + return (self._model_dir / f"{key}_driving_policy_tinygrad.pkl").exists() def _update_model_metadata(self): available_models_raw = self._params.get("AvailableModels", encoding='utf-8') @@ -173,30 +104,27 @@ class StarPilotDrivingModelLayout(StarPilotPanel): name = self._available_model_names[i].strip() if not key or not name: continue - series = self._available_model_series[i].strip() if i < len(self._available_model_series) else tr("Custom Series") self._model_file_to_name[key] = name self._model_series_map[key] = series if i < len(self._available_model_versions): - version = self._available_model_versions[i].strip() - if version: - self._model_version_map[key] = version + v = self._available_model_versions[i].strip() + if v: + self._model_version_map[key] = v if i < len(released_dates): - date = released_dates[i].strip() - if date: - self._model_released_dates[key] = date + d = released_dates[i].strip() + if d: + self._model_released_dates[key] = d model_key = self._params.get("Model") or self._params.get("DrivingModel") - if model_key: - if isinstance(model_key, bytes): - model_key = model_key.decode() + if model_key and isinstance(model_key, bytes): + model_key = model_key.decode() if not model_key or not self._is_model_installed(model_key): model_key = self._params.get_default_value("Model") or self._params.get_default_value("DrivingModel") or "" - if isinstance(model_key, bytes): + if model_key and isinstance(model_key, bytes): model_key = model_key.decode() self._current_model_name = self._model_file_to_name.get(model_key, "Default") - self._select_model_btn.action_item._text_source = self._current_model_name def _show_selection_dialog(self, title: str, options: dict[str, str] | list[str], current_val: str, on_confirm: Callable): if not options: @@ -204,9 +132,11 @@ class StarPilotDrivingModelLayout(StarPilotPanel): return if isinstance(options, list): + def _on_close_list(res, val): if res == DialogResult.CONFIRM: on_confirm(val) + dialog = SelectionDialog(title, options, current_val, on_close=_on_close_list) gui_app.set_modal_overlay(dialog) return @@ -219,15 +149,14 @@ class StarPilotDrivingModelLayout(StarPilotPanel): grouped[series] = [] grouped[series].append(name) name_to_key[name] = key - + for series in grouped: grouped[series].sort() - sorted_series = sorted(grouped.keys()) if "StarPilot" in sorted_series: sorted_series.remove("StarPilot") sorted_series.insert(0, "StarPilot") - + final_grouped = {s: grouped[s] for s in sorted_series} def _on_close_grouped(res, val): @@ -247,15 +176,15 @@ class StarPilotDrivingModelLayout(StarPilotPanel): comm_favs = [f.strip() for f in (self._params.get("CommunityFavorites", encoding='utf-8') or "").split(",") if f.strip()] dialog = SelectionDialog( - title, - final_grouped, - current_val, - on_close=_on_close_grouped, - model_released_dates=self._model_released_dates, - model_file_to_name=self._model_file_to_name, - user_favorites=user_favs, - community_favorites=comm_favs, - on_favorite_toggled=_on_favorite_toggled + title, + final_grouped, + current_val, + on_close=_on_close_grouped, + model_released_dates=self._model_released_dates, + model_file_to_name=self._model_file_to_name, + user_favorites=user_favs, + community_favorites=comm_favs, + on_favorite_toggled=_on_favorite_toggled, ) gui_app.set_modal_overlay(dialog) @@ -268,20 +197,17 @@ class StarPilotDrivingModelLayout(StarPilotPanel): self._params.put("Model", model_key) self._params.put("DrivingModel", model_key) self._params.put("DrivingModelName", installed_models[model_key]) - model_version = self._model_version_map.get(model_key, "") - if model_version: - self._params.put("ModelVersion", model_version) - self._params.put("DrivingModelVersion", model_version) + mv = self._model_version_map.get(model_key, "") + if mv: + self._params.put("ModelVersion", mv) + self._params.put("DrivingModelVersion", mv) self._update_model_metadata() - if ui_state.started: - reboot_dialog = ConfirmDialog( - tr("Reboot required to take effect. Reboot now?"), - tr("Reboot"), - tr("Cancel"), - on_close=lambda res: HARDWARE.reboot() if res == DialogResult.CONFIRM else None + gui_app.set_modal_overlay( + ConfirmDialog( + tr("Reboot required. Reboot now?"), tr("Reboot"), tr("Cancel"), on_close=lambda res: HARDWARE.reboot() if res == DialogResult.CONFIRM else None + ) ) - gui_app.set_modal_overlay(reboot_dialog) self._show_selection_dialog(tr("Select Driving Model"), installed_models, self._current_model_name, _on_confirm) @@ -294,136 +220,109 @@ class StarPilotDrivingModelLayout(StarPilotPanel): return not_installed = {k: v for k, v in self._model_file_to_name.items() if not self._is_model_installed(k)} - def _on_confirm(model_key): - self._params_memory.put("ModelToDownload", model_key) - self._params_memory.put("ModelDownloadProgress", "Downloading...") - - self._show_selection_dialog(tr("Select Model to Download"), not_installed, "", _on_confirm) + self._show_selection_dialog(tr("Select Model to Download"), not_installed, "", lambda mk: self._params_memory.put("ModelToDownload", mk)) def _on_delete_clicked(self): installed = {k: v for k, v in self._model_file_to_name.items() if self._is_model_installed(k)} - default_key = self._params.get_default_value("Model") or "" - if isinstance(default_key, bytes): - default_key = default_key.decode() - current_key = self._params.get("Model", encoding='utf-8') or "" - deletable = {k: v for k, v in installed.items() if k != default_key and k != current_key} - - def _on_confirm(model_key): - def _execute_delete(confirm_res): - if confirm_res == DialogResult.CONFIRM: - for file in self._model_dir.iterdir(): - if file.name.startswith(model_key): - file.unlink() - self._update_model_metadata() - - confirm = ConfirmDialog( - tr(f"Are you sure you want to delete the '{deletable[model_key]}' model?"), - tr("Delete"), - on_close=_execute_delete - ) - gui_app.set_modal_overlay(confirm) + dk = self._params.get_default_value("Model") or "" + if isinstance(dk, bytes): + dk = dk.decode() + ck = self._params.get("Model", encoding='utf-8') or "" + deletable = {k: v for k, v in installed.items() if k != dk and k != ck} + + def _on_confirm(mk): + def _execute_delete(res): + if res == DialogResult.CONFIRM: + for file in self._model_dir.iterdir(): + if file.name.startswith(mk): + file.unlink() + self._update_model_metadata() + + gui_app.set_modal_overlay(ConfirmDialog(tr(f"Delete '{deletable[mk]}'?"), tr("Delete"), on_close=_execute_delete)) self._show_selection_dialog(tr("Select Model to Delete"), deletable, "", _on_confirm) def _on_blacklist_clicked(self): blacklisted = [m.strip() for m in (self._params.get("BlacklistedModels", encoding='utf-8') or "").split(",") if m.strip()] - blacklisted = [b for b in blacklisted if b] def _on_action_selected(res, val): - if res == DialogResult.CONFIRM: - if val == tr("ADD"): - blacklistable = {k: v for k, v in self._model_file_to_name.items() if k not in blacklisted} - self._show_selection_dialog(tr("Add to Blacklist"), blacklistable, "", lambda k: self._params.put("BlacklistedModels", ",".join(blacklisted + [k]))) - elif val == tr("REMOVE"): - options = {k: self._model_file_to_name.get(k, k) for k in blacklisted} - def _remove(k): - blacklisted.remove(k) - self._params.put("BlacklistedModels", ",".join(blacklisted)) - self._show_selection_dialog(tr("Remove from Blacklist"), options, "", _remove) - elif val == tr("RESET ALL"): - self._params.remove("BlacklistedModels") + if res == DialogResult.CONFIRM: + if val == tr("ADD"): + blacklistable = {k: v for k, v in self._model_file_to_name.items() if k not in blacklisted} + self._show_selection_dialog(tr("Add to Blacklist"), blacklistable, "", lambda k: self._params.put("BlacklistedModels", ",".join(blacklisted + [k]))) + elif val == tr("REMOVE"): + options = {k: self._model_file_to_name.get(k, k) for k in blacklisted} - dialog = SelectionDialog( - tr("Manage Blacklist"), - [tr("ADD"), tr("REMOVE"), tr("RESET ALL")], - on_close=_on_action_selected - ) - gui_app.set_modal_overlay(dialog) + def _remove(k): + blacklisted.remove(k) + self._params.put("BlacklistedModels", ",".join(blacklisted)) + + self._show_selection_dialog(tr("Remove from Blacklist"), options, "", _remove) + elif val == tr("RESET ALL"): + self._params.remove("BlacklistedModels") + + gui_app.set_modal_overlay(SelectionDialog(tr("Manage Blacklist"), [tr("ADD"), tr("REMOVE"), tr("RESET ALL")], on_close=_on_action_selected)) def _on_scores_clicked(self): scores_raw = self._params.get("ModelDrivesAndScores", encoding='utf-8') or "" if not scores_raw: gui_app.set_modal_overlay(alert_dialog(tr("No model ratings found."))) return - try: - scores = json.loads(scores_raw) - lines = [f"{k}: {v.get('Score', 0)}% ({v.get('Drives', 0)} drives)" for k, v in scores.items()] - confirm = ConfirmDialog("\n".join(lines), tr("Close"), rich=True) - gui_app.set_modal_overlay(confirm) + scores = json.loads(scores_raw) + lines = [f"{k}: {v.get('Score', 0)}% ({v.get('Drives', 0)} drives)" for k, v in scores.items()] + gui_app.set_modal_overlay(ConfirmDialog("\n".join(lines), tr("Close"), rich=True)) except: - pass + pass def _on_model_randomizer_toggled(self, state: bool): self._params.put_bool("ModelRandomizer", state) if state: - not_installed = [k for k in self._model_file_to_name if not self._is_model_installed(k)] - if not_installed: - def _on_download_confirm(res): - if res == DialogResult.CONFIRM: - self._params_memory.put_bool("DownloadAllModels", True) - self._params_memory.put("ModelDownloadProgress", "Downloading...") - - confirm = ConfirmDialog( - tr("Model Randomizer works best with all models. Download all now?"), - tr("Download All"), - on_close=_on_download_confirm - ) - gui_app.set_modal_overlay(confirm) + not_installed = [k for k in self._model_file_to_name if not self._is_model_installed(k)] + if not_installed: + + def _on_download_confirm(res): + if res == DialogResult.CONFIRM: + self._params_memory.put_bool("DownloadAllModels", True) + self._params_memory.put("ModelDownloadProgress", "Downloading...") + + gui_app.set_modal_overlay(ConfirmDialog(tr("Download all models for Randomizer?"), tr("Download All"), on_close=_on_download_confirm)) + + def _on_recovery_power_clicked(self): + def on_close(res, val): + if res == DialogResult.CONFIRM: + self._params.put_float("RecoveryPower", val) + self._rebuild_grid() + + gui_app.set_modal_overlay( + SliderDialog(tr("Recovery Power"), 0.5, 2.0, 0.1, self._params.get_float("RecoveryPower", default=1.0), on_close, color="#1BA1E2") + ) + + def _on_stop_distance_clicked(self): + def on_close(res, val): + if res == DialogResult.CONFIRM: + self._params.put_float("StopDistance", val) + self._rebuild_grid() + + gui_app.set_modal_overlay(SliderDialog(tr("Stop Distance"), 4.0, 10.0, 0.5, self._params.get_float("StopDistance", default=6.0), on_close, color="#1BA1E2")) def _update_state(self): - if not self.is_visible: - return - model_to_download = self._params_memory.get("ModelToDownload", encoding='utf-8') or "" download_all = self._params_memory.get_bool("DownloadAllModels") - progress = self._params_memory.get("ModelDownloadProgress", encoding='utf-8') or "" is_downloading = bool(model_to_download or download_all) if is_downloading and (self._download_thread is None or not self._download_thread.is_alive()): - def _download_task(): - try: - if download_all: - print("Starting [All Models] download thread...") - self._model_manager.download_all_models() - else: - print(f"Starting [{model_to_download}] download thread...") - self._model_manager.download_model(model_to_download) - print("Download thread finished successfully.") - except Exception as e: - print(f"Download thread CRASHED: {e}") - import traceback - traceback.print_exc() - finally: - self._download_thread = None - - self._download_thread = threading.Thread(target=_download_task, daemon=True) - self._download_thread.start() - if is_downloading: - self._download_model_btn.action_item._text_source = tr("CANCEL") - self._download_model_btn.action_item._value_source = progress if progress else tr("Downloading...") - else: - self._download_model_btn.action_item._text_source = tr("DOWNLOAD") - parked = not ui_state.started - online = ui_state.sm["deviceState"].networkType != 0 if ui_state.sm.valid.get("deviceState", False) else True - if not online: - self._download_model_btn.action_item._value_source = tr("Offline...") - elif not parked: - self._download_model_btn.action_item._value_source = tr("Not parked") - else: - self._download_model_btn.action_item._value_source = "" - - all_installed = all(self._is_model_installed(k) for k in self._model_file_to_name) - if all_installed: - self._download_model_btn.action_item._value_source = tr("All Downloaded!") + def _download_task(): + try: + if download_all: + self._model_manager.download_all_models() + else: + self._model_manager.download_model(model_to_download) + except: + pass + finally: + self._download_thread = None + + self._download_thread = threading.Thread(target=_download_task, daemon=True) + self._download_thread.start() diff --git a/selfdrive/ui/layouts/settings/starpilot/lateral.py b/selfdrive/ui/layouts/settings/starpilot/lateral.py index e2a4d506e..168a2bea5 100644 --- a/selfdrive/ui/layouts/settings/starpilot/lateral.py +++ b/selfdrive/ui/layouts/settings/starpilot/lateral.py @@ -1,325 +1,126 @@ from __future__ import annotations - from openpilot.selfdrive.ui.lib.starpilot_state import starpilot_state +from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.lib.multilang import tr, tr_noop from openpilot.system.ui.widgets import DialogResult from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog -from openpilot.system.ui.widgets.list_view import button_item, value_button_item, button_toggle_item, toggle_item, value_item -from openpilot.system.ui.widgets.scroller_tici import Scroller +from openpilot.system.ui.widgets.selection_dialog import SelectionDialog from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import StarPilotPanel +from openpilot.selfdrive.ui.layouts.settings.starpilot.metro import SliderDialog class StarPilotAdvancedLateralLayout(StarPilotPanel): def __init__(self): super().__init__() - - items = [ - value_button_item( - lambda: ( - tr("Actuator Delay") - + (f" (Default: {starpilot_state.car_state.steerActuatorDelay:.2f})" if starpilot_state.car_state.steerActuatorDelay != 0 else "") - ), - "SteerDelay", - min_val=0.01, - max_val=1.0, - step=0.01, - button_text="Reset", - button_callback=lambda: self._params.put_float("SteerDelay", starpilot_state.car_state.steerActuatorDelay), - description=tr_noop( - "The time between openpilot's steering command and the vehicle's response. Increase if the vehicle reacts late; decrease if it feels jumpy. Auto-learned by default." - ), - enabled=lambda: starpilot_state.car_state.steerActuatorDelay != 0, - ), - value_button_item( - lambda: tr("Friction") + (f" (Default: {starpilot_state.car_state.friction:.2f})" if starpilot_state.car_state.friction != 0 else ""), - "SteerFriction", - min_val=0.0, - max_val=1.0, - step=0.01, - button_text="Reset", - button_callback=lambda: self._params.put_float("SteerFriction", starpilot_state.car_state.friction), - description=tr_noop( - "Compensates for steering friction. Increase if the wheel sticks near center; decrease if it jitters. Auto-learned by default." - ), - enabled=lambda: ( - starpilot_state.car_state.friction != 0 - and (not starpilot_state.car_state.hasAutoTune or (starpilot_state.car_state.hasAutoTune and self._params.get_bool("ForceAutoTuneOff"))) - ), - ), - value_button_item( - lambda: tr("Kp Factor") + (f" (Default: {starpilot_state.car_state.steerKp:.2f})" if starpilot_state.car_state.steerKp != 0 else ""), - "SteerKP", - min_val=lambda: starpilot_state.car_state.steerKp * 0.5, - max_val=lambda: starpilot_state.car_state.steerKp * 1.5, - step=0.01, - button_text="Reset", - button_callback=lambda: self._params.put_float("SteerKP", starpilot_state.car_state.steerKp), - description=tr_noop( - "How strongly openpilot corrects lane position. Higher is tighter but twitchier; lower is smoother but slower. Auto-learned by default." - ), - enabled=lambda: starpilot_state.car_state.steerKp != 0 and not starpilot_state.car_state.isAngleCar, - ), - value_button_item( - lambda: ( - tr("Lateral Acceleration") + (f" (Default: {starpilot_state.car_state.latAccelFactor:.2f})" if starpilot_state.car_state.latAccelFactor != 0 else "") - ), - "SteerLatAccel", - min_val=lambda: starpilot_state.car_state.latAccelFactor * 0.5, - max_val=lambda: starpilot_state.car_state.latAccelFactor * 1.5, - step=0.01, - button_text="Reset", - button_callback=lambda: self._params.put_float("SteerLatAccel", starpilot_state.car_state.latAccelFactor), - description=tr_noop( - "Maps steering torque to turning response. Increase for sharper turns; decrease for gentler steering. Auto-learned by default." - ), - enabled=lambda: ( - starpilot_state.car_state.latAccelFactor != 0 - and (not starpilot_state.car_state.hasAutoTune or (starpilot_state.car_state.hasAutoTune and self._params.get_bool("ForceAutoTuneOff"))) - ), - ), - value_button_item( - lambda: tr("Steer Ratio") + (f" (Default: {starpilot_state.car_state.steerRatio:.2f})" if starpilot_state.car_state.steerRatio != 0 else ""), - "SteerRatio", - min_val=lambda: starpilot_state.car_state.steerRatio * 0.5, - max_val=lambda: starpilot_state.car_state.steerRatio * 1.5, - step=0.01, - button_text="Reset", - button_callback=lambda: self._params.put_float("SteerRatio", starpilot_state.car_state.steerRatio), - description=tr_noop( - "The relationship between steering wheel rotation and road wheel angle. Increase if steering feels too quick or twitchy; decrease if it feels too slow or weak. Auto-learned by default." - ), - enabled=lambda: ( - starpilot_state.car_state.steerRatio != 0 - and (not starpilot_state.car_state.hasAutoTune or (starpilot_state.car_state.hasAutoTune and self._params.get_bool("ForceAutoTuneOff"))) - ), - ), - toggle_item( - tr_noop("Force Auto-Tune On"), - tr_noop("Force-enable openpilot's live auto-tuning for \"Friction\" and \"Lateral Acceleration\"."), - self._params.get_bool("ForceAutoTune"), - callback=lambda x: self._params.put_bool("ForceAutoTune", x), - enabled=lambda: not starpilot_state.car_state.hasAutoTune and not starpilot_state.car_state.isAngleCar, - ), - toggle_item( - tr_noop("Force Auto-Tune Off"), - tr_noop("Force-disable openpilot's live auto-tuning for \"Friction\" and \"Lateral Acceleration\" and use the set value instead."), - self._params.get_bool("ForceAutoTuneOff"), - callback=lambda x: self._params.put_bool("ForceAutoTuneOff", x), - enabled=lambda: starpilot_state.car_state.hasAutoTune, - ), - toggle_item( - tr_noop("Force Torque Controller"), - tr_noop("Use torque-based steering control instead of angle-based control for smoother lane keeping, especially in curves."), - self._params.get_bool("ForceTorqueController"), - callback=lambda x: self._on_reboot_toggle("ForceTorqueController", x), - enabled=lambda: not starpilot_state.car_state.isAngleCar and not starpilot_state.car_state.isTorqueCar, - ), + self.CATEGORIES = [ + {"title": tr_noop("Actuator Delay"), "type": "value", "get_value": lambda: f"{self._params.get_float('SteerDelay'):.2f}s", "on_click": lambda: self._show_float_selector("SteerDelay", 0.0, 0.5, 0.01, "s"), "icon": "toggle_icons/icon_advanced_lateral_tune.png", "color": "#1BA1E2"}, + {"title": tr_noop("Friction"), "type": "value", "get_value": lambda: f"{self._params.get_float('SteerFriction'):.3f}", "on_click": lambda: self._show_float_selector("SteerFriction", 0.0, 0.5, 0.005), "icon": "toggle_icons/icon_advanced_lateral_tune.png", "color": "#1BA1E2"}, + {"title": tr_noop("Kp Factor"), "type": "value", "get_value": lambda: f"{self._params.get_float('SteerKP'):.2f}", "on_click": lambda: self._show_float_selector("SteerKP", 0.5, 2.5, 0.01), "icon": "toggle_icons/icon_advanced_lateral_tune.png", "color": "#1BA1E2"}, + {"title": tr_noop("Lateral Accel"), "type": "value", "get_value": lambda: f"{self._params.get_float('SteerLatAccel'):.2f}", "on_click": lambda: self._show_float_selector("SteerLatAccel", 0.5, 5.0, 0.01), "icon": "toggle_icons/icon_advanced_lateral_tune.png", "color": "#1BA1E2"}, + {"title": tr_noop("Steer Ratio"), "type": "value", "get_value": lambda: f"{self._params.get_float('SteerRatio'):.2f}", "on_click": lambda: self._show_float_selector("SteerRatio", 5.0, 25.0, 0.01), "icon": "toggle_icons/icon_advanced_lateral_tune.png", "color": "#1BA1E2"}, + {"title": tr_noop("Force Auto-Tune On"), "type": "toggle", "get_state": lambda: self._params.get_bool("ForceAutoTune"), "set_state": lambda x: self._params.put_bool("ForceAutoTune", x), "icon": "toggle_icons/icon_tuning.png", "color": "#1BA1E2"}, + {"title": tr_noop("Force Auto-Tune Off"), "type": "toggle", "get_state": lambda: self._params.get_bool("ForceAutoTuneOff"), "set_state": lambda x: self._params.put_bool("ForceAutoTuneOff", x), "icon": "toggle_icons/icon_tuning.png", "color": "#1BA1E2"}, + {"title": tr_noop("Force Torque Controller"), "type": "toggle", "get_state": lambda: self._params.get_bool("ForceTorqueController"), "set_state": lambda x: self._on_reboot_toggle("ForceTorqueController", x), "icon": "toggle_icons/icon_advanced_lateral_tune.png", "color": "#1BA1E2"}, ] - self._scroller = Scroller(items, line_separator=True, spacing=0) + self._rebuild_grid() + + def _show_float_selector(self, key, min_v, max_v, step, unit=""): + def on_close(res, val): + if res == DialogResult.CONFIRM: + self._params.put_float(key, float(val)) + self._rebuild_grid() + gui_app.set_modal_overlay(SliderDialog(tr(key), min_v, max_v, step, self._params.get_float(key), on_close, unit=unit, color="#1BA1E2")) def _on_reboot_toggle(self, key, state): self._params.put_bool(key, state) from openpilot.selfdrive.ui.ui_state import ui_state - if ui_state.started: - from openpilot.system.ui.lib.application import gui_app - - def _confirm_reboot(res): - gui_app.set_modal_overlay(None) - if res == DialogResult.CONFIRM: - from openpilot.system.hardware import HARDWARE - HARDWARE.reboot() - - dialog = ConfirmDialog("Reboot required to take effect. Reboot now?", "Reboot", "Cancel", on_close=_confirm_reboot) + dialog = ConfirmDialog(tr("Reboot required. Reboot now?"), tr("Reboot"), tr("Cancel"), on_close=lambda res: HARDWARE.reboot() if res == DialogResult.CONFIRM else None) gui_app.set_modal_overlay(dialog) class StarPilotAlwaysOnLateralLayout(StarPilotPanel): def __init__(self): super().__init__() - - items = [ - toggle_item( - tr_noop("Always On Lateral"), - tr_noop("openpilot's steering remains active even when the accelerator or brake pedals are pressed."), - self._params.get_bool("AlwaysOnLateral"), - callback=lambda x: self._on_reboot_toggle("AlwaysOnLateral", x), - icon="toggle_icons/icon_always_on_lateral.png", - starpilot_icon=True, - ), - toggle_item( - tr_noop("Enable With LKAS"), - tr_noop("Enable \"Always On Lateral\" whenever \"LKAS\" is on, even when openpilot is not engaged."), - self._params.get_bool("AlwaysOnLateralLKAS"), - callback=lambda x: self._params.put_bool("AlwaysOnLateralLKAS", x), - enabled=lambda: starpilot_state.car_state.lkasAllowedForAOL, - ), - value_item( - tr_noop("Pause on Brake Press Below"), - "PauseAOLOnBrake", - min_val=0, - max_val=99, - step=1, - unit="mph", - description=tr_noop("Pause \"Always On Lateral\" below the set speed while the brake pedal is pressed."), - is_metric=True, - ), + self.CATEGORIES = [ + {"title": tr_noop("Always On Lateral"), "type": "toggle", "get_state": lambda: self._params.get_bool("AlwaysOnLateral"), "set_state": lambda x: self._on_reboot_toggle("AlwaysOnLateral", x), "icon": "toggle_icons/icon_always_on_lateral.png", "color": "#1BA1E2"}, + {"title": tr_noop("Enable With LKAS"), "type": "toggle", "get_state": lambda: self._params.get_bool("AlwaysOnLateralLKAS"), "set_state": lambda x: self._params.put_bool("AlwaysOnLateralLKAS", x), "icon": "toggle_icons/icon_always_on_lateral.png", "color": "#1BA1E2"}, + {"title": tr_noop("Pause Below"), "type": "value", "get_value": lambda: f"{self._params.get_int('PauseAOLOnBrake')} mph", "on_click": lambda: self._show_speed_selector("PauseAOLOnBrake"), "icon": "toggle_icons/icon_always_on_lateral.png", "color": "#1BA1E2"}, ] - self._scroller = Scroller(items, line_separator=True, spacing=0) + self._rebuild_grid() + + def _show_speed_selector(self, key): + def on_close(res, val): + if res == DialogResult.CONFIRM: + self._params.put_int(key, int(val)) + self._rebuild_grid() + gui_app.set_modal_overlay(SliderDialog(tr(key), 0, 100, 1, self._params.get_int(key), on_close, unit=" mph", color="#1BA1E2")) def _on_reboot_toggle(self, key, state): self._params.put_bool(key, state) from openpilot.selfdrive.ui.ui_state import ui_state - if ui_state.started: - from openpilot.system.ui.lib.application import gui_app - - def _confirm_reboot(res): - gui_app.set_modal_overlay(None) - if res == DialogResult.CONFIRM: - from openpilot.system.hardware import HARDWARE - HARDWARE.reboot() - - dialog = ConfirmDialog("Reboot required to take effect. Reboot now?", "Reboot", "Cancel", on_close=_confirm_reboot) - gui_app.set_modal_overlay(dialog) + gui_app.set_modal_overlay(ConfirmDialog(tr("Reboot required. Reboot now?"), tr("Reboot"), tr("Cancel"), on_close=lambda res: HARDWARE.reboot() if res == DialogResult.CONFIRM else None)) class StarPilotLaneChangesLayout(StarPilotPanel): def __init__(self): super().__init__() - - def _get_lane_change_labels(): - labels = {0.0: tr("Instant")} - for i in range(1, 51): - val = i / 10.0 - labels[val] = f"{val:.1f} seconds" if val != 1.0 else "1.0 second" - return labels - - items = [ - toggle_item( - tr_noop("Lane Changes"), - tr_noop("Allow openpilot to change lanes."), - self._params.get_bool("LaneChanges"), - callback=lambda x: self._params.put_bool("LaneChanges", x), - icon="toggle_icons/icon_lane.png", - starpilot_icon=True, - ), - toggle_item( - tr_noop("Automatic Lane Changes"), - tr_noop("When the turn signal is on, openpilot will automatically change lanes. No steering-wheel nudge required!"), - self._params.get_bool("NudgelessLaneChange"), - callback=lambda x: self._params.put_bool("NudgelessLaneChange", x), - ), - value_item( - tr_noop("Lane Change Delay"), - "LaneChangeTime", - min_val=0.0, - max_val=5.0, - step=0.1, - description=tr_noop("Delay between turn signal activation and the start of an automatic lane change."), - labels=_get_lane_change_labels(), - enabled=lambda: self._params.get_bool("LaneChanges") and self._params.get_bool("NudgelessLaneChange"), - ), - value_item( - tr_noop("Minimum Lane Change Speed"), - "MinimumLaneChangeSpeed", - min_val=0, - max_val=99, - step=1, - unit="mph", - description=tr_noop("Lowest speed at which openpilot will change lanes."), - is_metric=True, - ), - value_item( - tr_noop("Minimum Lane Width"), - "LaneDetectionWidth", - min_val=0.0, - max_val=15.0, - step=0.1, - unit="feet", - description=tr_noop("Prevent automatic lane changes into lanes narrower than the set width."), - enabled=lambda: self._params.get_bool("LaneChanges") and self._params.get_bool("NudgelessLaneChange"), - is_metric=True, - ), - toggle_item( - tr_noop("One Lane Change Per Signal"), - tr_noop("Limit automatic lane changes to one per turn-signal activation."), - self._params.get_bool("OneLaneChange"), - callback=lambda x: self._params.put_bool("OneLaneChange", x), - ), + self.CATEGORIES = [ + {"title": tr_noop("Lane Changes"), "type": "toggle", "get_state": lambda: self._params.get_bool("LaneChanges"), "set_state": lambda s: self._params.put_bool("LaneChanges", s), "icon": "toggle_icons/icon_lane.png", "color": "#1BA1E2"}, + {"title": tr_noop("Automatic Lane Changes"), "type": "toggle", "get_state": lambda: self._params.get_bool("NudgelessLaneChange"), "set_state": lambda s: self._params.put_bool("NudgelessLaneChange", s), "icon": "toggle_icons/icon_lane.png", "color": "#1BA1E2"}, + {"title": tr_noop("Lane Change Delay"), "type": "value", "get_value": lambda: f"{self._params.get_float('LaneChangeTime'):.1f}s", "on_click": lambda: self._show_float_selector("LaneChangeTime", 0.0, 5.0, 0.1, "s"), "icon": "toggle_icons/icon_lane.png", "color": "#1BA1E2"}, + {"title": tr_noop("Min Lane Change Speed"), "type": "value", "get_value": lambda: f"{self._params.get_int('MinimumLaneChangeSpeed')} mph", "on_click": lambda: self._show_speed_selector("MinimumLaneChangeSpeed"), "icon": "toggle_icons/icon_lane.png", "color": "#1BA1E2"}, + {"title": tr_noop("Minimum Lane Width"), "type": "value", "get_value": lambda: f"{self._params.get_float('LaneDetectionWidth'):.1f} ft", "on_click": lambda: self._show_float_selector("LaneDetectionWidth", 0.0, 15.0, 0.1, " ft"), "icon": "toggle_icons/icon_lane.png", "color": "#1BA1E2"}, + {"title": tr_noop("One Lane Change Per Signal"), "type": "toggle", "get_state": lambda: self._params.get_bool("OneLaneChange"), "set_state": lambda s: self._params.put_bool("OneLaneChange", s), "icon": "toggle_icons/icon_lane.png", "color": "#1BA1E2"}, ] - self._scroller = Scroller(items, line_separator=True, spacing=0) + self._rebuild_grid() + + def _show_speed_selector(self, key): + def on_close(res, val): + if res == DialogResult.CONFIRM: + self._params.put_int(key, int(val)) + self._rebuild_grid() + gui_app.set_modal_overlay(SliderDialog(tr(key), 0, 100, 1, self._params.get_int(key), on_close, unit=" mph", color="#1BA1E2")) + + def _show_float_selector(self, key, min_v, max_v, step, unit=""): + def on_close(res, val): + if res == DialogResult.CONFIRM: + self._params.put_float(key, float(val)) + self._rebuild_grid() + gui_app.set_modal_overlay(SliderDialog(tr(key), min_v, max_v, step, self._params.get_float(key), on_close, unit=unit, color="#1BA1E2")) class StarPilotLateralTuneLayout(StarPilotPanel): def __init__(self): super().__init__() - - items = [ - toggle_item( - tr_noop("Force Turn Desires Below Lane Change Speed"), - tr_noop("While driving below the minimum lane change speed with an active turn signal, instruct openpilot to turn left/right."), - self._params.get_bool("TurnDesires"), - callback=lambda x: self._params.put_bool("TurnDesires", x), - ), - toggle_item( - tr_noop("Neural Network Feedforward (NNFF)"), - tr_noop( - "Twilsonco's \"Neural Network FeedForward\" controller. Uses a trained neural network model to predict steering torque based on vehicle speed, roll, and past/future planned path data for smoother, model-based steering." - ), - self._params.get_bool("NNFF"), - callback=lambda x: self._on_reboot_toggle("NNFF", x), - enabled=lambda: starpilot_state.car_state.hasNNFFLog and not starpilot_state.car_state.isAngleCar, - ), - toggle_item( - tr_noop("Neural Network Feedforward (NNFF) Lite"), - tr_noop( - "A lightweight version of Twilsonco's \"Neural Network FeedForward\" controller. Uses the \"look-ahead\" planned lateral jerk logic from the full model to help smoothen steering adjustments in curves, but does not use the full neural network for torque calculation." - ), - self._params.get_bool("NNFFLite"), - callback=lambda x: self._on_reboot_toggle("NNFFLite", x), - enabled=lambda: not self._params.get_bool("NNFF") and not starpilot_state.car_state.isAngleCar, - ), + self.CATEGORIES = [ + {"title": tr_noop("Force Turn Desires"), "type": "toggle", "get_state": lambda: self._params.get_bool("TurnDesires"), "set_state": lambda x: self._params.put_bool("TurnDesires", x), "icon": "toggle_icons/icon_lateral_tune.png", "color": "#1BA1E2"}, + {"title": tr_noop("NNFF"), "type": "toggle", "get_state": lambda: self._params.get_bool("NNFF"), "set_state": lambda x: self._on_reboot_toggle("NNFF", x), "icon": "toggle_icons/icon_lateral_tune.png", "color": "#1BA1E2"}, + {"title": tr_noop("NNFF Lite"), "type": "toggle", "get_state": lambda: self._params.get_bool("NNFFLite"), "set_state": lambda x: self._on_reboot_toggle("NNFFLite", x), "icon": "toggle_icons/icon_lateral_tune.png", "color": "#1BA1E2"}, ] - self._scroller = Scroller(items, line_separator=True, spacing=0) + self._rebuild_grid() def _on_reboot_toggle(self, key, state): self._params.put_bool(key, state) from openpilot.selfdrive.ui.ui_state import ui_state - if ui_state.started: - from openpilot.system.ui.lib.application import gui_app - - def _confirm_reboot(res): - gui_app.set_modal_overlay(None) - if res == DialogResult.CONFIRM: - from openpilot.system.hardware import HARDWARE - HARDWARE.reboot() - - dialog = ConfirmDialog("Reboot required to take effect. Reboot now?", "Reboot", "Cancel", on_close=_confirm_reboot) - gui_app.set_modal_overlay(dialog) + gui_app.set_modal_overlay(ConfirmDialog(tr("Reboot required. Reboot now?"), tr("Reboot"), tr("Cancel"), on_close=lambda res: HARDWARE.reboot() if res == DialogResult.CONFIRM else None)) class StarPilotLateralQOLLayout(StarPilotPanel): def __init__(self): super().__init__() - - items = [ - value_button_item( - tr_noop("Pause Steering Below"), - "PauseLateralSpeed", - min_val=0, - max_val=99, - step=1, - unit="mph", - description=tr_noop("Pause steering below the set speed."), - sub_toggles=[("PauseLateralOnSignal", True)], - labels={0: tr_noop("Off")}, - is_metric=True, - ) + self.CATEGORIES = [ + {"title": tr_noop("Pause Steering Below"), "type": "value", "get_value": lambda: f"{self._params.get_int('PauseLateralSpeed')} mph", "on_click": lambda: self._show_speed_selector("PauseLateralSpeed"), "icon": "toggle_icons/icon_quality_of_life.png", "color": "#1BA1E2"} ] + self._rebuild_grid() - self._scroller = Scroller(items, line_separator=True, spacing=0) + def _show_speed_selector(self, key): + def on_close(res, val): + if res == DialogResult.CONFIRM: + self._params.put_int(key, int(val)) + self._rebuild_grid() + gui_app.set_modal_overlay(SliderDialog(tr(key), 0, 100, 1, self._params.get_int(key), on_close, unit=" mph", color="#1BA1E2")) class StarPilotLateralLayout(StarPilotPanel): def __init__(self): super().__init__() - self._sub_panels = { "advanced_lateral": StarPilotAdvancedLateralLayout(), "always_on_lateral": StarPilotAlwaysOnLateralLayout(), @@ -327,54 +128,14 @@ class StarPilotLateralLayout(StarPilotPanel): "lateral_tune": StarPilotLateralTuneLayout(), "qol": StarPilotLateralQOLLayout(), } - - for name, panel in self._sub_panels.items(): - if hasattr(panel, 'set_navigate_callback'): - panel.set_navigate_callback(self._navigate_to) - if hasattr(panel, 'set_back_callback'): - panel.set_back_callback(self._go_back) - - items = [ - button_item( - tr_noop("Advanced Lateral Tuning"), - lambda: tr("MANAGE"), - tr_noop("Advanced steering control changes to fine-tune how openpilot drives."), - callback=lambda: self._navigate_to("advanced_lateral"), - icon="toggle_icons/icon_advanced_lateral_tune.png", - starpilot_icon=True, - ), - button_item( - tr_noop("Always On Lateral"), - lambda: tr("MANAGE"), - tr_noop("openpilot's steering remains active even when the accelerator or brake pedals are pressed."), - callback=lambda: self._navigate_to("always_on_lateral"), - icon="toggle_icons/icon_always_on_lateral.png", - starpilot_icon=True, - ), - button_item( - tr_noop("Lane Changes"), - lambda: tr("MANAGE"), - tr_noop("Allow openpilot to change lanes."), - callback=lambda: self._navigate_to("lane_changes"), - icon="toggle_icons/icon_lane.png", - starpilot_icon=True, - ), - button_item( - tr_noop("Lateral Tuning"), - lambda: tr("MANAGE"), - tr_noop("Miscellaneous steering control changes to fine-tune how openpilot drives."), - callback=lambda: self._navigate_to("lateral_tune"), - icon="toggle_icons/icon_lateral_tune.png", - starpilot_icon=True, - ), - button_item( - tr_noop("Quality of Life"), - lambda: tr("MANAGE"), - tr_noop("Steering control changes to fine-tune how openpilot drives."), - callback=lambda: self._navigate_to("qol"), - icon="toggle_icons/icon_quality_of_life.png", - starpilot_icon=True, - ), + self.CATEGORIES = [ + {"title": tr_noop("Advanced Lateral Tuning"), "panel": "advanced_lateral", "icon": "toggle_icons/icon_advanced_lateral_tune.png", "color": "#1BA1E2"}, + {"title": tr_noop("Always On Lateral"), "panel": "always_on_lateral", "icon": "toggle_icons/icon_always_on_lateral.png", "color": "#1BA1E2"}, + {"title": tr_noop("Lane Changes"), "panel": "lane_changes", "icon": "toggle_icons/icon_lane.png", "color": "#1BA1E2"}, + {"title": tr_noop("Lateral Tuning"), "panel": "lateral_tune", "icon": "toggle_icons/icon_lateral_tune.png", "color": "#1BA1E2"}, + {"title": tr_noop("Quality of Life"), "panel": "qol", "icon": "toggle_icons/icon_quality_of_life.png", "color": "#1BA1E2"}, ] - - self._scroller = Scroller(items, line_separator=True, spacing=0) + for name, panel in self._sub_panels.items(): + if hasattr(panel, 'set_navigate_callback'): panel.set_navigate_callback(self._navigate_to) + if hasattr(panel, 'set_back_callback'): panel.set_back_callback(self._go_back) + self._rebuild_grid() diff --git a/selfdrive/ui/layouts/settings/starpilot/longitudinal.py b/selfdrive/ui/layouts/settings/starpilot/longitudinal.py index 53b10e148..bf471c9f4 100644 --- a/selfdrive/ui/layouts/settings/starpilot/longitudinal.py +++ b/selfdrive/ui/layouts/settings/starpilot/longitudinal.py @@ -1,368 +1,831 @@ from __future__ import annotations - +from openpilot.selfdrive.ui.lib.starpilot_state import starpilot_state +from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.lib.multilang import tr, tr_noop -from openpilot.system.ui.widgets.list_view import button_item, value_item -from openpilot.system.ui.widgets.scroller_tici import Scroller +from openpilot.system.ui.widgets import DialogResult +from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog +from openpilot.system.ui.widgets.selection_dialog import SelectionDialog +from openpilot.system.ui.widgets.input_dialog import InputDialog from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import StarPilotPanel +from openpilot.selfdrive.ui.layouts.settings.starpilot.metro import SliderDialog + class StarPilotLongitudinalLayout(StarPilotPanel): def __init__(self): super().__init__() - - # Main panel items - items = [ - button_item( - tr_noop("Advanced Longitudinal Tuning"), - lambda: tr("MANAGE"), - tr_noop("Advanced acceleration and braking control changes to fine-tune how openpilot drives."), - ), - button_item( - tr_noop("Conditional Experimental Mode"), - lambda: tr("MANAGE"), - tr_noop("Automatically switch to Experimental Mode when set conditions are met."), - ), - button_item( - tr_noop("Curve Speed Controller"), - lambda: tr("MANAGE"), - tr_noop("Automatically slow down for upcoming curves using data learned from your driving style."), - ), - button_item( - tr_noop("Driving Personalities"), - lambda: tr("MANAGE"), - tr_noop("Customize the Driving Personalities to better match your driving style."), - ), - button_item( - tr_noop("Longitudinal Tuning"), - lambda: tr("MANAGE"), - tr_noop("Acceleration and braking control changes to fine-tune how openpilot drives."), - ), - button_item( - tr_noop("Quality of Life"), - lambda: tr("MANAGE"), - tr_noop("Miscellaneous acceleration and braking control changes to fine-tune how openpilot drives."), - ), - button_item( - tr_noop("Weather"), - lambda: tr("MANAGE"), - tr_noop("Adjust driving behavior based on weather conditions."), - callback=lambda: self._navigate_to("weather"), - ), - ] - - self._scroller = Scroller(items, line_separator=True, spacing=0) - - # Sub-panels self._sub_panels = { + "advanced": StarPilotAdvancedLongitudinalLayout(), + "conditional": StarPilotConditionalExperimentalLayout(), + "curve": StarPilotCurveSpeedLayout(), + "personalities": StarPilotPersonalitiesLayout(), + "tuning": StarPilotLongitudinalTuneLayout(), + "qol": StarPilotLongitudinalQOLLayout(), + "slc": StarPilotSpeedLimitControllerLayout(), "weather": StarPilotWeatherLayout(), - "low_visibility": StarPilotLowVisibilityLayout(), - "rain": StarPilotRainLayout(), - "rainstorm": StarPilotRainStormLayout(), - "snow": StarPilotSnowLayout(), + # Personality Sub-panels + "traffic_personality": StarPilotPersonalityProfileLayout("Traffic"), + "aggressive_personality": StarPilotPersonalityProfileLayout("Aggressive"), + "standard_personality": StarPilotPersonalityProfileLayout("Standard"), + "relaxed_personality": StarPilotPersonalityProfileLayout("Relaxed"), + # SLC Sub-panels + "slc_offsets": StarPilotSLCOffsetsLayout(), + "slc_qol": StarPilotSLCQOLLayout(), + "slc_visuals": StarPilotSLCVisualsLayout(), + # Weather Sub-panels + "low_visibility": StarPilotWeatherBase("LowVisibility"), + "rain": StarPilotWeatherBase("Rain"), + "rainstorm": StarPilotWeatherBase("RainStorm"), + "snow": StarPilotWeatherBase("Snow"), } - # Wire up navigation callbacks for sub-panels + self.CATEGORIES = [ + {"title": tr_noop("Advanced Longitudinal Tuning"), "panel": "advanced", "icon": "toggle_icons/icon_advanced_longitudinal_tune.png", "color": "#1BA1E2"}, + {"title": tr_noop("Conditional Experimental Mode"), "panel": "conditional", "icon": "toggle_icons/icon_conditional.png", "color": "#1BA1E2"}, + {"title": tr_noop("Curve Speed Controller"), "panel": "curve", "icon": "toggle_icons/icon_speed_map.png", "color": "#1BA1E2"}, + {"title": tr_noop("Driving Personalities"), "panel": "personalities", "icon": "toggle_icons/icon_personality.png", "color": "#1BA1E2"}, + {"title": tr_noop("Longitudinal Tuning"), "panel": "tuning", "icon": "toggle_icons/icon_longitudinal_tune.png", "color": "#1BA1E2"}, + {"title": tr_noop("Quality of Life"), "panel": "qol", "icon": "toggle_icons/icon_quality_of_life.png", "color": "#1BA1E2"}, + {"title": tr_noop("Speed Limit Controller"), "panel": "slc", "icon": "toggle_icons/icon_speed_limit.png", "color": "#1BA1E2"}, + {"title": tr_noop("Weather"), "panel": "weather", "icon": "toggle_icons/icon_rainbow.png", "color": "#1BA1E2"}, + ] + for name, panel in self._sub_panels.items(): if hasattr(panel, 'set_navigate_callback'): panel.set_navigate_callback(self._navigate_to) if hasattr(panel, 'set_back_callback'): panel.set_back_callback(self._go_back) + self._rebuild_grid() + + +class StarPilotAdvancedLongitudinalLayout(StarPilotPanel): + def __init__(self): + super().__init__() + self.CATEGORIES = [ + { + "title": tr_noop("EV Tuning"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("EVTuning"), + "set_state": lambda s: self._params.put_bool("EVTuning", s), + "color": "#1BA1E2", + }, + { + "title": tr_noop("Truck Tuning"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("TruckTuning"), + "set_state": lambda s: self._params.put_bool("TruckTuning", s), + "color": "#1BA1E2", + }, + { + "title": tr_noop("Actuator Delay"), + "type": "value", + "get_value": lambda: f"{self._params.get_float('LongitudinalActuatorDelay'):.2f}s", + "on_click": lambda: self._show_float_selector("LongitudinalActuatorDelay", 0.0, 1.0, 0.01, "s"), + "color": "#1BA1E2", + }, + { + "title": tr_noop("Max Acceleration"), + "type": "value", + "get_value": lambda: f"{self._params.get_float('MaxDesiredAcceleration'):.1f}m/s²", + "on_click": lambda: self._show_float_selector("MaxDesiredAcceleration", 0.1, 4.0, 0.1, "m/s²"), + "color": "#1BA1E2", + }, + { + "title": tr_noop("Start Accel"), + "type": "value", + "get_value": lambda: f"{self._params.get_float('StartAccel'):.2f}m/s²", + "on_click": lambda: self._show_float_selector("StartAccel", 0.0, 4.0, 0.01, "m/s²"), + "color": "#1BA1E2", + }, + { + "title": tr_noop("Stop Accel"), + "type": "value", + "get_value": lambda: f"{self._params.get_float('StopAccel'):.2f}m/s²", + "on_click": lambda: self._show_float_selector("StopAccel", -4.0, 0.0, 0.01, "m/s²"), + "color": "#1BA1E2", + }, + { + "title": tr_noop("Stopping Rate"), + "type": "value", + "get_value": lambda: f"{self._params.get_float('StoppingDecelRate'):.3f}m/s²", + "on_click": lambda: self._show_float_selector("StoppingDecelRate", 0.001, 1.0, 0.001, "m/s²"), + "color": "#1BA1E2", + }, + { + "title": tr_noop("VEgo Starting"), + "type": "value", + "get_value": lambda: f"{self._params.get_float('VEgoStarting'):.2f}m/s", + "on_click": lambda: self._show_float_selector("VEgoStarting", 0.01, 1.0, 0.01, "m/s"), + "color": "#1BA1E2", + }, + { + "title": tr_noop("VEgo Stopping"), + "type": "value", + "get_value": lambda: f"{self._params.get_float('VEgoStopping'):.2f}m/s", + "on_click": lambda: self._show_float_selector("VEgoStopping", 0.01, 1.0, 0.01, "m/s"), + "color": "#1BA1E2", + }, + ] + self._rebuild_grid() + + def _show_float_selector(self, key, min_v, max_v, step, unit=""): + def on_close(res, val): + if res == DialogResult.CONFIRM: + self._params.put_float(key, float(val)) + self._rebuild_grid() + + gui_app.set_modal_overlay(SliderDialog(tr(key), min_v, max_v, step, self._params.get_float(key), on_close, unit=unit, color="#1BA1E2")) + + +class StarPilotConditionalExperimentalLayout(StarPilotPanel): + def __init__(self): + super().__init__() + self.CATEGORIES = [ + { + "title": tr_noop("Conditional Experimental"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("ConditionalExperimental"), + "set_state": lambda s: self._params.put_bool("ConditionalExperimental", s), + "icon": "toggle_icons/icon_conditional.png", + "color": "#1BA1E2", + }, + { + "title": tr_noop("Below Speed"), + "type": "value", + "get_value": lambda: f"{self._params.get_int('CESpeed')} mph", + "on_click": lambda: self._show_speed_selector("CESpeed"), + "color": "#1BA1E2", + }, + { + "title": tr_noop("Curves"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("CECurves"), + "set_state": lambda s: self._params.put_bool("CECurves", s), + "color": "#1BA1E2", + }, + { + "title": tr_noop("Curves Lead"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("CECurvesLead"), + "set_state": lambda s: self._params.put_bool("CECurvesLead", s), + "visible": lambda: self._params.get_bool("CECurves"), + "color": "#1BA1E2", + }, + { + "title": tr_noop("Stop Lights"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("CEStopLights"), + "set_state": lambda s: self._params.put_bool("CEStopLights", s), + "color": "#1BA1E2", + }, + { + "title": tr_noop("Lead Detected"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("CELead"), + "set_state": lambda s: self._params.put_bool("CELead", s), + "color": "#1BA1E2", + }, + { + "title": tr_noop("Slower Lead"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("CESlowerLead"), + "set_state": lambda s: self._params.put_bool("CESlowerLead", s), + "color": "#1BA1E2", + }, + { + "title": tr_noop("Stopped Lead"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("CEStoppedLead"), + "set_state": lambda s: self._params.put_bool("CEStoppedLead", s), + "color": "#1BA1E2", + }, + { + "title": tr_noop("Predicted Stop"), + "type": "value", + "get_value": lambda: f"{self._params.get_int('CEModelStopTime')}s", + "on_click": lambda: self._show_int_selector("CEModelStopTime", 0, 10, "s"), + "color": "#1BA1E2", + }, + { + "title": tr_noop("Signal Below"), + "type": "value", + "get_value": lambda: f"{self._params.get_int('CESignalSpeed')} mph", + "on_click": lambda: self._show_speed_selector("CESignalSpeed"), + "color": "#1BA1E2", + }, + { + "title": tr_noop("Speed Lead"), + "type": "value", + "get_value": lambda: f"{self._params.get_int('CESpeedLead')} mph", + "on_click": lambda: self._show_speed_selector("CESpeedLead"), + "color": "#1BA1E2", + }, + { + "title": tr_noop("Signal Lane Detection"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("CESignalLaneDetection"), + "set_state": lambda s: self._params.put_bool("CESignalLaneDetection", s), + "visible": lambda: self._params.get_int("CESignalSpeed") > 0, + "color": "#1BA1E2", + }, + { + "title": tr_noop("Status Widget"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("ShowCEMStatus"), + "set_state": lambda s: self._params.put_bool("ShowCEMStatus", s), + "color": "#1BA1E2", + }, + ] + self._rebuild_grid() + + def _show_speed_selector(self, key): + def on_close(res, val): + if res == DialogResult.CONFIRM: + self._params.put_int(key, int(val)) + self._rebuild_grid() + + gui_app.set_modal_overlay(SliderDialog(tr(key), 0, 100, 1, self._params.get_int(key), on_close, unit=" mph", color="#1BA1E2")) + + def _show_int_selector(self, key, min_v, max_v, unit=""): + def on_close(res, val): + if res == DialogResult.CONFIRM: + self._params.put_int(key, int(val)) + self._rebuild_grid() + + gui_app.set_modal_overlay(SliderDialog(tr(key), min_v, max_v, 1, self._params.get_int(key), on_close, unit=unit, color="#1BA1E2")) + + +class StarPilotCurveSpeedLayout(StarPilotPanel): + def __init__(self): + super().__init__() + self.CATEGORIES = [ + { + "title": tr_noop("Curve Speed Controller"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("CurveSpeedController"), + "set_state": lambda s: self._params.put_bool("CurveSpeedController", s), + "icon": "toggle_icons/icon_speed_map.png", + "color": "#1BA1E2", + }, + { + "title": tr_noop("Status Widget"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("ShowCSCStatus"), + "set_state": lambda s: self._params.put_bool("ShowCSCStatus", s), + "color": "#1BA1E2", + }, + { + "title": tr_noop("Calibrated Lateral Accel"), + "type": "value", + "get_value": lambda: f"{self._params_memory.get_float('CalibratedLateralAcceleration'):.2f} m/s²", + "on_click": lambda: None, + "color": "#1BA1E2", + }, + { + "title": tr_noop("Calibration Progress"), + "type": "value", + "get_value": lambda: f"{self._params_memory.get_float('CalibrationProgress'):.2f}%", + "on_click": lambda: None, + "color": "#1BA1E2", + }, + { + "title": tr_noop("Reset Curve Data"), + "type": "hub", + "on_click": lambda: self._reset_curve_data(), + "color": "#1BA1E2", + }, + ] + self._rebuild_grid() + + def _reset_curve_data(self): + def on_close(res): + if res == DialogResult.CONFIRM: + self._params.remove("CalibratedLateralAcceleration") + self._params.remove("CalibrationProgress") + self._rebuild_grid() + + gui_app.set_modal_overlay(ConfirmDialog(tr("Reset Curve Data?"), tr("Confirm"), on_close=on_close)) + + +class StarPilotPersonalitiesLayout(StarPilotPanel): + def __init__(self): + super().__init__() + self.CATEGORIES = [ + {"title": tr_noop("Traffic"), "panel": "traffic_personality", "icon": "toggle_icons/icon_personality.png", "color": "#1BA1E2"}, + {"title": tr_noop("Aggressive"), "panel": "aggressive_personality", "icon": "toggle_icons/icon_personality.png", "color": "#1BA1E2"}, + {"title": tr_noop("Standard"), "panel": "standard_personality", "icon": "toggle_icons/icon_personality.png", "color": "#1BA1E2"}, + {"title": tr_noop("Relaxed"), "panel": "relaxed_personality", "icon": "toggle_icons/icon_personality.png", "color": "#1BA1E2"}, + ] + self._rebuild_grid() + + +class StarPilotPersonalityProfileLayout(StarPilotPanel): + def __init__(self, profile: str): + super().__init__() + self._profile = profile + follow_min = 1.0 if profile == "Traffic" else 0.5 + follow_max = 2.5 if profile == "Traffic" else 3.0 + self.CATEGORIES = [ + { + "title": tr_noop("Follow Distance"), + "type": "value", + "get_value": lambda: f"{self._params.get_float(self._profile + 'Follow'):.2f}s", + "on_click": lambda: self._show_float_selector(self._profile + "Follow", follow_min, follow_max, 0.05, "s"), + "color": "#1BA1E2", + }, + { + "title": tr_noop("Follow High"), + "type": "value", + "get_value": lambda: f"{self._params.get_float(self._profile + 'FollowHigh'):.2f}s", + "on_click": lambda: self._show_float_selector(self._profile + "FollowHigh", 1.0, 3.0, 0.05, "s"), + "visible": lambda: self._profile != "Traffic", + "color": "#1BA1E2", + }, + { + "title": tr_noop("Accel Smoothness"), + "type": "value", + "get_value": lambda: f"{self._params.get_int(self._profile + 'JerkAcceleration')}%", + "on_click": lambda: self._show_int_selector(self._profile + "JerkAcceleration", 25, 200, "%"), + "color": "#1BA1E2", + }, + { + "title": tr_noop("Brake Smoothness"), + "type": "value", + "get_value": lambda: f"{self._params.get_int(self._profile + 'JerkDeceleration')}%", + "on_click": lambda: self._show_int_selector(self._profile + "JerkDeceleration", 25, 200, "%"), + "color": "#1BA1E2", + }, + { + "title": tr_noop("Safety Gap Bias"), + "type": "value", + "get_value": lambda: f"{self._params.get_int(self._profile + 'JerkDanger')}%", + "on_click": lambda: self._show_int_selector(self._profile + "JerkDanger", 25, 200, "%"), + "color": "#1BA1E2", + }, + { + "title": tr_noop("Slowdown Response"), + "type": "value", + "get_value": lambda: f"{self._params.get_int(self._profile + 'JerkSpeedDecrease')}%", + "on_click": lambda: self._show_int_selector(self._profile + "JerkSpeedDecrease", 25, 200, "%"), + "color": "#1BA1E2", + }, + { + "title": tr_noop("Speed-Up Response"), + "type": "value", + "get_value": lambda: f"{self._params.get_int(self._profile + 'JerkSpeed')}%", + "on_click": lambda: self._show_int_selector(self._profile + "JerkSpeed", 25, 200, "%"), + "color": "#1BA1E2", + }, + { + "title": tr_noop("Reset to Defaults"), + "type": "hub", + "on_click": lambda: self._reset_profile(), + "color": "#1BA1E2", + }, + ] + self._rebuild_grid() + + def _reset_profile(self): + def on_close(res): + if res == DialogResult.CONFIRM: + for key in ["Follow", "FollowHigh", "JerkAcceleration", "JerkDeceleration", "JerkDanger", "JerkSpeedDecrease", "JerkSpeed"]: + self._params.remove(self._profile + key) + self._rebuild_grid() + + gui_app.set_modal_overlay(ConfirmDialog(tr("Reset to Defaults?"), tr("Confirm"), on_close=on_close)) + + def _show_float_selector(self, key, min_v, max_v, step, unit=""): + def on_close(res, val): + if res == DialogResult.CONFIRM: + self._params.put_float(key, float(val)) + self._rebuild_grid() + + gui_app.set_modal_overlay(SliderDialog(tr(key), min_v, max_v, step, self._params.get_float(key), on_close, unit=unit, color="#1BA1E2")) + + def _show_int_selector(self, key, min_v, max_v, unit=""): + def on_close(res, val): + if res == DialogResult.CONFIRM: + self._params.put_int(key, int(val)) + self._rebuild_grid() + + gui_app.set_modal_overlay(SliderDialog(tr(key), min_v, max_v, 5, self._params.get_int(key), on_close, unit=unit, color="#1BA1E2")) + + +class StarPilotLongitudinalTuneLayout(StarPilotPanel): + def __init__(self): + super().__init__() + self.CATEGORIES = [ + { + "title": tr_noop("Acceleration Profile"), + "type": "value", + "get_value": lambda: self._params.get("AccelerationProfile", encoding='utf-8') or "Standard", + "on_click": lambda: self._show_selection("AccelerationProfile", ["Standard", "Eco", "Sport", "Sport+"]), + "color": "#1BA1E2", + }, + { + "title": tr_noop("Deceleration Profile"), + "type": "value", + "get_value": lambda: self._params.get("DecelerationProfile", encoding='utf-8') or "Standard", + "on_click": lambda: self._show_selection("DecelerationProfile", ["Standard", "Eco", "Sport"]), + "color": "#1BA1E2", + }, + { + "title": tr_noop("Human Acceleration"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("HumanAcceleration"), + "set_state": lambda s: self._params.put_bool("HumanAcceleration", s), + "color": "#1BA1E2", + }, + { + "title": tr_noop("Human Following"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("HumanFollowing"), + "set_state": lambda s: self._params.put_bool("HumanFollowing", s), + "color": "#1BA1E2", + }, + { + "title": tr_noop("Human Lane Changes"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("HumanLaneChanges"), + "set_state": lambda s: self._params.put_bool("HumanLaneChanges", s), + "color": "#1BA1E2", + }, + { + "title": tr_noop("Lead Detection"), + "type": "value", + "get_value": lambda: f"{self._params.get_int('LeadDetectionThreshold')}%", + "on_click": lambda: self._show_int_selector("LeadDetectionThreshold", 25, 50, "%"), + "color": "#1BA1E2", + }, + { + "title": tr_noop("Taco Tune"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("TacoTune"), + "set_state": lambda s: self._params.put_bool("TacoTune", s), + "color": "#1BA1E2", + }, + ] + self._rebuild_grid() + + def _show_selection(self, key, options): + def on_select(res, val): + if res == DialogResult.CONFIRM: + self._params.put(key, val) + self._rebuild_grid() + + gui_app.set_modal_overlay(SelectionDialog(tr(key), options, self._params.get(key, encoding='utf-8') or "Standard", on_close=on_select)) + + def _show_int_selector(self, key, min_v, max_v, unit=""): + def on_close(res, val): + if res == DialogResult.CONFIRM: + self._params.put_int(key, int(val)) + self._rebuild_grid() + + gui_app.set_modal_overlay(SliderDialog(tr(key), min_v, max_v, 1, self._params.get_int(key), on_close, unit=unit, color="#1BA1E2")) + + +class StarPilotLongitudinalQOLLayout(StarPilotPanel): + def __init__(self): + super().__init__() + self.CATEGORIES = [ + { + "title": tr_noop("Cruise Interval"), + "type": "value", + "get_value": lambda: f"{self._params.get_int('CustomCruise')} mph", + "on_click": lambda: self._show_speed_selector("CustomCruise"), + "color": "#1BA1E2", + }, + { + "title": tr_noop("Cruise Long"), + "type": "value", + "get_value": lambda: f"{self._params.get_int('CustomCruiseLong')} mph", + "on_click": lambda: self._show_speed_selector("CustomCruiseLong"), + "color": "#1BA1E2", + }, + { + "title": tr_noop("Reverse Cruise"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("ReverseCruise"), + "set_state": lambda s: self._params.put_bool("ReverseCruise", s), + "color": "#1BA1E2", + }, + { + "title": tr_noop("Force Stops"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("ForceStops"), + "set_state": lambda s: self._params.put_bool("ForceStops", s), + "color": "#1BA1E2", + }, + { + "title": tr_noop("Stopped Distance"), + "type": "value", + "get_value": lambda: f"{self._params.get_int('IncreasedStoppedDistance')} ft", + "on_click": lambda: self._show_int_selector("IncreasedStoppedDistance", 0, 10, " ft"), + "color": "#1BA1E2", + }, + { + "title": tr_noop("Set Speed Offset"), + "type": "value", + "get_value": lambda: f"+{self._params.get_int('SetSpeedOffset')} mph", + "on_click": lambda: self._show_int_selector("SetSpeedOffset", 0, 99, " mph"), + "color": "#1BA1E2", + }, + { + "title": tr_noop("Map Gears"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("MapGears"), + "set_state": lambda s: self._params.put_bool("MapGears", s), + "color": "#1BA1E2", + }, + { + "title": tr_noop("Map Acceleration"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("MapAcceleration"), + "set_state": lambda s: self._params.put_bool("MapAcceleration", s), + "visible": lambda: self._params.get_bool("MapGears"), + "color": "#1BA1E2", + }, + { + "title": tr_noop("Map Deceleration"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("MapDeceleration"), + "set_state": lambda s: self._params.put_bool("MapDeceleration", s), + "visible": lambda: self._params.get_bool("MapGears"), + "color": "#1BA1E2", + }, + ] + self._rebuild_grid() + + def _show_speed_selector(self, key): + def on_close(res, val): + if res == DialogResult.CONFIRM: + self._params.put_int(key, int(val)) + self._rebuild_grid() + + gui_app.set_modal_overlay(SliderDialog(tr(key), 0, 100, 1, self._params.get_int(key), on_close, unit=" mph", color="#1BA1E2")) + + def _show_int_selector(self, key, min_v, max_v, unit=""): + def on_close(res, val): + if res == DialogResult.CONFIRM: + self._params.put_int(key, int(val)) + self._rebuild_grid() + + gui_app.set_modal_overlay(SliderDialog(tr(key), min_v, max_v, 1, self._params.get_int(key), on_close, unit=unit, color="#1BA1E2")) + + +class StarPilotSpeedLimitControllerLayout(StarPilotPanel): + def __init__(self): + super().__init__() + self.CATEGORIES = [ + {"title": tr_noop("SLC Offsets"), "panel": "slc_offsets", "icon": "toggle_icons/icon_speed_limit.png", "color": "#1BA1E2"}, + {"title": tr_noop("SLC Quality of Life"), "panel": "slc_qol", "icon": "toggle_icons/icon_speed_limit.png", "color": "#1BA1E2"}, + {"title": tr_noop("SLC Visuals"), "panel": "slc_visuals", "icon": "toggle_icons/icon_speed_limit.png", "color": "#1BA1E2"}, + { + "title": tr_noop("Fallback Speed"), + "type": "value", + "get_value": lambda: self._params.get("SLCFallback", encoding='utf-8') or "Set Speed", + "on_click": lambda: self._show_selection("SLCFallback", ["Set Speed", "Experimental Mode", "Previous Limit"]), + "color": "#1BA1E2", + }, + { + "title": tr_noop("Override Speed"), + "type": "value", + "get_value": lambda: self._params.get("SLCOverride", encoding='utf-8') or "None", + "on_click": lambda: self._show_selection("SLCOverride", ["None", "Set With Gas Pedal", "Max Set Speed"]), + "color": "#1BA1E2", + }, + { + "title": tr_noop("Source Priority"), + "type": "value", + "get_value": lambda: self._params.get("SLCPriority1", encoding='utf-8') or "Dashboard", + "on_click": self._on_priority_clicked, + "color": "#1BA1E2", + }, + ] + self._rebuild_grid() + + def _on_priority_clicked(self): + options = ["Dashboard", "Map Data", "Highest", "Lowest"] + + def on_select(res, val): + if res == DialogResult.CONFIRM: + self._params.put("SLCPriority1", val) + self._rebuild_grid() + + gui_app.set_modal_overlay( + SelectionDialog(tr("SLC Priority"), options, self._params.get("SLCPriority1", encoding='utf-8') or "Dashboard", on_close=on_select) + ) + + def _show_selection(self, key, options): + def on_select(res, val): + if res == DialogResult.CONFIRM: + self._params.put(key, val) + self._rebuild_grid() + + gui_app.set_modal_overlay(SelectionDialog(tr(key), options, self._params.get(key, encoding='utf-8') or "None", on_close=on_select)) + + +class StarPilotSLCOffsetsLayout(StarPilotPanel): + def __init__(self): + super().__init__() + self.CATEGORIES = [] + for i in range(1, 8): + key = f"Offset{i}" + self.CATEGORIES.append( + { + "title": tr_noop(f"Offset {i}"), + "type": "value", + "get_value": lambda k=key: f"{self._params.get_int(k)} mph", + "on_click": lambda k=key: self._show_speed_selector(k), + "color": "#1BA1E2", + } + ) + self._rebuild_grid() + + def _show_speed_selector(self, key): + def on_close(res, val): + if res == DialogResult.CONFIRM: + self._params.put_int(key, int(val)) + self._rebuild_grid() + + gui_app.set_modal_overlay(SliderDialog(tr(key), -99, 100, 1, self._params.get_int(key), on_close, unit=" mph", color="#1BA1E2")) + + +class StarPilotSLCQOLLayout(StarPilotPanel): + def __init__(self): + super().__init__() + self.CATEGORIES = [ + { + "title": tr_noop("Match Speed on Engage"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("SetSpeedLimit"), + "set_state": lambda s: self._params.put_bool("SetSpeedLimit", s), + "color": "#1BA1E2", + }, + { + "title": tr_noop("Confirm New Limits"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("SLCConfirmation"), + "set_state": lambda s: self._params.put_bool("SLCConfirmation", s), + "color": "#1BA1E2", + }, + { + "title": tr_noop("Confirm Lower"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("SLCConfirmationLower"), + "set_state": lambda s: self._params.put_bool("SLCConfirmationLower", s), + "visible": lambda: self._params.get_bool("SLCConfirmation"), + "color": "#1BA1E2", + }, + { + "title": tr_noop("Confirm Higher"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("SLCConfirmationHigher"), + "set_state": lambda s: self._params.put_bool("SLCConfirmationHigher", s), + "visible": lambda: self._params.get_bool("SLCConfirmation"), + "color": "#1BA1E2", + }, + { + "title": tr_noop("Higher Lookahead"), + "type": "value", + "get_value": lambda: f"{self._params.get_int('SLCLookaheadHigher')}s", + "on_click": lambda: self._show_int_selector("SLCLookaheadHigher", 0, 30, "s"), + "color": "#1BA1E2", + }, + { + "title": tr_noop("Lower Lookahead"), + "type": "value", + "get_value": lambda: f"{self._params.get_int('SLCLookaheadLower')}s", + "on_click": lambda: self._show_int_selector("SLCLookaheadLower", 0, 30, "s"), + "color": "#1BA1E2", + }, + { + "title": tr_noop("Mapbox Fallback"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("SLCMapboxFiller"), + "set_state": lambda s: self._params.put_bool("SLCMapboxFiller", s), + "color": "#1BA1E2", + }, + ] + self._rebuild_grid() + + def _show_int_selector(self, key, min_v, max_v, unit=""): + def on_close(res, val): + if res == DialogResult.CONFIRM: + self._params.put_int(key, int(val)) + self._rebuild_grid() + + gui_app.set_modal_overlay(SliderDialog(tr(key), min_v, max_v, 1, self._params.get_int(key), on_close, unit=unit, color="#1BA1E2")) + + +class StarPilotSLCVisualsLayout(StarPilotPanel): + def __init__(self): + super().__init__() + self.CATEGORIES = [ + { + "title": tr_noop("Show SLC Offset"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("ShowSLCOffset"), + "set_state": lambda s: self._params.put_bool("ShowSLCOffset", s), + "color": "#1BA1E2", + }, + { + "title": tr_noop("Show Sources"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("SpeedLimitSources"), + "set_state": lambda s: self._params.put_bool("SpeedLimitSources", s), + "color": "#1BA1E2", + }, + ] + self._rebuild_grid() + class StarPilotWeatherLayout(StarPilotPanel): def __init__(self): super().__init__() - - items = [ - button_item( - tr_noop("Low Visibility"), - lambda: tr("MANAGE"), - tr_noop("Driving adjustments for fog, haze, or other low-visibility conditions."), - callback=lambda: self._navigate("low_visibility"), - ), - button_item( - tr_noop("Rain"), - lambda: tr("MANAGE"), - tr_noop("Driving adjustments for rainy conditions."), - callback=lambda: self._navigate("rain"), - ), - button_item( - tr_noop("Rainstorms"), - lambda: tr("MANAGE"), - tr_noop("Driving adjustments for rainstorms."), - callback=lambda: self._navigate("rainstorm"), - ), - button_item( - tr_noop("Snow"), - lambda: tr("MANAGE"), - tr_noop("Driving adjustments for snowy conditions."), - callback=lambda: self._navigate("snow"), - ), + self.CATEGORIES = [ + {"title": tr_noop("Low Visibility"), "panel": "low_visibility", "icon": "toggle_icons/icon_rainbow.png", "color": "#1BA1E2"}, + {"title": tr_noop("Rain"), "panel": "rain", "icon": "toggle_icons/icon_rainbow.png", "color": "#1BA1E2"}, + {"title": tr_noop("Rainstorms"), "panel": "rainstorm", "icon": "toggle_icons/icon_rainbow.png", "color": "#1BA1E2"}, + {"title": tr_noop("Snow"), "panel": "snow", "icon": "toggle_icons/icon_rainbow.png", "color": "#1BA1E2"}, + { + "title": tr_noop("Set Weather Key"), + "type": "hub", + "on_click": lambda: self._set_weather_key(), + "color": "#1BA1E2", + }, ] + self._rebuild_grid() - self._scroller = Scroller(items, line_separator=True, spacing=0) + def _set_weather_key(self): + options = ["ADD", "REMOVE"] - def _navigate(self, sub_panel: str): - if self._navigate_callback: - self._navigate_callback(sub_panel) + def on_select(res, val): + if res == DialogResult.CONFIRM: + if val == "ADD": -class StarPilotLowVisibilityLayout(StarPilotPanel): - def __init__(self): + def on_key(res, text): + if res == DialogResult.CONFIRM: + self._params.put("WeatherAPIKey", text) + self._rebuild_grid() + + gui_app.set_modal_overlay(InputDialog(tr("Weather API Key"), on_close=on_key)) + elif val == "REMOVE": + + def on_confirm(res): + if res == DialogResult.CONFIRM: + self._params.remove("WeatherAPIKey") + self._rebuild_grid() + + gui_app.set_modal_overlay(ConfirmDialog(tr("Remove API Key?"), tr("Confirm"), on_close=on_confirm)) + + gui_app.set_modal_overlay(SelectionDialog(tr("Weather API Key"), options, "ADD", on_close=on_select)) + + +class StarPilotWeatherBase(StarPilotPanel): + def __init__(self, suffix: str): super().__init__() - - def save_following(value: float): - self._params.put_int("IncreaseFollowingLowVisibility", int(value)) - - def save_stopped_distance(value: float): - self._params.put_int("IncreasedStoppedDistanceLowVisibility", int(value)) - - def save_reduce_accel(value: float): - self._params.put_int("ReduceAccelerationLowVisibility", int(value)) - - def save_reduce_lateral(value: float): - self._params.put_int("ReduceLateralAccelerationLowVisibility", int(value)) - - items = [ - value_item( - tr_noop("Increase Following Distance by:"), - lambda: self._params.get_int("IncreaseFollowingLowVisibility", return_default=True, default="0"), - min_val=0, - max_val=3, - step=0.5, - unit=" seconds", - description=tr_noop("Add extra space behind lead vehicles in low visibility. Increase for more space; decrease for tighter gaps."), - callback=save_following, - ), - value_item( - tr_noop("Increase Stopped Distance by:"), - lambda: self._params.get_int("IncreasedStoppedDistanceLowVisibility", return_default=True, default="0"), - min_val=0, - max_val=10, - step=1, - unit=" feet", - description=tr_noop("Add extra buffer when stopped behind vehicles in low visibility. Increase for more room; decrease for shorter gaps."), - callback=save_stopped_distance, - ), - value_item( - tr_noop("Reduce Acceleration by:"), - lambda: self._params.get_int("ReduceAccelerationLowVisibility", return_default=True, default="0"), - min_val=0, - max_val=50, - step=5, - unit="%", - description=tr_noop( - "Lower the maximum acceleration in low visibility. Increase for softer takeoffs; decrease for quicker but less stable takeoffs." - ), - callback=save_reduce_accel, - ), - value_item( - tr_noop("Reduce Speed in Curves by:"), - lambda: self._params.get_int("ReduceLateralAccelerationLowVisibility", return_default=True, default="0"), - min_val=0, - max_val=50, - step=5, - unit="%", - description=tr_noop( - "Lower the desired speed while driving through curves in low visibility. Increase for safer, gentler turns; decrease for more aggressive driving in curves." - ), - callback=save_reduce_lateral, - ), + self._suffix = suffix + self.CATEGORIES = [ + { + "title": tr_noop("Following Distance"), + "type": "value", + "get_value": lambda: f"+{self._params.get_int('IncreaseFollowing' + self._suffix)}s", + "on_click": lambda: self._show_value_selector("IncreaseFollowing" + self._suffix, 0, 3, 0.5, "s"), + "icon": "toggle_icons/icon_longitudinal_tune.png", + "color": "#1BA1E2", + }, + { + "title": tr_noop("Stopped Distance"), + "type": "value", + "get_value": lambda: f"+{self._params.get_int('IncreasedStoppedDistance' + self._suffix)} ft", + "on_click": lambda: self._show_value_selector("IncreasedStoppedDistance" + self._suffix, 0, 10, 1, " ft"), + "icon": "toggle_icons/icon_longitudinal_tune.png", + "color": "#1BA1E2", + }, + { + "title": tr_noop("Reduce Accel"), + "type": "value", + "get_value": lambda: f"{self._params.get_int('ReduceAcceleration' + self._suffix)}%", + "on_click": lambda: self._show_value_selector("ReduceAcceleration" + self._suffix, 0, 99, 1, "%"), + "icon": "toggle_icons/icon_longitudinal_tune.png", + "color": "#1BA1E2", + }, + { + "title": tr_noop("Reduce Curve Speed"), + "type": "value", + "get_value": lambda: f"{self._params.get_int('ReduceLateralAcceleration' + self._suffix)}%", + "on_click": lambda: self._show_value_selector("ReduceLateralAcceleration" + self._suffix, 0, 99, 1, "%"), + "icon": "toggle_icons/icon_longitudinal_tune.png", + "color": "#1BA1E2", + }, ] + self._rebuild_grid() - self._scroller = Scroller(items, line_separator=True, spacing=0) + def _show_value_selector(self, key, min_v, max_v, step, unit=""): + def on_close(res, val): + if res == DialogResult.CONFIRM: + self._params.put_int(key, int(float(val))) + self._rebuild_grid() -class StarPilotRainLayout(StarPilotPanel): - def __init__(self): - super().__init__() - - def save_following(value: float): - self._params.put_int("IncreaseFollowingRain", int(value)) - - def save_stopped_distance(value: float): - self._params.put_int("IncreasedStoppedDistanceRain", int(value)) - - def save_reduce_accel(value: float): - self._params.put_int("ReduceAccelerationRain", int(value)) - - def save_reduce_lateral(value: float): - self._params.put_int("ReduceLateralAccelerationRain", int(value)) - - items = [ - value_item( - tr_noop("Increase Following Distance by:"), - lambda: self._params.get_int("IncreaseFollowingRain", return_default=True, default="0"), - min_val=0, - max_val=3, - step=0.5, - unit=" seconds", - description=tr_noop("Add extra space behind lead vehicles in rain. Increase for more space; decrease for tighter gaps."), - callback=save_following, - ), - value_item( - tr_noop("Increase Stopped Distance by:"), - lambda: self._params.get_int("IncreasedStoppedDistanceRain", return_default=True, default="0"), - min_val=0, - max_val=10, - step=1, - unit=" feet", - description=tr_noop("Add extra buffer when stopped behind vehicles in rain. Increase for more room; decrease for shorter gaps."), - callback=save_stopped_distance, - ), - value_item( - tr_noop("Reduce Acceleration by:"), - lambda: self._params.get_int("ReduceAccelerationRain", return_default=True, default="0"), - min_val=0, - max_val=50, - step=5, - unit="%", - description=tr_noop("Lower the maximum acceleration in rain. Increase for softer takeoffs; decrease for quicker but less stable takeoffs."), - callback=save_reduce_accel, - ), - value_item( - tr_noop("Reduce Speed in Curves by:"), - lambda: self._params.get_int("ReduceLateralAccelerationRain", return_default=True, default="0"), - min_val=0, - max_val=50, - step=5, - unit="%", - description=tr_noop( - "Lower the desired speed while driving through curves in rain. Increase for safer, gentler turns; decrease for more aggressive driving in curves." - ), - callback=save_reduce_lateral, - ), - ] - - self._scroller = Scroller(items, line_separator=True, spacing=0) - -class StarPilotRainStormLayout(StarPilotPanel): - def __init__(self): - super().__init__() - - def save_following(value: float): - self._params.put_int("IncreaseFollowingRainStorm", int(value)) - - def save_stopped_distance(value: float): - self._params.put_int("IncreasedStoppedDistanceRainStorm", int(value)) - - def save_reduce_accel(value: float): - self._params.put_int("ReduceAccelerationRainStorm", int(value)) - - def save_reduce_lateral(value: float): - self._params.put_int("ReduceLateralAccelerationRainStorm", int(value)) - - items = [ - value_item( - tr_noop("Increase Following Distance by:"), - lambda: self._params.get_int("IncreaseFollowingRainStorm", return_default=True, default="0"), - min_val=0, - max_val=3, - step=0.5, - unit=" seconds", - description=tr_noop("Add extra space behind lead vehicles in a rainstorm. Increase for more space; decrease for tighter gaps."), - callback=save_following, - ), - value_item( - tr_noop("Increase Stopped Distance by:"), - lambda: self._params.get_int("IncreasedStoppedDistanceRainStorm", return_default=True, default="0"), - min_val=0, - max_val=10, - step=1, - unit=" feet", - description=tr_noop("Add extra buffer when stopped behind vehicles in a rainstorm. Increase for more room; decrease for shorter gaps."), - callback=save_stopped_distance, - ), - value_item( - tr_noop("Reduce Acceleration by:"), - lambda: self._params.get_int("ReduceAccelerationRainStorm", return_default=True, default="0"), - min_val=0, - max_val=10, - step=1, - unit=" feet", - description=tr_noop("Add extra buffer when stopped behind vehicles in rain. Increase for more room; decrease for shorter gaps."), - callback=save_reduce_accel, - ), - value_item( - tr_noop("Reduce Acceleration by:"), - lambda: self._params.get_int("ReduceAccelerationRain", return_default=True, default="0"), - min_val=0, - max_val=50, - step=5, - unit="%", - description=tr_noop("Lower the maximum acceleration in rain. Increase for softer takeoffs; decrease for quicker but less stable takeoffs."), - ), - value_item( - tr_noop("Reduce Speed in Curves by:"), - lambda: self._params.get_int("ReduceLateralAccelerationRain", return_default=True, default="0"), - min_val=0, - max_val=50, - step=5, - unit="%", - description=tr_noop( - "Lower the desired speed while driving through curves in rain. Increase for safer, gentler turns; decrease for more aggressive driving in curves." - ), - callback=save_reduce_lateral, - ), - ] - - self._scroller = Scroller(items, line_separator=True, spacing=0) - -class StarPilotSnowLayout(StarPilotPanel): - def __init__(self): - super().__init__() - - def save_following(value: float): - self._params.put_int("IncreaseFollowingSnow", int(value)) - - def save_stopped_distance(value: float): - self._params.put_int("IncreasedStoppedDistanceSnow", int(value)) - - def save_reduce_accel(value: float): - self._params.put_int("ReduceAccelerationSnow", int(value)) - - def save_reduce_lateral(value: float): - self._params.put_int("ReduceLateralAccelerationSnow", int(value)) - - items = [ - value_item( - tr_noop("Increase Following Distance by:"), - lambda: self._params.get_int("IncreaseFollowingSnow", return_default=True, default="0"), - min_val=0, - max_val=3, - step=0.5, - unit=" seconds", - description=tr_noop("Add extra space behind lead vehicles in snow. Increase for more space; decrease for tighter gaps."), - callback=save_following, - ), - value_item( - tr_noop("Increase Stopped Distance by:"), - lambda: self._params.get_int("IncreasedStoppedDistanceSnow", return_default=True, default="0"), - min_val=0, - max_val=10, - step=1, - unit=" feet", - description=tr_noop("Add extra buffer when stopped behind vehicles in snow. Increase for more room; decrease for shorter gaps."), - callback=save_stopped_distance, - ), - value_item( - tr_noop("Reduce Acceleration by:"), - lambda: self._params.get_int("ReduceAccelerationSnow", return_default=True, default="0"), - min_val=0, - max_val=50, - step=5, - unit="%", - description=tr_noop("Lower the maximum acceleration in snow. Increase for softer takeoffs; decrease for quicker but less stable takeoffs."), - callback=save_reduce_accel, - ), - value_item( - tr_noop("Reduce Speed in Curves by:"), - lambda: self._params.get_int("ReduceLateralAccelerationSnow", return_default=True, default="0"), - min_val=0, - max_val=50, - step=5, - unit="%", - description=tr_noop( - "Lower the desired speed while driving through curves in snow. Increase for safer, gentler turns; decrease for more aggressive driving in curves." - ), - callback=save_reduce_lateral, - ), - ] - - self._scroller = Scroller(items, line_separator=True, spacing=0) + curr = self._params.get_int(key) + gui_app.set_modal_overlay(SliderDialog(tr(key), min_v, max_v, step, curr, on_close, unit=unit, color="#1BA1E2")) diff --git a/selfdrive/ui/layouts/settings/starpilot/main_panel.py b/selfdrive/ui/layouts/settings/starpilot/main_panel.py index 214305652..34b9c8ed0 100644 --- a/selfdrive/ui/layouts/settings/starpilot/main_panel.py +++ b/selfdrive/ui/layouts/settings/starpilot/main_panel.py @@ -5,8 +5,7 @@ import pyray as rl from openpilot.common.params import Params from openpilot.system.ui.widgets import Widget from openpilot.system.ui.lib.multilang import tr, tr_noop -from openpilot.system.ui.widgets.scroller_tici import Scroller -from openpilot.system.ui.widgets.list_view import multiple_button_item, category_buttons_item +from openpilot.system.ui.lib.application import MousePos from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import StarPilotPanelType, StarPilotPanelInfo from openpilot.selfdrive.ui.layouts.settings.starpilot.sounds import StarPilotSoundsLayout @@ -22,46 +21,56 @@ from openpilot.selfdrive.ui.layouts.settings.starpilot.visuals import StarPilotV from openpilot.selfdrive.ui.layouts.settings.starpilot.themes import StarPilotThemesLayout from openpilot.selfdrive.ui.layouts.settings.starpilot.vehicle import StarPilotVehicleSettingsLayout from openpilot.selfdrive.ui.layouts.settings.starpilot.wheel import StarPilotWheelLayout +from openpilot.selfdrive.ui.layouts.settings.starpilot.developer import StarPilotDeveloperLayout + +from openpilot.selfdrive.ui.layouts.settings.starpilot.metro import TileGrid, HubTile, RadioTileGroup STARPILOT_ICONS_DIR = "toggle_icons" + class StarPilotLayout(Widget): CATEGORIES = [ { "title": "Alerts and Sounds", "icon": "icon_sound.png", - "desc": "Adjust alert volumes and enable custom notifications.", + "desc": "Adjust alert volumes and enable custom notifications.", "buttons": [("MANAGE", "SOUNDS", 0)], + "color": "#FF0097", }, { "title": "Driving Controls", "icon": "icon_steering.png", - "desc": "Fine-tune custom StarPilot acceleration, braking, and steering controls.", + "desc": "Fine-tune custom StarPilot acceleration, braking, and steering controls.", "buttons": [("DRIVING MODEL", "DRIVING_MODEL", 0), ("GAS / BRAKE", "LONGITUDINAL", 0), ("STEERING", "LATERAL", 0)], + "color": "#1BA1E2", }, { "title": "Navigation", "icon": "icon_navigate.png", - "desc": "Download map data for the Speed Limit Controller.", - "buttons": [("MAP DATA", "MAPS", 0), ("NAVIGATION", "NAVIGATION", 1)], + "desc": "Download map data for the Speed Limit Controller.", + "buttons": [("MAP DATA", "MAPS", 0), ("NAVIGATION", "NAVIGATION", 0)], + "color": "#8CBF26", }, { "title": "System Settings", "icon": "icon_system.png", - "desc": "Manage backups, device settings, screen options, storage, and tools to keep StarPilot running smoothly.", - "buttons": [("DATA", "DATA", 0), ("DEVICE CONTROLS", "DEVICE", 2), ("UTILITIES", "UTILITIES", 0)], + "desc": "Manage backups, device settings, screen options, storage, and tools to keep StarPilot running smoothly.", + "buttons": [("DATA", "DATA", 0), ("DEVICE CONTROLS", "DEVICE", 0), ("UTILITIES", "UTILITIES", 0)], + "color": "#FA6800", }, { "title": "Theme and Appearance", "icon": "icon_display.png", - "desc": "Customize the look of the driving screen and interface, including themes!", - "buttons": [("APPEARANCE", "VISUALS", 0), ("THEME", "THEMES", 0)], + "desc": "Customize the look of the driving screen and interface, including themes!", + "buttons": [("APPEARANCE", "VISUALS", 0), ("THEME", "THEMES", 0), ("DEVELOPER", "DEVELOPER", 0)], + "color": "#A200FF", }, { "title": "Vehicle Settings", "icon": "icon_vehicle.png", - "desc": "Configure car-specific options and steering wheel button mappings.", - "buttons": [("VEHICLE SETTINGS", "VEHICLE", 0), ("WHEEL CONTROLS", "WHEEL", 1)], + "desc": "Configure car-specific options and steering wheel button mappings.", + "buttons": [("VEHICLE SETTINGS", "VEHICLE", 0), ("WHEEL CONTROLS", "WHEEL", 0)], + "color": "#FFC40D", }, ] @@ -70,18 +79,12 @@ class StarPilotLayout(Widget): self._params = Params() self._current_panel = StarPilotPanelType.MAIN + self._current_category_idx: int | None = None self._depth_callback: Callable | None = None self._settings_layout = None - self._panel_stack: list[tuple[StarPilotPanelType, str]] = [] - self._sub_panel_callbacks: dict[str, Callable] = {} - - self._toggle_tuning_levels: dict[str, int] = {} - all_keys = self._params.all_keys() - for key in all_keys: - level = self._params.get_tuning_level(key) - if level is not None: - self._toggle_tuning_levels[key] = level + self._panel_stack: list[tuple[StarPilotPanelType, str]] = [] + self._sub_panel_callbacks: dict[str, Callable] = {} self._panels = { StarPilotPanelType.MAIN: StarPilotPanelInfo("", None), @@ -98,78 +101,18 @@ class StarPilotLayout(Widget): StarPilotPanelType.THEMES: StarPilotPanelInfo(tr_noop("Themes"), StarPilotThemesLayout()), StarPilotPanelType.VEHICLE: StarPilotPanelInfo(tr_noop("Vehicle Settings"), StarPilotVehicleSettingsLayout()), StarPilotPanelType.WHEEL: StarPilotPanelInfo(tr_noop("Wheel Controls"), StarPilotWheelLayout()), + StarPilotPanelType.DEVELOPER: StarPilotPanelInfo(tr_noop("Developer"), StarPilotDeveloperLayout()), } self._setup_longitudinal_sub_panels() self._setup_sounds_sub_panels() self._setup_lateral_sub_panels() + self._setup_navigation_sub_panels() + self._setup_maps_sub_panels() + self._setup_developer_sub_panels() - for panel_type in [ - StarPilotPanelType.SOUNDS, - StarPilotPanelType.DRIVING_MODEL, - ]: - panel = self._panels[panel_type].instance - if panel and hasattr(panel, 'set_tuning_levels'): - panel.set_tuning_levels(self._toggle_tuning_levels) - - tuning_levels = [tr("Minimal"), tr("Standard"), tr("Advanced"), tr("Developer")] - tuning_level_str = self._params.get("TuningLevel", return_default=True, default="1") - current_tuning_level = int(tuning_level_str) if tuning_level_str else 1 - - items = [ - multiple_button_item( - tr_noop("Tuning Level"), - tr_noop( - "Choose your tuning level. Lower levels keep it simple; higher levels unlock more toggles for finer control.\n\n" - "Minimal - Ideal for those who prefer simplicity or ease of use\n" - "Standard - Recommended for most users for a balanced experience\n" - "Advanced - Fine-tuning for experienced users\n" - "Developer - Highly customizable settings for seasoned enthusiasts" - ), - tuning_levels, - current_tuning_level, - callback=self._on_tuning_level_changed, - icon=f"{STARPILOT_ICONS_DIR}/icon_tuning.png", - starpilot_icon=True, - ), - ] - - panel_type_map = { - "SOUNDS": StarPilotPanelType.SOUNDS, - "DRIVING_MODEL": StarPilotPanelType.DRIVING_MODEL, - "LONGITUDINAL": StarPilotPanelType.LONGITUDINAL, - "LATERAL": StarPilotPanelType.LATERAL, - "MAPS": StarPilotPanelType.MAPS, - "NAVIGATION": StarPilotPanelType.NAVIGATION, - "DATA": StarPilotPanelType.DATA, - "DEVICE": StarPilotPanelType.DEVICE, - "UTILITIES": StarPilotPanelType.UTILITIES, - "VISUALS": StarPilotPanelType.VISUALS, - "THEMES": StarPilotPanelType.THEMES, - "VEHICLE": StarPilotPanelType.VEHICLE, - "WHEEL": StarPilotPanelType.WHEEL, - } - - for cat in self.CATEGORIES: - filtered_buttons = [] - for btn_label, panel_key, min_level in cat["buttons"]: - if current_tuning_level >= min_level: - panel_type = panel_type_map[panel_key] - callback = lambda p=panel_type: self._set_current_panel(p) - filtered_buttons.append((tr(btn_label), callback)) - - if filtered_buttons: - full_icon_path = f"{STARPILOT_ICONS_DIR}/{cat['icon']}" - item = category_buttons_item( - title=tr(cat["title"]), - buttons=filtered_buttons, - description=tr(cat["desc"]), - icon=full_icon_path, - starpilot_icon=True, - ) - items.append(item) - - self._main_scroller = Scroller(items, line_separator=True, spacing=0) + self._main_grid = TileGrid(columns=None, padding=20) + self._rebuild_grid() def set_depth_callback(self, callback: Callable): self._depth_callback = callback @@ -180,14 +123,47 @@ class StarPilotLayout(Widget): def navigate_back(self): if self._panel_stack: self._panel_stack.pop() - if self._panel_stack: self._update_sub_panel_visibility() - else: - self._set_current_panel(StarPilotPanelType.MAIN) + self._update_depth() + elif self._current_panel != StarPilotPanelType.MAIN: + if self._current_category_idx is not None: + cat_info = self.CATEGORIES[self._current_category_idx] + vis_btns = cat_info["buttons"] + if len(vis_btns) > 1: + self._set_current_panel(StarPilotPanelType.MAIN) + else: + self._current_category_idx = None + self._set_current_panel(StarPilotPanelType.MAIN) + else: + self._set_current_panel(StarPilotPanelType.MAIN) + elif self._current_category_idx is not None: + self._current_category_idx = None + self._rebuild_grid() + if self._depth_callback: + self._depth_callback(0) + + def _update_depth(self): + depth = 0 + if self._current_panel != StarPilotPanelType.MAIN: + if self._current_category_idx is not None: + cat_info = self.CATEGORIES[self._current_category_idx] + vis_btns = cat_info["buttons"] + depth = 2 if len(vis_btns) > 1 else 1 + else: + depth = 1 + # Deep nesting check + if self._panel_stack: + depth += len(self._panel_stack) + elif self._current_category_idx is not None: + depth = 1 + + if self._depth_callback: + self._depth_callback(depth) def _push_sub_panel(self, sub_panel_name: str): self._panel_stack.append((self._current_panel, sub_panel_name)) self._update_sub_panel_visibility() + self._update_depth() def _update_sub_panel_visibility(self): if self._current_panel == StarPilotPanelType.LONGITUDINAL: @@ -202,6 +178,24 @@ class StarPilotLayout(Widget): current_sub = self._get_current_sub_panel() if hasattr(sounds, '_navigate_to'): sounds._current_sub_panel = current_sub + elif self._current_panel == StarPilotPanelType.NAVIGATION: + nav = self._panels[StarPilotPanelType.NAVIGATION].instance + if nav: + current_sub = self._get_current_sub_panel() + if hasattr(nav, '_navigate_to'): + nav._current_sub_panel = current_sub + elif self._current_panel == StarPilotPanelType.MAPS: + maps = self._panels[StarPilotPanelType.MAPS].instance + if maps: + current_sub = self._get_current_sub_panel() + if hasattr(maps, '_navigate_to'): + maps._current_sub_panel = current_sub + elif self._current_panel == StarPilotPanelType.DEVELOPER: + developer = self._panels[StarPilotPanelType.DEVELOPER].instance + if developer: + current_sub = self._get_current_sub_panel() + if hasattr(developer, '_navigate_to'): + developer._current_sub_panel = current_sub def _get_current_sub_panel(self) -> str: if self._panel_stack and self._panel_stack[-1][0] == self._current_panel: @@ -223,36 +217,23 @@ class StarPilotLayout(Widget): if lateral and hasattr(lateral, 'set_navigate_callback'): lateral.set_navigate_callback(self._push_sub_panel) - def _on_tuning_level_changed(self, index: int): - self._params.put_nonblocking("TuningLevel", index) - if self._settings_layout: - self._settings_layout.refresh_developer_visibility() - for panel_info in self._panels.values(): - panel = panel_info.instance - if panel and hasattr(panel, 'refresh_visibility'): - panel.refresh_visibility() - self._rebuild_main_scroller(index) + def _setup_navigation_sub_panels(self): + nav = self._panels[StarPilotPanelType.NAVIGATION].instance + if nav and hasattr(nav, 'set_navigate_callback'): + nav.set_navigate_callback(self._push_sub_panel) - def _rebuild_main_scroller(self, tuning_level: int): - tuning_levels = [tr("Minimal"), tr("Standard"), tr("Advanced"), tr("Developer")] + def _setup_maps_sub_panels(self): + maps = self._panels[StarPilotPanelType.MAPS].instance + if maps and hasattr(maps, 'set_navigate_callback'): + maps.set_navigate_callback(self._push_sub_panel) - items = [ - multiple_button_item( - tr_noop("Tuning Level"), - tr_noop( - "Choose your tuning level. Lower levels keep it simple; higher levels unlock more toggles for finer control.\n\n" - "Minimal - Ideal for those who prefer simplicity or ease of use\n" - "Standard - Recommended for most users for a balanced experience\n" - "Advanced - Fine-tuning for experienced users\n" - "Developer - Highly customizable settings for seasoned enthusiasts" - ), - tuning_levels, - tuning_level, - callback=self._on_tuning_level_changed, - icon=f"{STARPILOT_ICONS_DIR}/icon_tuning.png", - starpilot_icon=True, - ), - ] + def _setup_developer_sub_panels(self): + developer = self._panels[StarPilotPanelType.DEVELOPER].instance + if developer and hasattr(developer, 'set_navigate_callback'): + developer.set_navigate_callback(self._push_sub_panel) + + def _rebuild_grid(self): + self._main_grid.clear() panel_type_map = { "SOUNDS": StarPilotPanelType.SOUNDS, @@ -268,28 +249,57 @@ class StarPilotLayout(Widget): "THEMES": StarPilotPanelType.THEMES, "VEHICLE": StarPilotPanelType.VEHICLE, "WHEEL": StarPilotPanelType.WHEEL, + "DEVELOPER": StarPilotPanelType.DEVELOPER, } - for cat in self.CATEGORIES: - filtered_buttons = [] - for btn_label, panel_key, min_level in cat["buttons"]: - if tuning_level >= min_level: - panel_type = panel_type_map[panel_key] - callback = lambda p=panel_type: self._set_current_panel(p) - filtered_buttons.append((tr(btn_label), callback)) + if self._current_category_idx is None: + # Main Categories Grid + for i, cat in enumerate(self.CATEGORIES): + visible_buttons = cat["buttons"] + if not visible_buttons: + continue - if filtered_buttons: - full_icon_path = f"{STARPILOT_ICONS_DIR}/{cat['icon']}" - item = category_buttons_item( + def on_click(idx=i): + cat_info = self.CATEGORIES[idx] + vis_btns = cat_info["buttons"] + if len(vis_btns) == 1: + self._current_category_idx = idx + self._set_current_panel(panel_type_map[vis_btns[0][1]]) + else: + self._current_category_idx = idx + self._rebuild_grid() + if self._depth_callback: + self._depth_callback(1) + + tile = HubTile( title=tr(cat["title"]), - buttons=filtered_buttons, - description=tr(cat["desc"]), - icon=full_icon_path, + desc=tr(cat["desc"]), + icon_path=f"{STARPILOT_ICONS_DIR}/{cat['icon']}", + on_click=on_click, starpilot_icon=True, + bg_color=cat.get("color"), ) - items.append(item) + self._main_grid.add_tile(tile) + else: + # Sub-buttons Grid for selected Category + cat = self.CATEGORIES[self._current_category_idx] + visible_buttons = cat["buttons"] - self._main_scroller = Scroller(items, line_separator=True, spacing=0) + for label, panel_key, _ in visible_buttons: + p_type = panel_type_map[panel_key] + + def on_btn_click(p=p_type): + self._set_current_panel(p) + + tile = HubTile( + title=tr(label), + desc="", + icon_path=f"{STARPILOT_ICONS_DIR}/{cat['icon']}", # Reuse category icon for sub-tiles + on_click=on_btn_click, + starpilot_icon=True, + bg_color=cat.get("color"), + ) + self._main_grid.add_tile(tile) def _set_current_panel(self, panel_type: StarPilotPanelType): if panel_type != self._current_panel: @@ -298,14 +308,14 @@ class StarPilotLayout(Widget): self._current_panel = panel_type if panel_type != StarPilotPanelType.MAIN: self._panels[panel_type].instance.show_event() + else: + self._rebuild_grid() - depth = 1 if panel_type != StarPilotPanelType.MAIN else 0 - if self._depth_callback: - self._depth_callback(depth) + self._update_depth() def _render(self, rect: rl.Rectangle): if self._current_panel == StarPilotPanelType.MAIN: - self._main_scroller.render(rect) + self._main_grid.render(rect) else: panel = self._panels[self._current_panel] if panel.instance: @@ -313,9 +323,7 @@ class StarPilotLayout(Widget): def show_event(self): super().show_event() - if self._current_panel == StarPilotPanelType.MAIN: - self._main_scroller.show_event() - else: + if self._current_panel != StarPilotPanelType.MAIN: self._panels[self._current_panel].instance.show_event() def hide_event(self): diff --git a/selfdrive/ui/layouts/settings/starpilot/maps.py b/selfdrive/ui/layouts/settings/starpilot/maps.py index 8cfe91089..8ba8be3b0 100644 --- a/selfdrive/ui/layouts/settings/starpilot/maps.py +++ b/selfdrive/ui/layouts/settings/starpilot/maps.py @@ -1,25 +1,164 @@ from __future__ import annotations +import os +import shutil +from pathlib import Path +from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.lib.multilang import tr, tr_noop -from openpilot.system.ui.widgets.list_view import button_item -from openpilot.system.ui.widgets.scroller_tici import Scroller +from openpilot.system.ui.widgets import DialogResult +from openpilot.system.ui.widgets.selection_dialog import SelectionDialog +from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog, alert_dialog from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import StarPilotPanel +# --- Map Data Definitions --- +MIDWEST_MAP = {"IL": "Illinois", "IN": "Indiana", "IA": "Iowa", "KS": "Kansas", "MI": "Michigan", "MN": "Minnesota", "MO": "Missouri", "NE": "Nebraska", "ND": "North Dakota", "OH": "Ohio", "SD": "South Dakota", "WI": "Wisconsin"} +NORTHEAST_MAP = {"CT": "Connecticut", "ME": "Maine", "MA": "Massachusetts", "NH": "New Hampshire", "NJ": "New Jersey", "NY": "New York", "PA": "Pennsylvania", "RI": "Rhode Island", "VT": "Vermont"} +SOUTH_MAP = {"AL": "Alabama", "AR": "Arkansas", "DE": "Delaware", "DC": "District of Columbia", "FL": "Florida", "GA": "Georgia", "KY": "Kentucky", "LA": "Louisiana", "MD": "Maryland", "MS": "Mississippi", "NC": "North Carolina", "OK": "Oklahoma", "SC": "South Carolina", "TN": "Tennessee", "TX": "Texas", "VA": "Virginia", "WV": "West Virginia"} +WEST_MAP = {"AK": "Alaska", "AZ": "Arizona", "CA": "California", "CO": "Colorado", "HI": "Hawaii", "ID": "Idaho", "MT": "Montana", "NV": "Nevada", "NM": "New Mexico", "OR": "Oregon", "UT": "Utah", "WA": "Washington", "WY": "Wyoming"} +TERRITORIES_MAP = {"AS": "American Samoa", "GU": "Guam", "MP": "Northern Mariana Islands", "PR": "Puerto Rico", "VI": "Virgin Islands"} + +AFRICA_MAP = {"DZ": "Algeria", "AO": "Angola", "BJ": "Benin", "BW": "Botswana", "BF": "Burkina Faso", "BI": "Burundi", "CM": "Cameroon", "CF": "Central African Republic", "TD": "Chad", "KM": "Comoros", "CG": "Congo (Brazzaville)", "CD": "Congo (Kinshasa)", "DJ": "Djibouti", "EG": "Egypt", "GQ": "Equatorial Guinea", "ER": "Eritrea", "ET": "Ethiopia", "GA": "Gabon", "GM": "Gambia", "GH": "Ghana", "GN": "Guinea", "GW": "Guinea-Bissau", "CI": "Ivory Coast", "KE": "Kenya", "LS": "Lesotho", "LR": "Liberia", "LY": "Libya", "MG": "Madagascar", "MW": "Malawi", "ML": "Mali", "MR": "Mauritania", "MA": "Morocco", "MZ": "Mozambique", "NA": "Namibia", "NE": "Niger", "NG": "Nigeria", "RW": "Rwanda", "SN": "Senegal", "SL": "Sierra Leone", "SO": "Somalia", "ZA": "South Africa", "SS": "South Sudan", "SD": "Sudan", "SZ": "Swaziland", "TZ": "Tanzania", "TG": "Togo", "TN": "Tunisia", "UG": "Uganda", "ZM": "Zambia", "ZW": "Zimbabwe"} +ANTARCTICA_MAP = {"AQ": "Antarctica"} +ASIA_MAP = {"AF": "Afghanistan", "AM": "Armenia", "AZ": "Azerbaijan", "BH": "Bahrain", "BD": "Bangladesh", "BT": "Bhutan", "BN": "Brunei", "KH": "Cambodia", "CN": "China", "CY": "Cyprus", "TL": "East Timor", "HK": "Hong Kong", "IN": "India", "ID": "Indonesia", "IR": "Iran", "IQ": "Iraq", "IL": "Israel", "JP": "Japan", "JO": "Jordan", "KZ": "Kazakhstan", "KW": "Kuwait", "KG": "Kyrgyzstan", "LA": "Laos", "LB": "Lebanon", "MY": "Malaysia", "MV": "Maldives", "MO": "Macao", "MN": "Mongolia", "MM": "Myanmar", "NP": "Nepal", "KP": "North Korea", "OM": "Oman", "PK": "Pakistan", "PS": "Palestine", "PH": "Philippines", "QA": "Qatar", "RU": "Russia", "SA": "Saudi Arabia", "SG": "Singapore", "KR": "South Korea", "LK": "Sri Lanka", "SY": "Syria", "TW": "Taiwan", "TJ": "Tajikistan", "TH": "Thailand", "TR": "Turkey", "TM": "Turkmenistan", "AE": "United Arab Emirates", "UZ": "Uzbekistan", "VN": "Vietnam", "YE": "Yemen"} +EUROPE_MAP = {"AL": "Albania", "AT": "Austria", "BY": "Belarus", "BE": "Belgium", "BA": "Bosnia and Herzegovina", "BG": "Bulgaria", "HR": "Croatia", "CZ": "Czech Republic", "DK": "Denmark", "EE": "Estonia", "FI": "Finland", "FR": "France", "GE": "Georgia", "DE": "Germany", "GR": "Greece", "HU": "Hungary", "IS": "Iceland", "IE": "Ireland", "IT": "Italy", "KZ": "Kazakhstan", "LV": "Latvia", "LT": "Lithuania", "LU": "Luxembourg", "MK": "Macedonia", "MD": "Moldova", "ME": "Montenegro", "NL": "Netherlands", "NO": "Norway", "PL": "Poland", "PT": "Portugal", "RO": "Romania", "RS": "Serbia", "SK": "Slovakia", "SI": "Slovenia", "ES": "Spain", "SE": "Sweden", "CH": "Switzerland", "TR": "Turkey", "UA": "Ukraine", "GB": "United Kingdom"} +NORTH_AMERICA_MAP = {"BS": "Bahamas", "BZ": "Belize", "CA": "Canada", "CR": "Costa Rica", "CU": "Cuba", "DO": "Dominican Republic", "SV": "El Salvador", "GL": "Greenland", "GD": "Grenada", "GT": "Guatemala", "HT": "Haiti", "HN": "Honduras", "JM": "Jamaica", "MX": "Mexico", "NI": "Nicaragua", "PA": "Panama", "TT": "Trinidad and Tobago", "US": "United States"} +OCEANIA_MAP = {"AU": "Australia", "FJ": "Fiji", "TF": "French Southern Territories", "NC": "New Caledonia", "NZ": "New Zealand", "PG": "Papua New Guinea", "SB": "Solomon Islands", "VU": "Vanuatu"} +SOUTH_AMERICA_MAP = {"AR": "Argentina", "BO": "Bolivia", "BR": "Brazil", "CL": "Chile", "CO": "Colombia", "EC": "Ecuador", "FK": "Falkland Islands", "GY": "Guyana", "PY": "Paraguay", "PE": "Peru", "SR": "Suriname", "UY": "Uruguay", "VE": "Venezuela"} + + +class StarPilotMapRegionLayout(StarPilotPanel): + def __init__(self, region_map: dict[str, str]): + super().__init__() + self.CATEGORIES = [] + + for key, name in sorted(region_map.items(), key=lambda item: item[1]): + self.CATEGORIES.append({ + "title": name, + "type": "toggle", + "get_state": lambda k=key: self._get_map_state(k), + "set_state": lambda s, k=key: self._set_map_state(k, s), + "color": "#8CBF26" + }) + self._rebuild_grid() + + def _get_map_state(self, key): + selected_raw = self._params.get("MapsSelected", encoding='utf-8') or "" + selected = [k.strip() for k in selected_raw.split(",") if k.strip()] + return key in selected + + def _set_map_state(self, key, state): + selected_raw = self._params.get("MapsSelected", encoding='utf-8') or "" + selected = [k.strip() for k in selected_raw.split(",") if k.strip()] + + if state and key not in selected: + selected.append(key) + elif not state and key in selected: + selected.remove(key) + + self._params.put("MapsSelected", ",".join(selected)) + + +class StarPilotMapCountriesLayout(StarPilotPanel): + def __init__(self): + super().__init__() + self.CATEGORIES = [ + {"title": tr_noop("Africa"), "panel": "africa", "icon": "toggle_icons/icon_map.png", "color": "#8CBF26"}, + {"title": tr_noop("Antarctica"), "panel": "antarctica", "icon": "toggle_icons/icon_map.png", "color": "#8CBF26"}, + {"title": tr_noop("Asia"), "panel": "asia", "icon": "toggle_icons/icon_map.png", "color": "#8CBF26"}, + {"title": tr_noop("Europe"), "panel": "europe", "icon": "toggle_icons/icon_map.png", "color": "#8CBF26"}, + {"title": tr_noop("North America"), "panel": "north_america", "icon": "toggle_icons/icon_map.png", "color": "#8CBF26"}, + {"title": tr_noop("Oceania"), "panel": "oceania", "icon": "toggle_icons/icon_map.png", "color": "#8CBF26"}, + {"title": tr_noop("South America"), "panel": "south_america", "icon": "toggle_icons/icon_map.png", "color": "#8CBF26"}, + ] + self._rebuild_grid() + +class StarPilotMapStatesLayout(StarPilotPanel): + def __init__(self): + super().__init__() + self.CATEGORIES = [ + {"title": tr_noop("Midwest"), "panel": "midwest", "icon": "toggle_icons/icon_map.png", "color": "#8CBF26"}, + {"title": tr_noop("Northeast"), "panel": "northeast", "icon": "toggle_icons/icon_map.png", "color": "#8CBF26"}, + {"title": tr_noop("South"), "panel": "south", "icon": "toggle_icons/icon_map.png", "color": "#8CBF26"}, + {"title": tr_noop("West"), "panel": "west", "icon": "toggle_icons/icon_map.png", "color": "#8CBF26"}, + {"title": tr_noop("Territories"), "panel": "territories", "icon": "toggle_icons/icon_map.png", "color": "#8CBF26"}, + ] + self._rebuild_grid() + class StarPilotMapsLayout(StarPilotPanel): def __init__(self): super().__init__() + + self._sub_panels = { + "countries": StarPilotMapCountriesLayout(), + "states": StarPilotMapStatesLayout(), + "africa": StarPilotMapRegionLayout(AFRICA_MAP), + "antarctica": StarPilotMapRegionLayout(ANTARCTICA_MAP), + "asia": StarPilotMapRegionLayout(ASIA_MAP), + "europe": StarPilotMapRegionLayout(EUROPE_MAP), + "north_america": StarPilotMapRegionLayout(NORTH_AMERICA_MAP), + "oceania": StarPilotMapRegionLayout(OCEANIA_MAP), + "south_america": StarPilotMapRegionLayout(SOUTH_AMERICA_MAP), + "midwest": StarPilotMapRegionLayout(MIDWEST_MAP), + "northeast": StarPilotMapRegionLayout(NORTHEAST_MAP), + "south": StarPilotMapRegionLayout(SOUTH_MAP), + "west": StarPilotMapRegionLayout(WEST_MAP), + "territories": StarPilotMapRegionLayout(TERRITORIES_MAP), + } - items = [ - button_item( - tr_noop("Download Map Data"), - lambda: tr("DOWNLOAD"), - tr_noop("Download map data for the Speed Limit Controller."), - ), - button_item( - tr_noop("Manage Map Data"), - lambda: tr("MANAGE"), - tr_noop("View or delete downloaded map data."), - ), + self.CATEGORIES = [ + {"title": tr_noop("Download Maps"), "type": "hub", "on_click": self._on_download, "icon": "toggle_icons/icon_map.png", "color": "#8CBF26"}, + {"title": tr_noop("Auto Update Schedule"), "type": "value", "get_value": lambda: self._params.get("PreferredSchedule", encoding='utf-8') or "Manually", "on_click": self._on_schedule, "icon": "toggle_icons/icon_calendar.png", "color": "#8CBF26"}, + {"title": tr_noop("Countries"), "panel": "countries", "icon": "toggle_icons/icon_map.png", "color": "#8CBF26"}, + {"title": tr_noop("U.S. States"), "panel": "states", "icon": "toggle_icons/icon_map.png", "color": "#8CBF26"}, + {"title": tr_noop("Storage Used"), "type": "value", "get_value": self._get_storage, "on_click": lambda: None, "icon": "toggle_icons/icon_system.png", "color": "#8CBF26"}, + {"title": tr_noop("Remove Maps"), "type": "hub", "on_click": self._on_remove, "icon": "toggle_icons/icon_map.png", "color": "#8CBF26"}, ] + + for name, panel in self._sub_panels.items(): + if hasattr(panel, 'set_navigate_callback'): panel.set_navigate_callback(self._navigate_to) + if hasattr(panel, 'set_back_callback'): panel.set_back_callback(self._go_back) + + self._rebuild_grid() - self._scroller = Scroller(items, line_separator=True, spacing=0) + def _get_storage(self) -> str: + maps_path = Path("/data/media/0/osm/offline") + if not maps_path.exists(): + return "0 MB" + total_size = sum(f.stat().st_size for f in maps_path.rglob('*') if f.is_file()) + mb = total_size / (1024 * 1024) + if mb > 1024: + return f"{(mb / 1024):.2f} GB" + return f"{mb:.2f} MB" + + def _on_schedule(self): + options = ["Manually", "Weekly", "Monthly"] + current = self._params.get("PreferredSchedule", encoding='utf-8') or "Manually" + def on_select(res, val): + if res == DialogResult.CONFIRM: + self._params.put("PreferredSchedule", val) + self._rebuild_grid() + gui_app.set_modal_overlay(SelectionDialog(tr("Auto Update Schedule"), options, current, on_close=on_select)) + + def _on_download(self): + selected_raw = self._params.get("MapsSelected", encoding='utf-8') or "" + selected = [k.strip() for k in selected_raw.split(",") if k.strip()] + if not selected: + gui_app.set_modal_overlay(alert_dialog(tr("Please select at least one region or state first!"))) + return + + def on_confirm(res): + if res == DialogResult.CONFIRM: + self._params_memory.put_bool("DownloadMaps", True) + gui_app.set_modal_overlay(alert_dialog(tr("Map download started in background."))) + + gui_app.set_modal_overlay(ConfirmDialog(tr("Start downloading maps for selected regions?"), tr("Download"), on_close=on_confirm)) + + def _on_remove(self): + def on_confirm(res): + if res == DialogResult.CONFIRM: + maps_path = Path("/data/media/0/osm/offline") + if maps_path.exists(): + shutil.rmtree(maps_path, ignore_errors=True) + gui_app.set_modal_overlay(alert_dialog(tr("Maps removed."))) + self._rebuild_grid() + gui_app.set_modal_overlay(ConfirmDialog(tr("Delete all downloaded map data?"), tr("Remove"), on_close=on_confirm)) diff --git a/selfdrive/ui/layouts/settings/starpilot/metro.py b/selfdrive/ui/layouts/settings/starpilot/metro.py new file mode 100644 index 000000000..5dab4a3dd --- /dev/null +++ b/selfdrive/ui/layouts/settings/starpilot/metro.py @@ -0,0 +1,412 @@ +from __future__ import annotations +import pyray as rl +from collections.abc import Callable +from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.lib.text_measure import measure_text_cached +from openpilot.system.ui.widgets import Widget, DialogResult + + +def hex_to_color(hex_str: str) -> rl.Color: + hex_str = hex_str.lstrip('#') + return rl.Color(int(hex_str[0:2], 16), int(hex_str[2:4], 16), int(hex_str[4:6], 16), 255) + + +class MetroTile(Widget): + def __init__(self, bg_color: rl.Color | str = rl.Color(54, 77, 239, 255), on_click: Callable | None = None): + super().__init__() + self.bg_color = hex_to_color(bg_color) if isinstance(bg_color, str) else bg_color + self.on_click = on_click + self._is_pressed = False + + def _handle_mouse_press(self, mouse_pos: MousePos): + if rl.check_collision_point_rec(mouse_pos, self._rect): + self._is_pressed = True + + def _handle_mouse_release(self, mouse_pos: MousePos): + if self._is_pressed: + if rl.check_collision_point_rec(mouse_pos, self._rect) and self.on_click: + self.on_click() + self._is_pressed = False + + def _pressed_color(self, base: rl.Color | None = None) -> rl.Color: + """Return the press-darkened version of base (defaults to self.bg_color).""" + c = base if base is not None else self.bg_color + if not self._is_pressed: + return c + return rl.Color(max(0, c.r - 20), max(0, c.g - 20), max(0, c.b - 20), c.a) + + def _draw_text_fit(self, font: rl.Font, text: str, pos: rl.Vector2, max_width: float, font_size: float, align_right: bool = False): + """Draws text scaled down to fit within max_width if necessary.""" + size = measure_text_cached(font, text, int(font_size)) + actual_font_size = font_size + if size.x > max_width: + actual_font_size = font_size * (max_width / size.x) + render_width = max_width + else: + render_width = size.x + + nudge_y = (font_size - actual_font_size) / 2 + draw_x = pos.x + if align_right: + draw_x = pos.x + max_width - render_width + + rl.draw_text_ex(font, text, rl.Vector2(draw_x, pos.y + nudge_y), actual_font_size, 0, rl.WHITE) + + def _draw_watermark(self, rect: rl.Rectangle, icon: rl.Texture2D | None): + if not icon: + return + rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height)) + w_scale = 1.6 + iw, ih = icon.width * w_scale, icon.height * w_scale + ix = rect.x + rect.width - iw - 15 + iy = rect.y + rect.height - ih - 15 + rl.draw_texture_pro(icon, rl.Rectangle(0, 0, icon.width, icon.height), rl.Rectangle(ix, iy, iw, ih), rl.Vector2(0, 0), 0, rl.Color(255, 255, 255, 80)) + rl.end_scissor_mode() + + def _render(self, rect: rl.Rectangle): + pass + + +class HubTile(MetroTile): + def __init__( + self, title: str, desc: str, icon_path: str, on_click: Callable | None = None, starpilot_icon: bool = False, bg_color: rl.Color | str | None = None + ): + if bg_color: + super().__init__(bg_color=bg_color, on_click=on_click) + else: + super().__init__(on_click=on_click) + self.title = title + self.desc = desc + if icon_path: + if starpilot_icon: + self._icon = gui_app.starpilot_texture(icon_path, 100, 100) + else: + self._icon = gui_app.texture(icon_path, 100, 100) + else: + self._icon = None + self._font_title = gui_app.font(FontWeight.BOLD) + self._font_desc = gui_app.font(FontWeight.NORMAL) + + def _render(self, rect: rl.Rectangle): + self.set_rect(rect) + color = self._pressed_color() + rl.draw_rectangle_rounded(rect, 0.15, 10, color) + self._draw_watermark(rect, self._icon) + padding = 30 + if self._icon: + siw, sih = self._icon.width * 0.45, self._icon.height * 0.45 + rl.draw_texture_pro( + self._icon, + rl.Rectangle(0, 0, self._icon.width, self._icon.height), + rl.Rectangle(rect.x + padding, rect.y + padding, siw, sih), + rl.Vector2(0, 0), + 0, + rl.WHITE, + ) + title_x = rect.x + padding + (65 if self._icon else 0) + max_title_width = rect.width - (title_x - rect.x) - padding + self._draw_text_fit(self._font_title, self.title, rl.Vector2(title_x, rect.y + padding + 3), max_title_width, 42) + + +class ToggleTile(MetroTile): + def __init__( + self, title: str, get_state: Callable[[], bool], set_state: Callable[[bool], None], icon_path: str | None = None, bg_color: rl.Color | str | None = None + ): + if bg_color: + super().__init__(bg_color=bg_color) + else: + super().__init__(bg_color=rl.Color(0, 163, 0, 255)) + self.title = title + self.get_state = get_state + self.set_state = set_state + self._icon = gui_app.starpilot_texture(icon_path, 80, 80) if icon_path else None + self._font = gui_app.font(FontWeight.BOLD) + self._active_color = self.bg_color + self._inactive_color = rl.Color(120, 120, 120, 255) + + def _handle_mouse_release(self, mouse_pos: MousePos): + if self._is_pressed: + if rl.check_collision_point_rec(mouse_pos, self._rect): + self.set_state(not self.get_state()) + self._is_pressed = False + + def _render(self, rect: rl.Rectangle): + self.set_rect(rect) + active = self.get_state() + base_color = self._active_color if active else self._inactive_color + color = self._pressed_color(base_color) + rl.draw_rectangle_rounded(rect, 0.15, 10, color) + self._draw_watermark(rect, self._icon) + padding = 25 + if self._icon: + siw, sih = self._icon.width * 0.45, self._icon.height * 0.45 + rl.draw_texture_pro( + self._icon, + rl.Rectangle(0, 0, self._icon.width, self._icon.height), + rl.Rectangle(rect.x + padding, rect.y + padding, siw, sih), + rl.Vector2(0, 0), + 0, + rl.WHITE, + ) + title_x = rect.x + padding + (55 if self._icon else 0) + max_title_width = rect.width - (title_x - rect.x) - padding + self._draw_text_fit(self._font, self.title, rl.Vector2(title_x, rect.y + padding + 2), max_title_width, 35) + state_text = tr("ON") if active else tr("OFF") + ts = measure_text_cached(self._font, state_text, 30) + rl.draw_text_ex(self._font, state_text, rl.Vector2(rect.x + rect.width - ts.x - padding, rect.y + rect.height - 50), 30, 0, rl.WHITE) + + +class ValueTile(MetroTile): + def __init__(self, title: str, get_value: Callable[[], str], on_click: Callable, icon_path: str | None = None, bg_color: rl.Color | str | None = None): + super().__init__(bg_color=bg_color, on_click=on_click) + self.title = title + self.get_value = get_value + self._icon = gui_app.starpilot_texture(icon_path, 80, 80) if icon_path else None + self._font = gui_app.font(FontWeight.BOLD) + + def _render(self, rect: rl.Rectangle): + self.set_rect(rect) + color = self._pressed_color() + rl.draw_rectangle_rounded(rect, 0.15, 10, color) + self._draw_watermark(rect, self._icon) + padding = 25 + if self._icon: + siw, sih = self._icon.width * 0.45, self._icon.height * 0.45 + rl.draw_texture_pro( + self._icon, + rl.Rectangle(0, 0, self._icon.width, self._icon.height), + rl.Rectangle(rect.x + padding, rect.y + padding, siw, sih), + rl.Vector2(0, 0), + 0, + rl.WHITE, + ) + title_x = rect.x + padding + (55 if self._icon else 0) + max_title_width = rect.width - (title_x - rect.x) - padding + self._draw_text_fit(self._font, self.title, rl.Vector2(title_x, rect.y + padding + 2), max_title_width, 35) + + val_text = self.get_value() + # Bottom value: scale to fit if it's too long (common for Car Models) + max_val_width = rect.width - 2 * padding + val_pos = rl.Vector2(rect.x + padding, rect.y + rect.height - 55) + self._draw_text_fit(self._font, val_text, val_pos, max_val_width, 35, align_right=True) + + +class MetroSlider(Widget): + def __init__( + self, + min_val: float, + max_val: float, + step: float, + current_val: float, + on_change: Callable[[float], None], + unit: str = "", + labels: dict[float, str] | None = None, + color: rl.Color = rl.Color(54, 77, 239, 255), + ): + super().__init__() + self.min_val, self.max_val, self.step, self.current_val = min_val, max_val, step, current_val + self.on_change, self.unit, self.labels, self.color = on_change, unit, labels or {}, color + self._is_dragging = False + self._font = gui_app.font(FontWeight.BOLD) + + def _handle_mouse_press(self, mouse_pos: MousePos): + if rl.check_collision_point_rec(mouse_pos, self._rect): + self._is_dragging = True + self._update_val_from_mouse(mouse_pos) + + def _handle_mouse_release(self, mouse_pos: MousePos): + self._is_dragging = False + + def _update_val_from_mouse(self, mouse_pos: MousePos): + rel_x = max(0, min(1, (mouse_pos.x - self._rect.x) / self._rect.width)) + val = self.min_val + rel_x * (self.max_val - self.min_val) + snapped = max(self.min_val, min(self.max_val, self.min_val + round((val - self.min_val) / self.step) * self.step)) + if snapped != self.current_val: + self.current_val = snapped + self.on_change(self.current_val) + + def _render(self, rect: rl.Rectangle): + self.set_rect(rect) + if self._is_dragging: + self._update_val_from_mouse(rl.get_mouse_position()) + track_h = 20 + track_rect = rl.Rectangle(rect.x, rect.y + (rect.height - track_h) / 2, rect.width, track_h) + rl.draw_rectangle_rounded(track_rect, 1.0, 10, rl.Color(60, 60, 60, 255)) + fill_w = ((self.current_val - self.min_val) / (self.max_val - self.min_val)) * rect.width + rl.draw_rectangle_rounded(rl.Rectangle(rect.x, rect.y + (rect.height - track_h) / 2, fill_w, track_h), 1.0, 10, self.color) + thumb_w, thumb_h = 40, 60 + thumb_x, thumb_y = rect.x + fill_w - thumb_w / 2, rect.y + (rect.height - thumb_h) / 2 + rl.draw_rectangle_rounded(rl.Rectangle(thumb_x, thumb_y, thumb_w, thumb_h), 0.2, 10, rl.WHITE) + val_str = self.labels.get(self.current_val, f"{self.current_val:.2f}".rstrip('0').rstrip('.') + self.unit) + ts = measure_text_cached(self._font, val_str, 35) + rl.draw_text_ex(self._font, val_str, rl.Vector2(thumb_x + (thumb_w - ts.x) / 2, thumb_y - 45), 35, 0, rl.WHITE) + + +class SliderDialog(Widget): + def __init__( + self, + title: str, + min_val: float, + max_val: float, + step: float, + current_val: float, + on_close: Callable, + unit: str = "", + labels: dict[float, str] | None = None, + color: rl.Color | str = "#FF0097", + ): + super().__init__() + self.title, self._user_callback = title, on_close + self._color = hex_to_color(color) if isinstance(color, str) else color + self._font_title, self._font_btn = gui_app.font(FontWeight.BOLD), gui_app.font(FontWeight.BOLD) + self._slider = MetroSlider(min_val, max_val, step, current_val, self._on_slider_change, unit, labels, self._color) + self._current_val, self._is_pressed_ok, self._is_pressed_cancel = current_val, False, False + + def _on_slider_change(self, val): + self._current_val = val + + def _handle_mouse_press(self, mouse_pos: MousePos): + self._slider._handle_mouse_press(mouse_pos) + if rl.check_collision_point_rec(mouse_pos, self._ok_rect): + self._is_pressed_ok = True + if rl.check_collision_point_rec(mouse_pos, self._cancel_rect): + self._is_pressed_cancel = True + + def _handle_mouse_release(self, mouse_pos: MousePos): + self._slider._handle_mouse_release(mouse_pos) + if self._is_pressed_ok: + if rl.check_collision_point_rec(mouse_pos, self._ok_rect): + self._user_callback(DialogResult.CONFIRM, self._current_val) + gui_app.set_modal_overlay(None) + self._is_pressed_ok = False + if self._is_pressed_cancel: + if rl.check_collision_point_rec(mouse_pos, self._cancel_rect): + self._user_callback(DialogResult.CANCEL, self._current_val) + gui_app.set_modal_overlay(None) + self._is_pressed_cancel = False + + def _render(self, rect: rl.Rectangle): + rl.draw_rectangle(0, 0, gui_app.width, gui_app.height, rl.Color(0, 0, 0, 160)) + dialog_w, dialog_h = 1000, 500 + dx, dy = rect.x + (rect.width - dialog_w) / 2, rect.y + (rect.height - dialog_h) / 2 + self._ok_rect = rl.Rectangle(dx + dialog_w - 450, dy + dialog_h - 120, 350, 80) + self._cancel_rect = rl.Rectangle(dx + 100, dy + dialog_h - 120, 350, 80) + + d_rect = rl.Rectangle(dx, dy, dialog_w, dialog_h) + rl.draw_rectangle_rounded(d_rect, 0.05, 10, rl.Color(30, 30, 30, 255)) + rl.draw_rectangle_rounded_lines(d_rect, 0.05, 10, self._color) + ts = measure_text_cached(self._font_title, self.title, 50) + rl.draw_text_ex(self._font_title, self.title, rl.Vector2(dx + (dialog_w - ts.x) / 2, dy + 40), 50, 0, rl.WHITE) + + slider_rect = rl.Rectangle(dx + 100, dy + 200, dialog_w - 200, 100) + self._slider.render(slider_rect) + + # Cancel Button + c_color = rl.Color(60, 60, 60, 255) if not self._is_pressed_cancel else rl.Color(40, 40, 40, 255) + rl.draw_rectangle_rounded(self._cancel_rect, 0.2, 10, c_color) + cts = measure_text_cached(self._font_btn, tr("CANCEL"), 35) + rl.draw_text_ex(self._font_btn, tr("CANCEL"), rl.Vector2(self._cancel_rect.x + (350 - cts.x) / 2, self._cancel_rect.y + (80 - cts.y) / 2), 35, 0, rl.WHITE) + + # OK Button + ok_color = self._color if not self._is_pressed_ok else rl.Color(max(0, self._color.r - 40), max(0, self._color.g - 40), max(0, self._color.b - 40), 255) + rl.draw_rectangle_rounded(self._ok_rect, 0.2, 10, ok_color) + ots = measure_text_cached(self._font_btn, tr("OK"), 35) + rl.draw_text_ex(self._font_btn, tr("OK"), rl.Vector2(self._ok_rect.x + (350 - ots.x) / 2, self._ok_rect.y + (80 - ots.y) / 2), 35, 0, rl.WHITE) + + return DialogResult.NO_ACTION + + +class RadioTileGroup(Widget): + def __init__(self, title: str, options: list[str], current_index: int, on_change: Callable): + super().__init__() + self.title, self.options, self.current_index, self.on_change = title, options, current_index, on_change + self._font, self._font_title = gui_app.font(FontWeight.BOLD), gui_app.font(FontWeight.NORMAL) + self._bg_color, self._active_color, self._inactive_color = rl.Color(41, 41, 41, 255), rl.Color(54, 77, 239, 255), rl.Color(80, 80, 80, 255) + self._pressed_index, self._option_rects = -1, [] + + def set_index(self, index: int): + self.current_index = index + + def _handle_mouse_press(self, mouse_pos: MousePos): + for i, r in enumerate(self._option_rects): + if rl.check_collision_point_rec(mouse_pos, r): + self._pressed_index = i + return + + def _handle_mouse_release(self, mouse_pos: MousePos): + if self._pressed_index != -1: + if rl.check_collision_point_rec(mouse_pos, self._option_rects[self._pressed_index]): + if self.current_index != self._pressed_index: + self.current_index = self._pressed_index + self.on_change(self.current_index) + self._pressed_index = -1 + + def _render(self, rect: rl.Rectangle): + self.set_rect(rect) + self._option_rects.clear() + title_size = measure_text_cached(self._font_title, self.title, 40) + rl.draw_text_ex(self._font_title, self.title, rl.Vector2(rect.x, rect.y + (rect.height - title_size.y) / 2), 40, 0, rl.WHITE) + padding, option_w = 20, 200 + start_x = rect.x + rect.width - (len(self.options) * (option_w + padding)) + for i, opt in enumerate(self.options): + r = rl.Rectangle(start_x + i * (option_w + padding), rect.y, option_w, rect.height) + self._option_rects.append(r) + is_active = i == self.current_index + color = self._active_color if is_active else self._inactive_color + if i == self._pressed_index: + color = rl.Color(max(0, color.r - 20), max(0, color.g - 20), max(0, color.b - 20), 255) + rl.draw_rectangle_rounded(r, 0.15, 10, color) + ts = measure_text_cached(self._font, opt, 35) + rl.draw_text_ex(self._font, opt, rl.Vector2(r.x + (r.width - ts.x) / 2, r.y + (r.height - ts.y) / 2), 35, 0, rl.WHITE) + + +class TileGrid(Widget): + def __init__(self, columns: int | None = None, padding: int = 20): + super().__init__() + self._columns, self.padding, self.tiles = columns, padding, [] + + def add_tile(self, tile: Widget): + self.tiles.append(tile) + + def clear(self): + self.tiles.clear() + + def _render(self, rect: rl.Rectangle): + self.set_rect(rect) + if not self.tiles: + return + + # Snapshot the tiles list to prevent IndexError if on_click modifies self.tiles mid-loop + tiles_to_render = list(self.tiles) + count = len(tiles_to_render) + + if self._columns is not None: + cols = self._columns + else: + if count == 1: + cols = 1 + elif count == 2: + cols = 2 + elif count == 3: + cols = 3 + elif count == 4: + cols = 2 + elif count <= 6: + cols = 3 + else: + cols = 4 + rows = (count + cols - 1) // cols + tile_h = (rect.height - (self.padding * (rows - 1))) / rows + + tile_idx = 0 + for r in range(rows): + remaining = count - tile_idx + if remaining <= 0: + break + items_in_row = min(cols, remaining) + row_tile_w = (rect.width - (self.padding * (items_in_row - 1))) / items_in_row + for c in range(items_in_row): + tile = tiles_to_render[tile_idx] + tile.render(rl.Rectangle(rect.x + c * (row_tile_w + self.padding), rect.y + r * (tile_h + self.padding), row_tile_w, tile_h)) + tile_idx += 1 diff --git a/selfdrive/ui/layouts/settings/starpilot/navigation.py b/selfdrive/ui/layouts/settings/starpilot/navigation.py index 32a200d84..b8decb751 100644 --- a/selfdrive/ui/layouts/settings/starpilot/navigation.py +++ b/selfdrive/ui/layouts/settings/starpilot/navigation.py @@ -1,25 +1,134 @@ from __future__ import annotations - +from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.lib.multilang import tr, tr_noop -from openpilot.system.ui.widgets.list_view import toggle_item, button_item -from openpilot.system.ui.widgets.scroller_tici import Scroller +from openpilot.system.ui.widgets import DialogResult +from openpilot.system.ui.widgets.selection_dialog import SelectionDialog +from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog, alert_dialog +from openpilot.system.ui.widgets.input_dialog import InputDialog from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import StarPilotPanel + class StarPilotNavigationLayout(StarPilotPanel): def __init__(self): super().__init__() - - items = [ - toggle_item( - tr_noop("Use Live Map Data"), - tr_noop("Use live map data for real-time navigation updates."), - False, - ), - button_item( - tr_noop("Navigation Settings"), - lambda: tr("MANAGE"), - tr_noop("Configure navigation-specific options like route preferences."), - ), + self._sub_panels = { + "mapbox": StarPilotMapboxLayout(), + } + self.CATEGORIES = [ + {"title": tr_noop("Mapbox Credentials"), "panel": "mapbox", "icon": "toggle_icons/icon_navigate.png", "color": "#8CBF26"}, + {"title": tr_noop("Setup Instructions"), "type": "hub", "on_click": self._on_setup, "icon": "toggle_icons/icon_navigate.png", "color": "#8CBF26"}, + { + "title": tr_noop("Speed Limit Filler"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("SpeedLimitFiller"), + "set_state": lambda s: self._params.put_bool("SpeedLimitFiller", s), + "icon": "toggle_icons/icon_speed_limit.png", + "color": "#8CBF26", + }, + {"title": tr_noop("Search Destination"), "type": "hub", "on_click": self._on_search, "icon": "toggle_icons/icon_navigate.png", "color": "#8CBF26"}, + { + "title": tr_noop("Home Address"), + "type": "value", + "get_value": lambda: self._params.get("HomeAddress", encoding='utf-8') or tr("Not set"), + "on_click": self._on_home, + "icon": "toggle_icons/icon_navigate.png", + "color": "#8CBF26", + }, + { + "title": tr_noop("Work Address"), + "type": "value", + "get_value": lambda: self._params.get("WorkAddress", encoding='utf-8') or tr("Not set"), + "on_click": self._on_work, + "icon": "toggle_icons/icon_navigate.png", + "color": "#8CBF26", + }, ] + for name, panel in self._sub_panels.items(): + if hasattr(panel, 'set_navigate_callback'): + panel.set_navigate_callback(self._navigate_to) + if hasattr(panel, 'set_back_callback'): + panel.set_back_callback(self._go_back) + self._rebuild_grid() - self._scroller = Scroller(items, line_separator=True, spacing=0) + def _on_setup(self): + gui_app.set_modal_overlay( + alert_dialog(tr("Mapbox Setup:\n1. Create account at mapbox.com\n2. Generate Public/Secret keys\n3. Add keys in 'Mapbox Credentials'")) + ) + + def _on_search(self): + def on_close(res, text): + if res == DialogResult.CONFIRM and text: + self._params.put("SearchAddress", text) + + gui_app.set_modal_overlay(InputDialog(tr("Search Destination"), "", on_close=on_close)) + + def _on_home(self): + current = self._params.get("HomeAddress", encoding='utf-8') or "" + + def on_close(res, text): + if res == DialogResult.CONFIRM: + self._params.put("HomeAddress", text) + self._rebuild_grid() + + gui_app.set_modal_overlay(InputDialog(tr("Home Address"), current, on_close=on_close)) + + def _on_work(self): + current = self._params.get("WorkAddress", encoding='utf-8') or "" + + def on_close(res, text): + if res == DialogResult.CONFIRM: + self._params.put("WorkAddress", text) + self._rebuild_grid() + + gui_app.set_modal_overlay(InputDialog(tr("Work Address"), current, on_close=on_close)) + + +class StarPilotMapboxLayout(StarPilotPanel): + def __init__(self): + super().__init__() + self.CATEGORIES = [ + { + "title": tr_noop("Public Mapbox Key"), + "type": "value", + "get_value": self._get_key_display, + "on_click": lambda: self._on_key("MapboxPublicKey", "pk."), + "color": "#8CBF26", + }, + { + "title": tr_noop("Secret Mapbox Key"), + "type": "value", + "get_value": self._get_secret_display, + "on_click": lambda: self._on_key("MapboxSecretKey", "sk."), + "color": "#8CBF26", + }, + ] + self._rebuild_grid() + + def _get_key_display(self): + v = self._params.get("MapboxPublicKey", encoding='utf-8') or "" + return f"{v[:8]}..." if v else tr("Not set") + + def _get_secret_display(self): + v = self._params.get("MapboxSecretKey", encoding='utf-8') or "" + return "********" if v else tr("Not set") + + def _on_key(self, key, prefix): + current = self._params.get(key, encoding='utf-8') or "" + if current: + + def on_remove(res): + if res == DialogResult.CONFIRM: + self._params.remove(key) + self._rebuild_grid() + + gui_app.set_modal_overlay(ConfirmDialog(tr(f"Remove your {key.replace('Mapbox', '')} key?"), tr("Remove"), on_close=on_remove)) + else: + + def on_close(res, text): + if res == DialogResult.CONFIRM and text: + if not text.startswith(prefix): + text = prefix + text + self._params.put(key, text) + self._rebuild_grid() + + gui_app.set_modal_overlay(InputDialog(tr(f"Enter {key.replace('Mapbox', 'Mapbox ')}"), "", on_close=on_close)) diff --git a/selfdrive/ui/layouts/settings/starpilot/panel.py b/selfdrive/ui/layouts/settings/starpilot/panel.py index 5c68eefc0..a4f6b0f8a 100644 --- a/selfdrive/ui/layouts/settings/starpilot/panel.py +++ b/selfdrive/ui/layouts/settings/starpilot/panel.py @@ -6,8 +6,10 @@ from enum import IntEnum import pyray as rl from openpilot.common.params import Params +from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.widgets import Widget + class StarPilotPanelType(IntEnum): MAIN = 0 SOUNDS = 1 @@ -23,23 +25,30 @@ class StarPilotPanelType(IntEnum): THEMES = 11 VEHICLE = 12 WHEEL = 13 + DEVELOPER = 14 + @dataclass class StarPilotPanelInfo: name: str instance: Widget + +from openpilot.selfdrive.ui.layouts.settings.starpilot.metro import TileGrid, HubTile, ToggleTile, ValueTile + + class StarPilotPanel(Widget): def __init__(self): super().__init__() self._params = Params() self._params_memory = Params(memory=True) - self._tuning_levels: dict[str, int] = {} self._navigate_callback: Callable | None = None self._back_callback: Callable | None = None self._current_sub_panel = "" self._sub_panels: dict[str, Widget] = {} self._scroller = None + self._tile_grid = None + self.CATEGORIES = [] def set_navigate_callback(self, callback: Callable): self._navigate_callback = callback @@ -47,8 +56,42 @@ class StarPilotPanel(Widget): def set_back_callback(self, callback: Callable): self._back_callback = callback - def set_tuning_levels(self, levels: dict[str, int]): - self._tuning_levels = levels + def _rebuild_grid(self): + if not self.CATEGORIES: + return + + if self._tile_grid is None: + self._tile_grid = TileGrid(columns=None, padding=20) + + self._tile_grid.clear() + + for cat in self.CATEGORIES: + visible_fn = cat.get("visible") + if visible_fn is not None and not visible_fn(): + continue + + tile_type = cat.get("type", "hub") + if tile_type == "hub": + on_click = cat.get("on_click") + if on_click is None: + on_click = lambda c=cat: self._navigate_to(c["panel"]) + + tile = HubTile( + title=tr(cat["title"]), + desc=tr(cat.get("desc", "")), + icon_path=cat.get("icon"), + on_click=on_click, + starpilot_icon=cat.get("starpilot_icon", True), + bg_color=cat.get("color"), + ) + elif tile_type == "toggle": + tile = ToggleTile(title=tr(cat["title"]), get_state=cat["get_state"], set_state=cat["set_state"], icon_path=cat.get("icon"), bg_color=cat.get("color")) + elif tile_type == "value": + tile = ValueTile(title=tr(cat["title"]), get_value=cat["get_value"], on_click=cat["on_click"], icon_path=cat.get("icon"), bg_color=cat.get("color")) + else: + continue + + self._tile_grid.add_tile(tile) def _navigate_to(self, sub_panel: str): self._current_sub_panel = sub_panel @@ -63,11 +106,14 @@ class StarPilotPanel(Widget): def _render(self, rect: rl.Rectangle): if self._current_sub_panel and self._current_sub_panel in self._sub_panels: self._sub_panels[self._current_sub_panel].render(rect) + elif self.CATEGORIES and self._tile_grid: + self._tile_grid.render(rect) elif self._scroller: self._scroller.render(rect) def show_event(self): super().show_event() + self._rebuild_grid() if self._current_sub_panel and self._current_sub_panel in self._sub_panels: self._sub_panels[self._current_sub_panel].show_event() elif self._scroller: diff --git a/selfdrive/ui/layouts/settings/starpilot/sounds.py b/selfdrive/ui/layouts/settings/starpilot/sounds.py index 060f81753..258129b58 100644 --- a/selfdrive/ui/layouts/settings/starpilot/sounds.py +++ b/selfdrive/ui/layouts/settings/starpilot/sounds.py @@ -4,12 +4,14 @@ from pathlib import Path from openpilot.common.basedir import BASEDIR from openpilot.frogpilot.common.frogpilot_variables import ACTIVE_THEME_PATH +from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.lib.multilang import tr, tr_noop -from openpilot.system.ui.widgets.list_view import button_item, toggle_item, value_button_item -from openpilot.system.ui.widgets.scroller_tici import Scroller +from openpilot.system.ui.widgets import DialogResult +from openpilot.system.ui.widgets.selection_dialog import SelectionDialog from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.selfdrive.ui.lib.starpilot_state import starpilot_state from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import StarPilotPanel +from openpilot.selfdrive.ui.layouts.settings.starpilot.metro import TileGrid, ToggleTile, SliderDialog class StarPilotSoundsLayout(StarPilotPanel): VOLUME_KEYS = [ @@ -32,39 +34,36 @@ class StarPilotSoundsLayout(StarPilotPanel): def __init__(self): super().__init__() - items = [ - button_item( - tr_noop("Alert Volume Controller"), - lambda: tr("MANAGE"), - tr_noop("Set how loud each type of openpilot alert is to keep routine prompts from becoming distracting."), - callback=lambda: self._navigate_to("volume_control"), - icon="toggle_icons/icon_mute.png", - starpilot_icon=True, - ), - button_item( - tr_noop("StarPilot Alerts"), - lambda: tr("MANAGE"), - tr_noop("Optional StarPilot alerts that highlight driving events in a more noticeable way."), - callback=lambda: self._navigate_to("custom_alerts"), - icon="toggle_icons/icon_green_light.png", - starpilot_icon=True, - ), - ] - - self._scroller = Scroller(items, line_separator=True, spacing=0) - self._sub_panels = { "volume_control": StarPilotVolumeControlLayout(), "custom_alerts": StarPilotCustomAlertsLayout(), } - # Wire up navigation callbacks for sub-panels + self.CATEGORIES = [ + { + "title": tr_noop("Alert Volume Controller"), + "panel": "volume_control", + "desc": tr_noop("Adjust volume levels for different alert types."), + "icon": "toggle_icons/icon_mute.png", + "color": "#FF0097" + }, + { + "title": tr_noop("StarPilot Alerts"), + "panel": "custom_alerts", + "desc": tr_noop("Enable or disable specific StarPilot-only alerts."), + "icon": "toggle_icons/icon_green_light.png", + "color": "#FF0097" + }, + ] + for name, panel in self._sub_panels.items(): if hasattr(panel, 'set_navigate_callback'): panel.set_navigate_callback(self._navigate_to) if hasattr(panel, 'set_back_callback'): panel.set_back_callback(self._go_back) + self._rebuild_grid() + def refresh_visibility(self): for panel in self._sub_panels.values(): if hasattr(panel, 'refresh_visibility'): @@ -72,53 +71,13 @@ class StarPilotSoundsLayout(StarPilotPanel): class StarPilotVolumeControlLayout(StarPilotPanel): VOLUME_INFO = { - "DisengageVolume": { - "title": tr_noop("Disengage Volume"), - "desc": tr_noop( - "Set the volume for alerts when openpilot disengages.

Examples include: \"Cruise Fault: Restart the Car\", \"Parking Brake Engaged\", \"Pedal Pressed\"." - ), - "min": 0, - }, - "EngageVolume": { - "title": tr_noop("Engage Volume"), - "desc": tr_noop("Set the volume for the chime when openpilot engages, such as after pressing the \"RESUME\" or \"SET\" steering wheel buttons."), - "min": 0, - }, - "PromptVolume": { - "title": tr_noop("Prompt Volume"), - "desc": tr_noop( - "Set the volume for prompts that need attention.

Examples include: \"Car Detected in Blindspot\", \"Steering Temporarily Unavailable\", \"Turn Exceeds Steering Limit\"." - ), - "min": 0, - }, - "PromptDistractedVolume": { - "title": tr_noop("Prompt Distracted Volume"), - "desc": tr_noop( - "Set the volume for prompts when openpilot detects driver distraction or unresponsiveness.

Examples include: \"Pay Attention\", \"Touch Steering Wheel\"." - ), - "min": 0, - }, - "RefuseVolume": { - "title": tr_noop("Refuse Volume"), - "desc": tr_noop( - "Set the volume for alerts when openpilot refuses to engage.

Examples include: \"Brake Hold Active\", \"Door Open\", \"Seatbelt Unlatched\"." - ), - "min": 0, - }, - "WarningSoftVolume": { - "title": tr_noop("Warning Soft Volume"), - "desc": tr_noop( - "Set the volume for softer warnings about potential risks.

Examples include: \"BRAKE! Risk of Collision\", \"Steering Temporarily Unavailable\"." - ), - "min": 25, - }, - "WarningImmediateVolume": { - "title": tr_noop("Warning Immediate Volume"), - "desc": tr_noop( - "Set the volume for the loudest warnings that require urgent attention.

Examples include: \"DISENGAGE IMMEDIATELY — Driver Distracted\", \"DISENGAGE IMMEDIATELY — Driver Unresponsive\"." - ), - "min": 25, - }, + "DisengageVolume": {"title": tr_noop("Disengage Volume"), "icon": "toggle_icons/icon_mute.png", "min": 0}, + "EngageVolume": {"title": tr_noop("Engage Volume"), "icon": "toggle_icons/icon_green_light.png", "min": 0}, + "PromptVolume": {"title": tr_noop("Prompt Volume"), "icon": "toggle_icons/icon_message.png", "min": 0}, + "PromptDistractedVolume": {"title": tr_noop("Distracted Volume"), "icon": "toggle_icons/icon_display.png", "min": 0}, + "RefuseVolume": {"title": tr_noop("Refuse Volume"), "icon": "toggle_icons/icon_mute.png", "min": 0}, + "WarningSoftVolume": {"title": tr_noop("Warning Soft"), "icon": "toggle_icons/icon_conditional.png", "min": 25}, + "WarningImmediateVolume": {"title": tr_noop("Warning Immediate"), "icon": "toggle_icons/icon_conditional.png", "min": 25}, } _sound_player_process = None @@ -126,140 +85,126 @@ class StarPilotVolumeControlLayout(StarPilotPanel): def __init__(self): super().__init__() self._init_sound_player() - - volume_labels = {0.0: tr("Muted"), 101.0: tr("Auto")} - for i in range(1, 101): - volume_labels[float(i)] = f"{i}%" - - items = [] + + self.CATEGORIES = [] for key in StarPilotSoundsLayout.VOLUME_KEYS: info = self.VOLUME_INFO[key] - items.append( - value_button_item( - info["title"], - key, - min_val=info["min"], - max_val=101, - step=1, - button_text="Test", - button_callback=lambda k=key: self._test_sound(k), - description=info["desc"], - labels=volume_labels, - ) - ) + + def get_val(k=key): + v = self._params.get_int(k, return_default=True, default=100) + if v == 0: return tr("Muted") + if v == 101: return tr("Auto") + return f"{v}%" - self._scroller = Scroller(items, line_separator=True, spacing=0) + def on_click(k=key, i=info): + self._show_volume_selector(k, i) + + self.CATEGORIES.append({ + "title": info["title"], + "type": "value", + "get_value": get_val, + "on_click": on_click, + "icon": info["icon"], + "color": "#FF0097" + }) + + self._rebuild_grid() + + def _show_volume_selector(self, key: str, info: dict): + current_v = self._params.get_int(key, return_default=True, default=100) + + def on_close(res, val): + if res == DialogResult.CONFIRM: + new_v = int(val) + if new_v != 101 and new_v < info["min"]: + new_v = info["min"] + self._params.put_int(key, new_v) + self._test_sound(key) + self._rebuild_grid() + + gui_app.set_modal_overlay(SliderDialog( + tr(info["title"]), 0, 101, 1, current_v, on_close, + unit="%", labels={0: tr("Muted"), 101: tr("Auto")}, color="#FF0097" + )) @classmethod def _init_sound_player(cls): - if cls._sound_player_process is not None: - return - + if cls._sound_player_process is not None: return program = """ import numpy as np import sounddevice as sd import sys import wave - while True: try: line = sys.stdin.readline() - if not line: - break + if not line: break path, volume = line.strip().split('|') - sound_file = wave.open(path, 'rb') audio = np.frombuffer(sound_file.readframes(sound_file.getnframes()), dtype=np.int16).astype(np.float32) / 32768.0 - sd.play(audio * float(volume), sound_file.getframerate()) sd.wait() - except Exception: - pass + except: pass """ + cls._sound_player_process = subprocess.Popen(["python3", "-u", "-c", program], stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - cls._sound_player_process = subprocess.Popen( - ["python3", "-u", "-c", program], - stdin=subprocess.PIPE, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) + def _test_sound(self, key: str): + base_name = key.replace("Volume", "") + if ui_state.started: + self._params_memory.put("TestAlert", base_name[0].lower() + base_name[1:]) + else: + self._play_sound_offroad(key) def _play_sound_offroad(self, key: str): base_name = key.replace("Volume", "") snake_case = "".join(["_" + c.lower() if c.isupper() else c for c in base_name]).lstrip("_") stock_path = Path(BASEDIR) / "selfdrive" / "assets" / "sounds" / f"{snake_case}.wav" theme_path = ACTIVE_THEME_PATH / "sounds" / f"{snake_case}.wav" - sound_path = theme_path if theme_path.exists() else stock_path - if not sound_path.exists(): - return - + if not sound_path.exists(): return volume = self._params.get_int(key, return_default=True, default=100) / 100.0 - try: self._sound_player_process.stdin.write(f"{sound_path}|{volume}\n".encode()) self._sound_player_process.stdin.flush() - except Exception: - pass - - def _test_sound(self, key: str): - base_name = key.replace("Volume", "") - if ui_state.started: - camel_case = base_name[0].lower() + base_name[1:] - self._params_memory.put("TestAlert", camel_case) - else: - self._play_sound_offroad(key) + except: pass class StarPilotCustomAlertsLayout(StarPilotPanel): ALERT_INFO = { - "GoatScream": { - "title": tr_noop("Goat Scream"), - "desc": tr_noop( - "Play the infamous \"Goat Scream\" when the steering controller reaches its limit. Based on the \"Turn Exceeds Steering Limit\" event." - ), - }, - "GreenLightAlert": { - "title": tr_noop("Green Light Alert"), - "desc": tr_noop( - "Play an alert when the model predicts a red light has turned green.

Disclaimer: openpilot does not explicitly detect traffic lights. This alert is based on end-to-end model predictions from camera input and may trigger even when the light has not changed." - ), - }, - "LeadDepartingAlert": { - "title": tr_noop("Lead Departing Alert"), - "desc": tr_noop("Play an alert when the lead vehicle departs from a stop."), - }, - "LoudBlindspotAlert": { - "title": tr_noop("Loud \"Car Detected in Blindspot\" Alert"), - "desc": tr_noop( - "Play a louder alert if a vehicle is in the blind spot when attempting to change lanes. Based on the \"Car Detected in Blindspot\" event." - ), - }, - "SpeedLimitChangedAlert": { - "title": tr_noop("Speed Limit Changed Alert"), - "desc": tr_noop("Play an alert when the posted speed limit changes."), - }, + "GoatScream": {"title": tr_noop("Goat Scream"), "icon": "toggle_icons/icon_sound.png"}, + "GreenLightAlert": {"title": tr_noop("Green Light"), "icon": "toggle_icons/icon_green_light.png"}, + "LeadDepartingAlert": {"title": tr_noop("Lead Departure"), "icon": "toggle_icons/icon_steering.png"}, + "LoudBlindspotAlert": {"title": tr_noop("Loud Blindspot"), "icon": "toggle_icons/icon_display.png"}, + "SpeedLimitChangedAlert": {"title": tr_noop("Speed Limit"), "icon": "toggle_icons/icon_speed_limit.png"}, } def __init__(self): super().__init__() - self._toggle_items = {} - + self.CATEGORIES = [] for key in StarPilotSoundsLayout.CUSTOM_ALERTS_KEYS: info = self.ALERT_INFO[key] - self._toggle_items[key] = toggle_item( - info["title"], - info["desc"], - self._params.get_bool(key), - callback=lambda s, k=key: self._params.put_bool(k, s), - ) - - self._scroller = Scroller(list(self._toggle_items.values()), line_separator=True, spacing=0) + self.CATEGORIES.append({ + "title": info["title"], + "type": "toggle", + "get_state": lambda k=key: self._params.get_bool(k), + "set_state": lambda s, k=key: self._params.put_bool(k, s), + "icon": info["icon"], + "color": "#FF0097", + "key": key # Store for visibility check + }) + self._rebuild_grid() def refresh_visibility(self): - current_level = int(self._params.get("TuningLevel", return_default=True, default="1") or "1") - for key, item in self._toggle_items.items(): - min_level = self._tuning_levels.get(key, 0) - visible = current_level >= min_level + self._rebuild_grid() + + def _rebuild_grid(self): + # Override to add custom BSM/SLC visibility logic + if not self.CATEGORIES: return + if self._tile_grid is None: self._tile_grid = TileGrid(columns=None, padding=20) + self._tile_grid.clear() + + for cat in self.CATEGORIES: + key = cat.get("key") + visible = True if key == "LoudBlindspotAlert": visible &= starpilot_state.car_state.hasBSM @@ -268,4 +213,12 @@ class StarPilotCustomAlertsLayout(StarPilotPanel): starpilot_state.car_state.hasOpenpilotLongitudinal and self._params.get_bool("SpeedLimitController") ) - item.set_visible(visible) + if not visible: continue + + tile = ToggleTile( title=tr(cat["title"]), + get_state=cat["get_state"], + set_state=cat["set_state"], + icon_path=cat.get("icon"), + bg_color=cat.get("color") + ) + self._tile_grid.add_tile(tile) diff --git a/selfdrive/ui/layouts/settings/starpilot/themes.py b/selfdrive/ui/layouts/settings/starpilot/themes.py index 17301b5d1..352a18c6f 100644 --- a/selfdrive/ui/layouts/settings/starpilot/themes.py +++ b/selfdrive/ui/layouts/settings/starpilot/themes.py @@ -1,30 +1,160 @@ from __future__ import annotations - +from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.lib.multilang import tr, tr_noop -from openpilot.system.ui.widgets.list_view import button_item -from openpilot.system.ui.widgets.scroller_tici import Scroller +from openpilot.system.ui.widgets import DialogResult +from openpilot.system.ui.widgets.selection_dialog import SelectionDialog from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import StarPilotPanel + class StarPilotThemesLayout(StarPilotPanel): def __init__(self): super().__init__() - items = [ - button_item( - tr_noop("Select Theme"), - lambda: tr("SELECT"), - tr_noop("Select a theme for the StarPilot interface."), - ), - button_item( - tr_noop("Holiday Themes"), - lambda: tr("MANAGE"), - tr_noop("Enable or disable holiday-themed visuals."), - ), - button_item( - tr_noop("Custom Theme"), - lambda: tr("MANAGE"), - tr_noop("Create or import a custom theme."), - ), + self._sub_panels = { + "personalize": StarPilotPersonalizeLayout(), + } + + self.CATEGORIES = [ + { + "title": tr_noop("Personalize openpilot"), + "panel": "personalize", + "icon": "toggle_icons/icon_frog.png", + "color": "#A200FF", + "desc": tr_noop("Customize the overall look and feel."), + }, + { + "title": tr_noop("Holiday Themes"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("HolidayThemes"), + "set_state": lambda s: self._params.put_bool("HolidayThemes", s), + "icon": "toggle_icons/icon_calendar.png", + "color": "#A200FF", + }, + { + "title": tr_noop("Rainbow Path"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("RainbowPath"), + "set_state": lambda s: self._params.put_bool("RainbowPath", s), + "icon": "toggle_icons/icon_rainbow.png", + "color": "#A200FF", + }, + { + "title": tr_noop("Random Events"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("RandomEvents"), + "set_state": lambda s: self._params.put_bool("RandomEvents", s), + "icon": "toggle_icons/icon_random.png", + "color": "#A200FF", + }, + { + "title": tr_noop("Random Themes"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("RandomThemes"), + "set_state": lambda s: self._params.put_bool("RandomThemes", s), + "icon": "toggle_icons/icon_random_themes.png", + "color": "#A200FF", + }, + {"title": tr_noop("Startup Alert"), "type": "hub", "on_click": self._on_startup_alert, "color": "#A200FF"}, ] - self._scroller = Scroller(items, line_separator=True, spacing=0) + for name, panel in self._sub_panels.items(): + if hasattr(panel, 'set_navigate_callback'): + panel.set_navigate_callback(self._navigate_to) + if hasattr(panel, 'set_back_callback'): + panel.set_back_callback(self._go_back) + + self._rebuild_grid() + + def _on_startup_alert(self): + options = ["Stock", "FrogPilot", "Clear"] + current_top = self._params.get("StartupMessageTop", encoding='utf-8') or "" + if current_top == "Be ready to take over at any time": + current = "Stock" + elif current_top == "Hop in and buckle up!": + current = "FrogPilot" + else: + current = "Clear" + + def on_select(res, val): + if res == DialogResult.CONFIRM: + if val == "Stock": + self._params.put("StartupMessageTop", "Be ready to take over at any time") + self._params.put("StartupMessageBottom", "Always keep hands on wheel and eyes on road") + elif val == "FrogPilot": + self._params.put("StartupMessageTop", "Hop in and buckle up!") + self._params.put("StartupMessageBottom", "Human-tested, frog-approved") + else: + self._params.remove("StartupMessageTop") + self._params.remove("StartupMessageBottom") + + gui_app.set_modal_overlay(SelectionDialog(tr("Startup Alert"), options, current, on_close=on_select)) + + +class StarPilotPersonalizeLayout(StarPilotPanel): + def __init__(self): + super().__init__() + self.CATEGORIES = [ + { + "title": tr_noop("Boot Logo"), + "type": "value", + "get_value": lambda: self._params.get("BootLogo", encoding='utf-8') or "Stock", + "on_click": lambda: self._show_theme_selector("BootLogo"), + "color": "#A200FF", + }, + { + "title": tr_noop("Color Scheme"), + "type": "value", + "get_value": lambda: self._params.get("ColorScheme", encoding='utf-8') or "Stock", + "on_click": lambda: self._show_theme_selector("ColorScheme"), + "color": "#A200FF", + }, + { + "title": tr_noop("Distance Icons"), + "type": "value", + "get_value": lambda: self._params.get("DistanceIconPack", encoding='utf-8') or "Stock", + "on_click": lambda: self._show_theme_selector("DistanceIconPack"), + "color": "#A200FF", + }, + { + "title": tr_noop("Icon Pack"), + "type": "value", + "get_value": lambda: self._params.get("IconPack", encoding='utf-8') or "Stock", + "on_click": lambda: self._show_theme_selector("IconPack"), + "color": "#A200FF", + }, + { + "title": tr_noop("Turn Signals"), + "type": "value", + "get_value": lambda: self._params.get("SignalAnimation", encoding='utf-8') or "Stock", + "on_click": lambda: self._show_theme_selector("SignalAnimation"), + "color": "#A200FF", + }, + { + "title": tr_noop("Sound Pack"), + "type": "value", + "get_value": lambda: self._params.get("SoundPack", encoding='utf-8') or "Stock", + "on_click": lambda: self._show_theme_selector("SoundPack"), + "color": "#A200FF", + }, + { + "title": tr_noop("Steering Wheel"), + "type": "value", + "get_value": lambda: self._params.get("WheelIcon", encoding='utf-8') or "Stock", + "on_click": lambda: self._show_theme_selector("WheelIcon"), + "color": "#A200FF", + }, + ] + self._rebuild_grid() + + def _show_theme_selector(self, key): + # Ported logic for theme selection. In a real environment we'd scan directories. + # For now, we'll provide a simplified selection based on current param. + themes = ["Stock", "Frog", "Cyberpunk", "Minimal"] + current = self._params.get(key, encoding='utf-8') or "Stock" + + def on_select(res, val): + if res == DialogResult.CONFIRM: + self._params.put(key, val) + self._rebuild_grid() + + gui_app.set_modal_overlay(SelectionDialog(tr(key), themes, current, on_close=on_select)) diff --git a/selfdrive/ui/layouts/settings/starpilot/utilities.py b/selfdrive/ui/layouts/settings/starpilot/utilities.py index 0c96fe9b0..22b5a64ae 100644 --- a/selfdrive/ui/layouts/settings/starpilot/utilities.py +++ b/selfdrive/ui/layouts/settings/starpilot/utilities.py @@ -1,30 +1,163 @@ from __future__ import annotations - +import json +from openpilot.system.hardware import HARDWARE +from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.lib.multilang import tr, tr_noop -from openpilot.system.ui.widgets.list_view import button_item -from openpilot.system.ui.widgets.scroller_tici import Scroller +from openpilot.system.ui.widgets import DialogResult +from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog, alert_dialog +from openpilot.system.ui.widgets.selection_dialog import SelectionDialog +from openpilot.system.ui.widgets.input_dialog import InputDialog from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import StarPilotPanel +EXCLUDED_KEYS = { + "AvailableModels", + "AvailableModelNames", + "FrogPilotStats", + "GithubSshKeys", + "GithubUsername", + "MapBoxRequests", + "ModelDrivesAndScores", + "OverpassRequests", + "SpeedLimits", + "SpeedLimitsFiltered", + "UpdaterAvailableBranches", +} + +REPORT_CATEGORIES = [ + "Acceleration feels harsh or jerky", + "An alert was unclear and I'm not sure what it meant", + "Braking is too sudden or uncomfortable", + "I'm not sure if this is normal or a bug:", + "My steering wheel buttons aren't working", + "openpilot disengages when I don't expect it", + "openpilot feels sluggish or slow to respond", + "Something else (please describe)", +] + + class StarPilotUtilitiesLayout(StarPilotPanel): def __init__(self): super().__init__() - - items = [ - button_item( - tr_noop("Update StarPilot"), - lambda: tr("CHECK"), - tr_noop("Check for updates and update StarPilot to the latest version."), - ), - button_item( - tr_noop("Reset Settings"), - lambda: tr("RESET"), - tr_noop("Reset all StarPilot settings to their default values."), - ), - button_item( - tr_noop("View Logs"), - lambda: tr("VIEW"), - tr_noop("View StarPilot logs for debugging."), - ), + self.CATEGORIES = [ + { + "title": tr_noop("Debug Mode"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("DebugMode"), + "set_state": lambda s: self._params.put_bool("DebugMode", s), + "color": "#FA6800", + }, + {"title": tr_noop("Flash Panda"), "type": "hub", "on_click": self._on_flash_panda, "color": "#FA6800"}, + { + "title": tr_noop("Force Drive State"), + "type": "value", + "get_value": self._get_force_drive_state, + "on_click": self._on_force_drive_state, + "color": "#FA6800", + }, + { + "title": tr_noop("The Pond"), + "type": "value", + "get_value": lambda: tr("Paired") if self._params.get_bool("PondPaired") else tr("Not paired"), + "on_click": self._on_pond_clicked, + "color": "#FA6800", + }, + {"title": tr_noop("Report Issue"), "type": "hub", "on_click": self._on_report_issue, "color": "#FA6800"}, + {"title": tr_noop("Reset to Defaults"), "type": "hub", "on_click": self._on_reset_defaults, "color": "#FA6800"}, + {"title": tr_noop("Reset to Stock"), "type": "hub", "on_click": self._on_reset_stock, "color": "#FA6800"}, ] + self._rebuild_grid() - self._scroller = Scroller(items, line_separator=True, spacing=0) + def _get_force_drive_state(self): + if self._params.get_bool("ForceOnroad"): + return tr("Onroad") + if self._params.get_bool("ForceOffroad"): + return tr("Offroad") + return tr("Default") + + def _on_flash_panda(self): + def _do_flash(res): + if res == DialogResult.CONFIRM: + self._params_memory.put_bool("FlashPanda", True) + gui_app.set_modal_overlay(alert_dialog(tr("Panda flashing started. Device will reboot when finished."))) + + gui_app.set_modal_overlay(ConfirmDialog(tr("Flash Panda firmware?"), tr("Flash"), on_close=_do_flash)) + + def _on_force_drive_state(self): + options = [tr("Offroad"), tr("Onroad"), tr("Default")] + + def on_select(res, val): + if res == DialogResult.CONFIRM: + if val == tr("Offroad"): + self._params.put_bool("ForceOffroad", True) + self._params.put_bool("ForceOnroad", False) + elif val == tr("Onroad"): + self._params.put_bool("ForceOnroad", True) + self._params.put_bool("ForceOffroad", False) + else: + self._params.put_bool("ForceOffroad", False) + self._params.put_bool("ForceOnroad", False) + self._rebuild_grid() + + current = self._get_force_drive_state() + gui_app.set_modal_overlay(SelectionDialog(tr("Force Drive State"), options, current, on_close=on_select)) + + def _on_pond_clicked(self): + paired = self._params.get_bool("PondPaired") + if paired: + + def on_unpair(res): + if res == DialogResult.CONFIRM: + self._params.put_bool("PondPaired", False) + gui_app.set_modal_overlay(alert_dialog(tr("Unpaired from The Pond."))) + self._rebuild_grid() + + gui_app.set_modal_overlay(ConfirmDialog(tr("Unpair from The Pond?"), tr("Unpair"), on_close=on_unpair)) + else: + gui_app.set_modal_overlay(alert_dialog(tr("Visit frogpilot.com/the_pond to pair your device."))) + + def _on_report_issue(self): + def on_category(res, val): + if res != DialogResult.CONFIRM: + return + discord_user = self._params.get("DiscordUsername", encoding='utf-8') or "" + + def on_discord(res2, username): + if res2 == DialogResult.CONFIRM and username: + self._params.put("DiscordUsername", username) + report = json.dumps({"DiscordUser": username, "Issue": val}) + self._params_memory.put("IssueReported", report) + gui_app.set_modal_overlay(alert_dialog(tr("Issue reported. Thank you!"))) + + gui_app.set_modal_overlay(InputDialog(tr("Discord Username"), discord_user or "", on_close=on_discord)) + + gui_app.set_modal_overlay(SelectionDialog(tr("Select Issue"), REPORT_CATEGORIES, on_close=on_category)) + + def _on_reset_defaults(self): + def _do_reset(res): + if res == DialogResult.CONFIRM: + all_keys = self._params.all_keys() + for k in all_keys: + if k in EXCLUDED_KEYS: + continue + default = self._params.get_default_value(k) + if default is not None: + self._params.put(k, default) + gui_app.set_modal_overlay(alert_dialog(tr("Toggles reset to defaults."))) + self._rebuild_grid() + + gui_app.set_modal_overlay(ConfirmDialog(tr("Reset all toggles to defaults?"), tr("Reset"), on_close=_do_reset)) + + def _on_reset_stock(self): + def _do_reset(res): + if res == DialogResult.CONFIRM: + all_keys = self._params.all_keys() + for k in all_keys: + if k in EXCLUDED_KEYS: + continue + stock = self._params.get_stock_value(k) + if stock is not None: + self._params.put(k, stock) + gui_app.set_modal_overlay(alert_dialog(tr("Toggles reset to stock openpilot."))) + self._rebuild_grid() + + gui_app.set_modal_overlay(ConfirmDialog(tr("Reset all toggles to stock openpilot?"), tr("Reset"), on_close=_do_reset)) diff --git a/selfdrive/ui/layouts/settings/starpilot/vehicle.py b/selfdrive/ui/layouts/settings/starpilot/vehicle.py index 01cc2d0df..6d00c817f 100644 --- a/selfdrive/ui/layouts/settings/starpilot/vehicle.py +++ b/selfdrive/ui/layouts/settings/starpilot/vehicle.py @@ -1,20 +1,516 @@ from __future__ import annotations +import os +import re +from pathlib import Path +from openpilot.system.hardware import HARDWARE +from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.lib.multilang import tr, tr_noop -from openpilot.system.ui.widgets.list_view import button_item -from openpilot.system.ui.widgets.scroller_tici import Scroller +from openpilot.system.ui.widgets import DialogResult +from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog +from openpilot.system.ui.widgets.selection_dialog import SelectionDialog from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import StarPilotPanel +from openpilot.selfdrive.ui.layouts.settings.starpilot.metro import SliderDialog, TileGrid, HubTile, ToggleTile, ValueTile +from openpilot.selfdrive.ui.lib.starpilot_state import starpilot_state + +MAKE_TO_FOLDER = { + "acura": "honda", + "audi": "volkswagen", + "buick": "gm", + "cadillac": "gm", + "chevrolet": "gm", + "chrysler": "chrysler", + "cupra": "volkswagen", + "dodge": "chrysler", + "ford": "ford", + "genesis": "hyundai", + "gmc": "gm", + "holden": "gm", + "honda": "honda", + "hyundai": "hyundai", + "jeep": "chrysler", + "kia": "hyundai", + "lexus": "toyota", + "lincoln": "ford", + "man": "volkswagen", + "mazda": "mazda", + "nissan": "nissan", + "peugeot": "psa", + "ram": "chrysler", + "rivian": "rivian", + "seat": "volkswagen", + "škoda": "volkswagen", + "subaru": "subaru", + "tesla": "tesla", + "toyota": "toyota", + "volkswagen": "volkswagen", +} + + +def get_car_names(car_make: str): + folder = MAKE_TO_FOLDER.get(car_make.lower()) + if not folder: + return [], {} + + # Path to values.py in opendbc + values_path = Path(__file__).parents[4] / "opendbc" / "car" / folder / "values.py" + if not values_path.exists(): + return [], {} + + with open(values_path, "r") as f: + content = f.read() + + # Clean comments + content = re.sub(r'#.*', '', content) + + # Find platforms and car names + platforms = re.findall(r'(\w+)\s*=\s*\w+\s*\(', content) + car_models = {} + car_names = [] + + # This is a simplified version of the C++ regex logic + # In values.py, CarDocs often appears as CarDocs("Name", ...) + matches = re.finditer(r'CarDocs\w*\s*\(\s*"([^"]+)"', content) + for match in matches: + name = match.group(1) + if name.lower().startswith(car_make.lower()): + # Find the platform name by looking backwards for the nearest platform assignment + # For now, we'll just store the name + car_names.append(name) + + return sorted(list(set(car_names))), car_models + + +def _lock_doors_timer_labels(): + labels: dict[float, str] = {0.0: tr("Never")} + for i in range(5, 305, 5): + labels[float(i)] = f"{i}s" + return labels + class StarPilotVehicleSettingsLayout(StarPilotPanel): def __init__(self): super().__init__() + self._sub_panels = { + "gm": StarPilotGMVehicleLayout(), + "hkg": StarPilotHKGVehicleLayout(), + "subaru": StarPilotSubaruVehicleLayout(), + "toyota": StarPilotToyotaVehicleLayout(), + "info": StarPilotVehicleInfoLayout(), + } - items = [ - button_item( - tr_noop("Vehicle Settings"), - lambda: tr("MANAGE"), - tr_noop("Configure car-specific options like model name and features."), - ), + self.CATEGORIES = [ + { + "title": tr_noop("Car Make"), + "type": "value", + "get_value": lambda: self._params.get("CarMake", encoding='utf-8') or tr("None"), + "on_click": self._on_select_make, + "color": "#FFC40D", + }, + { + "title": tr_noop("Car Model"), + "type": "value", + "get_value": lambda: self._params.get("CarModelName", encoding='utf-8') or tr("None"), + "on_click": self._on_select_model, + "color": "#FFC40D", + }, + { + "title": tr_noop("Disable Fingerprinting"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("ForceFingerprint"), + "set_state": lambda s: self._params.put_bool("ForceFingerprint", s), + "color": "#FFC40D", + }, + { + "title": tr_noop("Disable openpilot Long"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("DisableOpenpilotLongitudinal"), + "set_state": self._on_disable_long, + "color": "#FFC40D", + }, + {"title": tr_noop("GM Settings"), "panel": "gm", "icon": "toggle_icons/icon_vehicle.png", "color": "#FFC40D", "key": "gm"}, + {"title": tr_noop("HKG Settings"), "panel": "hkg", "icon": "toggle_icons/icon_vehicle.png", "color": "#FFC40D", "key": "hkg"}, + {"title": tr_noop("Subaru Settings"), "panel": "subaru", "icon": "toggle_icons/icon_vehicle.png", "color": "#FFC40D", "key": "subaru"}, + {"title": tr_noop("Toyota Settings"), "panel": "toyota", "icon": "toggle_icons/icon_vehicle.png", "color": "#FFC40D", "key": "toyota"}, + {"title": tr_noop("Vehicle Info"), "panel": "info", "icon": "toggle_icons/icon_vehicle.png", "color": "#FFC40D"}, ] - self._scroller = Scroller(items, line_separator=True, spacing=0) + for name, panel in self._sub_panels.items(): + if hasattr(panel, 'set_navigate_callback'): + panel.set_navigate_callback(self._navigate_to) + if hasattr(panel, 'set_back_callback'): + panel.set_back_callback(self._go_back) + + self._rebuild_grid() + + def _rebuild_grid(self): + if not self.CATEGORIES: + return + if self._tile_grid is None: + self._tile_grid = TileGrid(columns=None, padding=20) + self._tile_grid.clear() + + cs = starpilot_state.car_state + for cat in self.CATEGORIES: + key = cat.get("key") + visible = True + + if key == "gm": + visible = cs.isGM + elif key == "hkg": + visible = cs.isHKG + elif key == "subaru": + visible = cs.isSubaru + elif key == "toyota": + visible = cs.isToyota + + if not visible: + continue + + tile_type = cat.get("type", "hub") + if tile_type == "hub": + on_click = cat.get("on_click") + if on_click is None: + on_click = lambda c=cat: self._navigate_to(c["panel"]) + tile = HubTile( + title=tr(cat["title"]), + desc=tr(cat.get("desc", "")), + icon_path=cat.get("icon"), + on_click=on_click, + starpilot_icon=cat.get("starpilot_icon", True), + bg_color=cat.get("color"), + ) + elif tile_type == "toggle": + tile = ToggleTile(title=tr(cat["title"]), get_state=cat["get_state"], set_state=cat["set_state"], icon_path=cat.get("icon"), bg_color=cat.get("color")) + elif tile_type == "value": + tile = ValueTile(title=tr(cat["title"]), get_value=cat["get_value"], on_click=cat["on_click"], icon_path=cat.get("icon"), bg_color=cat.get("color")) + else: + continue + + self._tile_grid.add_tile(tile) + + def _on_select_make(self): + makes = sorted(list(MAKE_TO_FOLDER.keys())) + makes = [m.capitalize() for m in makes] + + def on_select(res, val): + if res == DialogResult.CONFIRM: + self._params.put("CarMake", val) + self._params.remove("CarModel") + self._params.remove("CarModelName") + self._rebuild_grid() + + gui_app.set_modal_overlay(SelectionDialog(tr("Select Make"), makes, self._params.get("CarMake", encoding='utf-8') or "", on_close=on_select)) + + def _on_select_model(self): + make = self._params.get("CarMake", encoding='utf-8') + if not make: + gui_app.set_modal_overlay(ConfirmDialog(tr("Please select a Car Make first!"), tr("OK"), on_close=lambda r: None)) + return + + models, _ = get_car_names(make) + if not models: + gui_app.set_modal_overlay(ConfirmDialog(tr("No models found for this make."), tr("OK"), on_close=lambda r: None)) + return + + def on_select(res, val): + if res == DialogResult.CONFIRM: + self._params.put("CarModelName", val) + # In a real build we'd map name to platform code here + self._rebuild_grid() + + gui_app.set_modal_overlay(SelectionDialog(tr("Select Model"), models, self._params.get("CarModelName", encoding='utf-8') or "", on_close=on_select)) + + def _on_disable_long(self, state): + if state: + + def on_confirm(res): + if res == DialogResult.CONFIRM: + self._params.put_bool("DisableOpenpilotLongitudinal", True) + from openpilot.selfdrive.ui.ui_state import ui_state + + if ui_state.started: + HARDWARE.reboot() + self._rebuild_grid() + + gui_app.set_modal_overlay(ConfirmDialog(tr("Disable openpilot longitudinal control?"), tr("Disable"), on_close=on_confirm)) + else: + self._params.put_bool("DisableOpenpilotLongitudinal", False) + self._rebuild_grid() + + +class StarPilotGMVehicleLayout(StarPilotPanel): + def __init__(self): + super().__init__() + self.CATEGORIES = [ + { + "title": tr_noop("Pedal for Long"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("GMPedalLongitudinal"), + "set_state": lambda s: self._params.put_bool("GMPedalLongitudinal", s), + "color": "#FFC40D", + "key": "GMPedalLongitudinal", + }, + { + "title": tr_noop("Remote Start Panda"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("RemoteStartBootsComma"), + "set_state": lambda s: self._params.put_bool("RemoteStartBootsComma", s), + "color": "#FFC40D", + }, + { + "title": tr_noop("Volt SNG Hack"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("VoltSNG"), + "set_state": lambda s: self._params.put_bool("VoltSNG", s), + "color": "#FFC40D", + "key": "VoltSNG", + }, + ] + self._rebuild_grid() + + def _rebuild_grid(self): + if not self.CATEGORIES: + return + if self._tile_grid is None: + self._tile_grid = TileGrid(columns=None, padding=20) + self._tile_grid.clear() + + cs = starpilot_state.car_state + for cat in self.CATEGORIES: + key = cat.get("key") + visible = True + + if key == "GMPedalLongitudinal": + visible = cs.hasPedal + elif key == "VoltSNG": + visible = cs.isVolt and not cs.hasSNG + + if not visible: + continue + + tile = ToggleTile(title=tr(cat["title"]), get_state=cat["get_state"], set_state=cat["set_state"], icon_path=cat.get("icon"), bg_color=cat.get("color")) + self._tile_grid.add_tile(tile) + + +class StarPilotHKGVehicleLayout(StarPilotPanel): + def __init__(self): + super().__init__() + self.CATEGORIES = [ + { + "title": tr_noop("Taco Bell Torque Hack"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("TacoTuneHacks"), + "set_state": lambda s: self._params.put_bool("TacoTuneHacks", s), + "color": "#FFC40D", + "key": "TacoTuneHacks", + }, + ] + self._rebuild_grid() + + def _rebuild_grid(self): + if not self.CATEGORIES: + return + if self._tile_grid is None: + self._tile_grid = TileGrid(columns=None, padding=20) + self._tile_grid.clear() + + cs = starpilot_state.car_state + for cat in self.CATEGORIES: + key = cat.get("key") + visible = True + + if key == "TacoTuneHacks": + visible = cs.isHKGCanFd + + if not visible: + continue + + tile = ToggleTile(title=tr(cat["title"]), get_state=cat["get_state"], set_state=cat["set_state"], icon_path=cat.get("icon"), bg_color=cat.get("color")) + self._tile_grid.add_tile(tile) + + +class StarPilotSubaruVehicleLayout(StarPilotPanel): + def __init__(self): + super().__init__() + self.CATEGORIES = [ + { + "title": tr_noop("Stop and Go"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("SubaruSNG"), + "set_state": lambda s: self._params.put_bool("SubaruSNG", s), + "color": "#FFC40D", + }, + ] + self._rebuild_grid() + + +class StarPilotToyotaVehicleLayout(StarPilotPanel): + def __init__(self): + super().__init__() + self.CATEGORIES = [ + { + "title": tr_noop("Auto Lock Doors"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("LockDoors"), + "set_state": lambda s: self._params.put_bool("LockDoors", s), + "color": "#FFC40D", + }, + { + "title": tr_noop("Auto Unlock Doors"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("UnlockDoors"), + "set_state": lambda s: self._params.put_bool("UnlockDoors", s), + "color": "#FFC40D", + }, + { + "title": tr_noop("Lock Doors Timer"), + "type": "value", + "get_value": lambda: _lock_doors_timer_labels().get(self._params.get_int('LockDoorsTimer'), f"{self._params.get_int('LockDoorsTimer')}s"), + "on_click": self._show_lock_timer_selector, + "color": "#FFC40D", + }, + { + "title": tr_noop("Dashboard Speed Offset"), + "type": "value", + "get_value": lambda: f"{self._params.get_float('ClusterOffset'):.3f}x", + "on_click": self._show_offset_selector, + "color": "#FFC40D", + }, + { + "title": tr_noop("Stop-and-Go Hack"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("SNGHack"), + "set_state": lambda s: self._params.put_bool("SNGHack", s), + "color": "#FFC40D", + "key": "SNGHack", + }, + { + "title": tr_noop("FrogsGoMoo Tweak"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("FrogsGoMoosTweak"), + "set_state": lambda s: self._params.put_bool("FrogsGoMoosTweak", s), + "color": "#FFC40D", + "key": "FrogsGoMoosTweak", + }, + ] + self._rebuild_grid() + + def _rebuild_grid(self): + if not self.CATEGORIES: + return + if self._tile_grid is None: + self._tile_grid = TileGrid(columns=None, padding=20) + self._tile_grid.clear() + + cs = starpilot_state.car_state + for cat in self.CATEGORIES: + key = cat.get("key") + visible = True + + if key == "SNGHack": + visible = not cs.hasSNG + elif key == "FrogsGoMoosTweak": + visible = cs.hasOpenpilotLongitudinal + + if not visible: + continue + + tile_type = cat.get("type", "hub") + if tile_type == "toggle": + tile = ToggleTile(title=tr(cat["title"]), get_state=cat["get_state"], set_state=cat["set_state"], icon_path=cat.get("icon"), bg_color=cat.get("color")) + elif tile_type == "value": + tile = ValueTile(title=tr(cat["title"]), get_value=cat["get_value"], on_click=cat["on_click"], icon_path=cat.get("icon"), bg_color=cat.get("color")) + else: + continue + + self._tile_grid.add_tile(tile) + + def _show_lock_timer_selector(self): + def on_close(res, val): + if res == DialogResult.CONFIRM: + self._params.put_int("LockDoorsTimer", int(val)) + self._rebuild_grid() + + gui_app.set_modal_overlay( + SliderDialog(tr("Lock Doors Timer"), 0, 300, 5, self._params.get_int("LockDoorsTimer"), on_close, labels=_lock_doors_timer_labels(), color="#FFC40D") + ) + + def _show_offset_selector(self): + def on_close(res, val): + if res == DialogResult.CONFIRM: + self._params.put_float("ClusterOffset", float(val)) + self._rebuild_grid() + + gui_app.set_modal_overlay( + SliderDialog(tr("Dashboard Speed Offset"), 1.000, 1.050, 0.001, self._params.get_float("ClusterOffset"), on_close, unit="x", color="#FFC40D") + ) + + +class StarPilotVehicleInfoLayout(StarPilotPanel): + def __init__(self): + super().__init__() + self.CATEGORIES = [ + { + "title": tr_noop("Radar Support"), + "type": "value", + "get_value": lambda: tr("Yes") if starpilot_state.car_state.hasRadar else tr("No"), + "on_click": lambda: None, + "color": "#FFC40D", + }, + { + "title": tr_noop("Longitudinal Support"), + "type": "value", + "get_value": lambda: tr("Yes") if starpilot_state.car_state.hasOpenpilotLongitudinal else tr("No"), + "on_click": lambda: None, + "color": "#FFC40D", + }, + { + "title": tr_noop("Blind Spot Support"), + "type": "value", + "get_value": lambda: tr("Yes") if starpilot_state.car_state.hasBSM else tr("No"), + "on_click": lambda: None, + "color": "#FFC40D", + }, + { + "title": tr_noop("Hardware Detected"), + "type": "value", + "get_value": lambda: ( + ", ".join( + filter( + None, + [ + tr("Pedal") if starpilot_state.car_state.canUsePedal else "", + tr("SDSU") if starpilot_state.car_state.canUseSDSU else "", + tr("ZSS") if starpilot_state.car_state.hasZSS else "", + ], + ) + ) + or tr("None") + ), + "on_click": lambda: None, + "color": "#FFC40D", + }, + { + "title": tr_noop("Pedal Support"), + "type": "value", + "get_value": lambda: tr("Yes") if starpilot_state.car_state.canUsePedal else tr("No"), + "on_click": lambda: None, + "color": "#FFC40D", + }, + { + "title": tr_noop("SDSU Support"), + "type": "value", + "get_value": lambda: tr("Yes") if starpilot_state.car_state.canUseSDSU else tr("No"), + "on_click": lambda: None, + "color": "#FFC40D", + }, + { + "title": tr_noop("SNG Support"), + "type": "value", + "get_value": lambda: tr("Yes") if starpilot_state.car_state.hasSNG else tr("No"), + "on_click": lambda: None, + "color": "#FFC40D", + }, + ] + self._rebuild_grid() diff --git a/selfdrive/ui/layouts/settings/starpilot/visuals.py b/selfdrive/ui/layouts/settings/starpilot/visuals.py index 84efe8733..077d89409 100644 --- a/selfdrive/ui/layouts/settings/starpilot/visuals.py +++ b/selfdrive/ui/layouts/settings/starpilot/visuals.py @@ -1,40 +1,509 @@ from __future__ import annotations - +from openpilot.selfdrive.ui.lib.starpilot_state import starpilot_state +from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.lib.multilang import tr, tr_noop -from openpilot.system.ui.widgets.list_view import button_item -from openpilot.system.ui.widgets.scroller_tici import Scroller +from openpilot.system.ui.widgets import DialogResult +from openpilot.system.ui.widgets.selection_dialog import SelectionDialog from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import StarPilotPanel +from openpilot.selfdrive.ui.layouts.settings.starpilot.metro import TileGrid, ToggleTile, SliderDialog + + +class StarPilotThemesLayout(StarPilotPanel): + def __init__(self): + super().__init__() + self._sub_panels = { + "personalize": StarPilotPersonalizeLayout(), + } + self.CATEGORIES = [ + {"title": tr_noop("Personalize openpilot"), "panel": "personalize", "icon": "toggle_icons/icon_frog.png", "color": "#A200FF"}, + { + "title": tr_noop("Holiday Themes"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("HolidayThemes"), + "set_state": lambda s: self._params.put_bool("HolidayThemes", s), + "icon": "toggle_icons/icon_calendar.png", + "color": "#A200FF", + }, + { + "title": tr_noop("Rainbow Path"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("RainbowPath"), + "set_state": lambda s: self._params.put_bool("RainbowPath", s), + "icon": "toggle_icons/icon_rainbow.png", + "color": "#A200FF", + }, + { + "title": tr_noop("Random Events"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("RandomEvents"), + "set_state": lambda s: self._params.put_bool("RandomEvents", s), + "icon": "toggle_icons/icon_random.png", + "color": "#A200FF", + }, + { + "title": tr_noop("Random Themes"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("RandomThemes"), + "set_state": lambda s: self._params.put_bool("RandomThemes", s), + "icon": "toggle_icons/icon_random_themes.png", + "color": "#A200FF", + }, + ] + for name, panel in self._sub_panels.items(): + if hasattr(panel, 'set_navigate_callback'): + panel.set_navigate_callback(self._navigate_to) + if hasattr(panel, 'set_back_callback'): + panel.set_back_callback(self._go_back) + self._rebuild_grid() + + +class StarPilotPersonalizeLayout(StarPilotPanel): + def __init__(self): + super().__init__() + self.CATEGORIES = [ + {"title": tr_noop("Boot Logo"), "type": "hub", "on_click": lambda: self._show_theme_selector("BootLogo"), "color": "#A200FF"}, + {"title": tr_noop("Color Scheme"), "type": "hub", "on_click": lambda: self._show_theme_selector("ColorScheme"), "color": "#A200FF"}, + {"title": tr_noop("Distance Icons"), "type": "hub", "on_click": lambda: self._show_theme_selector("DistanceIconPack"), "color": "#A200FF"}, + {"title": tr_noop("Icon Pack"), "type": "hub", "on_click": lambda: self._show_theme_selector("IconPack"), "color": "#A200FF"}, + {"title": tr_noop("Turn Signals"), "type": "hub", "on_click": lambda: self._show_theme_selector("SignalAnimation"), "color": "#A200FF"}, + {"title": tr_noop("Sound Pack"), "type": "hub", "on_click": lambda: self._show_theme_selector("SoundPack"), "color": "#A200FF"}, + {"title": tr_noop("Steering Wheel"), "type": "hub", "on_click": lambda: self._show_theme_selector("WheelIcon"), "color": "#A200FF"}, + ] + self._rebuild_grid() + + def _show_theme_selector(self, key): + themes = ["Stock", "Frog", "Cyberpunk", "Minimal"] + current = self._params.get(key, encoding='utf-8') or "Stock" + + def on_select(res, val): + if res == DialogResult.CONFIRM: + self._params.put(key, val) + self._rebuild_grid() + + gui_app.set_modal_overlay(SelectionDialog(tr(key), themes, current, on_close=on_select)) + class StarPilotVisualsLayout(StarPilotPanel): def __init__(self): super().__init__() - - items = [ - button_item( - tr_noop("Advanced UI Controls"), - lambda: tr("MANAGE"), - tr_noop("Advanced visual changes to fine-tune how the driving screen looks."), - ), - button_item( - tr_noop("Driving Screen Widgets"), - lambda: tr("MANAGE"), - tr_noop("Custom StarPilot widgets for the driving screen."), - ), - button_item( - tr_noop("Model UI"), - lambda: tr("MANAGE"), - tr_noop("Model visualizations for the driving path, lane lines, path edges, and road edges."), - ), - button_item( - tr_noop("Navigation Widgets"), - lambda: tr("MANAGE"), - tr_noop("Speed limits, and other navigation widgets."), - ), - button_item( - tr_noop("Quality of Life"), - lambda: tr("MANAGE"), - tr_noop("Miscellaneous visual changes to fine-tune how the driving screen looks."), - ), + self._sub_panels = { + "advanced": StarPilotAdvancedVisualsLayout(), + "widgets": StarPilotVisualWidgetsLayout(), + "model": StarPilotModelUILayout(), + "navigation": StarPilotNavigationVisualsLayout(), + "qol": StarPilotVisualQOLLayout(), + } + self.CATEGORIES = [ + {"title": tr_noop("Advanced UI Controls"), "panel": "advanced", "icon": "toggle_icons/icon_advanced_device.png", "color": "#A200FF"}, + {"title": tr_noop("Driving Screen Widgets"), "panel": "widgets", "icon": "toggle_icons/icon_display.png", "color": "#A200FF"}, + {"title": tr_noop("Model UI"), "panel": "model", "icon": "toggle_icons/icon_road.png", "color": "#A200FF"}, + {"title": tr_noop("Navigation Widgets"), "panel": "navigation", "icon": "toggle_icons/icon_map.png", "color": "#A200FF"}, + {"title": tr_noop("Quality of Life"), "panel": "qol", "icon": "toggle_icons/icon_quality_of_life.png", "color": "#A200FF"}, ] + for name, panel in self._sub_panels.items(): + if hasattr(panel, 'set_navigate_callback'): + panel.set_navigate_callback(self._navigate_to) + if hasattr(panel, 'set_back_callback'): + panel.set_back_callback(self._go_back) + self._rebuild_grid() - self._scroller = Scroller(items, line_separator=True, spacing=0) + +class StarPilotAdvancedVisualsLayout(StarPilotPanel): + def __init__(self): + super().__init__() + self.CATEGORIES = [ + { + "title": tr_noop("Hide Speed"), + "type": "toggle", + "key": "HideSpeed", + "get_state": lambda: self._params.get_bool("HideSpeed"), + "set_state": lambda s: self._params.put_bool("HideSpeed", s), + "icon": "toggle_icons/icon_display.png", + "color": "#A200FF", + }, + { + "title": tr_noop("Hide Lead Marker"), + "type": "toggle", + "key": "HideLeadMarker", + "get_state": lambda: self._params.get_bool("HideLeadMarker"), + "set_state": lambda s: self._params.put_bool("HideLeadMarker", s), + "icon": "toggle_icons/icon_display.png", + "color": "#A200FF", + }, + { + "title": tr_noop("Hide Max Speed"), + "type": "toggle", + "key": "HideMaxSpeed", + "get_state": lambda: self._params.get_bool("HideMaxSpeed"), + "set_state": lambda s: self._params.put_bool("HideMaxSpeed", s), + "icon": "toggle_icons/icon_display.png", + "color": "#A200FF", + }, + { + "title": tr_noop("Hide Alerts"), + "type": "toggle", + "key": "HideAlerts", + "get_state": lambda: self._params.get_bool("HideAlerts"), + "set_state": lambda s: self._params.put_bool("HideAlerts", s), + "icon": "toggle_icons/icon_display.png", + "color": "#A200FF", + }, + { + "title": tr_noop("Hide Speed Limit"), + "type": "toggle", + "key": "HideSpeedLimit", + "get_state": lambda: self._params.get_bool("HideSpeedLimit"), + "set_state": lambda s: self._params.put_bool("HideSpeedLimit", s), + "icon": "toggle_icons/icon_display.png", + "color": "#A200FF", + }, + { + "title": tr_noop("Wheel Speed"), + "type": "toggle", + "key": "WheelSpeed", + "get_state": lambda: self._params.get_bool("WheelSpeed"), + "set_state": lambda s: self._params.put_bool("WheelSpeed", s), + "icon": "toggle_icons/icon_display.png", + "color": "#A200FF", + }, + ] + self._rebuild_grid() + + def _rebuild_grid(self): + if not self.CATEGORIES: + return + if self._tile_grid is None: + self._tile_grid = TileGrid(columns=None, padding=20) + self._tile_grid.clear() + + for cat in self.CATEGORIES: + key = cat.get("key") + visible = True + + if key == "HideLeadMarker": + visible &= starpilot_state.car_state.hasOpenpilotLongitudinal + + if not visible: + continue + + tile = ToggleTile(title=tr(cat["title"]), get_state=cat["get_state"], set_state=cat["set_state"], icon_path=cat.get("icon"), bg_color=cat.get("color")) + self._tile_grid.add_tile(tile) + + +class StarPilotVisualWidgetsLayout(StarPilotPanel): + def __init__(self): + super().__init__() + self.CATEGORIES = [ + { + "title": tr_noop("Acceleration Path"), + "type": "toggle", + "key": "AccelerationPath", + "get_state": lambda: self._params.get_bool("AccelerationPath"), + "set_state": lambda s: self._params.put_bool("AccelerationPath", s), + "icon": "toggle_icons/icon_road.png", + "color": "#A200FF", + }, + { + "title": tr_noop("Adjacent Lanes"), + "type": "toggle", + "key": "AdjacentPath", + "get_state": lambda: self._params.get_bool("AdjacentPath"), + "set_state": lambda s: self._params.put_bool("AdjacentPath", s), + "icon": "toggle_icons/icon_road.png", + "color": "#A200FF", + }, + { + "title": tr_noop("Blind Spot Path"), + "type": "toggle", + "key": "BlindSpotPath", + "get_state": lambda: self._params.get_bool("BlindSpotPath"), + "set_state": lambda s: self._params.put_bool("BlindSpotPath", s), + "icon": "toggle_icons/icon_road.png", + "color": "#A200FF", + }, + { + "title": tr_noop("Compass"), + "type": "toggle", + "key": "Compass", + "get_state": lambda: self._params.get_bool("Compass"), + "set_state": lambda s: self._params.put_bool("Compass", s), + "icon": "toggle_icons/icon_navigate.png", + "color": "#A200FF", + }, + { + "title": tr_noop("Personality Button"), + "type": "toggle", + "key": "OnroadDistanceButton", + "get_state": lambda: self._params.get_bool("OnroadDistanceButton"), + "set_state": lambda s: self._params.put_bool("OnroadDistanceButton", s), + "icon": "toggle_icons/icon_personality.png", + "color": "#A200FF", + }, + { + "title": tr_noop("Pedal Indicators"), + "type": "toggle", + "key": "PedalsOnUI", + "get_state": lambda: self._params.get_bool("PedalsOnUI"), + "set_state": lambda s: self._params.put_bool("PedalsOnUI", s), + "icon": "toggle_icons/icon_display.png", + "color": "#A200FF", + }, + { + "title": tr_noop("Dynamic Pedals"), + "type": "toggle", + "key": "DynamicPedalsOnUI", + "get_state": lambda: self._params.get_bool("DynamicPedalsOnUI"), + "set_state": lambda s: self._set_exclusive_pedal("DynamicPedalsOnUI", "StaticPedalsOnUI", s), + "icon": "toggle_icons/icon_display.png", + "color": "#A200FF", + }, + { + "title": tr_noop("Static Pedals"), + "type": "toggle", + "key": "StaticPedalsOnUI", + "get_state": lambda: self._params.get_bool("StaticPedalsOnUI"), + "set_state": lambda s: self._set_exclusive_pedal("StaticPedalsOnUI", "DynamicPedalsOnUI", s), + "icon": "toggle_icons/icon_display.png", + "color": "#A200FF", + }, + { + "title": tr_noop("Rotating Wheel"), + "type": "toggle", + "key": "RotatingWheel", + "get_state": lambda: self._params.get_bool("RotatingWheel"), + "set_state": lambda s: self._params.put_bool("RotatingWheel", s), + "icon": "toggle_icons/icon_steering.png", + "color": "#A200FF", + }, + ] + self._rebuild_grid() + + def _set_exclusive_pedal(self, key, other_key, state): + self._params.put_bool(key, state) + if state: + self._params.put_bool(other_key, False) + self._rebuild_grid() + + def _rebuild_grid(self): + if not self.CATEGORIES: + return + if self._tile_grid is None: + self._tile_grid = TileGrid(columns=None, padding=20) + self._tile_grid.clear() + + pedals_on_ui = self._params.get_bool("PedalsOnUI") + + for cat in self.CATEGORIES: + key = cat.get("key") + visible = True + + if key == "AccelerationPath": + visible &= starpilot_state.car_state.hasOpenpilotLongitudinal + elif key == "BlindSpotPath": + visible &= starpilot_state.car_state.hasBSM + elif key == "PedalsOnUI": + visible &= starpilot_state.car_state.hasOpenpilotLongitudinal + elif key == "DynamicPedalsOnUI": + visible &= starpilot_state.car_state.hasOpenpilotLongitudinal and pedals_on_ui + elif key == "StaticPedalsOnUI": + visible &= starpilot_state.car_state.hasOpenpilotLongitudinal and pedals_on_ui + + if not visible: + continue + + tile = ToggleTile(title=tr(cat["title"]), get_state=cat["get_state"], set_state=cat["set_state"], icon_path=cat.get("icon"), bg_color=cat.get("color")) + self._tile_grid.add_tile(tile) + + +class StarPilotModelUILayout(StarPilotPanel): + def __init__(self): + super().__init__() + self.CATEGORIES = [ + { + "title": tr_noop("Dynamic Path"), + "type": "toggle", + "key": "DynamicPathWidth", + "get_state": lambda: self._params.get_bool("DynamicPathWidth"), + "set_state": lambda s: self._params.put_bool("DynamicPathWidth", s), + "icon": "toggle_icons/icon_road.png", + "color": "#A200FF", + }, + { + "title": tr_noop("Lane Line Width"), + "type": "value", + "key": "LaneLinesWidth", + "get_value": lambda: self._get_lane_lines_display(), + "on_click": lambda: self._show_int_selector("LaneLinesWidth", 0, 24, self._get_lane_lines_unit()), + "icon": "toggle_icons/icon_road.png", + "color": "#A200FF", + }, + { + "title": tr_noop("Path Edge Width"), + "type": "value", + "key": "PathEdgeWidth", + "get_value": lambda: f"{self._params.get_int('PathEdgeWidth')}%", + "on_click": lambda: self._show_int_selector("PathEdgeWidth", 0, 100, "%"), + "icon": "toggle_icons/icon_road.png", + "color": "#A200FF", + }, + { + "title": tr_noop("Path Width"), + "type": "value", + "key": "PathWidth", + "get_value": lambda: self._get_path_width_display(), + "on_click": lambda: self._show_path_width_selector(), + "icon": "toggle_icons/icon_road.png", + "color": "#A200FF", + }, + { + "title": tr_noop("Road Edge Width"), + "type": "value", + "key": "RoadEdgesWidth", + "get_value": lambda: self._get_road_edges_display(), + "on_click": lambda: self._show_int_selector("RoadEdgesWidth", 0, 24, self._get_road_edges_unit()), + "icon": "toggle_icons/icon_road.png", + "color": "#A200FF", + }, + ] + self._rebuild_grid() + + def _get_lane_lines_unit(self): + return "cm" if self._params.get_bool("IsMetric") else "in" + + def _get_lane_lines_display(self): + val = self._params.get_int('LaneLinesWidth') + if self._params.get_bool("IsMetric"): + return f"{int(val * 2.54)}cm" + return f"{val}in" + + def _get_path_width_unit(self): + return "m" if self._params.get_bool("IsMetric") else "ft" + + def _get_path_width_display(self): + val = self._params.get_float('PathWidth') + if self._params.get_bool("IsMetric"): + return f"{val / 3.28084:.1f}m" + return f"{val:.1f}ft" + + def _get_road_edges_unit(self): + return "cm" if self._params.get_bool("IsMetric") else "in" + + def _get_road_edges_display(self): + val = self._params.get_int('RoadEdgesWidth') + if self._params.get_bool("IsMetric"): + return f"{int(val * 2.54)}cm" + return f"{val}in" + + def _show_path_width_selector(self): + if self._params.get_bool("IsMetric"): + self._show_float_selector("PathWidth", 0, 10, 0.1, "m", convert=lambda v: v / 3.28084, unconvert=lambda v: v * 3.28084) + else: + self._show_float_selector("PathWidth", 0, 10, 0.1, "ft") + + def _show_int_selector(self, key, min_v, max_v, unit=""): + def on_close(res, val): + if res == DialogResult.CONFIRM: + self._params.put_int(key, int(val)) + self._rebuild_grid() + + gui_app.set_modal_overlay(SliderDialog(tr(key), min_v, max_v, 1, self._params.get_int(key), on_close, unit=unit, color="#A200FF")) + + def _show_float_selector(self, key, min_v, max_v, step, unit="", convert=None, unconvert=None): + current = self._params.get_float(key) + if convert: + current = convert(current) + + def on_close(res, val): + if res == DialogResult.CONFIRM: + v = float(val) + if unconvert: + v = unconvert(v) + self._params.put_float(key, v) + self._rebuild_grid() + + gui_app.set_modal_overlay(SliderDialog(tr(key), min_v, max_v, step, current, on_close, unit=unit, color="#A200FF")) + + +class StarPilotNavigationVisualsLayout(StarPilotPanel): + def __init__(self): + super().__init__() + self.CATEGORIES = [ + { + "title": tr_noop("Road Name"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("RoadNameUI"), + "set_state": lambda s: self._params.put_bool("RoadNameUI", s), + "icon": "toggle_icons/icon_navigate.png", + "color": "#A200FF", + }, + { + "title": tr_noop("Speed Limits"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("ShowSpeedLimits"), + "set_state": lambda s: self._params.put_bool("ShowSpeedLimits", s), + "icon": "toggle_icons/icon_speed_limit.png", + "color": "#A200FF", + }, + { + "title": tr_noop("Mapbox Limits"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("SLCMapboxFiller"), + "set_state": lambda s: self._params.put_bool("SLCMapboxFiller", s), + "icon": "toggle_icons/icon_speed_limit.png", + "color": "#A200FF", + }, + { + "title": tr_noop("Vienna Signs"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("UseVienna"), + "set_state": lambda s: self._params.put_bool("UseVienna", s), + "icon": "toggle_icons/icon_speed_limit.png", + "color": "#A200FF", + }, + ] + self._rebuild_grid() + + +class StarPilotVisualQOLLayout(StarPilotPanel): + def __init__(self): + super().__init__() + self.CAMERA_VIEWS = ["Auto", "Driver", "Standard", "Wide"] + self.CATEGORIES = [ + { + "title": tr_noop("Camera View"), + "type": "value", + "key": "CameraView", + "get_value": lambda: tr(self.CAMERA_VIEWS[self._params.get_int('CameraView')]), + "on_click": lambda: self._show_camera_view_selector(), + "icon": "toggle_icons/icon_display.png", + "color": "#A200FF", + }, + { + "title": tr_noop("Driver Camera"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("DriverCamera"), + "set_state": lambda s: self._params.put_bool("DriverCamera", s), + "icon": "toggle_icons/icon_display.png", + "color": "#A200FF", + }, + { + "title": tr_noop("Stopped Timer"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("StoppedTimer"), + "set_state": lambda s: self._params.put_bool("StoppedTimer", s), + "icon": "toggle_icons/icon_display.png", + "color": "#A200FF", + }, + ] + self._rebuild_grid() + + def _show_camera_view_selector(self): + current = self._params.get_int("CameraView") + + def on_select(res, val): + if res == DialogResult.CONFIRM: + idx = self.CAMERA_VIEWS.index(val) + self._params.put_int("CameraView", idx) + self._rebuild_grid() + + gui_app.set_modal_overlay(SelectionDialog(tr("Camera View"), self.CAMERA_VIEWS, self.CAMERA_VIEWS[current], on_close=on_select)) diff --git a/selfdrive/ui/layouts/settings/starpilot/wheel.py b/selfdrive/ui/layouts/settings/starpilot/wheel.py index 78827082a..102c40240 100644 --- a/selfdrive/ui/layouts/settings/starpilot/wheel.py +++ b/selfdrive/ui/layouts/settings/starpilot/wheel.py @@ -1,20 +1,107 @@ from __future__ import annotations - +from openpilot.selfdrive.ui.lib.starpilot_state import starpilot_state +from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.lib.multilang import tr, tr_noop -from openpilot.system.ui.widgets.list_view import button_item -from openpilot.system.ui.widgets.scroller_tici import Scroller +from openpilot.system.ui.widgets import DialogResult +from openpilot.system.ui.widgets.selection_dialog import SelectionDialog from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import StarPilotPanel +ACTION_NAMES = ["No Action", "Change Personality", "Force Coast", "Pause Steering", "Pause Accel/Brake", "Toggle Experimental", "Toggle Traffic"] +ACTION_IDS = {name: i for i, name in enumerate(ACTION_NAMES)} + + class StarPilotWheelLayout(StarPilotPanel): def __init__(self): super().__init__() - - items = [ - button_item( - tr_noop("Wheel Controls"), - lambda: tr("MANAGE"), - tr_noop("Configure steering wheel button mappings for custom controls."), - ), + self.CATEGORIES = [ + { + "title": tr_noop("Remap Cancel Button"), + "type": "toggle", + "get_state": lambda: self._params.get_bool("RemapCancelToDistance"), + "set_state": lambda s: self._params.put_bool("RemapCancelToDistance", s), + "color": "#FFC40D", + }, + { + "title": tr_noop("Distance Button"), + "type": "value", + "get_value": lambda: self._get_action_name("DistanceButtonControl"), + "on_click": lambda: self._show_action_picker("DistanceButtonControl"), + "color": "#FFC40D", + }, + { + "title": tr_noop("Distance (Long Press)"), + "type": "value", + "get_value": lambda: self._get_action_name("LongDistanceButtonControl"), + "on_click": lambda: self._show_action_picker("LongDistanceButtonControl"), + "color": "#FFC40D", + }, + { + "title": tr_noop("Distance (Very Long)"), + "type": "value", + "get_value": lambda: self._get_action_name("VeryLongDistanceButtonControl"), + "on_click": lambda: self._show_action_picker("VeryLongDistanceButtonControl"), + "color": "#FFC40D", + }, + { + "title": tr_noop("LKAS Button"), + "type": "value", + "get_value": lambda: self._get_action_name("LKASButtonControl"), + "on_click": lambda: self._show_action_picker("LKASButtonControl"), + "key": "LKASButtonControl", + "color": "#FFC40D", + }, ] + self._rebuild_grid() - self._scroller = Scroller(items, line_separator=True, spacing=0) + def _get_action_name(self, key): + idx = self._params.get_int(key) + if 0 <= idx < len(ACTION_NAMES): + return ACTION_NAMES[idx] + return ACTION_NAMES[0] + + def _get_available_actions(self): + actions = list(ACTION_NAMES[:1]) # No Action + cs = starpilot_state.car_state + if cs.hasOpenpilotLongitudinal: + actions.extend(ACTION_NAMES[1:]) + return actions + + def _show_action_picker(self, key): + actions = self._get_available_actions() + current = self._get_action_name(key) + if current not in actions: + current = actions[0] + + def on_select(res, val): + if res == DialogResult.CONFIRM: + self._params.put_int(key, ACTION_IDS.get(val, 0)) + self._rebuild_grid() + + gui_app.set_modal_overlay(SelectionDialog(tr(key), actions, current, on_close=on_select)) + + def _rebuild_grid(self): + if not self.CATEGORIES: + return + if self._tile_grid is None: + self._tile_grid = __import__('openpilot.selfdrive.ui.layouts.settings.starpilot.metro', fromlist=['TileGrid']).TileGrid(columns=None, padding=20) + self._tile_grid.clear() + cs = starpilot_state.car_state + for cat in self.CATEGORIES: + key = cat.get("key") + visible = True + if key == "LKASButtonControl": + visible &= not cs.isSubaru + if not visible: + continue + tile_type = cat.get("type", "hub") + if tile_type == "toggle": + from openpilot.selfdrive.ui.layouts.settings.starpilot.metro import ToggleTile + + tile = ToggleTile(title=tr(cat["title"]), get_state=cat["get_state"], set_state=cat["set_state"], bg_color=cat.get("color")) + elif tile_type == "value": + from openpilot.selfdrive.ui.layouts.settings.starpilot.metro import ValueTile + + tile = ValueTile(title=tr(cat["title"]), get_value=cat["get_value"], on_click=cat["on_click"], bg_color=cat.get("color")) + else: + continue + self._tile_grid.add_tile(tile) diff --git a/selfdrive/ui/layouts/sidebar.py b/selfdrive/ui/layouts/sidebar.py index 050cd795b..f0925d23a 100644 --- a/selfdrive/ui/layouts/sidebar.py +++ b/selfdrive/ui/layouts/sidebar.py @@ -1,9 +1,12 @@ import pyray as rl +import socket import time from dataclasses import dataclass from collections.abc import Callable from cereal import log +from openpilot.common.params import Params from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.selfdrive.ui.onroad.gif_player import load_starpilot_asset 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 @@ -18,6 +21,11 @@ FONT_SIZE = 35 SETTINGS_BTN = rl.Rectangle(50, 35, 200, 117) HOME_BTN = rl.Rectangle(60, 860, 180, 180) +# FrogPilot click regions (for metric cycling) +FP_TEMP_REGION = rl.Rectangle(30, 338, 240, 126) +FP_COMPUTE_REGION = rl.Rectangle(30, 496, 240, 126) +FP_STORAGE_REGION = rl.Rectangle(30, 654, 240, 126) + ThermalStatus = log.DeviceState.ThermalStatus NetworkType = log.DeviceState.NetworkType @@ -73,11 +81,34 @@ class Sidebar(Widget): self._connect_status = MetricData(tr_noop("CONNECT"), tr_noop("OFFLINE"), Colors.WARNING) self._recording_audio = False + # FrogPilot metrics (replaces stock slots when toggles are on) + self._fp_temp = MetricData(tr_noop("TEMP"), tr_noop("--"), Colors.GOOD) + self._fp_compute = MetricData(tr_noop("CPU"), tr_noop("--"), Colors.GOOD) + self._fp_storage = MetricData(tr_noop("MEM"), tr_noop("--"), Colors.GOOD) + + # FrogPilot click-to-cycle state (0=stock, then 1..N for options) + self._fp_temp_cycle = 0 # 0=temp, 1=numerical temp + self._fp_compute_cycle = 0 # 0=stock, 1=CPU, 2=GPU + self._fp_storage_cycle = 0 # 0=stock, 1=memory, 2=storage left, 3=storage used + + self._toggles: dict = {} + + # IP address (cached, refreshed rarely) + self._ip_address: str = "" + self._ip_refresh_time: float = 0.0 + 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._mic_img = gui_app.texture("icons/microphone.png", 30, 30) self._mic_indicator_rect = rl.Rectangle(0, 0, 0, 0) + + # FrogPilot: theme GIF buttons (lazy loaded) + self._home_gif = None + self._flag_gif = None + self._settings_gif = None + self._gifs_loaded = False + self._font_regular = gui_app.font(FontWeight.NORMAL) self._font_bold = gui_app.font(FontWeight.SEMI_BOLD) @@ -86,8 +117,7 @@ class Sidebar(Widget): 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): + 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 @@ -102,6 +132,29 @@ class Sidebar(Widget): def _update_state(self): sm = ui_state.sm + self._toggles = ui_state.frogpilot_toggles + + # FrogPilot: load theme GIFs on first update + if not self._gifs_loaded: + self._gifs_loaded = True + try: + self._home_gif = load_starpilot_asset("active_theme/icons/button_home.gif", int(HOME_BTN.width)) + except Exception: + self._home_gif = None + try: + self._flag_gif = load_starpilot_asset("active_theme/icons/button_flag.gif", int(HOME_BTN.width)) + except Exception: + self._flag_gif = None + try: + self._settings_gif = load_starpilot_asset("active_theme/icons/button_settings.gif", int(SETTINGS_BTN.width)) + except Exception: + self._settings_gif = None + + # Advance GIF animations + for gif in (self._home_gif, self._flag_gif, self._settings_gif): + if gif is not None: + gif.play() + if not sm.updated['deviceState']: return @@ -112,6 +165,19 @@ class Sidebar(Widget): self._update_temperature_status(device_state) self._update_connection_status(device_state) self._update_panda_status() + self._update_frogpilot_metrics(device_state) + + # Refresh IP address rarely + now = time.monotonic() + if now - self._ip_refresh_time > 30.0: + self._ip_refresh_time = now + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + self._ip_address = s.getsockname()[0] + s.close() + except Exception: + self._ip_address = "" def _update_network_status(self, device_state): self._net_type = NETWORK_TYPES.get(device_state.networkType.raw, tr_noop("Unknown")) @@ -143,6 +209,82 @@ class Sidebar(Widget): else: self._panda_status.update(tr_noop("VEHICLE"), tr_noop("ONLINE"), Colors.GOOD) + def _update_frogpilot_metrics(self, device_state): + t = self._toggles + is_metric = ui_state.is_metric + + # --- Temperature slot: numerical temp or stock --- + dev_ui = t.get("developer_ui", False) + if dev_ui and t.get("numerical_temp", False): + temps_c = list(device_state.cpuTempC) if device_state.cpuTempC else [] + temp_c = max(temps_c) if temps_c else 0.0 + if t.get("fahrenheit", False): + val = f"{temp_c * 9 / 5 + 32:.0f}F" + else: + val = f"{temp_c:.0f}C" + color = Colors.DANGER if temp_c >= 90 else Colors.WARNING if temp_c >= 75 else Colors.GOOD + custom = t.get("sidebar_color1") + if custom: + color = self._parse_color(custom) + self._fp_temp.update(tr_noop("TEMP"), val, color) + else: + self._fp_temp = self._temp_status + + # --- Compute slot: CPU/GPU or stock --- + show_cpu = t.get("show_cpu", False) + show_gpu = t.get("show_gpu", False) + if show_cpu or show_gpu: + if show_gpu: + gpu_pct = device_state.gpuUsagePercent if device_state.gpuUsagePercent else 0 + val = f"{gpu_pct}%" + color = Colors.DANGER if gpu_pct >= 85 else Colors.WARNING if gpu_pct >= 70 else Colors.GOOD + self._fp_compute.update(tr_noop("GPU"), val, color) + else: + cpu_loads = list(device_state.cpuUsagePercent) if device_state.cpuUsagePercent else [] + avg = sum(cpu_loads) / max(len(cpu_loads), 1) if cpu_loads else 0 + val = f"{int(avg)}%" + color = Colors.DANGER if avg >= 85 else Colors.WARNING if avg >= 70 else Colors.GOOD + self._fp_compute.update(tr_noop("CPU"), val, color) + custom = t.get("sidebar_color2") + if custom: + self._fp_compute.color = self._parse_color(custom) + else: + self._fp_compute = self._panda_status + + # --- Storage slot: memory/storage or stock --- + show_mem = t.get("show_memory_usage", False) + show_storage_left = t.get("show_storage_left", False) + show_storage_used = t.get("show_storage_used", False) + if show_mem: + mem_pct = device_state.memoryUsagePercent if device_state.memoryUsagePercent else 0 + val = f"{mem_pct}%" + color = Colors.DANGER if mem_pct >= 85 else Colors.WARNING if mem_pct >= 70 else Colors.GOOD + self._fp_storage.update(tr_noop("MEM"), val, color) + elif show_storage_left or show_storage_used: + if sm_valid := ui_state.sm.valid.get("frogpilotDeviceState", False): + fp_dev = ui_state.sm["frogpilotDeviceState"] + gb = fp_dev.freeSpace if show_storage_left else fp_dev.usedSpace + label = tr_noop("SSD LEFT") if show_storage_left else tr_noop("SSD USED") + val = f"{gb:.0f} GB" + color = Colors.DANGER if (show_storage_left and gb < 5) else Colors.WARNING if (show_storage_left and gb < 25) else Colors.GOOD + self._fp_storage.update(label, val, color) + else: + self._fp_storage = self._connect_status + else: + self._fp_storage = self._connect_status + custom = t.get("sidebar_color3") + if custom: + self._fp_storage.color = self._parse_color(custom) + + @staticmethod + def _parse_color(color_str: str) -> rl.Color: + s = color_str.lstrip('#') + if len(s) == 8: + return rl.Color(int(s[2:4], 16), int(s[4:6], 16), int(s[6:8], 16), int(s[0:2], 16)) + if len(s) == 6: + return rl.Color(int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16), 255) + return rl.Color(255, 255, 255, 255) + def _handle_mouse_release(self, mouse_pos: MousePos): if rl.check_collision_point_rec(mouse_pos, SETTINGS_BTN): if self._on_settings_click: @@ -153,22 +295,47 @@ class Sidebar(Widget): elif self._recording_audio and rl.check_collision_point_rec(mouse_pos, self._mic_indicator_rect): if self._open_settings_callback: self._open_settings_callback() + # FrogPilot: click-to-cycle metrics when developer_ui is on + elif self._toggles.get("developer_ui", False): + if rl.check_collision_point_rec(mouse_pos, FP_TEMP_REGION): + self._fp_temp_cycle = (self._fp_temp_cycle + 1) % 3 + p = Params() + p.put_bool("Fahrenheit", self._fp_temp_cycle == 2) + p.put_bool("NumericalTemp", self._fp_temp_cycle >= 1) + elif rl.check_collision_point_rec(mouse_pos, FP_COMPUTE_REGION): + self._fp_compute_cycle = (self._fp_compute_cycle + 1) % 3 + p = Params() + p.put_bool("ShowCPU", self._fp_compute_cycle == 1) + p.put_bool("ShowGPU", self._fp_compute_cycle == 2) + elif rl.check_collision_point_rec(mouse_pos, FP_STORAGE_REGION): + self._fp_storage_cycle = (self._fp_storage_cycle + 1) % 4 + p = Params() + p.put_bool("ShowMemoryUsage", self._fp_storage_cycle == 1) + p.put_bool("ShowStorageLeft", self._fp_storage_cycle == 2) + p.put_bool("ShowStorageUsed", self._fp_storage_cycle == 3) def _draw_buttons(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) - # Settings button + # Settings button (GIF theme or stock) 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) + gif_tex = self._settings_gif.texture if self._settings_gif is not None else None + settings_tex = gif_tex if gif_tex is not None else self._settings_img + rl.draw_texture(settings_tex, int(SETTINGS_BTN.x), int(SETTINGS_BTN.y), tint) - # Home/Flag button + # Home/Flag button (GIF theme or stock) 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 + if ui_state.started: + gif_tex = self._flag_gif.texture if self._flag_gif is not None else None + button_tex = gif_tex if gif_tex is not None else self._flag_img + else: + gif_tex = self._home_gif.texture if self._home_gif is not None else None + button_tex = gif_tex if gif_tex is not None 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) + rl.draw_texture(button_tex, int(HOME_BTN.x), int(HOME_BTN.y), tint) # Microphone button if self._recording_audio: @@ -178,10 +345,23 @@ class Sidebar(Widget): 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) + 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, + ) def _draw_network_indicator(self, rect: rl.Rectangle): + # FrogPilot: IP address replaces signal dots + if self._toggles.get("ip_metrics", False) and self._ip_address: + text_pos = rl.Vector2(rect.x + 58, rect.y + 200) + rl.draw_text_ex(self._font_regular, self._ip_address, text_pos, 30, 0, Colors.WHITE) + # Still show network type below + text_pos2 = rl.Vector2(rect.x + 58, rect.y + 247) + rl.draw_text_ex(self._font_regular, tr(self._net_type), text_pos2, FONT_SIZE, 0, Colors.WHITE) + return + # Signal strength dots x_start = rect.x + 58 y_pos = rect.y + 196 @@ -200,7 +380,20 @@ class Sidebar(Widget): 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, 338), (self._panda_status, 496), (self._connect_status, 654)] + t = self._toggles + use_fp = ( + t.get("numerical_temp", False) + or t.get("show_cpu", False) + or t.get("show_gpu", False) + or t.get("show_memory_usage", False) + or t.get("show_storage_left", False) + or t.get("show_storage_used", False) + ) + + if use_fp: + metrics = [(self._fp_temp, 338), (self._fp_compute, 496), (self._fp_storage, 654)] + else: + 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) @@ -222,8 +415,5 @@ class Sidebar(Widget): for text in labels: text_size = measure_text_cached(self._font_bold, text, FONT_SIZE) text_y += text_size.y - text_pos = rl.Vector2( - metric_rect.x + 22 + (metric_rect.width - 22 - text_size.x) / 2, - text_y - ) + text_pos = rl.Vector2(metric_rect.x + 22 + (metric_rect.width - 22 - text_size.x) / 2, text_y) rl.draw_text_ex(self._font_bold, text, text_pos, FONT_SIZE, 0, Colors.WHITE) diff --git a/selfdrive/ui/onroad/alert_renderer.py b/selfdrive/ui/onroad/alert_renderer.py index a81fbfc44..7e8c73976 100644 --- a/selfdrive/ui/onroad/alert_renderer.py +++ b/selfdrive/ui/onroad/alert_renderer.py @@ -32,9 +32,9 @@ SELFDRIVE_UNRESPONSIVE_TIMEOUT = 10 # Seconds # Constants ALERT_COLORS = { - AlertStatus.normal: rl.Color(0x15, 0x15, 0x15, 0xF1), # #151515 with alpha 0xF1 + AlertStatus.normal: rl.Color(0x15, 0x15, 0x15, 0xF1), # #151515 with alpha 0xF1 AlertStatus.userPrompt: rl.Color(0xDA, 0x6F, 0x25, 0xF1), # #DA6F25 with alpha 0xF1 - AlertStatus.critical: rl.Color(0xC9, 0x22, 0x31, 0xF1), # #C92231 with alpha 0xF1 + AlertStatus.critical: rl.Color(0xC9, 0x22, 0x31, 0xF1), # #C92231 with alpha 0xF1 } @@ -76,14 +76,21 @@ class AlertRenderer(Widget): self.font_bold: rl.Font = gui_app.font(FontWeight.BOLD) # font size is set dynamically - self._full_text1_label = Label("", font_size=0, font_weight=FontWeight.BOLD, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, - text_alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP) - self._full_text2_label = Label("", font_size=ALERT_FONT_BIG, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, - text_alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP) + self._full_text1_label = Label( + "", + font_size=0, + font_weight=FontWeight.BOLD, + text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, + text_alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP, + ) + self._full_text2_label = Label( + "", font_size=ALERT_FONT_BIG, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, text_alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP + ) def get_alert(self, sm: messaging.SubMaster) -> Alert | None: """Generate the current alert based on selfdrive state.""" ss = sm['selfdriveState'] + t = ui_state.frogpilot_toggles # Check if selfdriveState messages have stopped arriving recv_frame = sm.recv_frame['selfdriveState'] @@ -93,12 +100,16 @@ class AlertRenderer(Widget): # 1. Never received selfdriveState since going onroad waiting_for_startup = recv_frame < ui_state.started_frame if waiting_for_startup and time_since_onroad > 5: + if t.get("force_onroad", False): + return None return ALERT_STARTUP_PENDING # 2. Lost communication with selfdriveState after receiving it if TICI and not waiting_for_startup: ss_missing = time.monotonic() - sm.recv_time['selfdriveState'] if ss_missing > SELFDRIVE_STATE_TIMEOUT: + if t.get("force_onroad", False): + return None if ss.enabled and (ss_missing - SELFDRIVE_STATE_TIMEOUT) < SELFDRIVE_UNRESPONSIVE_TIMEOUT: return ALERT_CRITICAL_TIMEOUT return ALERT_CRITICAL_REBOOT @@ -107,12 +118,23 @@ class AlertRenderer(Widget): if ss.alertSize == 0: return None + # Hide non-critical alerts when FrogPilot toggle is on + if t.get("hide_alerts", False) and ss.alertStatus == AlertStatus.normal: + return None + # Don't get old alert if recv_frame < ui_state.started_frame: return None # Return current alert - return Alert(text1=ss.alertText1, text2=ss.alertText2, size=ss.alertSize.raw, status=ss.alertStatus.raw) + alert_text1 = ss.alertText1 + alert_text2 = ss.alertText2 + + # FrogPilot: random events crash emoji + if t.get("random_events", False) and "Crashed" in alert_text1: + alert_text1 = "\U0001f4a9 " + alert_text1 + + return Alert(text1=alert_text1, text2=alert_text2, size=ss.alertSize.raw, status=ss.alertStatus.raw) def _render(self, rect: rl.Rectangle): alert = self.get_alert(ui_state.sm) @@ -123,10 +145,7 @@ class AlertRenderer(Widget): self._draw_background(alert_rect, alert) text_rect = rl.Rectangle( - alert_rect.x + ALERT_PADDING, - alert_rect.y + ALERT_PADDING, - alert_rect.width - 2 * ALERT_PADDING, - alert_rect.height - 2 * ALERT_PADDING + alert_rect.x + ALERT_PADDING, alert_rect.y + ALERT_PADDING, alert_rect.width - 2 * ALERT_PADDING, alert_rect.height - 2 * ALERT_PADDING ) self._draw_text(text_rect, alert) @@ -135,8 +154,7 @@ class AlertRenderer(Widget): return rect h = ALERT_HEIGHTS.get(size, rect.height) - return rl.Rectangle(rect.x + ALERT_MARGIN, rect.y + rect.height - h + ALERT_MARGIN, - rect.width - ALERT_MARGIN * 2, h - ALERT_MARGIN * 2) + return rl.Rectangle(rect.x + ALERT_MARGIN, rect.y + rect.height - h + ALERT_MARGIN, rect.width - ALERT_MARGIN * 2, h - ALERT_MARGIN * 2) def _draw_background(self, rect: rl.Rectangle, alert: Alert) -> None: color = ALERT_COLORS.get(alert.status, ALERT_COLORS[AlertStatus.normal]) diff --git a/selfdrive/ui/onroad/augmented_road_view.py b/selfdrive/ui/onroad/augmented_road_view.py index 1f202141c..98e75acad 100644 --- a/selfdrive/ui/onroad/augmented_road_view.py +++ b/selfdrive/ui/onroad/augmented_road_view.py @@ -10,6 +10,8 @@ from openpilot.selfdrive.ui.onroad.driver_state import DriverStateRenderer from openpilot.selfdrive.ui.onroad.hud_renderer import HudRenderer from openpilot.selfdrive.ui.onroad.model_renderer import ModelRenderer from openpilot.selfdrive.ui.onroad.cameraview import CameraView +from openpilot.selfdrive.ui.onroad.frogpilot_overlay import FrogPilotOverlay +from openpilot.selfdrive.ui.widgets.developer_sidebar import DeveloperSidebar from openpilot.system.ui.lib.application import gui_app from openpilot.common.transformations.camera import DEVICE_CAMERAS, DeviceCameraConfig, view_frame_from_device_frame from openpilot.common.transformations.orientation import rot_from_euler @@ -48,6 +50,8 @@ class AugmentedRoadView(CameraView): self._hud_renderer = HudRenderer() self.alert_renderer = AlertRenderer() self.driver_state_renderer = DriverStateRenderer() + self._frogpilot_overlay = FrogPilotOverlay(self.model_renderer, self._hud_renderer, self.driver_state_renderer) + self._developer_sidebar = DeveloperSidebar() # debug self._pm = messaging.PubMaster(['uiDebug']) @@ -73,12 +77,7 @@ class AugmentedRoadView(CameraView): # Enable scissor mode to clip all rendering within content rectangle boundaries # This creates a rendering viewport that prevents graphics from drawing outside the border - rl.begin_scissor_mode( - int(self._content_rect.x), - int(self._content_rect.y), - int(self._content_rect.width), - int(self._content_rect.height) - ) + rl.begin_scissor_mode(int(self._content_rect.x), int(self._content_rect.y), int(self._content_rect.width), int(self._content_rect.height)) # Render the base camera view super()._render(rect) @@ -89,8 +88,15 @@ class AugmentedRoadView(CameraView): self.alert_renderer.render(self._content_rect) self.driver_state_renderer.render(self._content_rect) - # Custom UI extension point - add custom overlays here - # Use self._content_rect for positioning within camera bounds + # StarPilot overlays (speed limits, compass, turn signals, etc.) + self._frogpilot_overlay.render(self._content_rect) + + # Developer sidebar (right-side metrics panel) + t = ui_state.frogpilot_toggles + if t.get("developer_sidebar", False): + sb_w = 300 + sb_rect = rl.Rectangle(self._content_rect.x + self._content_rect.width - sb_w, self._content_rect.y, sb_w, self._content_rect.height) + self._developer_sidebar.render(sb_rect) # End clipping region rl.end_scissor_mode() @@ -109,14 +115,13 @@ class AugmentedRoadView(CameraView): def _handle_mouse_release(self, _): # We only call click callback on press if not interacting with HUD - pass + self._frogpilot_overlay._handle_mouse_release(rl.get_mouse_position()) def _draw_border(self, rect: rl.Rectangle): rl.draw_rectangle_lines_ex(rect, UI_BORDER_SIZE, rl.BLACK) border_roundness = 0.12 border_color = BORDER_COLORS.get(ui_state.status, BORDER_COLORS[UIStatus.DISENGAGED]) - border_rect = rl.Rectangle(rect.x + UI_BORDER_SIZE, rect.y + UI_BORDER_SIZE, - rect.width - 2 * UI_BORDER_SIZE, rect.height - 2 * UI_BORDER_SIZE) + border_rect = rl.Rectangle(rect.x + UI_BORDER_SIZE, rect.y + UI_BORDER_SIZE, rect.width - 2 * UI_BORDER_SIZE, rect.height - 2 * UI_BORDER_SIZE) rl.draw_rectangle_rounded_lines_ex(border_rect, border_roundness, 10, UI_BORDER_SIZE, border_color) def _switch_stream_if_needed(self, sm): @@ -160,12 +165,7 @@ class AugmentedRoadView(CameraView): def _calc_frame_matrix(self, rect: rl.Rectangle) -> np.ndarray: # Check if we can use cached matrix - cache_key = ( - ui_state.sm.recv_frame['liveCalibration'], - self._content_rect.width, - self._content_rect.height, - self.stream_type - ) + cache_key = (ui_state.sm.recv_frame['liveCalibration'], self._content_rect.width, self._content_rect.height, self.stream_type) if cache_key == self._matrix_cache_key and self._cached_matrix is not None: return self._cached_matrix @@ -202,17 +202,9 @@ class AugmentedRoadView(CameraView): # Cache the computed transformation matrix to avoid recalculations self._matrix_cache_key = cache_key - self._cached_matrix = np.array([ - [zoom * 2 * cx / w, 0, -x_offset / w * 2], - [0, zoom * 2 * cy / h, -y_offset / h * 2], - [0, 0, 1.0] - ]) + self._cached_matrix = np.array([[zoom * 2 * cx / w, 0, -x_offset / w * 2], [0, zoom * 2 * cy / h, -y_offset / h * 2], [0, 0, 1.0]]) - video_transform = np.array([ - [zoom, 0.0, (w / 2 + x - x_offset) - (cx * zoom)], - [0.0, zoom, (h / 2 + y - y_offset) - (cy * zoom)], - [0.0, 0.0, 1.0] - ]) + video_transform = np.array([[zoom, 0.0, (w / 2 + x - x_offset) - (cx * zoom)], [0.0, zoom, (h / 2 + y - y_offset) - (cy * zoom)], [0.0, 0.0, 1.0]]) self.model_renderer.set_transform(video_transform @ calib_transform) return self._cached_matrix diff --git a/selfdrive/ui/onroad/driver_state.py b/selfdrive/ui/onroad/driver_state.py index 7b3181d1a..50cdab8ce 100644 --- a/selfdrive/ui/onroad/driver_state.py +++ b/selfdrive/ui/onroad/driver_state.py @@ -10,17 +10,44 @@ from openpilot.system.ui.widgets import Widget AlertSize = log.SelfdriveState.AlertSize # Default 3D coordinates for face keypoints as a NumPy array -DEFAULT_FACE_KPTS_3D = np.array([ - [-5.98, -51.20, 8.00], [-17.64, -49.14, 8.00], [-23.81, -46.40, 8.00], [-29.98, -40.91, 8.00], - [-32.04, -37.49, 8.00], [-34.10, -32.00, 8.00], [-36.16, -21.03, 8.00], [-36.16, 6.40, 8.00], - [-35.47, 10.51, 8.00], [-32.73, 19.43, 8.00], [-29.30, 26.29, 8.00], [-24.50, 33.83, 8.00], - [-19.01, 41.37, 8.00], [-14.21, 46.17, 8.00], [-12.16, 47.54, 8.00], [-4.61, 49.60, 8.00], - [4.99, 49.60, 8.00], [12.53, 47.54, 8.00], [14.59, 46.17, 8.00], [19.39, 41.37, 8.00], - [24.87, 33.83, 8.00], [29.67, 26.29, 8.00], [33.10, 19.43, 8.00], [35.84, 10.51, 8.00], - [36.53, 6.40, 8.00], [36.53, -21.03, 8.00], [34.47, -32.00, 8.00], [32.42, -37.49, 8.00], - [30.36, -40.91, 8.00], [24.19, -46.40, 8.00], [18.02, -49.14, 8.00], [6.36, -51.20, 8.00], - [-5.98, -51.20, 8.00], -], dtype=np.float32) +DEFAULT_FACE_KPTS_3D = np.array( + [ + [-5.98, -51.20, 8.00], + [-17.64, -49.14, 8.00], + [-23.81, -46.40, 8.00], + [-29.98, -40.91, 8.00], + [-32.04, -37.49, 8.00], + [-34.10, -32.00, 8.00], + [-36.16, -21.03, 8.00], + [-36.16, 6.40, 8.00], + [-35.47, 10.51, 8.00], + [-32.73, 19.43, 8.00], + [-29.30, 26.29, 8.00], + [-24.50, 33.83, 8.00], + [-19.01, 41.37, 8.00], + [-14.21, 46.17, 8.00], + [-12.16, 47.54, 8.00], + [-4.61, 49.60, 8.00], + [4.99, 49.60, 8.00], + [12.53, 47.54, 8.00], + [14.59, 46.17, 8.00], + [19.39, 41.37, 8.00], + [24.87, 33.83, 8.00], + [29.67, 26.29, 8.00], + [33.10, 19.43, 8.00], + [35.84, 10.51, 8.00], + [36.53, 6.40, 8.00], + [36.53, -21.03, 8.00], + [34.47, -32.00, 8.00], + [32.42, -37.49, 8.00], + [30.36, -40.91, 8.00], + [24.19, -46.40, 8.00], + [18.02, -49.14, 8.00], + [6.36, -51.20, 8.00], + [-5.98, -51.20, 8.00], + ], + dtype=np.float32, +) # UI constants BTN_SIZE = 192 @@ -39,6 +66,7 @@ ARC_ANGLES = np.linspace(0.0, np.pi, ARC_POINT_COUNT, dtype=np.float32) @dataclass class ArcData: """Data structure for arc rendering parameters.""" + x: float y: float width: float @@ -78,8 +106,7 @@ class DriverStateRenderer(Widget): self.engaged_color = rl.Color(26, 242, 66, 255) self.disengaged_color = rl.Color(139, 139, 139, 255) - self.set_visible(lambda: (ui_state.sm["selfdriveState"].alertSize == AlertSize.none and - ui_state.sm.recv_frame["driverStateV2"] > ui_state.started_frame)) + self.set_visible(lambda: ui_state.sm["selfdriveState"].alertSize == AlertSize.none and ui_state.sm.recv_frame["driverStateV2"] > ui_state.started_frame) def _render(self, rect): # Set opacity based on active state @@ -106,6 +133,15 @@ class DriverStateRenderer(Widget): if self.v_arc_data: rl.draw_spline_linear(self.v_arc_lines, len(self.v_arc_lines), self.v_arc_data.thickness, self.arc_color) + @property + def dm_icon_position(self) -> tuple[float, float]: + """Screen position of the driver monitoring icon center.""" + return (self.position_x, self.position_y) + + @property + def is_right_hand_drive(self) -> bool: + return self.is_rhd + def _update_state(self): """Update the driver monitoring state based on model data""" sm = ui_state.sm @@ -181,20 +217,16 @@ class DriverStateRenderer(Widget): # Horizontal arc h_width = abs(delta_x) self.h_arc_data = self._calculate_arc_data( - delta_x, h_width, self.position_x, self.position_y - ARC_LENGTH / 2, - self.driver_pose_sins[1], self.driver_pose_diff[1], is_horizontal=True + delta_x, h_width, self.position_x, self.position_y - ARC_LENGTH / 2, self.driver_pose_sins[1], self.driver_pose_diff[1], is_horizontal=True ) # Vertical arc v_height = abs(delta_y) self.v_arc_data = self._calculate_arc_data( - delta_y, v_height, self.position_x - ARC_LENGTH / 2, self.position_y, - self.driver_pose_sins[0], self.driver_pose_diff[0], is_horizontal=False + delta_y, v_height, self.position_x - ARC_LENGTH / 2, self.position_y, self.driver_pose_sins[0], self.driver_pose_diff[0], is_horizontal=False ) - def _calculate_arc_data( - self, delta: float, size: float, x: float, y: float, sin_val: float, diff_val: float, is_horizontal: bool - ): + def _calculate_arc_data(self, delta: float, size: float, x: float, y: float, sin_val: float, diff_val: float, is_horizontal: bool): """Calculate arc data and pre-compute arc points.""" if size <= 0: return None diff --git a/selfdrive/ui/onroad/exp_button.py b/selfdrive/ui/onroad/exp_button.py index e5d817141..1a43667ad 100644 --- a/selfdrive/ui/onroad/exp_button.py +++ b/selfdrive/ui/onroad/exp_button.py @@ -5,13 +5,20 @@ from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.widgets import Widget +# Status-aware background colors +_BG_AOL = rl.Color(145, 155, 149, 204) # Always on lateral: grey-green +_BG_CEM = rl.Color(0xDA, 0x6F, 0x25, 204) # Conditional experimental: orange +_BG_TRAFFIC = rl.Color(0xC9, 0x22, 0x31, 204) # Traffic mode: red + class ExpButton(Widget): def __init__(self, button_size: int, icon_size: int): super().__init__() self._params = Params() + self._params_memory = Params(memory=True) self._experimental_mode: bool = False self._engageable: bool = False + self._icon_size = icon_size # State hold mechanism self._hold_duration = 2.0 # seconds @@ -22,6 +29,11 @@ class ExpButton(Widget): self._black_bg: rl.Color = rl.Color(0, 0, 0, 166) self._txt_wheel: rl.Texture = gui_app.texture('icons/chffr_wheel.png', icon_size, icon_size) self._txt_exp: rl.Texture = gui_app.texture('icons/experimental.png', icon_size, icon_size) + + # FrogPilot custom wheel (lazy loaded) + self._custom_wheel: rl.Texture | None = None + self._custom_wheel_loaded: bool = False + self._rect = rl.Rectangle(0, 0, button_size, button_size) def set_rect(self, rect: rl.Rectangle) -> None: @@ -29,9 +41,22 @@ class ExpButton(Widget): def _update_state(self) -> None: selfdrive_state = ui_state.sm["selfdriveState"] - self._experimental_mode = selfdrive_state.experimentalMode + self._toggles = ui_state.frogpilot_toggles + + # Conditional experimental mode: use CEStatus instead of ExperimentalMode + if self._toggles.get("conditional_experimental_mode", False): + self._experimental_mode = self._params_memory.get_int("CEStatus", default=0) >= 2 + else: + self._experimental_mode = selfdrive_state.experimentalMode + self._engageable = selfdrive_state.engageable or selfdrive_state.enabled + # FrogPilot: hot-reload custom wheel image + if self._params_memory.get_bool("UpdateWheelImage"): + self._custom_wheel = None + self._custom_wheel_loaded = False + self._params_memory.put_bool("UpdateWheelImage", False) + def _handle_mouse_release(self, _): super()._handle_mouse_release(_) if self._is_toggle_allowed(): @@ -45,12 +70,57 @@ class ExpButton(Widget): def _render(self, rect: rl.Rectangle) -> None: center_x = int(self._rect.x + self._rect.width // 2) center_y = int(self._rect.y + self._rect.height // 2) + t = self._toggles self._white_color.a = 180 if self.is_pressed or not self._engageable else 255 - texture = self._txt_exp if self._held_or_actual_mode() else self._txt_wheel - rl.draw_circle(center_x, center_y, self._rect.width / 2, self._black_bg) - rl.draw_texture(texture, center_x - texture.width // 2, center_y - texture.height // 2, self._white_color) + # Status-aware background color + bg = self._black_bg + if ui_state.traffic_mode_enabled: + bg = _BG_TRAFFIC + elif ui_state.always_on_lateral_active: + bg = _BG_AOL + elif self._held_or_actual_mode(): + ce_status = self._params_memory.get_int("CEStatus", default=0) if t.get("conditional_experimental_mode", False) else 0 + if ce_status >= 2: + bg = _BG_CEM + + # Custom wheel image or stock + if self._held_or_actual_mode(): + texture = self._txt_exp + elif t.get("wheel_image", False): + texture = self._get_custom_wheel() + else: + texture = self._txt_wheel + + rl.draw_circle(center_x, center_y, self._rect.width / 2, bg) + + # Rotating wheel support + if t.get("rotating_wheel", False) and not self._held_or_actual_mode(): + self._draw_rotated_wheel(texture, center_x, center_y) + else: + rl.draw_texture(texture, center_x - texture.width // 2, center_y - texture.height // 2, self._white_color) + + def _get_custom_wheel(self) -> rl.Texture: + if not self._custom_wheel_loaded: + self._custom_wheel_loaded = True + try: + self._custom_wheel = gui_app.starpilot_texture("active_theme/steering_wheel/wheel.png", self._icon_size, self._icon_size) + except Exception: + self._custom_wheel = None + return self._custom_wheel if self._custom_wheel is not None else self._txt_wheel + + def _draw_rotated_wheel(self, texture: rl.Texture, center_x: int, center_y: int) -> None: + """Draw the wheel texture rotated by steering angle.""" + car_state = ui_state.sm["carState"] + angle = car_state.steeringAngleDeg + # Clamp to reasonable range and normalize to texture rotation + rotation = max(-90.0, min(90.0, angle)) + + source = rl.Rectangle(0, 0, texture.width, texture.height) + dest = rl.Rectangle(center_x, center_y, texture.width, texture.height) + origin = rl.Vector2(texture.width / 2, texture.height / 2) + rl.draw_texture_pro(texture, source, dest, origin, rotation, self._white_color) def _held_or_actual_mode(self): now = time.monotonic() diff --git a/selfdrive/ui/onroad/frogpilot_overlay.py b/selfdrive/ui/onroad/frogpilot_overlay.py new file mode 100644 index 000000000..609551e56 --- /dev/null +++ b/selfdrive/ui/onroad/frogpilot_overlay.py @@ -0,0 +1,1425 @@ +import colorsys +import json +import math +import time +import pyray as rl +from dataclasses import dataclass +from cereal import log +from openpilot.common.params import Params +from openpilot.selfdrive.ui import UI_BORDER_SIZE +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.selfdrive.ui.onroad.gif_player import load_starpilot_asset +from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.text_measure import measure_text_cached +from openpilot.system.ui.widgets import Widget + +MS_TO_MPH = 2.23694 +MS_TO_KPH = 3.6 +METER_TO_FOOT = 3.28084 + +# Asset sizes for rendering +_ASSET_SIZE = 200 # Native pixel size of FrogPilot PNG/GIF assets +_ICON_SIZE = 60 # Small icon size for speed limit sources +_SIGN_ASSET_SIZE = 150 # Size for speed limit sign assets + +# Speed limit sign dimensions (screen pixels) +_US_SIGN_HEIGHT = 186 +_EU_SIGN_SIZE = 176 +_SIGN_MARGIN = 12 + +# Bottom bar widget size +_WIDGET_SIZE = 159 # img_size(144) + UI_BORDER_SIZE(30)/2 + +# Curve speed box dimensions +_CSC_SIZE = 130 + +# Standstill timer thresholds (seconds) +_STANDSTILL_GREEN = 60 +_STANDSTILL_ORANGE = 150 +_STANDSTILL_RED = 300 + +# Border colors matching QT bg_colors (selfdrive/ui/ui.h) +_GREEN = rl.Color(0x17, 0x86, 0x44, 0xF1) # STATUS_ENGAGED (23, 134, 68, 241) +_YELLOW = rl.Color(0xFF, 0xFF, 0x00, 0xF1) # STATUS_CEM_DISABLED (255, 255, 0, 241) +_ORANGE = rl.Color(0xDA, 0x6F, 0x25, 0xF1) # STATUS_EXPERIMENTAL_MODE_ENABLED (218, 111, 37, 241) +_RED = rl.Color(0xC9, 0x22, 0x31, 0xF1) # STATUS_TRAFFIC_MODE_ENABLED (201, 34, 49, 241) +_TEAL = rl.Color(0x0A, 0xBA, 0xB5, 0xF1) # STATUS_ALWAYS_ON_LATERAL_ACTIVE (10, 186, 181, 241) + +_BLACK_T = rl.Color(0, 0, 0, 166) # Translucent black background +_WHITE = rl.WHITE + + +@dataclass +class Stopwatch: + _start: float = -1.0 + + def start(self): + if self._start < 0: + self._start = time.monotonic() + + def stop(self): + self._start = -1.0 + + @property + def running(self) -> bool: + return self._start >= 0 + + @property + def elapsed_ms(self) -> float: + if self._start < 0: + return 0 + return (time.monotonic() - self._start) * 1000.0 + + @property + def elapsed_s(self) -> float: + if self._start < 0: + return 0 + return time.monotonic() - self._start + + +class FrogPilotOverlay(Widget): + """All 27 StarPilot onroad overlay features.""" + + def __init__(self, model_renderer, hud_renderer, driver_state_renderer): + super().__init__() + self._model = model_renderer + self._hud = hud_renderer + self._dm = driver_state_renderer + self._params_memory = Params(memory=True) + + # Toggles cache + self._toggles: dict = {} + + # GPS cache (avoids per-frame json.loads in compass) + self._cached_gps_raw: bytes = b"" + self._cached_bearing: int = 0 + + # Pre-computed gradient constants (avoids per-frame allocation) + from openpilot.system.ui.lib.shader_polygon import Gradient + + self._blind_spot_gradient = Gradient( + start=(0.0, 1.0), + end=(0.0, 0.0), + colors=[rl.Color(201, 34, 49, 102), rl.Color(201, 34, 49, 89), rl.Color(201, 34, 49, 0)], + stops=[0.0, 0.5, 1.0], + ) + + # Speed limit state + self._speed_limit: float = 0 + self._speed_limit_str: str = "–" + self._speed_limit_offset_str: str = "–" + self._slc_overridden_speed: float = 0 + self._speed_limit_changed: bool = False + self._unconfirmed_speed: float = 0 + self._speed_limit_source: str = "" + self._dashboard_sl: float = 0 + self._map_sl: float = 0 + self._mapbox_sl: float = 0 + self._next_sl: float = 0 + self._speed_limit_rect = rl.Rectangle(0, 0, 0, 0) + + # Curve speed state + self._csc_controlling: bool = False + self._csc_speed: float = 0 + self._csc_training: bool = False + self._road_curvature: float = 0 + + # Turn signal state + self._blinker_left: bool = False + self._blinker_right: bool = False + self._blindspot_left: bool = False + self._blindspot_right: bool = False + self._signal_style: str = "None" + self._signal_anim_frame: int = 0 + self._signal_anim_tick: float = 0 + self._signal_anim_length: float = 0.05 + + # Bottom bar state + self._lateral_paused: bool = False + self._longitudinal_paused: bool = False + self._force_coast: bool = False + self._weather_id: int = 0 + self._weather_daytime: bool = True + self._road_name: str = "" + self._hide_bottom_icons: bool = False + + # Standstill timer + self._standstill_timer = Stopwatch() + self._standstill_duration: int = 0 + + # Stopping point + self._red_light: bool = False + self._stopping_distance: float = 0 + + # Pending speed limit + self._pending_timer = Stopwatch() + + # CSC glow animation + self._glow_timer = Stopwatch() + + # Pedal state + self._acceleration_ego: float = 0 + self._brake_lights: bool = False + + # Speed conversion + self._speed_conversion: float = MS_TO_MPH + self._speed_unit: str = "mph" + self._distance_conversion: float = METER_TO_FOOT + self._distance_unit: str = " feet" + self._speed: float = 0 + self._is_cruise_set: bool = False + + # FPS tracking + from collections import deque + + self._fps_times: deque = deque(maxlen=60) + self._fps_last_time: float = 0.0 + + # Steering torque + self._torque: float = 0 + self._smoothed_torque: float = 0 + + # GIF players (lazily loaded) + self._gif_cem_curve = None + self._gif_cem_lead = None + self._gif_cem_speed = None + self._gif_cem_stop = None + self._gif_cem_turn = None + self._gif_chill = None + self._gif_experimental = None + self._gif_weather_day = None + self._gif_weather_night = None + self._gif_weather_rain = None + self._gif_weather_snow = None + self._gif_weather_fog = None + self._gif_signal_left = None + self._gif_signal_right = None + + # Static textures (lazily loaded) + self._tex_brake_pedal = None + self._tex_gas_pedal = None + self._tex_curve_speed = None + self._tex_stop_sign = None + self._tex_paused = None + self._tex_speed_icon = None + self._tex_turn_icon = None + self._tex_dashboard_icon = None + self._tex_mapbox_icon = None + self._tex_map_data_icon = None + self._tex_next_maps_icon = None + + # Personality button textures (lazily loaded) + self._tex_traffic = None + self._tex_aggressive = None + self._tex_standard = None + self._tex_relaxed = None + + # Font cache + self._font_bold = gui_app.font(FontWeight.BOLD) + self._font_semi = gui_app.font(FontWeight.SEMI_BOLD) + self._font_demi = gui_app.font(FontWeight.NORMAL) + + # Constants + self._btn_size = 192 + + # --- Asset loading --- + + def _ensure_assets(self): + """Lazily load all assets on first render.""" + if self._tex_brake_pedal is not None: + return + self._tex_brake_pedal = gui_app.starpilot_texture("other_images/brake_pedal.png", self._btn_size, self._btn_size) + self._tex_gas_pedal = gui_app.starpilot_texture("other_images/gas_pedal.png", self._btn_size, self._btn_size) + self._tex_curve_speed = gui_app.starpilot_texture("other_images/curve_speed.png", self._btn_size, self._btn_size) + self._tex_stop_sign = gui_app.starpilot_texture("other_images/stop_sign.png", self._btn_size, self._btn_size) + self._tex_paused = gui_app.starpilot_texture("other_images/paused_icon.png", _WIDGET_SIZE, _WIDGET_SIZE) + self._tex_speed_icon = gui_app.starpilot_texture("other_images/speed_icon.png", _WIDGET_SIZE, _WIDGET_SIZE) + self._tex_turn_icon = gui_app.starpilot_texture("other_images/turn_icon.png", _WIDGET_SIZE, _WIDGET_SIZE) + self._tex_dashboard_icon = gui_app.starpilot_texture("other_images/dashboard_icon.png", _ICON_SIZE, _ICON_SIZE) + self._tex_mapbox_icon = gui_app.starpilot_texture("other_images/mapbox_icon.png", _ICON_SIZE, _ICON_SIZE) + self._tex_map_data_icon = gui_app.starpilot_texture("other_images/offline_maps_icon.png", _ICON_SIZE, _ICON_SIZE) + self._tex_next_maps_icon = gui_app.starpilot_texture("other_images/offline_maps_icon.png", _ICON_SIZE, _ICON_SIZE) + self._tex_next_maps_icon = gui_app.starpilot_texture("other_images/next_maps_icon.png", _ICON_SIZE, _ICON_SIZE) + + # Personality button icons + self._tex_traffic = gui_app.starpilot_texture("active_theme/distance_icons/traffic.png", self._btn_size, self._btn_size) + self._tex_aggressive = gui_app.starpilot_texture("active_theme/distance_icons/aggressive.png", self._btn_size, self._btn_size) + self._tex_standard = gui_app.starpilot_texture("active_theme/distance_icons/standard.png", self._btn_size, self._btn_size) + self._tex_relaxed = gui_app.starpilot_texture("active_theme/distance_icons/relaxed.png", self._btn_size, self._btn_size) + + # GIF players + self._gif_cem_curve = load_starpilot_asset("other_images/curve_icon.gif", _WIDGET_SIZE) + self._gif_cem_lead = load_starpilot_asset("other_images/lead_icon.gif", _WIDGET_SIZE) + self._gif_cem_speed = load_starpilot_asset("other_images/speed_icon.gif", _WIDGET_SIZE) + self._gif_cem_stop = load_starpilot_asset("other_images/light_icon.gif", _WIDGET_SIZE) + self._gif_cem_turn = load_starpilot_asset("other_images/turn_icon.gif", _WIDGET_SIZE) + self._gif_chill = load_starpilot_asset("other_images/chill_mode_icon.gif", _WIDGET_SIZE) + self._gif_experimental = load_starpilot_asset("other_images/experimental_mode_icon.gif", _WIDGET_SIZE) + self._gif_weather_day = load_starpilot_asset("other_images/weather_clear_day.gif", _WIDGET_SIZE) + self._gif_weather_night = load_starpilot_asset("other_images/weather_clear_night.gif", _WIDGET_SIZE) + self._gif_weather_rain = load_starpilot_asset("other_images/weather_rain.gif", _WIDGET_SIZE) + self._gif_weather_snow = load_starpilot_asset("other_images/weather_snow.gif", _WIDGET_SIZE) + self._gif_weather_fog = load_starpilot_asset("other_images/weather_low_visibility.gif", _WIDGET_SIZE) + + # Start all GIF animations + for gif in [ + self._gif_cem_curve, + self._gif_cem_lead, + self._gif_cem_speed, + self._gif_cem_stop, + self._gif_cem_turn, + self._gif_chill, + self._gif_experimental, + self._gif_weather_day, + self._gif_weather_night, + self._gif_weather_rain, + self._gif_weather_snow, + self._gif_weather_fog, + ]: + gif.play() + + # --- State update --- + + def _update_state(self): + sm = ui_state.sm + new_toggles = ui_state.frogpilot_toggles + if new_toggles is not self._toggles: + self._toggles = new_toggles + + # Speed/distance units + if ui_state.is_metric or self._toggles.get("use_si_metrics", False): + self._speed_conversion = 1.0 if self._toggles.get("use_si_metrics", False) else MS_TO_KPH + self._speed_unit = " m/s" if self._toggles.get("use_si_metrics", False) else ("km/h" if ui_state.is_metric else "mph") + self._distance_conversion = 1.0 + self._distance_unit = " meters" + if not ui_state.is_metric and not self._toggles.get("use_si_metrics", False): + self._speed_conversion = MS_TO_MPH + self._speed_unit = "mph" + else: + self._speed_conversion = MS_TO_MPH + self._speed_unit = "mph" + self._distance_conversion = METER_TO_FOOT + self._distance_unit = " feet" + + # Car state + car_state = sm["carState"] + self._speed = max(0.0, car_state.vEgo * self._speed_conversion) + self._blinker_left = car_state.leftBlinker + self._blinker_right = car_state.rightBlinker + self._blindspot_left = car_state.leftBlindspot + self._blindspot_right = car_state.rightBlindspot + self._acceleration_ego = car_state.aEgo + + # Cruise state + controls = sm["controlsState"] + v_cruise = car_state.vCruiseCluster + set_speed = controls.vCruiseDEPRECATED if v_cruise == 0.0 else v_cruise + self._is_cruise_set = 0 < set_speed < 255 + + # FrogPilot plan data + fp_plan = ui_state.frogpilot_plan + if fp_plan is not None: + slc_overridden = fp_plan.slcOverriddenSpeed + self._slc_overridden_speed = slc_overridden + raw_limit = slc_overridden if slc_overridden != 0 else fp_plan.slcSpeedLimit + if slc_overridden == 0 and not self._toggles.get("show_speed_limit_offset", False): + raw_limit += fp_plan.slcSpeedLimitOffset + raw_limit *= MS_TO_KPH if ui_state.is_metric else MS_TO_MPH + self._speed_limit = raw_limit + self._speed_limit_str = str(round(raw_limit)) if raw_limit > 1 else "–" + + offset_val = fp_plan.slcSpeedLimitOffset * self._speed_conversion + if offset_val != 0: + sign = "+" if offset_val > 0 else "-" + self._speed_limit_offset_str = f"{sign}{abs(round(offset_val))}" + else: + self._speed_limit_offset_str = "–" + + self._speed_limit_changed = fp_plan.speedLimitChanged + self._unconfirmed_speed = fp_plan.unconfirmedSlcSpeedLimit + if self._unconfirmed_speed > 1: + self._unconfirmed_speed *= MS_TO_KPH if ui_state.is_metric else MS_TO_MPH + self._speed_limit_source = fp_plan.slcSpeedLimitSource + self._map_sl = fp_plan.slcMapSpeedLimit + self._mapbox_sl = fp_plan.slcMapboxSpeedLimit + self._next_sl = fp_plan.slcNextSpeedLimit + if not ui_state.is_metric: + self._map_sl *= MS_TO_MPH + self._mapbox_sl *= MS_TO_MPH + self._next_sl *= MS_TO_MPH + else: + self._map_sl *= MS_TO_KPH + self._mapbox_sl *= MS_TO_KPH + self._next_sl *= MS_TO_KPH + + # Curve speed + self._csc_controlling = fp_plan.cscControllingSpeed + self._csc_speed = fp_plan.cscSpeed + self._csc_training = fp_plan.cscTraining + self._road_curvature = fp_plan.roadCurvature + + # Road name + try: + self._road_name = sm["mapdOut"].roadName if sm.valid.get("mapdOut", False) else "" + except Exception: + self._road_name = "" + + # Weather + self._weather_id = fp_plan.weatherId + self._weather_daytime = fp_plan.weatherDaytime + + # Stopping + self._red_light = fp_plan.redLight + model = sm["modelV2"] + pos_x = model.position.x + self._stopping_distance = pos_x[32] if len(pos_x) > 32 else 0.0 + + # FrogPilot car state + if sm.valid.get("frogpilotCarState", False): + fp_cs = sm["frogpilotCarState"] + self._lateral_paused = fp_cs.pauseLateral + self._longitudinal_paused = fp_cs.pauseLongitudinal + self._force_coast = fp_cs.forceCoast + self._brake_lights = fp_cs.brakeLights + self._dashboard_sl = fp_cs.dashboardSpeedLimit * self._speed_conversion + else: + self._lateral_paused = False + self._longitudinal_paused = False + self._force_coast = False + self._brake_lights = False + + # Torque (from carControl) + if sm.valid.get("carControl", False): + self._torque = -sm["carControl"].actuators.torque + abs_t = abs(self._torque) + self._smoothed_torque = 0.25 * abs_t + 0.75 * self._smoothed_torque + if abs(self._smoothed_torque - abs_t) < 0.01: + self._smoothed_torque = abs_t + + # Hide bottom icons when alerts are showing + ss_alert = sm["selfdriveState"].alertSize + self._hide_bottom_icons = ss_alert != log.SelfdriveState.AlertSize.none + if sm.valid.get("frogpilotSelfdriveState", False): + fp_alert = sm["frogpilotSelfdriveState"].alertSize + self._hide_bottom_icons |= fp_alert != log.FrogPilotSelfdriveState.AlertSize.none + self._hide_bottom_icons |= self._signal_style.startswith("traditional") and (self._blinker_left or self._blinker_right) + + # Standstill timer + is_standstill = car_state.standstill + if is_standstill and self._toggles.get("stopped_timer", False): + self._standstill_timer.start() + self._standstill_duration = int(self._standstill_timer.elapsed_s) + else: + self._standstill_timer.stop() + self._standstill_duration = 0 + + # Pending speed limit timer + if self._speed_limit_changed: + self._pending_timer.start() + else: + self._pending_timer.stop() + + # CSC training glow + if self._csc_training: + self._glow_timer.start() + else: + self._glow_timer.stop() + + # Signal animation + if (self._blinker_left or self._blinker_right) and self._signal_style != "None": + now = time.monotonic() + if now - self._signal_anim_tick >= self._signal_anim_length: + self._signal_anim_frame += 1 + self._signal_anim_tick = now + else: + self._signal_anim_frame = 0 + + # --- Main render --- + + def _render(self, rect: rl.Rectangle): + if not ui_state.started: + return + self._ensure_assets() + + t = self._toggles + + # Bottom bar icons (ordered by position cascade) + if not self._hide_bottom_icons and t.get("cem_status", False): + self._draw_cem_status() + + if not self._hide_bottom_icons and t.get("compass", False): + self._draw_compass() + + if t.get("csc_status", False) and not self._speed_limit_changed: + if self._csc_training: + self._draw_curve_speed_training() + elif self._is_cruise_set and self._csc_controlling: + self._draw_curve_speed() + + if not self._hide_bottom_icons and self._lateral_paused: + self._draw_lateral_paused() + + if not self._hide_bottom_icons and (self._force_coast or self._longitudinal_paused): + self._draw_longitudinal_paused() + + if t.get("pedals_on_ui", False): + self._draw_pedal_icons() + + if self._speed_limit_changed: + self._draw_pending_speed_limit() + + if t.get("radar_tracks", False): + self._draw_radar_tracks() + + if t.get("road_name_ui", False): + self._draw_road_name() + + hide_sl = not self._speed_limit_changed and t.get("hide_speed_limit", False) + if not hide_sl and (t.get("show_speed_limits", False) or t.get("speed_limit_controller", False)): + self._draw_speed_limit() + else: + self._speed_limit_rect = rl.Rectangle(0, 0, 0, 0) + + if t.get("speed_limit_sources", False): + self._draw_speed_limit_sources() + + if self._standstill_duration != 0: + self._draw_standstill_timer() + + if t.get("show_stopping_point", False) and self._red_light: + self._draw_stopping_point() + + if (self._blinker_left or self._blinker_right) and self._signal_style != "None": + self._draw_turn_signals() + + if not self._hide_bottom_icons: + self._draw_weather() + + # Border effects + if t.get("steering_metrics", False): + self._draw_steering_torque_border(rect) + + if t.get("signal_metrics", False) or t.get("blind_spot_metrics", False): + self._draw_turn_signal_border(rect) + + # FPS + if t.get("show_fps", False): + self._draw_fps(rect) + + # Lead metrics text + if t.get("lead_info", False): + self._draw_lead_metrics() + + # Driving personality button + if t.get("onroad_distance_button", False): + self._draw_driving_personality_button() + + # --- Mouse handling for pending speed limit --- + + def _handle_mouse_release(self, mouse_pos): + if self._speed_limit_changed and self._speed_limit_rect.width > 0: + if rl.check_collision_point_rec(mouse_pos, self._speed_limit_rect): + self._params_memory.put_bool("SpeedLimitAccepted", True) + + # --- Drawing helpers --- + + def _draw_rounded_box(self, x, y, w, h, radius=24, bg=None, border_color=None, border_width=10): + """Draw a rounded rectangle with optional border.""" + r = rl.Rectangle(x, y, w, h) + if bg: + rl.draw_rectangle_rounded(r, 0.15, 10, bg) + if border_color: + rl.draw_rectangle_rounded_lines_ex(r, 0.15, 10, border_width, border_color) + + def _draw_text_outlined(self, text, x, y, font, size, color, outline_color=rl.BLACK, outline_w=3): + """Draw text with a dark outline for readability.""" + rl.draw_text_ex(font, text, rl.Vector2(x + outline_w, y + outline_w), size, 0, outline_color) + rl.draw_text_ex(font, text, rl.Vector2(x, y), size, 0, color) + + def _draw_texture_centered(self, tex, cx, cy): + """Draw a texture centered at (cx, cy).""" + rl.draw_texture(tex, int(cx - tex.width / 2), int(cy - tex.height / 2), _WHITE) + + def _draw_texture_in_box(self, tex, box_x, box_y, box_w, box_h): + """Draw a texture centered within a box.""" + self._draw_texture_centered(tex, box_x + box_w / 2, box_y + box_h / 2) + + # --- Feature 1: Speed Limit Sign --- + + def _draw_speed_limit(self): + ssr = self._hud.set_speed_rect + if ssr.width <= 0: + return + + # Translate from content-local to screen-absolute + ox, oy = self._rect.x, self._rect.y + is_us = not self._toggles.get("speed_limit_vienna", False) + sl_str = self._speed_limit_str + + if is_us: + sign_h = _US_SIGN_HEIGHT + sign_w = ssr.width - 2 * _SIGN_MARGIN + sign_x = ox + ssr.x + _SIGN_MARGIN + sign_y = oy + ssr.y + ssr.height - sign_h - _SIGN_MARGIN + else: + sign_h = _EU_SIGN_SIZE + sign_w = sign_h + sign_x = ox + ssr.x + (ssr.width - sign_w) / 2 + sign_y = oy + ssr.y + ssr.height - sign_h - _SIGN_MARGIN + + self._speed_limit_rect = rl.Rectangle(sign_x, sign_y, sign_w, sign_h) + is_override = self._slc_overridden_speed != 0 + alpha_mult = 0.25 if is_override else 1.0 + + if is_us: + # US style: white rect with black inner border + rl.draw_rectangle_rounded(self._speed_limit_rect, 0.15, 10, rl.WHITE) + rl.draw_rectangle_rounded_lines_ex(self._speed_limit_rect, 0.15, 10, 6, rl.Color(0, 0, 0, int(255 * alpha_mult))) + + show_offset = self._toggles.get("show_speed_limit_offset", False) and not is_override + if show_offset: + self._draw_label_centered("LIMIT", sign_x, sign_y + 22, sign_w, self._font_demi, 28, alpha_mult) + self._draw_label_centered(sl_str, sign_x, sign_y + 51, sign_w, self._font_bold, 70, alpha_mult) + self._draw_label_centered(self._speed_limit_offset_str, sign_x, sign_y + 120, sign_w, self._font_demi, 50, alpha_mult) + else: + self._draw_label_centered("SPEED", sign_x, sign_y + 22, sign_w, self._font_demi, 28, alpha_mult) + self._draw_label_centered("LIMIT", sign_x, sign_y + 51, sign_w, self._font_demi, 28, alpha_mult) + self._draw_label_centered(sl_str, sign_x, sign_y + 85, sign_w, self._font_bold, 70, alpha_mult) + else: + # EU style: white circle with red border + center = rl.Vector2(sign_x + sign_w / 2, sign_y + sign_h / 2) + rl.draw_circle(int(center.x), int(center.y), sign_w / 2, rl.WHITE) + rl.draw_circle(int(center.x), int(center.y), sign_w / 2 - 16, rl.Color(201, 34, 49, int(255 * alpha_mult))) + + show_offset = self._toggles.get("show_speed_limit_offset", False) and not is_override + if show_offset: + self._draw_label_centered(sl_str, sign_x, sign_y + 20, sign_w, self._font_bold, 60, alpha_mult) + self._draw_label_centered(self._speed_limit_offset_str, sign_x, sign_y + 100, sign_w, self._font_demi, 40, alpha_mult) + else: + self._draw_label_centered(sl_str, sign_x, sign_y, sign_w, self._font_bold, 70, alpha_mult) + + def _draw_label_centered(self, text, x, y, w, font, size, alpha_mult=1.0): + ts = measure_text_cached(font, text, size) + tx = x + (w - ts.x) / 2 + color = rl.Color(0, 0, 0, int(255 * alpha_mult)) + rl.draw_text_ex(font, text, rl.Vector2(tx, y), size, 0, color) + + # --- Feature 2: Pending Speed Limit --- + + def _draw_pending_speed_limit(self): + if self._speed_limit_rect.width <= 0: + self._draw_speed_limit() + if self._speed_limit_rect.width <= 0: + return + + psl = rl.Rectangle( + self._speed_limit_rect.x + self._speed_limit_rect.width + _SIGN_MARGIN, + self._speed_limit_rect.y, + self._speed_limit_rect.width, + self._speed_limit_rect.height, + ) + is_vienna = self._toggles.get("speed_limit_vienna", False) + pending_str = str(round(self._unconfirmed_speed)) if self._unconfirmed_speed > 1 else "–" + + # Blinking border: 500ms on/off + blink_on = int(self._pending_timer.elapsed_ms) % 1000 < 500 + border_c = rl.Color(0, 0, 0, 255) if blink_on else rl.Color(201, 34, 49, 255) + + if not is_vienna: + rl.draw_rectangle_rounded(psl, 0.15, 10, rl.WHITE) + rl.draw_rectangle_rounded_lines_ex(psl, 0.15, 10, 6, border_c) + self._draw_label_centered("PENDING", psl.x, psl.y + 22, psl.width, self._font_demi, 28) + self._draw_label_centered("LIMIT", psl.x, psl.y + 51, psl.width, self._font_demi, 28) + self._draw_label_centered(pending_str, psl.x, psl.y + 85, psl.width, self._font_bold, 70) + else: + center = rl.Vector2(psl.x + psl.width / 2, psl.y + psl.height / 2) + rl.draw_circle(int(center.x), int(center.y), psl.width / 2, rl.WHITE) + rl.draw_circle(int(center.x), int(center.y), psl.width / 2 - 16, rl.Color(201, 34, 49, 255)) + font_size = 60 if len(pending_str) >= 3 else 70 + self._draw_label_centered(pending_str, psl.x, psl.y + 20, psl.width, self._font_bold, font_size) + + # --- Feature 3: Speed Limit Sources --- + + def _draw_speed_limit_sources(self): + if self._speed_limit_rect.width <= 0: + return + + sx = self._speed_limit_rect.x - _SIGN_MARGIN + sy = self._speed_limit_rect.y + self._speed_limit_rect.height + UI_BORDER_SIZE + sw = 450 + sh = 60 + gap = UI_BORDER_SIZE / 2 + + sources = [ + ("Dashboard", self._tex_dashboard_icon, self._dashboard_sl), + ("Map Data", self._tex_map_data_icon, self._map_sl), + ("Mapbox", self._tex_mapbox_icon, self._mapbox_sl), + ("Upcoming", self._tex_next_maps_icon, self._next_sl), + ] + + for i, (title, icon, speed_val) in enumerate(sources): + ry = sy + i * (sh + gap) + is_active = self._speed_limit_source == title and speed_val != 0 + bg_c = rl.Color(201, 34, 49, 166) if is_active else _BLACK_T + self._draw_rounded_box(sx, ry, sw, sh, bg=bg_c, border_color=rl.Color(0, 0, 0, 0) if not is_active else rl.Color(201, 34, 49, 255)) + + if icon: + rl.draw_texture(icon, int(sx + 10), int(ry + (sh - icon.height) / 2), _WHITE) + + speed_text = f"{round(speed_val)} {self._speed_unit}" if speed_val != 0 else "N/A" + full_text = f"{title} - {speed_text}" + font = self._font_bold if is_active else self._font_demi + fs = 35 + ts = measure_text_cached(font, full_text, fs) + text_x = sx + _ICON_SIZE + 20 + text_y = ry + (sh - ts.y) / 2 + if is_active: + self._draw_text_outlined(full_text, text_x, text_y, font, fs, _WHITE) + else: + rl.draw_text_ex(font, full_text, rl.Vector2(text_x, text_y), fs, 0, _WHITE) + + # --- Feature 5: Curve Speed Control --- + + def _draw_curve_speed(self): + ssr = self._hud.set_speed_rect + ox, oy = self._rect.x, self._rect.y + csc_x = ox + ssr.x + ssr.width + UI_BORDER_SIZE + csc_y = oy + ssr.y + csc_w = _CSC_SIZE + csc_h = _CSC_SIZE + + # Curve icon + tex = self._tex_curve_speed + if tex: + if self._road_curvature >= 0: + # Mirror horizontally for right curves + src = rl.Rectangle(0, 0, -tex.width, tex.height) + rl.draw_texture_rec(tex, src, rl.Vector2(csc_x, csc_y), _WHITE) + else: + self._draw_texture_in_box(tex, csc_x, csc_y, csc_w, csc_h) + + # Speed text box + box_y = csc_y + csc_h + 10 + self._draw_rounded_box(csc_x, box_y, csc_w, 50, bg=rl.Color(0, 0, 255, 166), border_color=rl.Color(0, 0, 255, 255)) + csc_spd = min(self._speed, self._csc_speed * self._speed_conversion) + text = f"{round(csc_spd)}{self._speed_unit}" + rl.draw_text_ex(self._font_bold, text, rl.Vector2(csc_x + 20, box_y + 10), 45, 0, _WHITE) + + # --- Feature 6: Curve Speed Training --- + + def _draw_curve_speed_training(self): + ssr = self._hud.set_speed_rect + ox, oy = self._rect.x, self._rect.y + csc_x = ox + ssr.x + ssr.width + UI_BORDER_SIZE + csc_y = oy + ssr.y + + # Pulsing glow + phase = (self._glow_timer.elapsed_ms % 2000) / 2000.0 * 2 * math.pi + alpha_factor = 0.5 + 0.5 * math.sin(phase) + glow_alpha = int(255 * (0.3 + 0.7 * alpha_factor)) + glow_w = 8 + int(2 * alpha_factor) + glow_c = rl.Color(0, 0, 255, glow_alpha) + + # Box with glow border + self._draw_rounded_box(csc_x, csc_y, _CSC_SIZE, _CSC_SIZE, bg=_BLACK_T, border_color=glow_c, border_width=glow_w) + + tex = self._tex_curve_speed + if tex: + if self._road_curvature >= 0: + src = rl.Rectangle(0, 0, -tex.width, tex.height) + rl.draw_texture_rec(tex, src, rl.Vector2(csc_x, csc_y), _WHITE) + else: + self._draw_texture_in_box(tex, csc_x, csc_y, _CSC_SIZE, _CSC_SIZE) + + # Training label + box_y = csc_y + _CSC_SIZE + 10 + self._draw_rounded_box(csc_x, box_y, _CSC_SIZE, 40, bg=_BLACK_T) + rl.draw_text_ex(self._font_bold, "Training...", rl.Vector2(csc_x + 20, box_y + 8), 35, 0, _WHITE) + + # --- Feature 7: Turn Signals --- + + def _draw_turn_signals(self): + if self._standstill_duration != 0 and self._signal_style == "static": + return + + content = self._rect + is_left = self._blinker_left + + # Load signal images on first use + if self._gif_signal_left is None: + self._load_signal_images() + + # Determine which frames to use + frames = self._gif_signal_left if is_left else self._gif_signal_right + if not frames: + # Fallback: use turn_icon as static + tex = self._tex_turn_icon + if not tex: + return + sx = content.x + (content.width * 0.375 - tex.width) if is_left else content.x + content.width * 0.625 + sy = content.y + if self._signal_style == "static": + sy += tex.height / 2 + else: + sy += content.height - tex.height + rl.draw_texture(tex, int(sx), int(sy), _WHITE) + return + + frame_idx = self._signal_anim_frame % len(frames) + tex = frames[frame_idx] + + if self._signal_style == "static": + sx = content.x + (content.width * 0.375 - tex.width) if is_left else content.x + content.width * 0.625 + sy = content.y + tex.height / 2 + else: + if is_left: + sx = content.x + content.width - (frame_idx + 1) * tex.width + else: + sx = content.x + frame_idx * tex.width + sy = content.y + content.height - tex.height + + rl.draw_texture(tex, int(sx), int(sy), _WHITE) + + def _load_signal_images(self): + """Load theme-based signal images from active_theme/signals/.""" + self._gif_signal_left = [] + self._gif_signal_right = [] + try: + from importlib.resources import files, as_file + import os + + frogpilot_assets = files("openpilot.frogpilot").joinpath("assets") + signals_dir = frogpilot_assets.joinpath("active_theme/signals") + with as_file(signals_dir) as d: + path = str(d) + if os.path.isdir(path): + files_list = sorted(os.listdir(path)) + for f in files_list: + fp = os.path.join(path, f) + if f.lower().endswith(".gif"): + from openpilot.selfdrive.ui.onroad.gif_player import GifPlayer + + gp = GifPlayer(fp, _ASSET_SIZE) + gp.play() + for i in range(gp.frame_count): + self._gif_signal_left.append(gp._frames[i]) + # Right = flipped (we'll just reuse left for now) + self._gif_signal_right = self._gif_signal_left + self._signal_style = "traditional_gif" + self._signal_anim_length = 0.05 + return + elif f.lower().endswith(".png"): + from openpilot.selfdrive.ui.onroad.gif_player import StaticTexture + + st = StaticTexture(fp, _ASSET_SIZE) + tex = st.current_texture() + if tex: + self._gif_signal_left.append(tex) + self._gif_signal_right.append(tex) + self._signal_style = "traditional" + elif "_" in f: + parts = f.split("_") + if len(parts) == 2: + self._signal_style = parts[0] + try: + self._signal_anim_length = int(parts[1]) / 1000.0 + except ValueError: + pass + if not self._gif_signal_left: + self._signal_style = "None" + except Exception: + self._signal_style = "None" + + # --- Feature 8: Turn Signal Border --- + + def _draw_turn_signal_border(self, rect: rl.Rectangle): + show_blindspot = (self._blindspot_left or self._blindspot_right) and self._toggles.get("blind_spot_metrics", False) + show_signal = (self._blinker_left or self._blinker_right) and self._toggles.get("signal_metrics", False) + + if not show_blindspot and not show_signal: + return + + flicker_on = int(time.monotonic() * 2) % 2 == 0 # ~500ms flicker + + def get_color(blindspot, turn_signal): + if turn_signal and show_signal: + if blindspot: + return _RED if flicker_on else _YELLOW + return _ORANGE if flicker_on else rl.Color(0, 0, 0, 0) + elif blindspot and show_blindspot: + return _RED + return rl.Color(0, 0, 0, 0) + + left_c = get_color(self._blindspot_left, self._blinker_left) + right_c = get_color(self._blindspot_right, self._blinker_right) + + half_w = rect.width / 2 + if left_c.a > 0: + rl.draw_rectangle(int(rect.x), int(rect.y), int(half_w), int(rect.height), left_c) + if right_c.a > 0: + rl.draw_rectangle(int(rect.x + half_w), int(rect.y), int(half_w), int(rect.height), right_c) + + # --- Feature 9: Blind Spot Path --- + + def draw_blind_spot_path(self, left_vertices, right_vertices): + if not self._toggles.get("blind_spot_path", False): + return + from openpilot.system.ui.lib.shader_polygon import draw_polygon + + if self._blindspot_left and left_vertices.size > 0: + draw_polygon(self._rect, left_vertices, gradient=self._blind_spot_gradient) + if self._blindspot_right and right_vertices.size > 0: + draw_polygon(self._rect, right_vertices, gradient=self._blind_spot_gradient) + + # --- Feature 10: Adjacent Paths --- + + def draw_adjacent_paths(self, left_vertices, right_vertices, lane_width_left, lane_width_right): + if not self._toggles.get("adjacent_path_metrics", False): + return + from openpilot.system.ui.lib.shader_polygon import draw_polygon, Gradient + + def paint_path(vertices, is_left, is_blindspot, lane_width): + if lane_width == 0 or vertices.size == 0: + return + if is_blindspot and self._toggles.get("blind_spot_path", False): + draw_polygon(self._rect, vertices, gradient=self._blind_spot_gradient) + else: + lane_det = self._toggles.get("lane_detection_width", 3.5) + ratio = min(lane_width / max(lane_det, 0.1), 1.0) + hue = (ratio * ratio) * (120.0 / 360.0) + colors = [ + rl.Color(int(255 * hue), int(255 * (1 - hue)), 0, 102), + rl.Color(int(255 * hue), int(255 * (1 - hue)), 0, 89), + rl.Color(int(255 * hue), int(255 * (1 - hue)), 0, 0), + ] + g = Gradient(start=(0.0, 1.0), end=(0.0, 0.0), colors=colors, stops=[0.0, 0.5, 1.0]) + draw_polygon(self._rect, vertices, gradient=g) + + # Width text + text = f"{lane_width * self._distance_conversion:.2f}{self._distance_unit}" + mid = len(vertices) // 2 + anchor = vertices[mid // 2] if is_left else vertices[mid + (len(vertices) - mid) // 2] + self._draw_text_outlined(text, anchor[0], anchor[1], self._font_demi, 45, _WHITE) + + paint_path(left_vertices, True, self._blindspot_left, lane_width_left) + paint_path(right_vertices, False, self._blindspot_right, lane_width_right) + + # --- Feature 11: Compass --- + + def _draw_compass(self): + dm_x, dm_y = self._dm.dm_icon_position + if dm_x == 0 and dm_y == 0: + return + + ox, oy = self._rect.x, self._rect.y + rhd = self._dm.is_right_hand_drive + compass_x = ox + (UI_BORDER_SIZE if rhd else (self._rect.width - UI_BORDER_SIZE - _WIDGET_SIZE)) + compass_y = oy + dm_y - _WIDGET_SIZE / 2 + + # Draw background + self._draw_rounded_box(compass_x, compass_y, _WIDGET_SIZE, _WIDGET_SIZE, bg=_BLACK_T, border_color=rl.BLACK) + + # Get bearing (cached — json.loads only when param changes) + raw = self._params_memory.get("LastGPSPosition", b"{}") or b"{}" + if raw != self._cached_gps_raw: + self._cached_gps_raw = raw + try: + self._cached_bearing = round(json.loads(raw).get("bearing", 0.0)) % 360 + except (json.JSONDecodeError, TypeError): + self._cached_bearing = 0 + bearing = self._cached_bearing + + # Simplified compass: draw cardinal direction text + directions = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"] + dir_idx = round(bearing / 45) % 8 + label = directions[dir_idx] + + # Center triangle pointer + tri_x = compass_x + _WIDGET_SIZE / 2 + tri_y = compass_y + _WIDGET_SIZE - 10 + rl.draw_triangle( + rl.Vector2(tri_x, tri_y - 30), + rl.Vector2(tri_x - 15, tri_y), + rl.Vector2(tri_x + 15, tri_y), + _WHITE, + ) + + # Direction label + ts = measure_text_cached(self._font_bold, label, 65) + rl.draw_text_ex( + self._font_bold, + label, + rl.Vector2( + compass_x + (_WIDGET_SIZE - ts.x) / 2, + compass_y + (_WIDGET_SIZE - 40 - ts.y) / 2, + ), + 65, + 0, + _WHITE, + ) + + # --- Feature 12: Road Name --- + + def _draw_road_name(self): + if not self._road_name: + return + ts = measure_text_cached(self._font_demi, self._road_name, 40) + rw = ts.x + 100 + rh = 50 + rx = self._rect.x + (self._rect.width - rw) / 2 + ry = self._rect.y + self._rect.height - rh - 5 + self._draw_rounded_box(rx, ry, rw, rh, bg=_BLACK_T, border_color=rl.BLACK) + rl.draw_text_ex( + self._font_demi, + self._road_name, + rl.Vector2( + rx + (rw - ts.x) / 2, + ry + (rh - ts.y) / 2, + ), + 40, + 0, + _WHITE, + ) + + # --- Feature 13: Weather --- + + def _draw_weather(self): + if self._weather_id == 0: + return + + dm_x, dm_y = self._dm.dm_icon_position + if dm_x == 0 and dm_y == 0: + return + + # Position relative to compass or DM icon + rhd = self._dm.is_right_hand_drive + ox, oy = self._rect.x, self._rect.y + wx = ox + (UI_BORDER_SIZE if rhd else (self._rect.width - UI_BORDER_SIZE - _WIDGET_SIZE)) + wy = oy + dm_y - _WIDGET_SIZE / 2 + + # Offset from compass if visible + if self._toggles.get("compass", False): + wx += (_WIDGET_SIZE + UI_BORDER_SIZE) if rhd else -(_WIDGET_SIZE + UI_BORDER_SIZE) + + self._draw_rounded_box(wx, wy, _WIDGET_SIZE, _WIDGET_SIZE, bg=_BLACK_T, border_color=rl.BLACK) + + # Select weather GIF + gif = self._gif_weather_day if self._weather_daytime else self._gif_weather_night + wid = self._weather_id + if 200 <= wid <= 232 or 300 <= wid <= 321 or 500 <= wid <= 531: + gif = self._gif_weather_rain + elif 600 <= wid <= 622: + gif = self._gif_weather_snow + elif 701 <= wid <= 762: + gif = self._gif_weather_fog + + if gif: + gif.update() + tex = gif.current_texture() + if tex: + self._draw_texture_in_box(tex, wx, wy, _WIDGET_SIZE, _WIDGET_SIZE) + + # --- Feature 14: Standstill Timer --- + + def _draw_standstill_timer(self): + dur = self._standstill_duration + + # Color transition + if dur < _STANDSTILL_GREEN: + color = _GREEN + elif dur < _STANDSTILL_ORANGE: + t = (dur - _STANDSTILL_GREEN) / (_STANDSTILL_ORANGE - _STANDSTILL_GREEN) + color = rl.Color( + int(_GREEN.r + t * (_ORANGE.r - _GREEN.r)), + int(_GREEN.g + t * (_ORANGE.g - _GREEN.g)), + int(_GREEN.b + t * (_ORANGE.b - _GREEN.b)), + 255, + ) + elif dur < _STANDSTILL_RED: + t = (dur - _STANDSTILL_ORANGE) / (_STANDSTILL_RED - _STANDSTILL_ORANGE) + color = rl.Color( + int(_ORANGE.r + t * (_RED.r - _ORANGE.r)), + int(_ORANGE.g + t * (_RED.g - _ORANGE.g)), + int(_ORANGE.b + t * (_RED.b - _ORANGE.b)), + 255, + ) + else: + color = _RED + + minutes = dur // 60 + seconds = dur % 60 + min_text = f"{minutes} minute{'s' if minutes != 1 else ''}" + sec_text = f"{seconds} second{'s' if seconds != 1 else ''}" + + # Minutes - large centered text + ts = measure_text_cached(self._font_bold, min_text, 176) + self._draw_text_outlined(min_text, self._rect.x + (self._rect.width - ts.x) / 2, self._rect.y + 170, self._font_bold, 176, color) + + # Seconds - smaller text below + ts2 = measure_text_cached(self._font_demi, sec_text, 66) + self._draw_text_outlined(sec_text, self._rect.x + (self._rect.width - ts2.x) / 2, self._rect.y + 260, self._font_demi, 66, _WHITE) + + # --- Feature 15: Stopping Point --- + + def _draw_stopping_point(self): + path_pts = self._model.path_points + if path_pts.size < 2: + return + + # Center of path front + cx = (path_pts[0][0] + path_pts[-1][0]) / 2 + cy = (path_pts[0][1] + path_pts[-1][1]) / 2 + + if self._tex_stop_sign: + sx = cx - self._tex_stop_sign.width / 2 + sy = cy - self._tex_stop_sign.height + rl.draw_texture(self._tex_stop_sign, int(sx), int(sy), _WHITE) + + if self._toggles.get("show_stopping_point_metrics", False): + dist = self._stopping_distance * self._distance_conversion + dist_text = f"{round(dist)}{self._distance_unit}" + ts = measure_text_cached(self._font_demi, dist_text, 45) + self._draw_text_outlined(dist_text, cx - ts.x / 2, sy - ts.y - 5, self._font_demi, 45, _WHITE) + + # --- Feature 16: CEM Status --- + + def _draw_cem_status(self): + dm_x, dm_y = self._dm.dm_icon_position + if dm_x == 0 and dm_y == 0: + return + + ox, oy = self._rect.x, self._rect.y + rhd = self._dm.is_right_hand_drive + cem_x = ox + dm_x + (-_WIDGET_SIZE - _WIDGET_SIZE if rhd else _WIDGET_SIZE) + cem_y = oy + dm_y - _WIDGET_SIZE / 2 + + cond_status = ui_state.conditional_status + exp_mode = ui_state.sm["selfdriveState"].experimentalMode + + # Border color + if cond_status == 1: + border_c = _YELLOW + elif exp_mode: + border_c = _ORANGE + else: + border_c = rl.BLACK + + self._draw_rounded_box(cem_x, cem_y, _WIDGET_SIZE, _WIDGET_SIZE, bg=rl.Color(0, 0, 0, 166), border_color=border_c, border_width=10) + + # Select icon + gif = self._gif_chill + if exp_mode: + if cond_status == 1: + gif = self._gif_chill + elif cond_status == 2: + gif = self._gif_experimental + elif cond_status == 3: + gif = self._gif_cem_curve + elif cond_status == 4: + gif = self._gif_cem_lead + elif cond_status == 5: + gif = self._gif_cem_turn + elif cond_status in (6, 7): + gif = self._gif_cem_speed + elif cond_status == 8: + gif = self._gif_cem_stop + else: + gif = self._gif_experimental + + if gif: + gif.update() + tex = gif.current_texture() + if tex: + self._draw_texture_in_box(tex, cem_x, cem_y, _WIDGET_SIZE, _WIDGET_SIZE) + + # --- Feature 17: Lateral Paused --- + + def _draw_lateral_paused(self): + dm_x, dm_y = self._dm.dm_icon_position + if dm_x == 0 and dm_y == 0: + return + + ox, oy = self._rect.x, self._rect.y + rhd = self._dm.is_right_hand_drive + # Position after CEM status or next to DM icon + if ui_state.conditional_status > 0: + base_x = dm_x + (-_WIDGET_SIZE - _WIDGET_SIZE if rhd else _WIDGET_SIZE) + else: + base_x = dm_x + lat_x = ox + base_x + (-UI_BORDER_SIZE - _WIDGET_SIZE - UI_BORDER_SIZE if rhd else UI_BORDER_SIZE + _WIDGET_SIZE + UI_BORDER_SIZE) + lat_y = oy + dm_y - _WIDGET_SIZE / 2 + + self._draw_rounded_box(lat_x, lat_y, _WIDGET_SIZE, _WIDGET_SIZE, bg=_BLACK_T, border_color=_ORANGE, border_width=10) + + if self._tex_turn_icon: + rl.draw_texture(self._tex_turn_icon, int(lat_x), int(lat_y), rl.Color(255, 255, 255, 128)) + if self._tex_paused: + rl.draw_texture(self._tex_paused, int(lat_x), int(lat_y), rl.Color(255, 255, 255, 191)) + + # --- Feature 18: Longitudinal Paused --- + + def _draw_longitudinal_paused(self): + dm_x, dm_y = self._dm.dm_icon_position + if dm_x == 0 and dm_y == 0: + return + + ox, oy = self._rect.x, self._rect.y + rhd = self._dm.is_right_hand_drive + # Position after lateral paused + if self._lateral_paused: + base_x = dm_x + (-UI_BORDER_SIZE - _WIDGET_SIZE - UI_BORDER_SIZE if rhd else UI_BORDER_SIZE + _WIDGET_SIZE + UI_BORDER_SIZE) + elif ui_state.conditional_status > 0: + base_x = dm_x + (-_WIDGET_SIZE - _WIDGET_SIZE if rhd else _WIDGET_SIZE) + else: + base_x = dm_x + lon_x = ox + base_x + (-UI_BORDER_SIZE - _WIDGET_SIZE - UI_BORDER_SIZE if rhd else UI_BORDER_SIZE + _WIDGET_SIZE + UI_BORDER_SIZE) + lon_y = oy + dm_y - _WIDGET_SIZE / 2 + + self._draw_rounded_box(lon_x, lon_y, _WIDGET_SIZE, _WIDGET_SIZE, bg=_BLACK_T, border_color=_ORANGE, border_width=10) + + if self._tex_speed_icon: + rl.draw_texture(self._tex_speed_icon, int(lon_x), int(lon_y), rl.Color(255, 255, 255, 128)) + if self._tex_paused: + rl.draw_texture(self._tex_paused, int(lon_x), int(lon_y), rl.Color(255, 255, 255, 191)) + + # --- Feature 19: Pedal Icons --- + + def _draw_pedal_icons(self): + exp_r = self._hud.exp_button_rect + ox, oy = self._rect.x, self._rect.y + start_x = ox + exp_r.x + start_y = oy + exp_r.y + exp_r.height + UI_BORDER_SIZE + + brake_opacity = 1.0 + gas_opacity = 1.0 + + if self._toggles.get("dynamic_pedals_on_ui", False): + is_standstill = self._standstill_duration > 0 or ui_state.sm["carState"].standstill + brake_opacity = 1.0 if is_standstill else (max(0.25, abs(self._acceleration_ego)) if self._acceleration_ego < -0.25 else 0.25) + gas_opacity = max(0.25, self._acceleration_ego) + elif self._toggles.get("static_pedals_on_ui", False): + is_standstill = self._standstill_duration > 0 or ui_state.sm["carState"].standstill + brake_opacity = 1.0 if (is_standstill or self._brake_lights or self._acceleration_ego < -0.25) else 0.25 + gas_opacity = 1.0 if self._acceleration_ego > 0.25 else 0.25 + + if self._tex_brake_pedal: + rl.draw_texture(self._tex_brake_pedal, int(start_x), int(start_y), rl.Color(255, 255, 255, int(255 * brake_opacity))) + if self._tex_gas_pedal: + rl.draw_texture(self._tex_gas_pedal, int(start_x + self._btn_size / 2), int(start_y), rl.Color(255, 255, 255, int(255 * gas_opacity))) + + # --- Feature 20: FPS Counter --- + + def _draw_fps(self, rect: rl.Rectangle): + now = time.monotonic() + self._fps_times.append(now) + if len(self._fps_times) < 2: + return + last_delta = self._fps_times[-1] - self._fps_times[-2] + if last_delta <= 0: + return + fps_current = 1.0 / last_delta + # Min/max over the deque (small fixed size, fast iteration) + max_dt = 0.0 + min_dt = float("inf") + for i in range(1, len(self._fps_times)): + dt = self._fps_times[i] - self._fps_times[i - 1] + if dt > 0: + if dt > max_dt: + max_dt = dt + if dt < min_dt: + min_dt = dt + fps_min = 1.0 / max_dt if max_dt > 0 else 0 + fps_max = 1.0 / min_dt if min_dt < float("inf") else 0 + text = f"FPS: {fps_current:.0f} (min: {fps_min:.0f}, max: {fps_max:.0f})" + ts = measure_text_cached(self._font_demi, text, 35) + x = rect.x + (rect.width - ts.x) / 2 + y = rect.y + rect.height - ts.y - 5 + self._draw_text_outlined(text, x, y, self._font_demi, 35, _WHITE) + + # --- Feature 21: Steering Torque Border --- + + def _draw_steering_torque_border(self, rect: rl.Rectangle): + torque_pct = min(abs(self._smoothed_torque) / 100.0, 1.0) + if torque_pct < 0.01: + return + if torque_pct < 0.5: + r = int(255 * (torque_pct * 2)) + g = 255 + else: + r = 255 + g = int(255 * (1.0 - (torque_pct - 0.5) * 2)) + color = rl.Color(r, g, 0, 120) + bw = UI_BORDER_SIZE + rx, ry = int(rect.x), int(rect.y) + rw, rh = int(rect.width), int(rect.height) + rl.draw_rectangle(rx, ry, rw, bw, color) + rl.draw_rectangle(rx, ry + rh - bw, rw, bw, color) + rl.draw_rectangle(rx, ry, bw, rh, color) + rl.draw_rectangle(rx + rw - bw, ry, bw, rh, color) + + # --- Feature 22: Radar Tracks --- + + def _draw_radar_tracks(self): + sm = ui_state.sm + if not sm.valid.get("liveTracks", False): + return + points = sm["liveTracks"].liveTracks.points + if not points: + return + for pt in points: + screen_pt = self._model.project_point(pt.dRel, -pt.yRel, 0) + if screen_pt is not None: + rl.draw_circle(int(screen_pt[0]), int(screen_pt[1]), 12.5, rl.Color(201, 34, 49, 255)) + + # --- Feature 23: Lead Metrics --- + + def _draw_lead_metrics(self): + """Draw lead distance, speed, and time gap below the lead chevron.""" + sm = ui_state.sm + radar_state = sm["radarState"] if sm.valid.get("radarState", False) else None + if not radar_state: + return + lead = radar_state.leadOne + if not lead or not lead.status: + return + + d_rel = lead.dRel # meters + v_rel = lead.vRel # m/s + car_state = sm["carState"] + v_ego = car_state.vEgo + + # Time gap: distance / speed + time_gap = d_rel / max(v_ego, 0.1) + + # Convert units + if ui_state.is_metric: + dist_text = f"{d_rel:.0f} m" + speed_text = f"{v_rel * 3.6:.0f} km/h" + else: + dist_text = f"{d_rel * 3.281:.0f} ft" + speed_text = f"{v_rel * 2.237:.0f} mph" + gap_text = f"{time_gap:.1f} s" + + # Position below lead chevron + sm = ui_state.sm + live_calib = sm["liveCalibration"] if sm.valid.get("liveCalibration", False) else None + z_offset = live_calib.height[0] if live_calib and live_calib.height else 1.22 + point = self._model.project_point(d_rel, 0, z_offset) + if point is None: + return + + ox, oy = self._rect.x, self._rect.y + x = point[0] - ox + y = point[1] - oy + 40 # Below chevron + + font = self._font_semi + size = 28 + spacing = 4 + + # Draw three metric texts + texts = [dist_text, speed_text, gap_text] + total_w = sum(measure_text_cached(font, t, size).x for t in texts) + spacing * (len(texts) - 1) + start_x = x - total_w / 2 + + for i, text in enumerate(texts): + tw = measure_text_cached(font, text, size).x + self._draw_text_outlined(text, start_x, y, font, size, _WHITE) + start_x += tw + spacing + + # --- Feature 24: Path Edges --- + + def _draw_path_edges(self): + """Draw colored path edge gradients based on conditional status. + + This renders a semi-transparent overlay on the path to indicate the active mode. + The actual path edge polygon width is controlled by model_renderer via path_edge_width toggle. + This method adds the color overlay. + """ + points = self._model.path_points + if points is None or len(points) == 0: + return + + # Color based on conditional status + cs = ui_state.conditional_status + if ui_state.always_on_lateral_active: + edge_color = rl.Color(0, 180, 180, 60) # Teal for AOL + elif cs >= 2: + edge_color = rl.Color(255, 165, 0, 60) # Orange for experimental + elif cs >= 1: + edge_color = rl.Color(255, 255, 0, 60) # Yellow for CEM active + elif ui_state.traffic_mode_enabled: + edge_color = rl.Color(201, 34, 49, 60) # Red for traffic + else: + return # No overlay in default state + + from openpilot.system.ui.lib.shader_polygon import draw_polygon + + draw_polygon(self._rect, points, edge_color) + + # --- Feature 25: Rainbow Path --- + + def _draw_rainbow_path(self): + """Draw rainbow gradient on path using speed-based HSL rotation.""" + from openpilot.system.ui.lib.shader_polygon import draw_polygon, Gradient + + points = self._model.path_points + if points is None or len(points) == 0: + return + + car_state = ui_state.sm["carState"] + speed = abs(car_state.vEgo) # m/s + max_speed = 35.0 # ~80 mph + t = min(speed / max_speed, 1.0) + + # Create rainbow gradient that shifts with speed + n_stops = 5 + colors = [] + stops = [] + for i in range(n_stops): + frac = i / (n_stops - 1) + hue = (frac * 0.8 + t * 0.2) % 1.0 # Shift hue with speed + r, g, b = colorsys.hls_to_rgb(hue, 0.5, 1.0) + alpha = int(60 * (1.0 - frac * 0.5)) # Fade toward end + colors.append(rl.Color(int(r * 255), int(g * 255), int(b * 255), alpha)) + stops.append(frac) + + gradient = Gradient( + start=(0.0, 1.0), + end=(0.0, 0.0), + colors=colors, + stops=stops, + ) + draw_polygon(self._rect, points, gradient=gradient) + + # --- Feature 26: Driving Personality Button --- + + def _draw_driving_personality_button(self): + """Draw 4-mode personality icon (traffic/aggressive/standard/relaxed) with themed icons.""" + if not ui_state.sm.valid.get("frogpilotCarState", False): + return + + fp_cs = ui_state.sm["frogpilotCarState"] + if fp_cs.trafficModeEnabled: + tex = self._tex_traffic + else: + personality = ui_state.personality + from cereal.log import LongitudinalPersonality + + if personality == LongitudinalPersonality.aggressive: + tex = self._tex_aggressive + elif personality == LongitudinalPersonality.relaxed: + tex = self._tex_relaxed + else: + tex = self._tex_standard + + if tex is None: + return + + # Position: bottom-left, above exp button area + ox, oy = self._rect.x, self._rect.y + btn_size = self._btn_size + x = ox + UI_BORDER_SIZE + 10 + y = oy + self._rect.height - btn_size - UI_BORDER_SIZE - 10 + + self._draw_rounded_box(x, y, btn_size, btn_size, bg=rl.Color(0, 0, 0, 100)) + self._draw_texture_in_box(tex, x, y, btn_size, btn_size) diff --git a/selfdrive/ui/onroad/gif_player.py b/selfdrive/ui/onroad/gif_player.py new file mode 100644 index 000000000..a2c0ce792 --- /dev/null +++ b/selfdrive/ui/onroad/gif_player.py @@ -0,0 +1,170 @@ +import ctypes +import time +import pyray as rl +from PIL import Image +from pathlib import Path +from typing import Optional, Union +from importlib.resources import files, as_file + + +class GifPlayer: + """Plays a GIF animation as Raylib textures, one frame at a time.""" + + def __init__(self, gif_path: str, size: int = 0): + self._frames: list[rl.Texture] = [] + self._frame_count: int = 0 + self._current_frame: int = 0 + self._frame_delay: float = 0.05 # seconds per frame + self._last_tick: float = 0.0 + self._playing: bool = False + self._loaded: bool = False + self._gif_path = gif_path + self._size = size + + def _ensure_loaded(self): + if self._loaded: + return + self._loaded = True + + path = Path(self._gif_path) + if not path.exists(): + return + + try: + img = Image.open(path) + except Exception: + return + + if getattr(img, "n_frames", 1) > 1: + # GIF with multiple frames + total_ms = 0 + for i in range(img.n_frames): + img.seek(i) + frame = img.convert("RGBA") + if self._size > 0 and frame.size != (self._size, self._size): + frame = frame.resize((self._size, self._size), Image.LANCZOS) + self._frames.append(self._make_texture(frame)) + total_ms += img.info.get("duration", 50) + self._frame_delay = max(0.02, total_ms / (img.n_frames * 1000.0)) + else: + # Static image + frame = img.convert("RGBA") + if self._size > 0 and frame.size != (self._size, self._size): + frame = frame.resize((self._size, self._size), Image.LANCZOS) + self._frames.append(self._make_texture(frame)) + self._frame_delay = 0.05 + + self._frame_count = len(self._frames) + + @staticmethod + def _make_texture(pil_img: Image.Image) -> rl.Texture: + raw = pil_img.tobytes("raw", "RGBA") + data = (ctypes.c_uint8 * len(raw)).from_buffer_copy(raw) + img = rl.Image( + rl.ffi.from_buffer(data), + pil_img.width, + pil_img.height, + 1, + rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_R8G8B8A8, + ) + tex = rl.load_texture_from_image(img) + rl.set_texture_filter(tex, rl.TextureFilter.TEXTURE_FILTER_BILINEAR) + rl.set_texture_wrap(tex, rl.TextureWrap.TEXTURE_WRAP_CLAMP) + return tex + + def play(self): + self._ensure_loaded() + if self._frame_count > 1 and not self._playing: + self._playing = True + self._last_tick = time.monotonic() + + def stop(self): + self._playing = False + self._current_frame = 0 + + def update(self): + if not self._playing or self._frame_count <= 1: + return + now = time.monotonic() + if now - self._last_tick >= self._frame_delay: + self._current_frame = (self._current_frame + 1) % self._frame_count + self._last_tick = now + + def current_texture(self) -> Optional[rl.Texture]: + self._ensure_loaded() + if self._frame_count == 0: + return None + return self._frames[self._current_frame] + + @property + def texture(self) -> Optional[rl.Texture]: + return self.current_texture() + + @property + def frame_count(self) -> int: + self._ensure_loaded() + return self._frame_count + + def unload(self): + for tex in self._frames: + rl.unload_texture(tex) + self._frames.clear() + self._frame_count = 0 + + +class StaticTexture: + """Wraps a single PNG as a texture, matching GifPlayer interface.""" + + def __init__(self, path: str, size: int = 0): + self._texture: Optional[rl.Texture] = None + self._loaded = False + self._path = path + self._size = size + + def _ensure_loaded(self): + if self._loaded: + return + self._loaded = True + path = Path(self._path) + if not path.exists(): + return + try: + img = Image.open(path).convert("RGBA") + if self._size > 0 and img.size != (self._size, self._size): + img = img.resize((self._size, self._size), Image.LANCZOS) + self._texture = GifPlayer._make_texture(img) + except Exception: + pass + + def current_texture(self) -> Optional[rl.Texture]: + self._ensure_loaded() + return self._texture + + @property + def texture(self) -> Optional[rl.Texture]: + return self.current_texture() + + def update(self): + pass + + def play(self): + pass + + def stop(self): + pass + + def unload(self): + if self._texture: + rl.unload_texture(self._texture) + self._texture = None + + +def load_starpilot_asset(rel_path: str, size: int = 0) -> Union[GifPlayer, StaticTexture]: + """Load a GIF or PNG from frogpilot/assets/.""" + frogpilot_assets = files("openpilot.frogpilot").joinpath("assets") + with as_file(frogpilot_assets.joinpath(rel_path)) as fspath: + full_path = str(fspath) + + if full_path.lower().endswith(".gif"): + return GifPlayer(full_path, size) + return StaticTexture(full_path, size) diff --git a/selfdrive/ui/onroad/hud_renderer.py b/selfdrive/ui/onroad/hud_renderer.py index 79f150dee..ad40d7812 100644 --- a/selfdrive/ui/onroad/hud_renderer.py +++ b/selfdrive/ui/onroad/hud_renderer.py @@ -75,6 +75,8 @@ class HudRenderer(Widget): def _update_state(self) -> None: """Update HUD state based on car state and controls state.""" sm = ui_state.sm + self._toggles = ui_state.frogpilot_toggles + if sm.recv_frame["carState"] < ui_state.started_frame: self.is_cruise_set = False self.set_speed = SET_SPEED_NA @@ -85,9 +87,7 @@ class HudRenderer(Widget): car_state = sm['carState'] v_cruise_cluster = car_state.vCruiseCluster - self.set_speed = ( - controls_state.vCruiseDEPRECATED if v_cruise_cluster == 0.0 else v_cruise_cluster - ) + self.set_speed = controls_state.vCruiseDEPRECATED if v_cruise_cluster == 0.0 else v_cruise_cluster self.is_cruise_set = 0 < self.set_speed < SET_SPEED_NA self.is_cruise_available = self.set_speed != -1 @@ -96,12 +96,17 @@ class HudRenderer(Widget): v_ego_cluster = car_state.vEgoCluster self.v_ego_cluster_seen = self.v_ego_cluster_seen or v_ego_cluster != 0.0 - v_ego = v_ego_cluster if self.v_ego_cluster_seen else car_state.vEgo + if self._toggles.get("use_wheel_speed", False): + v_ego = car_state.vEgo + else: + v_ego = v_ego_cluster if self.v_ego_cluster_seen else car_state.vEgo speed_conversion = CV.MS_TO_KPH if ui_state.is_metric else CV.MS_TO_MPH self.speed = max(0.0, v_ego * speed_conversion) def _render(self, rect: rl.Rectangle) -> None: """Render HUD elements to the screen.""" + t = self._toggles + # Draw the header background rl.draw_rectangle_gradient_v( int(rect.x), @@ -112,10 +117,11 @@ class HudRenderer(Widget): COLORS.HEADER_GRADIENT_END, ) - if self.is_cruise_available: + if self.is_cruise_available and not t.get("hide_max_speed", False): self._draw_set_speed(rect) - self._draw_current_speed(rect) + if not t.get("hide_speed", False): + self._draw_current_speed(rect) button_x = rect.x + rect.width - UI_CONFIG.border_size - UI_CONFIG.button_size button_y = rect.y + UI_CONFIG.border_size @@ -124,6 +130,20 @@ class HudRenderer(Widget): def user_interacting(self) -> bool: return self._exp_button.is_pressed + @property + def set_speed_rect(self) -> rl.Rectangle: + """The rect of the MAX speed indicator box.""" + set_speed_width = UI_CONFIG.set_speed_width_metric if ui_state.is_metric else UI_CONFIG.set_speed_width_imperial + x = 60 + (UI_CONFIG.set_speed_width_imperial - set_speed_width) // 2 + return rl.Rectangle(x, 45, set_speed_width, UI_CONFIG.set_speed_height) + + @property + def exp_button_rect(self) -> rl.Rectangle: + """The rect of the exp button (relative to content rect).""" + btn_x = self._rect.width - UI_CONFIG.border_size - UI_CONFIG.button_size + btn_y = UI_CONFIG.border_size + return rl.Rectangle(btn_x, btn_y, UI_CONFIG.button_size, UI_CONFIG.button_size) + def _draw_set_speed(self, rect: rl.Rectangle) -> None: """Draw the MAX speed indicator box.""" set_speed_width = UI_CONFIG.set_speed_width_metric if ui_state.is_metric else UI_CONFIG.set_speed_width_imperial diff --git a/selfdrive/ui/onroad/model_renderer.py b/selfdrive/ui/onroad/model_renderer.py index 3dd1daca4..240ed728a 100644 --- a/selfdrive/ui/onroad/model_renderer.py +++ b/selfdrive/ui/onroad/model_renderer.py @@ -16,15 +16,15 @@ MIN_DRAW_DISTANCE = 10.0 MAX_DRAW_DISTANCE = 100.0 THROTTLE_COLORS = [ - rl.Color(13, 248, 122, 102), # HSLF(148/360, 0.94, 0.51, 0.4) - rl.Color(114, 255, 92, 89), # HSLF(112/360, 1.0, 0.68, 0.35) - rl.Color(114, 255, 92, 0), # HSLF(112/360, 1.0, 0.68, 0.0) + rl.Color(13, 248, 122, 102), # HSLF(148/360, 0.94, 0.51, 0.4) + rl.Color(114, 255, 92, 89), # HSLF(112/360, 1.0, 0.68, 0.35) + rl.Color(114, 255, 92, 0), # HSLF(112/360, 1.0, 0.68, 0.0) ] NO_THROTTLE_COLORS = [ - rl.Color(242, 242, 242, 102), # HSLF(148/360, 0.0, 0.95, 0.4) + rl.Color(242, 242, 242, 102), # HSLF(148/360, 0.0, 0.95, 0.4) rl.Color(242, 242, 242, 89), # HSLF(112/360, 0.0, 0.95, 0.35) - rl.Color(242, 242, 242, 0), # HSLF(112/360, 0.0, 0.95, 0.0) + rl.Color(242, 242, 242, 0), # HSLF(112/360, 0.0, 0.95, 0.0) ] @@ -50,11 +50,15 @@ class ModelRenderer(Widget): self._prev_allow_throttle = True self._lane_line_probs = np.zeros(4, dtype=np.float32) self._road_edge_stds = np.zeros(2, dtype=np.float32) - self._lead_vehicles = [LeadVehicle(), LeadVehicle()] + self._lead_vehicles = [LeadVehicle(), LeadVehicle(), LeadVehicle(), LeadVehicle()] + self._adjacent_lead_count = 0 self._path_offset_z = HEIGHT_INIT[0] # Initialize ModelPoints objects self._path = ModelPoints() + self._path_edge = ModelPoints() + self._adjacent_left = ModelPoints() + self._adjacent_right = ModelPoints() self._lane_lines = [ModelPoints() for _ in range(4)] self._road_edges = [ModelPoints() for _ in range(2)] self._acceleration_x = np.empty((0,), dtype=np.float32) @@ -63,6 +67,7 @@ class ModelRenderer(Widget): self._car_space_transform = np.zeros((3, 3), dtype=np.float32) self._transform_dirty = True self._clip_region = None + self._lead_data_raw = [None, None] self._exp_gradient = Gradient( start=(0.0, 1.0), # Bottom of path @@ -76,25 +81,85 @@ class ModelRenderer(Widget): cp = messaging.log_from_bytes(car_params, car.CarParams) self._longitudinal_control = cp.openpilotLongitudinalControl + # FrogPilot state + self._toggles: dict = {} + self._model_ui: bool = False + self._accel_path_active: bool = False + + # Gradient caches (avoids per-frame object allocation) + self._rainbow_cache: Gradient | None = None + self._rainbow_speed_key: float = -1.0 + self._blend_gradient_cache: Gradient | None = None + self._blend_factor_cache: float = -1.0 + def set_transform(self, transform: np.ndarray): self._car_space_transform = transform.astype(np.float32) self._transform_dirty = True + @property + def car_space_transform(self) -> np.ndarray: + return self._car_space_transform + + def project_point(self, x: float, y: float, z: float) -> tuple[float, float] | None: + """Project a car-space (x, y, z) point to screen coordinates.""" + return self._map_to_screen(x, y, z) + + @property + def path_points(self) -> np.ndarray: + """Projected screen-space path polygon points.""" + return self._path.projected_points + + @property + def path_raw_points(self) -> np.ndarray: + """Raw car-space path points (Nx3).""" + return self._path.raw_points + + @property + def road_edge_points(self) -> list[np.ndarray]: + """Projected screen-space road edge polygons.""" + return [e.projected_points for e in self._road_edges] + + @property + def lane_line_points(self) -> list[np.ndarray]: + """Projected screen-space lane line polygons.""" + return [l.projected_points for l in self._lane_lines] + + @property + def adjacent_left_points(self) -> np.ndarray: + """Projected left adjacent lane polygon.""" + return self._adjacent_left.projected_points + + @property + def adjacent_right_points(self) -> np.ndarray: + """Projected right adjacent lane polygon.""" + return self._adjacent_right.projected_points + + @property + def lead_vehicles(self) -> list[LeadVehicle]: + return self._lead_vehicles + + @property + def lead_data(self) -> list: + """Raw radarState lead data (from last render).""" + return self._lead_data_raw + def _render(self, rect: rl.Rectangle): sm = ui_state.sm + t = ui_state.frogpilot_toggles + self._toggles = t + self._model_ui = t.get("model_ui", False) # Check if data is up-to-date - if (sm.recv_frame["liveCalibration"] < ui_state.started_frame or - sm.recv_frame["modelV2"] < ui_state.started_frame): + if sm.recv_frame["liveCalibration"] < ui_state.started_frame or sm.recv_frame["modelV2"] < ui_state.started_frame: return # Set up clipping region - self._clip_region = rl.Rectangle( - rect.x - CLIP_MARGIN, rect.y - CLIP_MARGIN, rect.width + 2 * CLIP_MARGIN, rect.height + 2 * CLIP_MARGIN - ) + self._clip_region = rl.Rectangle(rect.x - CLIP_MARGIN, rect.y - CLIP_MARGIN, rect.width + 2 * CLIP_MARGIN, rect.height + 2 * CLIP_MARGIN) # Update state self._experimental_mode = sm['selfdriveState'].experimentalMode + # Acceleration path: allow acceleration coloring independent of exp mode + self._accel_path_active = self._experimental_mode or t.get("acceleration_path", False) live_calib = sm['liveCalibration'] self._path_offset_z = live_calib.height[0] if live_calib.height else HEIGHT_INIT[0] @@ -107,6 +172,10 @@ class ModelRenderer(Widget): lead_one = radar_state.leadOne if radar_state else None render_lead_indicator = self._longitudinal_control and radar_state is not None + # Hide lead marker toggle + if t.get("hide_lead_marker", False): + render_lead_indicator = False + # Update model data when needed model_updated = sm.updated['modelV2'] if model_updated or sm.updated['radarState'] or self._transform_dirty: @@ -137,30 +206,43 @@ class ModelRenderer(Widget): # the fixed number of lane/edge slots used by the UI. for lane_line in self._lane_lines: lane_line.raw_points = np.empty((0, 3), dtype=np.float32) - for i, lane_line in enumerate(model.laneLines[:len(self._lane_lines)]): + for i, lane_line in enumerate(model.laneLines[: len(self._lane_lines)]): self._lane_lines[i].raw_points = np.array([lane_line.x, lane_line.y, lane_line.z], dtype=np.float32).T for road_edge in self._road_edges: road_edge.raw_points = np.empty((0, 3), dtype=np.float32) - for i, road_edge in enumerate(model.roadEdges[:len(self._road_edges)]): + for i, road_edge in enumerate(model.roadEdges[: len(self._road_edges)]): self._road_edges[i].raw_points = np.array([road_edge.x, road_edge.y, road_edge.z], dtype=np.float32).T lane_line_probs = np.array(model.laneLineProbs, dtype=np.float32) self._lane_line_probs = np.zeros(len(self._lane_lines), dtype=np.float32) - self._lane_line_probs[:min(len(self._lane_lines), len(lane_line_probs))] = lane_line_probs[:len(self._lane_lines)] + self._lane_line_probs[: min(len(self._lane_lines), len(lane_line_probs))] = lane_line_probs[: len(self._lane_lines)] road_edge_stds = np.array(model.roadEdgeStds, dtype=np.float32) self._road_edge_stds = np.ones(len(self._road_edges), dtype=np.float32) - self._road_edge_stds[:min(len(self._road_edges), len(road_edge_stds))] = road_edge_stds[:len(self._road_edges)] + self._road_edge_stds[: min(len(self._road_edges), len(road_edge_stds))] = road_edge_stds[: len(self._road_edges)] self._acceleration_x = np.array(model.acceleration.x, dtype=np.float32) def _update_leads(self, radar_state, path_x_array): """Update positions of lead vehicles""" - self._lead_vehicles = [LeadVehicle(), LeadVehicle()] + for lv in self._lead_vehicles: + lv.glow = [] + lv.chevron = [] + lv.fill_alpha = 0 + self._adjacent_lead_count = 0 + self._lead_data_raw = [None, None] leads = [radar_state.leadOne, radar_state.leadTwo] + # FrogPilot: lead detection probability threshold + t = self._toggles + prob_threshold = t.get("lead_detection_probability", 0.0) if self._model_ui else 0.0 + for i, lead_data in enumerate(leads): if lead_data and lead_data.status: + # Apply lead detection probability threshold + if prob_threshold > 0 and hasattr(lead_data, 'prob') and lead_data.prob < prob_threshold: + continue + self._lead_data_raw[i] = lead_data d_rel, y_rel, v_rel = lead_data.dRel, lead_data.yRel, lead_data.vRel idx = self._get_path_length_idx(path_x_array, d_rel) @@ -170,20 +252,48 @@ class ModelRenderer(Widget): if point: self._lead_vehicles[i] = self._update_lead_vehicle(d_rel, v_rel, point, self._rect) + # FrogPilot: adjacent lead tracking from radar + if t.get("adjacent_leads_ui", False) and ui_state.sm.valid.get("frogpilotRadarState", False): + fp_radar = ui_state.sm["frogpilotRadarState"] + adj_idx = 2 # slots 2 and 3 are for adjacent leads + for adj_lead in [fp_radar.leadLeft, fp_radar.leadRight]: + if adj_lead and adj_lead.status: + d_rel, y_rel, v_rel = adj_lead.dRel, adj_lead.yRel, adj_lead.vRel + idx = self._get_path_length_idx(path_x_array, d_rel) + z = self._path.raw_points[idx, 2] if idx < len(self._path.raw_points) else 0.0 + point = self._map_to_screen(d_rel, -y_rel, z + self._path_offset_z) + if point and adj_idx < len(self._lead_vehicles): + self._lead_vehicles[adj_idx] = self._update_lead_vehicle(d_rel, v_rel, point, self._rect) + self._adjacent_lead_count += 1 + adj_idx += 1 + def _update_model(self, lead, path_x_array): """Update model visualization data based on model message""" + t = self._toggles + model_ui = self._model_ui + + # FrogPilot custom widths + lane_width = t.get("lane_line_width", 0.025) if model_ui else 0.025 + edge_width = t.get("road_edge_width", 0.025) if model_ui else 0.025 + path_width = t.get("path_width", 0.9) if model_ui else 0.9 + + # Dynamic path width: scale by engagement + if model_ui and t.get("dynamic_path_width", False): + if ui_state.always_on_lateral_active: + path_width *= 0.75 + elif not ui_state.sm["selfdriveState"].enabled: + path_width *= 0.5 + max_distance = np.clip(path_x_array[-1], MIN_DRAW_DISTANCE, MAX_DRAW_DISTANCE) max_idx = self._get_path_length_idx(self._lane_lines[0].raw_points[:, 0], max_distance) # Update lane lines using raw points for i, lane_line in enumerate(self._lane_lines): - lane_line.projected_points = self._map_line_to_polygon( - lane_line.raw_points, 0.025 * self._lane_line_probs[i], 0.0, max_idx, max_distance - ) + lane_line.projected_points = self._map_line_to_polygon(lane_line.raw_points, lane_width * self._lane_line_probs[i], 0.0, max_idx, max_distance) # Update road edges using raw points for road_edge in self._road_edges: - road_edge.projected_points = self._map_line_to_polygon(road_edge.raw_points, 0.025, 0.0, max_idx, max_distance) + road_edge.projected_points = self._map_line_to_polygon(road_edge.raw_points, edge_width, 0.0, max_idx, max_distance) # Update path using raw points if lead and lead.status: @@ -191,9 +301,32 @@ class ModelRenderer(Widget): max_distance = np.clip(lead_d - min(lead_d * 0.35, 10.0), 0.0, max_distance) max_idx = self._get_path_length_idx(path_x_array, max_distance) - self._path.projected_points = self._map_line_to_polygon( - self._path.raw_points, 0.9, self._path_offset_z, max_idx, max_distance, allow_invert=False - ) + self._path.projected_points = self._map_line_to_polygon(self._path.raw_points, path_width, self._path_offset_z, max_idx, max_distance, allow_invert=False) + + # FrogPilot: path edge polygon (full-width outer polygon for edge rendering) + if model_ui and t.get("path_edge_width", 0) > 0: + self._path_edge.projected_points = self._map_line_to_polygon(self._path.raw_points, 0.9, self._path_offset_z, max_idx, max_distance, allow_invert=False) + else: + self._path_edge.projected_points = np.empty((0, 2), dtype=np.float32) + + # FrogPilot: adjacent lane polygons (averaged from lane line pairs) + if t.get("adjacent_paths", False) or t.get("adjacent_path_metrics", False) or t.get("blind_spot_path", False): + fp_plan = ui_state.frogpilot_plan + if fp_plan and len(self._lane_lines) >= 4: + lane_w_left = fp_plan.laneWidthLeft / 2.0 if fp_plan.laneWidthLeft else 1.75 + lane_w_right = fp_plan.laneWidthRight / 2.0 if fp_plan.laneWidthRight else 1.75 + self._adjacent_left.projected_points = self._map_averaged_line_to_polygon( + self._lane_lines[0].raw_points, self._lane_lines[1].raw_points, lane_w_left, 0.0, max_idx + ) + self._adjacent_right.projected_points = self._map_averaged_line_to_polygon( + self._lane_lines[2].raw_points, self._lane_lines[3].raw_points, lane_w_right, 0.0, max_idx + ) + else: + self._adjacent_left.projected_points = np.empty((0, 2), dtype=np.float32) + self._adjacent_right.projected_points = np.empty((0, 2), dtype=np.float32) + else: + self._adjacent_left.projected_points = np.empty((0, 2), dtype=np.float32) + self._adjacent_right.projected_points = np.empty((0, 2), dtype=np.float32) self._update_experimental_gradient() @@ -242,6 +375,30 @@ class ModelRenderer(Widget): stops=gradient_stops, ) + def _build_rainbow_gradient(self) -> Gradient: + """Build a rainbow gradient for the path based on current speed (cached).""" + speed = abs(ui_state.sm["carState"].vEgo) + speed_key = round(speed, 1) + if speed_key == self._rainbow_speed_key and self._rainbow_cache is not None: + return self._rainbow_cache + + self._rainbow_speed_key = speed_key + t = min(speed / 35.0, 1.0) # ~80 mph max + + n_stops = 5 + colors = [] + stops = [] + for i in range(n_stops): + frac = i / (n_stops - 1) + hue = (frac * 0.8 + t * 0.2) % 1.0 + r, g, b = colorsys.hls_to_rgb(hue, 0.5, 1.0) + alpha = int(80 * (1.0 - frac * 0.5)) + colors.append(rl.Color(int(r * 255), int(g * 255), int(b * 255), alpha)) + stops.append(frac) + + self._rainbow_cache = Gradient(start=(0.0, 1.0), end=(0.0, 0.0), colors=colors, stops=stops) + return self._rainbow_cache + def _update_lead_vehicle(self, d_rel, v_rel, point, rect): speed_buff, lead_buff = 10.0, 40.0 @@ -268,12 +425,24 @@ class ModelRenderer(Widget): def _draw_lane_lines(self): """Draw lane lines and road edges""" + t = self._toggles + model_ui = self._model_ui + custom_scheme = model_ui and t.get("color_scheme", "stock") != "stock" + + # Custom lane line color + lane_color_override = None + if custom_scheme and t.get("lane_lines_color"): + lane_color_override = self._parse_color(t["lane_lines_color"]) + for i, lane_line in enumerate(self._lane_lines): if lane_line.projected_points.size == 0: continue alpha = np.clip(self._lane_line_probs[i], 0.0, 0.7) - color = rl.Color(255, 255, 255, int(alpha * 255)) + if lane_color_override: + color = rl.Color(lane_color_override.r, lane_color_override.g, lane_color_override.b, int(alpha * 255)) + else: + color = rl.Color(255, 255, 255, int(alpha * 255)) draw_polygon(self._rect, lane_line.projected_points, color) for i, road_edge in enumerate(self._road_edges): @@ -289,11 +458,45 @@ class ModelRenderer(Widget): if not self._path.projected_points.size: return + t = self._toggles + model_ui = self._model_ui + custom_scheme = model_ui and t.get("color_scheme", "stock") != "stock" + + # Custom path color (applied when acceleration is low) + path_color_override = None + if custom_scheme and t.get("path_color"): + path_color_override = self._parse_color(t["path_color"]) + + # FrogPilot: draw path edge polygon first (status-colored outer layer) + edge_pts = self._path_edge.projected_points + if edge_pts.size > 0: + cs = ui_state.conditional_status + # Alpha tuned for Raylib shader polygon renderer (C++ uses 241 via QPainter) + _EDGE_ALPHA = 120 + if ui_state.always_on_lateral_active: + edge_color = rl.Color(0x0A, 0xBA, 0xB5, _EDGE_ALPHA) + elif cs >= 2: + edge_color = rl.Color(0xDA, 0x6F, 0x25, _EDGE_ALPHA) + elif cs >= 1: + edge_color = rl.Color(0xFF, 0xFF, 0x00, _EDGE_ALPHA) + elif ui_state.traffic_mode_enabled: + edge_color = rl.Color(0xC9, 0x22, 0x31, _EDGE_ALPHA) + else: + edge_color = rl.Color(255, 255, 255, 30) + if custom_scheme and t.get("path_edges_color"): + edge_color = self._parse_color(t["path_edges_color"]) + draw_polygon(self._rect, edge_pts, edge_color) + allow_throttle = sm['longitudinalPlan'].allowThrottle or not self._longitudinal_control self._blend_filter.update(int(allow_throttle)) - if self._experimental_mode: - # Draw with acceleration coloring + # FrogPilot: rainbow path (speed-based HSL gradient, integrated into path rendering) + if t.get("rainbow_path", False) and not t.get("acceleration_path", False): + rainbow = self._build_rainbow_gradient() + draw_polygon(self._rect, self._path.projected_points, gradient=rainbow) + + elif self._accel_path_active: + # Draw with acceleration coloring (experimental or acceleration_path toggle) if len(self._exp_gradient.colors) > 1: draw_polygon(self._rect, self._path.projected_points, gradient=self._exp_gradient) else: @@ -301,23 +504,51 @@ class ModelRenderer(Widget): else: # Blend throttle/no throttle colors based on transition blend_factor = round(self._blend_filter.x * 100) / 100 - blended_colors = self._blend_colors(NO_THROTTLE_COLORS, THROTTLE_COLORS, blend_factor) - gradient = Gradient( - start=(0.0, 1.0), # Bottom of path - end=(0.0, 0.0), # Top of path - colors=blended_colors, - stops=[0.0, 0.5, 1.0], - ) - draw_polygon(self._rect, self._path.projected_points, gradient=gradient) + + # Custom path color override: use when not accelerating + if path_color_override and blend_factor < 0.5: + c = path_color_override + draw_polygon(self._rect, self._path.projected_points, rl.Color(c.r, c.g, c.b, 102)) + else: + if blend_factor != self._blend_factor_cache: + self._blend_factor_cache = blend_factor + blended_colors = self._blend_colors(NO_THROTTLE_COLORS, THROTTLE_COLORS, blend_factor) + self._blend_gradient_cache = Gradient( + start=(0.0, 1.0), + end=(0.0, 0.0), + colors=blended_colors, + stops=[0.0, 0.5, 1.0], + ) + draw_polygon(self._rect, self._path.projected_points, gradient=self._blend_gradient_cache) + + # FrogPilot: adjacent lane paths (green-to-red based on width) + t = self._toggles + if t.get("adjacent_paths", False): + for pts in [self._adjacent_left.projected_points, self._adjacent_right.projected_points]: + if pts.size > 0: + draw_polygon(self._rect, pts, rl.Color(0, 255, 100, 50)) + if t.get("blind_spot_path", False): + for pts in [self._adjacent_left.projected_points, self._adjacent_right.projected_points]: + if pts.size > 0: + draw_polygon(self._rect, pts, rl.Color(201, 34, 49, 80)) def _draw_lead_indicator(self): + t = self._toggles + # Custom lead marker color + glow_color = rl.Color(218, 202, 37, 255) + chevron_color_base = rl.Color(201, 34, 49, 255) + if self._model_ui and t.get("lead_marker_color"): + custom = self._parse_color(t["lead_marker_color"]) + glow_color = rl.Color(custom.r, custom.g, custom.b, 255) + chevron_color_base = rl.Color(custom.r, custom.g, custom.b, 255) + # Draw lead vehicles if available for lead in self._lead_vehicles: if not lead.glow or not lead.chevron: continue - rl.draw_triangle_fan(lead.glow, len(lead.glow), rl.Color(218, 202, 37, 255)) - rl.draw_triangle_fan(lead.chevron, len(lead.chevron), rl.Color(201, 34, 49, lead.fill_alpha)) + rl.draw_triangle_fan(lead.glow, len(lead.glow), glow_color) + rl.draw_triangle_fan(lead.chevron, len(lead.chevron), rl.Color(chevron_color_base.r, chevron_color_base.g, chevron_color_base.b, lead.fill_alpha)) @staticmethod def _get_path_length_idx(pos_x_array: np.ndarray, path_distance: float) -> int: @@ -329,6 +560,9 @@ class ModelRenderer(Widget): def _map_to_screen(self, in_x, in_y, in_z): """Project a point in car space to screen space""" + if self._clip_region is None: + return None + input_pt = np.array([in_x, in_y, in_z]) pt = self._car_space_transform @ input_pt @@ -349,7 +583,7 @@ class ModelRenderer(Widget): return np.empty((0, 2), dtype=np.float32) # Slice points and filter non-negative x-coordinates - points = line[:max_idx + 1] + points = line[: max_idx + 1] # Interpolate around max_idx so path end is smooth (max_distance is always >= p0.x) if 0 < max_idx < line.shape[0] - 1: @@ -392,14 +626,8 @@ class ModelRenderer(Widget): y_min, y_max = clip.y, clip.y + clip.height # Filter points within clip region - left_in_clip = ( - (left_screen[0] >= x_min) & (left_screen[0] <= x_max) & - (left_screen[1] >= y_min) & (left_screen[1] <= y_max) - ) - right_in_clip = ( - (right_screen[0] >= x_min) & (right_screen[0] <= x_max) & - (right_screen[1] >= y_min) & (right_screen[1] <= y_max) - ) + left_in_clip = (left_screen[0] >= x_min) & (left_screen[0] <= x_max) & (left_screen[1] >= y_min) & (left_screen[1] <= y_max) + right_in_clip = (right_screen[0] >= x_min) & (right_screen[0] <= x_max) & (right_screen[1] >= y_min) & (right_screen[1] <= y_max) both_in_clip = left_in_clip & right_in_clip if not np.any(both_in_clip): @@ -420,15 +648,92 @@ class ModelRenderer(Widget): return np.vstack((left_screen.T, right_screen[:, ::-1].T)).astype(np.float32) + def _map_averaged_line_to_polygon(self, line1: np.ndarray, line2: np.ndarray, y_off: float, z_off: float, max_idx: int) -> np.ndarray: + """Convert two averaged 3D lane lines to a 2D polygon (center-of-lane path). + + Averages the Y coordinates of line1 and line2, then creates left/right points + at avg_y -/+ y_off. Uses X and Z from line1. + """ + if line1.shape[0] == 0 or line2.shape[0] == 0: + return np.empty((0, 2), dtype=np.float32) + + n = min(max_idx + 1, line1.shape[0], line2.shape[0]) + if n == 0: + return np.empty((0, 2), dtype=np.float32) + + # Build averaged points: X from line1, Y averaged, Z from line1 + points = np.zeros((n, 3), dtype=np.float32) + points[:, 0] = line1[:n, 0] # X from line1 + points[:, 1] = (line1[:n, 1] + line2[:n, 1]) / 2.0 # Y averaged + points[:, 2] = line1[:n, 2] # Z from line1 + + # Filter non-negative X + points = points[points[:, 0] >= 0] + if points.shape[0] < 2: + return np.empty((0, 2), dtype=np.float32) + + N = points.shape[0] + offsets = np.array([[0, -y_off, z_off], [0, y_off, z_off]], dtype=np.float32) + points_3d = points[None, :, :] + offsets[:, None, :] + points_3d = points_3d.reshape(2 * N, 3) + + proj = self._car_space_transform @ points_3d.T + proj = proj.reshape(3, 2, N) + left_proj = proj[:, 0, :] + right_proj = proj[:, 1, :] + + valid_proj = (np.abs(left_proj[2]) >= 1e-6) & (np.abs(right_proj[2]) >= 1e-6) + if not np.any(valid_proj): + return np.empty((0, 2), dtype=np.float32) + + left_screen = left_proj[:2, valid_proj] / left_proj[2, valid_proj][None, :] + right_screen = right_proj[:2, valid_proj] / right_proj[2, valid_proj][None, :] + + clip = self._clip_region + x_min, x_max = clip.x, clip.x + clip.width + y_min, y_max = clip.y, clip.y + clip.height + + left_in_clip = (left_screen[0] >= x_min) & (left_screen[0] <= x_max) & (left_screen[1] >= y_min) & (left_screen[1] <= y_max) + right_in_clip = (right_screen[0] >= x_min) & (right_screen[0] <= x_max) & (right_screen[1] >= y_min) & (right_screen[1] <= y_max) + both_in_clip = left_in_clip & right_in_clip + + if not np.any(both_in_clip): + return np.empty((0, 2), dtype=np.float32) + + left_screen = left_screen[:, both_in_clip] + right_screen = right_screen[:, both_in_clip] + + result = np.vstack((left_screen.T, right_screen[:, ::-1].T)).astype(np.float32) + + # Ground the path: extend bottom vertices to screen bottom (matches C++ mapAveragedLineToPolygon) + if result.shape[0] >= 4: + mid = result.shape[0] // 2 + screen_h = self._rect.y + self._rect.height + + # Extend left-bottom pair to screen bottom + for idx in [mid - 1, mid - 2]: + if 0 <= idx < result.shape[0] - 1: + dy = result[idx, 1] - result[idx + 1, 1] + if abs(dy) > 0.1: + slope = (result[idx, 0] - result[idx + 1, 0]) / dy + result[idx, 0] += (screen_h - result[idx, 1]) * slope + result[idx, 1] = screen_h + + # Extend right-bottom pair to screen bottom + for idx in [mid, mid + 1]: + if 0 < idx < result.shape[0]: + dy = result[idx, 1] - result[idx - 1, 1] + if abs(dy) > 0.1: + slope = (result[idx, 0] - result[idx - 1, 0]) / dy + result[idx, 0] += (screen_h - result[idx, 1]) * slope + result[idx, 1] = screen_h + + return result + @staticmethod def _hsla_to_color(h, s, l, a): rgb = colorsys.hls_to_rgb(h, l, s) - return rl.Color( - int(rgb[0] * 255), - int(rgb[1] * 255), - int(rgb[2] * 255), - int(a * 255) - ) + return rl.Color(int(rgb[0] * 255), int(rgb[1] * 255), int(rgb[2] * 255), int(a * 255)) @staticmethod def _blend_colors(begin_colors, end_colors, t): @@ -438,9 +743,17 @@ class ModelRenderer(Widget): return begin_colors inv_t = 1.0 - t - return [rl.Color( - int(inv_t * start.r + t * end.r), - int(inv_t * start.g + t * end.g), - int(inv_t * start.b + t * end.b), - int(inv_t * start.a + t * end.a) - ) for start, end in zip(begin_colors, end_colors, strict=True)] + return [ + rl.Color(int(inv_t * start.r + t * end.r), int(inv_t * start.g + t * end.g), int(inv_t * start.b + t * end.b), int(inv_t * start.a + t * end.a)) + for start, end in zip(begin_colors, end_colors, strict=True) + ] + + @staticmethod + def _parse_color(color_str: str) -> rl.Color: + """Parse a hex color string (e.g., '#FF0000' or 'FF0000') to rl.Color.""" + s = color_str.lstrip('#') + if len(s) == 8: # ARGB + return rl.Color(int(s[2:4], 16), int(s[4:6], 16), int(s[6:8], 16), int(s[0:2], 16)) + if len(s) == 6: # RGB + return rl.Color(int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16), 255) + return rl.Color(255, 255, 255, 255) diff --git a/selfdrive/ui/ui_state.py b/selfdrive/ui/ui_state.py index 7df9cd42e..4353c0dc2 100644 --- a/selfdrive/ui/ui_state.py +++ b/selfdrive/ui/ui_state.py @@ -1,3 +1,4 @@ +import json import pyray as rl import numpy as np import time @@ -58,8 +59,19 @@ class UIState: "rawAudioData", "frogpilotCarState", ] + ).extend( + [ + "frogpilotPlan", + "frogpilotSelfdriveState", + "frogpilotRadarState", + "frogpilotDeviceState", + "mapdOut", + "liveTracks", + ] ) + self._frogpilot_toggles: dict = {} + self.prime_state = PrimeState() # UI Status tracking @@ -150,8 +162,7 @@ class UIState: self.always_on_dm = self.params.get_bool("AlwaysOnDM") if self.sm.valid.get("frogpilotCarState", False): frogpilot_car_state = self.sm["frogpilotCarState"] - self.always_on_lateral_active = (not self.sm["selfdriveState"].enabled and - frogpilot_car_state.alwaysOnLateralEnabled) + self.always_on_lateral_active = not self.sm["selfdriveState"].enabled and frogpilot_car_state.alwaysOnLateralEnabled self.traffic_mode_enabled = frogpilot_car_state.trafficModeEnabled else: self.always_on_lateral_active = False @@ -159,6 +170,20 @@ class UIState: self.conditional_status = self.params_memory.get_int("CEStatus", default=0) if self.started else 0 + if self.sm.valid.get("frogpilotPlan", False) and self.sm.updated["frogpilotPlan"]: + try: + self._frogpilot_toggles = json.loads(self.sm["frogpilotPlan"].frogpilotToggles) + except (json.JSONDecodeError, TypeError): + pass + + @property + def frogpilot_toggles(self) -> dict: + return self._frogpilot_toggles + + @property + def frogpilot_plan(self): + return self.sm["frogpilotPlan"] if self.sm.valid.get("frogpilotPlan", False) else None + def _update_status(self) -> None: if self.started and self.sm.updated["selfdriveState"]: ss = self.sm["selfdriveState"] diff --git a/selfdrive/ui/widgets/developer_sidebar.py b/selfdrive/ui/widgets/developer_sidebar.py new file mode 100644 index 000000000..4f9c9705c --- /dev/null +++ b/selfdrive/ui/widgets/developer_sidebar.py @@ -0,0 +1,186 @@ +import time +import pyray as rl +from openpilot.common.params import Params +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 +from openpilot.system.ui.widgets import Widget + +METRIC_HEIGHT = 126 +METRIC_WIDTH = 275 +SIDEBAR_WIDTH = 300 +FONT_SIZE = 32 + + +def _fmt(v, decimals=2): + return f"{v:.{decimals}f}" if isinstance(v, float) else str(v) + + +class DeveloperSidebar(Widget): + def __init__(self): + super().__init__() + self._params = Params() + self._font_bold = gui_app.font(FontWeight.SEMI_BOLD) + self._font_normal = gui_app.font(FontWeight.NORMAL) + + # Engagement tracking + self._lat_time = 0.0 + self._long_time = 0.0 + self._total_time = 0.0 + self._last_update_time: float = time.monotonic() + + # Peak tracking + self._max_steer_angle = 0.0 + self._max_torque = 0.0 + + def _update_state(self): + sm = ui_state.sm + + now = time.monotonic() + dt = now - self._last_update_time + self._last_update_time = now + + # Engagement time tracking (only when onroad and not standstill) + if ui_state.started and sm.valid.get("carControl", False) and sm.valid.get("carState", False): + cc = sm["carControl"] + cs = sm["carState"] + if not cs.standstill and cs.gearShifter != 3: # not reverse + self._total_time += dt + if cc.latActive: + self._lat_time += dt + if cc.longActive: + self._long_time += dt + + # Peak tracking + if sm.valid.get("carControl", False): + torque = abs(sm["carControl"].actuators.torque) + if torque > self._max_torque: + self._max_torque = torque + if sm.valid.get("carState", False): + angle = abs(sm["carState"].steeringAngleDeg) + if angle > self._max_steer_angle: + self._max_steer_angle = angle + + def reset_engagement(self): + self._lat_time = 0.0 + self._long_time = 0.0 + self._total_time = 0.0 + self._max_steer_angle = 0.0 + self._max_torque = 0.0 + + def _get_metric_value(self, metric_id: int) -> tuple[str, str]: + """Return (label, value_string) for a metric ID (1-16).""" + sm = ui_state.sm + is_metric = ui_state.is_metric + conv = 1.0 if is_metric else 3.281 # m/s^2 to ft/s^2 + + if metric_id == 1: # ACCEL + a = sm["carState"].aEgo * conv if sm.valid.get("carState", False) else 0 + return ("ACCEL", f"{a:.2f}") + elif metric_id == 2: # MAX ACCEL + return ("MAX ACCEL", "-") + elif metric_id == 3: # STEER DELAY + d = sm["liveDelay"].lateralDelay if sm.valid.get("liveDelay", False) else 0 + return ("STEER DELAY", f"{d:.5f}") + elif metric_id == 4: # FRICTION + f = sm["liveTorqueParameters"].frictionCoefficientFiltered if sm.valid.get("liveTorqueParameters", False) else 0 + return ("FRICTION", f"{f:.5f}") + elif metric_id == 5: # LAT ACCEL + l = sm["liveTorqueParameters"].latAccelFactorFiltered if sm.valid.get("liveTorqueParameters", False) else 0 + return ("LAT ACCEL", f"{l:.5f}") + elif metric_id == 6: # STEER RATIO + r = sm["liveParameters"].steerRatio if sm.valid.get("liveParameters", False) else 0 + return ("STEER RATIO", f"{r:.5f}") + elif metric_id == 7: # STEER STIFF + s = sm["liveParameters"].stiffnessFactor if sm.valid.get("liveParameters", False) else 0 + return ("STEER STIFF", f"{s:.5f}") + elif metric_id == 8: # LATERAL % + pct = self._lat_time * 100 / max(self._total_time, 1) + return ("LATERAL %", f"{pct:.2f}%") + elif metric_id == 9: # LONG % + pct = self._long_time * 100 / max(self._total_time, 1) + return ("LONG %", f"{pct:.2f}%") + elif metric_id == 10: # STEER ANGLE + if sm.valid.get("carState", False): + angle = sm["carState"].steeringAngleDeg + if abs(self._max_torque) >= 50: + return ("STEER ANGLE", f"{angle:.0f} - ({self._max_steer_angle:.0f})") + return ("STEER ANGLE", f"{angle:.0f}") + return ("STEER ANGLE", "-") + elif metric_id == 11: # TORQUE % + if sm.valid.get("carControl", False): + torque = sm["carControl"].actuators.torque + if abs(torque) >= 50: + return ("TORQUE %", f"{torque:.0f} - ({self._max_torque:.0f})") + return ("TORQUE %", f"{torque:.0f}") + return ("TORQUE %", "-") + elif metric_id == 12: # ACT ACCEL + a = sm["carControl"].actuators.accel * conv if sm.valid.get("carControl", False) else 0 + return ("ACT ACCEL", f"{a:.2f}") + elif metric_id == 13: # DANGER % + if sm.valid.get("frogpilotPlan", False): + d = sm["frogpilotPlan"].dangerFactor * 100 + return ("DANGER %", f"{d:.2f}%") + return ("DANGER %", "-") + elif metric_id == 14: # ACCEL JERK + j = sm["frogpilotPlan"].accelerationJerk if sm.valid.get("frogpilotPlan", False) else 0 + return ("ACCEL JERK", f"{j}") + elif metric_id == 15: # DANGER JERK + j = sm["frogpilotPlan"].dangerJerk if sm.valid.get("frogpilotPlan", False) else 0 + return ("DANGER JERK", f"{j}") + elif metric_id == 16: # SPEED JERK + j = sm["frogpilotPlan"].speedJerk if sm.valid.get("frogpilotPlan", False) else 0 + return ("SPEED JERK", f"{j}") + return ("", "") + + def _render(self, rect: rl.Rectangle): + t = ui_state.frogpilot_toggles + if not t.get("developer_sidebar", False): + return + + # Read slot assignments (1-7 map to metric IDs 1-16, 0 = empty) + slots = [] + for i in range(1, 8): + mid = t.get(f"developer_sidebar_metric{i}", 0) + if mid and 1 <= mid <= 16: + slots.append(mid) + + if not slots: + return + + count = len(slots) + spacing = (rect.height - count * METRIC_HEIGHT) / max(count + 1, 1) + y = rect.y + spacing + custom_color = t.get("sidebar_color1", None) + + for mid in slots: + label, value = self._get_metric_value(mid) + if not label: + continue + self._draw_metric(rect.x + 12, y, METRIC_WIDTH, METRIC_HEIGHT, label, value, custom_color) + y += METRIC_HEIGHT + spacing + + def _draw_metric(self, x, y, w, h, label, value, custom_color=None): + metric_rect = rl.Rectangle(x, y, w, h) + + # Accent bar on right side (like QT developer sidebar) + accent_color = rl.Color(0x17, 0x86, 0x44, 255) # Green default + if custom_color: + s = custom_color.lstrip('#') + if len(s) >= 6: + accent_color = rl.Color(int(s[-6:-4], 16), int(s[-4:-2], 16), int(s[-2:], 16), 255) + + edge_rect = rl.Rectangle(x + w - 100, y + 4, 100, h - 8) + rl.begin_scissor_mode(int(x + w - 18), int(y), 18, int(h)) + rl.draw_rectangle_rounded(edge_rect, 0.3, 10, accent_color) + rl.end_scissor_mode() + + # Border + rl.draw_rectangle_rounded_lines_ex(metric_rect, 0.3, 10, 2, rl.Color(255, 255, 255, 85)) + + # Text + text = f"{label}\n{value}" + ts = measure_text_cached(self._font_bold, text, FONT_SIZE) + text_x = x + 22 + (w - 22 - ts.x) / 2 + text_y = y + (h - ts.y) / 2 + rl.draw_text_ex(self._font_bold, text, rl.Vector2(text_x, text_y), FONT_SIZE, 0, rl.WHITE) diff --git a/selfdrive/ui/widgets/drive_summary.py b/selfdrive/ui/widgets/drive_summary.py new file mode 100644 index 000000000..7afeb08a5 --- /dev/null +++ b/selfdrive/ui/widgets/drive_summary.py @@ -0,0 +1,183 @@ +import json +import pyray as rl +from dataclasses import dataclass +from openpilot.common.params import Params +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 +from openpilot.system.ui.widgets import Widget + +METER_TO_MILE = 0.000621371 +MS_TO_KPH = 3.6 +MS_TO_MPH = 2.237 + +# Random events: (json_key, display_name) +RANDOM_EVENTS = [ + ("accel30", "UwUs"), + ("accel35", "Loch Ness Encounters"), + ("accel40", "Visits to 1955"), + ("dejaVuCurve", "Deja Vu Moments"), + ("firefoxSteerSaturated", "Internet Explorer Weeeeeeees"), + ("hal9000", "HAL 9000 Denials"), + ("openpilotCrashedRandomEvent", "openpilot Crashes"), + ("thisIsFineSteerSaturated", "This Is Fine Moments"), + ("toBeContinued", "To Be Continued Moments"), + ("vCruise69", "Noices"), + ("yourFrogTriedToKillMe", "Attempted Frog Murders"), + ("youveGotMail", "Total Mail Received"), +] + + +@dataclass +class StatBox: + label: str = "" + value: str = "-" + + +class DriveSummary(Widget): + def __init__(self, show_random_events: bool = False): + super().__init__() + self._params = Params() + self._show_random_events = show_random_events + self._previous_stats: dict = {} + self._current_stats: dict = {} + self._stat_boxes: list[StatBox] = [] + self._random_events: list[tuple[str, int]] = [] + self._sorted_events: list[tuple[str, int]] = [] + + if show_random_events: + self._title = "Random Events Summary" + else: + self._title = "Drive Summary" + + def show_event(self): + """Called when widget becomes visible. Snapshot current stats.""" + raw = self._params.get("FrogPilotStats") + if raw: + try: + self._current_stats = json.loads(raw) + except (json.JSONDecodeError, TypeError): + self._current_stats = {} + else: + self._current_stats = {} + + self._previous_stats = dict(self._current_stats) + + # Pre-compute sorted random events (data is immutable while visible) + if self._show_random_events: + cur_events = self._current_stats.get("RandomEvents", {}) + prev_events = self._previous_stats.get("RandomEvents", {}) + self._sorted_events = [] + for key, name in RANDOM_EVENTS: + diff = cur_events.get(key, 0) - prev_events.get(key, 0) + if diff > 0: + self._sorted_events.append((name, diff)) + self._sorted_events.sort(key=lambda x: (-x[1], x[0])) + + def set_previous_stats(self, stats: dict): + """Set the baseline stats snapshot (taken when last went onroad).""" + self._previous_stats = stats + + def _render(self, rect: rl.Rectangle): + font_bold = gui_app.font(FontWeight.BOLD) + font_normal = gui_app.font(FontWeight.NORMAL) + + ox, oy = rect.x, rect.y + w = rect.width + + # Title + title_size = 48 + ts = measure_text_cached(font_bold, self._title, title_size) + rl.draw_text_ex(font_bold, self._title, rl.Vector2(ox + (w - ts.x) / 2, oy), title_size, 0, rl.WHITE) + + y = oy + ts.y + 30 + + if self._show_random_events: + self._render_random_events(font_bold, font_normal, ox, y, w) + else: + self._render_drive_stats(font_bold, font_normal, ox, y, w) + + def _render_drive_stats(self, font_bold, font_normal, ox, y, w): + cur = self._current_stats + prev = self._previous_stats + + tracked_time = cur.get("TrackedTime", 0) - prev.get("TrackedTime", 0) + engaged_time = (cur.get("AOLTime", 0) - prev.get("AOLTime", 0)) + (cur.get("LongitudinalTime", 0) - prev.get("LongitudinalTime", 0)) + exp_time = cur.get("ExperimentalModeTime", 0) - prev.get("ExperimentalModeTime", 0) + meters = cur.get("FrogPilotMeters", 0) - prev.get("FrogPilotMeters", 0) + + is_metric = ui_state.is_metric + + # Engagement % + eng_pct = f"{engaged_time * 100 / max(tracked_time, 1):.0f}%" if tracked_time > 0 else "-" + + # Distance + if is_metric: + dist = f"{meters / 1000:.1f} km" + else: + dist = f"{meters * METER_TO_MILE:.1f} mi" + + # Time + mins = tracked_time / 60 + hours = int(mins // 60) + rem_mins = int(mins % 60) + if hours > 0: + time_str = f"{hours}h {rem_mins}m" + else: + time_str = f"{rem_mins}m" + + # Exp % + exp_pct = f"{exp_time * 100 / max(tracked_time, 1):.0f}%" if tracked_time > 0 else "-" + + stats = [ + ("Engagement", eng_pct), + ("Distance", dist), + ("Drive Time", time_str), + ("Experimental", exp_pct), + ] + + box_w = (w - 30) / 2 + box_h = 180 + + for i, (label, value) in enumerate(stats): + col = i % 2 + row = i // 2 + bx = ox + col * (box_w + 10) + by = y + row * (box_h + 10) + self._draw_stat_box(font_bold, font_normal, bx, by, box_w, box_h, label, value) + + def _render_random_events(self, font_bold, font_normal, ox, y, w): + if not self._sorted_events: + text = "No Random Events Played!" + ts = measure_text_cached(font_normal, text, 36) + rl.draw_text_ex(font_normal, text, rl.Vector2(ox + (w - ts.x) / 2, y + 100), 36, 0, rl.Color(255, 255, 255, 150)) + return + + box_w = (w - 20) / 3 + box_h = 140 + + for i, (name, count) in enumerate(self._sorted_events[:12]): + col = i % 3 + row = i // 3 + bx = ox + col * (box_w + 10) + by = y + row * (box_h + 10) + self._draw_stat_box(font_bold, font_normal, bx, by, box_w, box_h, name, str(count)) + + def _draw_stat_box(self, font_bold, font_normal, x, y, w, h, label, value): + rect = rl.Rectangle(x, y, w, h) + rl.draw_rectangle_rounded(rect, 0.15, 10, rl.Color(57, 57, 57, 255)) + rl.draw_rectangle_rounded_lines_ex(rect, 0.15, 10, 2, rl.Color(255, 255, 255, 50)) + + # Label (top) + label_size = 28 + ls = measure_text_cached(font_normal, label, label_size) + lx = x + (w - ls.x) / 2 + ly = y + h * 0.15 + rl.draw_text_ex(font_normal, label, rl.Vector2(lx, ly), label_size, 0, rl.Color(170, 170, 170, 255)) + + # Value (center) + val_size = 56 + vs = measure_text_cached(font_bold, value, val_size) + vx = x + (w - vs.x) / 2 + vy = y + h * 0.45 + rl.draw_text_ex(font_bold, value, rl.Vector2(vx, vy), val_size, 0, rl.WHITE) diff --git a/system/ui/lib/application.py b/system/ui/lib/application.py index 4f657198d..06f10e0d7 100644 --- a/system/ui/lib/application.py +++ b/system/ui/lib/application.py @@ -225,6 +225,7 @@ class GuiApplication: self._modal_overlay = ModalOverlay() self._modal_overlay_shown = False self._modal_overlay_tick: Callable[[], None] | None = None + self._nav_stack: list = [] self._mouse = MouseState(self._scale) self._mouse_events: list[MouseEvent] = [] @@ -369,6 +370,41 @@ class GuiApplication: def set_modal_overlay_tick(self, tick_function: Callable | None): self._modal_overlay_tick = tick_function + def push_widget(self, widget): + if widget in self._nav_stack: + return + if self._nav_stack: + prev = self._nav_stack[-1] + if hasattr(prev, 'set_enabled'): + prev.set_enabled(False) + self._nav_stack.append(widget) + if hasattr(widget, 'show_event'): + widget.show_event() + if hasattr(widget, 'set_enabled'): + widget.set_enabled(True) + + def pop_widget(self, idx: int | None = None): + if len(self._nav_stack) < 2: + return + idx_to_pop = len(self._nav_stack) - 1 if idx is None else idx + if idx_to_pop <= 0 or idx_to_pop >= len(self._nav_stack): + return + if idx_to_pop == len(self._nav_stack) - 1: + prev = self._nav_stack[idx_to_pop - 1] + if hasattr(prev, 'set_enabled'): + prev.set_enabled(True) + widget = self._nav_stack.pop(idx_to_pop) + if hasattr(widget, 'hide_event'): + widget.hide_event() + + def _render_nav_stack(self) -> bool: + if not self._nav_stack: + return False + widget = self._nav_stack[-1] + if hasattr(widget, 'render'): + widget.render(rl.Rectangle(0, 0, self.width, self.height)) + return True + def set_should_render(self, should_render: bool): self._should_render = should_render @@ -523,7 +559,9 @@ class GuiApplication: rl.clear_background(rl.BLACK) # Handle modal overlay rendering and input processing - if self._handle_modal_overlay(): + if self._render_nav_stack(): + yield False + elif self._handle_modal_overlay(): # Allow a Widget to still run a function while overlay is shown if self._modal_overlay_tick is not None: self._modal_overlay_tick() diff --git a/system/ui/widgets/__init__.py b/system/ui/widgets/__init__.py index 39e18f8f6..9bc8c16dc 100644 --- a/system/ui/widgets/__init__.py +++ b/system/ui/widgets/__init__.py @@ -34,6 +34,7 @@ class Widget(abc.ABC): self._click_callback: Callable[[], None] | None = None self._multi_touch = False self.__was_awake = True + self._children: list = [] @property def rect(self) -> rl.Rectangle: @@ -180,9 +181,25 @@ class Widget(abc.ABC): def show_event(self): """Optionally handle show event. Parent must manually call this""" + for child in self._children: + child.show_event() def hide_event(self): """Optionally handle hide event. Parent must manually call this""" + for child in self._children: + child.hide_event() + + def _child(self, widget): + """Register a child widget for lifecycle propagation.""" + assert widget not in self._children, f"{type(widget).__name__} already a child of {type(self).__name__}" + self._children.append(widget) + return widget + + def dismiss(self, callback: Callable[[], None] | None = None): + """Dismiss this widget from the nav stack.""" + gui_app.pop_widget() + if callback: + callback() SWIPE_AWAY_THRESHOLD = 80 # px to dismiss after releasing diff --git a/system/ui/widgets/input_dialog.py b/system/ui/widgets/input_dialog.py index ff9d475bc..b71b0df3f 100644 --- a/system/ui/widgets/input_dialog.py +++ b/system/ui/widgets/input_dialog.py @@ -1,118 +1,43 @@ -import pyray as rl from collections.abc import Callable -from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.widgets import Widget, DialogResult -from openpilot.system.ui.widgets.button import Button, ButtonStyle -from openpilot.system.ui.widgets.label import Label, FontWeight -from openpilot.system.ui.widgets.keyboard import Keyboard, KeyboardLayout +from openpilot.system.ui.widgets.keyboard import Keyboard -MARGIN = 50 -BUTTON_HEIGHT = 160 -OUTER_MARGIN_X = 200 -OUTER_MARGIN_Y = 150 -BACKGROUND_COLOR = rl.Color(27, 27, 27, 255) class InputDialog(Widget): def __init__(self, title: str, default_text: str = "", hint_text: str = "", on_close: Callable[[DialogResult, str], None] | None = None): super().__init__() - self._title = title - self._text = default_text - self._hint = hint_text + self._default_text = default_text self._on_close = on_close - self._dialog_result = DialogResult.NO_ACTION - - self._title_label = Label(title, 70, FontWeight.BOLD, text_color=rl.Color(201, 201, 201, 255)) - self._cancel_button = Button("Cancel", self._cancel_button_callback) - self._confirm_button = Button("Confirm", self._confirm_button_callback, button_style=ButtonStyle.PRIMARY) - - self._keyboard = Keyboard(self._on_key_pressed, self._on_keyboard_done, layout=KeyboardLayout.QWERTY) - - self._font = gui_app.font(FontWeight.MEDIUM) - def _on_key_pressed(self, key: str): - if key == "\b": - self._text = self._text[:-1] - else: - self._text += key + self._keyboard = Keyboard(callback=self._on_keyboard_result) + self._keyboard.set_title(title) + self._keyboard.set_text(default_text) - def _on_keyboard_done(self): - self._confirm_button_callback() - - def _cancel_button_callback(self): - self._dialog_result = DialogResult.CANCEL + def _on_keyboard_result(self, result: DialogResult): + if self._dialog_result != DialogResult.NO_ACTION: + return + self._dialog_result = result if self._on_close: - self._on_close(self._dialog_result, self._text) - - def _confirm_button_callback(self): - self._dialog_result = DialogResult.CONFIRM - if self._on_close: - self._on_close(self._dialog_result, self._text) + self._on_close(result, self._keyboard.text) @property def result(self) -> DialogResult: return self._dialog_result - + @property def text(self) -> str: - return self._text + return self._keyboard.text def show_event(self): super().show_event() self._dialog_result = DialogResult.NO_ACTION + self._keyboard.show_event() + self._keyboard.clear() + if self._default_text: + self._keyboard.set_text(self._default_text) - def _render(self, rect: rl.Rectangle): - # Dim background - rl.draw_rectangle(0, 0, int(rect.width), int(rect.height), rl.Color(0, 0, 0, 200)) - - # Dialog Box - dialog_rect = rl.Rectangle( - rect.x + OUTER_MARGIN_X, - rect.y + OUTER_MARGIN_Y, - rect.width - 2 * OUTER_MARGIN_X, - rect.height - 2 * OUTER_MARGIN_Y, - ) - rl.draw_rectangle_rounded(dialog_rect, 0.05, 10, BACKGROUND_COLOR) - - # Title - title_rect = rl.Rectangle(dialog_rect.x + MARGIN, dialog_rect.y + MARGIN, dialog_rect.width - 2 * MARGIN, 100) - self._title_label.render(title_rect) - - # Text Input Field - input_rect = rl.Rectangle(dialog_rect.x + MARGIN, title_rect.y + title_rect.height + 40, dialog_rect.width - 2 * MARGIN, 120) - rl.draw_rectangle_rounded(input_rect, 0.1, 10, rl.Color(40, 40, 40, 255)) - - display_text = self._text - text_color = rl.WHITE - if not display_text: - display_text = self._hint - text_color = rl.Color(128, 128, 128, 255) - - text_size = rl.measure_text_ex(self._font, display_text, 50, 0) - text_pos = rl.Vector2(input_rect.x + 40, input_rect.y + (input_rect.height - text_size.y) / 2) - rl.draw_text_ex(self._font, display_text, text_pos, 50, 0, text_color) - - # Blinking cursor - if (rl.get_time() % 1.0) < 0.5: - cursor_x = text_pos.x + (text_size.x if self._text else 0) + 5 - rl.draw_rectangle(int(cursor_x), int(text_pos.y), 4, 50, rl.WHITE) - - # Keyboard - keyboard_rect = rl.Rectangle( - dialog_rect.x + MARGIN, - input_rect.y + input_rect.height + 40, - dialog_rect.width - 2 * MARGIN, - 400 - ) - self._keyboard.render(keyboard_rect) - - # Buttons - btn_y = dialog_rect.y + dialog_rect.height - BUTTON_HEIGHT - MARGIN - btn_width = (dialog_rect.width - 3 * MARGIN) / 2 - - cancel_rect = rl.Rectangle(dialog_rect.x + MARGIN, btn_y, btn_width, BUTTON_HEIGHT) - confirm_rect = rl.Rectangle(dialog_rect.x + 2 * MARGIN + btn_width, btn_y, btn_width, BUTTON_HEIGHT) - - self._cancel_button.render(cancel_rect) - self._confirm_button.render(confirm_rect) + def _render(self, rect): + self._keyboard.render(rect) + return self._dialog_result diff --git a/system/ui/widgets/keyboard.py b/system/ui/widgets/keyboard.py index 4ec92f507..0eec40611 100644 --- a/system/ui/widgets/keyboard.py +++ b/system/ui/widgets/keyboard.py @@ -1,12 +1,13 @@ from functools import partial import time from typing import Literal +from collections.abc import Callable import pyray as rl from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.lib.multilang import tr -from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets import Widget, DialogResult from openpilot.system.ui.widgets.button import ButtonStyle, Button from openpilot.system.ui.widgets.inputbox import InputBox from openpilot.system.ui.widgets.label import Label @@ -58,7 +59,14 @@ KEYBOARD_LAYOUTS = { class Keyboard(Widget): - def __init__(self, max_text_size: int = 255, min_text_size: int = 0, password_mode: bool = False, show_password_toggle: bool = False): + def __init__( + self, + max_text_size: int = 255, + min_text_size: int = 0, + password_mode: bool = False, + show_password_toggle: bool = False, + callback: Callable[[DialogResult], None] | None = None, + ): super().__init__() self._layout_name: Literal["lowercase", "uppercase", "numbers", "specials"] = "lowercase" self._caps_lock = False @@ -71,6 +79,7 @@ class Keyboard(Widget): self._input_box = InputBox(max_text_size) self._password_mode = password_mode self._show_password_toggle = show_password_toggle + self._callback = callback # Backspace key repeat tracking self._backspace_pressed: bool = False @@ -78,6 +87,8 @@ class Keyboard(Widget): self._backspace_last_repeat: float = 0.0 self._render_return_status = -1 + self._first_render = False + self._skip_input = False self._cancel_button = Button(lambda: tr("Cancel"), self._cancel_button_callback) self._eye_button = Button("", self._eye_button_callback, button_style=ButtonStyle.TRANSPARENT) @@ -98,12 +109,18 @@ class Keyboard(Widget): for _, key in enumerate(keys): if key in self._key_icons: texture = self._key_icons[key] - self._all_keys[key] = Button("", partial(self._key_callback, key), icon=texture, - button_style=ButtonStyle.PRIMARY if key == ENTER_KEY else ButtonStyle.KEYBOARD, multi_touch=True) + self._all_keys[key] = Button( + "", + partial(self._key_callback, key), + icon=texture, + button_style=ButtonStyle.PRIMARY if key == ENTER_KEY else ButtonStyle.KEYBOARD, + multi_touch=True, + ) else: self._all_keys[key] = Button(key, partial(self._key_callback, key), button_style=ButtonStyle.KEYBOARD, font_size=85, multi_touch=True) - self._all_keys[CAPS_LOCK_KEY] = Button("", partial(self._key_callback, CAPS_LOCK_KEY), icon=self._key_icons[CAPS_LOCK_KEY], - button_style=ButtonStyle.KEYBOARD, multi_touch=True) + self._all_keys[CAPS_LOCK_KEY] = Button( + "", partial(self._key_callback, CAPS_LOCK_KEY), icon=self._key_icons[CAPS_LOCK_KEY], button_style=ButtonStyle.KEYBOARD, multi_touch=True + ) def set_text(self, text: str): self._input_box.text = text @@ -122,20 +139,42 @@ class Keyboard(Widget): self._title.set_text(title) self._sub_title.set_text(sub_title) - def _eye_button_callback(self): - self._password_mode = not self._password_mode + def set_callback(self, callback: Callable[[DialogResult], None] | None): + self._callback = callback + + def show_event(self): + super().show_event() + self._skip_input = True + + def _process_mouse_events(self): + if not self._skip_input: + super()._process_mouse_events() def _cancel_button_callback(self): self.clear() - self._render_return_status = 0 + if self in gui_app._nav_stack: + gui_app.pop_widget() + else: + self._render_return_status = 0 + if self._callback: + self._callback(DialogResult.CANCEL) + + def _eye_button_callback(self): + self._password_mode = not self._password_mode def _key_callback(self, k): if k == ENTER_KEY: - self._render_return_status = 1 + if self in gui_app._nav_stack: + gui_app.pop_widget() + else: + self._render_return_status = 1 + if self._callback: + self._callback(DialogResult.CONFIRM) else: self.handle_key_press(k) def _render(self, rect: rl.Rectangle): + self._skip_input = False rect = rl.Rectangle(rect.x + CONTENT_MARGIN, rect.y + CONTENT_MARGIN, rect.width - 2 * CONTENT_MARGIN, rect.height - 2 * CONTENT_MARGIN) self._title.render(rl.Rectangle(rect.x, rect.y, rect.width, 95)) self._sub_title.render(rl.Rectangle(rect.x, rect.y + 95, rect.width, 60))