Compare commits

...

1 Commits

Author SHA1 Message Date
firestarsdog 14d0e9a2c3 Rays 2026-03-23 22:11:35 -04:00
35 changed files with 7405 additions and 1626 deletions
+111 -15
View File
@@ -1,4 +1,6 @@
import json
import time import time
import datetime
import pyray as rl import pyray as rl
from collections.abc import Callable from collections.abc import Callable
from enum import IntEnum 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.exp_mode_button import ExperimentalModeButton
from openpilot.selfdrive.ui.widgets.prime import PrimeWidget from openpilot.selfdrive.ui.widgets.prime import PrimeWidget
from openpilot.selfdrive.ui.widgets.setup import SetupWidget 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.text_measure import measure_text_cached
from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
from openpilot.system.ui.lib.multilang import tr, trn from openpilot.system.ui.lib.multilang import tr, trn
@@ -58,6 +62,14 @@ class HomeLayout(Widget):
self._prime_widget = PrimeWidget() self._prime_widget = PrimeWidget()
self._setup_widget = SetupWidget() 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._exp_mode_button = ExperimentalModeButton()
self._setup_callbacks() self._setup_callbacks()
@@ -71,6 +83,34 @@ class HomeLayout(Widget):
self.offroad_alert.set_dismiss_callback(lambda: self._set_state(HomeLayoutState.HOME)) 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) 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): def set_settings_callback(self, callback: Callable):
self.settings_callback = callback self.settings_callback = callback
@@ -104,24 +144,18 @@ class HomeLayout(Widget):
self._render_alerts_view() self._render_alerts_view()
def _update_state(self): def _update_state(self):
self.header_rect = rl.Rectangle( self.header_rect = rl.Rectangle(self._rect.x + CONTENT_MARGIN, self._rect.y + CONTENT_MARGIN, self._rect.width - 2 * CONTENT_MARGIN, HEADER_HEIGHT)
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_y = self._rect.y + CONTENT_MARGIN + HEADER_HEIGHT + SPACING
content_height = self._rect.height - CONTENT_MARGIN - HEADER_HEIGHT - SPACING - CONTENT_MARGIN content_height = self._rect.height - CONTENT_MARGIN - HEADER_HEIGHT - SPACING - CONTENT_MARGIN
self.content_rect = rl.Rectangle( self.content_rect = rl.Rectangle(self._rect.x + CONTENT_MARGIN, content_y, self._rect.width - 2 * CONTENT_MARGIN, content_height)
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 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.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.right_column_rect = rl.Rectangle(self.content_rect.x + left_width + SPACING, self.content_rect.y, RIGHT_COLUMN_WIDTH, self.content_rect.height)
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.x = self.header_rect.x
self.update_notif_rect.y = self.header_rect.y + (self.header_rect.height - 60) // 2 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): def _handle_mouse_release(self, mouse_pos: MousePos):
super()._handle_mouse_release(mouse_pos) 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): if self.update_available and rl.check_collision_point_rec(mouse_pos, self.update_notif_rect):
self._set_state(HomeLayoutState.UPDATE) self._set_state(HomeLayoutState.UPDATE)
elif self.alert_count > 0 and rl.check_collision_point_rec(mouse_pos, self.alert_notif_rect): 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 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 # Update notification button
if self.update_available: if self.update_available:
version_text_width -= self.update_notif_rect.width version_text_width -= self.update_notif_rect.width
@@ -175,8 +224,9 @@ class HomeLayout(Widget):
if self.update_available or self.alert_count > 0: if self.update_available or self.alert_count > 0:
version_text_width -= SPACING * 1.5 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_rect = rl.Rectangle(
version_text_width, self.header_rect.height) 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) gui_label(version_rect, self._version_text, 48, rl.WHITE, alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT)
def _render_home_content(self): def _render_home_content(self):
@@ -190,13 +240,19 @@ class HomeLayout(Widget):
self.offroad_alert.render(self.content_rect) self.offroad_alert.render(self.content_rect)
def _render_left_column(self): 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): def _render_right_column(self):
if self._random_events_active:
self._random_events_summary.render(self.right_column_rect)
return
exp_height = 125 exp_height = 125
exp_rect = rl.Rectangle( exp_rect = rl.Rectangle(self.right_column_rect.x, self.right_column_rect.y, self.right_column_rect.width, exp_height)
self.right_column_rect.x, self.right_column_rect.y, self.right_column_rect.width, exp_height
)
self._exp_mode_button.render(exp_rect) self._exp_mode_button.render(exp_rect)
setup_rect = rl.Rectangle( setup_rect = rl.Rectangle(
@@ -207,6 +263,46 @@ class HomeLayout(Widget):
) )
self._setup_widget.render(setup_rect) 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): def _refresh(self):
self._version_text = self._get_version_text() self._version_text = self._get_version_text()
update_available = self.update_alert.refresh() update_available = self.update_alert.refresh()
+43 -12
View File
@@ -1,6 +1,7 @@
import os import os
import time import time
import datetime import datetime
from pathlib import Path
from openpilot.common.time_helpers import system_time_valid from openpilot.common.time_helpers import system_time_valid
from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.system.ui.lib.application import gui_app 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_btn.action_item.set_value(ui_state.params.get("UpdaterTargetBranch") or "")
self._branch_dialog: MultiOptionDialog | None = None self._branch_dialog: MultiOptionDialog | None = None
self._scroller = Scroller([ self._scroller = Scroller(
self._onroad_label, [
self._version_item, self._onroad_label,
self._auto_updates_toggle, self._version_item,
self._download_btn, self._auto_updates_toggle,
self._install_btn, self._download_btn,
self._branch_btn, self._install_btn,
button_item(lambda: tr("Uninstall"), lambda: tr("UNINSTALL"), callback=self._on_uninstall), self._branch_btn,
], line_separator=True, spacing=0) 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): def show_event(self):
self._scroller.show_event() self._scroller.show_event()
@@ -170,18 +176,43 @@ class SoftwareLayout(Widget):
# Start downloading # Start downloading
self._waiting_for_updater = True self._waiting_for_updater = True
self._waiting_start_ts = time.monotonic() self._waiting_start_ts = time.monotonic()
ui_state.params_memory.put_bool("ManualUpdateInitiated", True)
os.system("pkill -SIGHUP -f system.updated.updated") os.system("pkill -SIGHUP -f system.updated.updated")
def _on_auto_updates_toggle(self, enabled: bool): def _on_auto_updates_toggle(self, enabled: bool):
ui_state.params.put_bool("AutomaticUpdates", enabled) ui_state.params.put_bool("AutomaticUpdates", enabled)
def _on_uninstall(self): def _on_uninstall(self):
def handle_uninstall_confirmation(result): def handle_step1(result):
if result == DialogResult.CONFIRM: 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")) 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): def _on_install_update(self):
# Trigger reboot to install update # Trigger reboot to install update
+223 -15
View File
@@ -1,25 +1,233 @@
from __future__ import annotations 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.lib.multilang import tr, tr_noop
from openpilot.system.ui.widgets.list_view import button_item from openpilot.system.ui.widgets import DialogResult
from openpilot.system.ui.widgets.scroller_tici import Scroller 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 from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import StarPilotPanel
class StarPilotDataLayout(StarPilotPanel): class StarPilotDataLayout(StarPilotPanel):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.CATEGORIES = [
items = [ {"title": tr_noop("Manage Backups"), "panel": "backups", "icon": "toggle_icons/icon_system.png", "color": "#FA6800"},
button_item( {"title": tr_noop("Toggle Backups"), "panel": "toggle_backups", "icon": "toggle_icons/icon_system.png", "color": "#FA6800"},
tr_noop("Manage Backups"), {"title": tr_noop("Manage Storage"), "panel": "storage", "icon": "toggle_icons/icon_system.png", "color": "#FA6800"},
lambda: tr("MANAGE"), {
tr_noop("<b>Create, restore, or delete backups</b> of your StarPilot settings."), "title": tr_noop("Delete Driving Data"),
), "type": "hub",
button_item( "on_click": self._on_delete_driving_data,
tr_noop("Manage Storage"), "icon": "toggle_icons/icon_system.png",
lambda: tr("MANAGE"), "color": "#FA6800",
tr_noop("<b>View and manage storage usage</b> for models, maps, and other data."), },
), {
"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"
@@ -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)
+265 -15
View File
@@ -1,25 +1,275 @@
from __future__ import annotations 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.lib.multilang import tr, tr_noop
from openpilot.system.ui.widgets.list_view import button_item from openpilot.system.ui.widgets import DialogResult
from openpilot.system.ui.widgets.scroller_tici import Scroller 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.panel import StarPilotPanel
from openpilot.selfdrive.ui.layouts.settings.starpilot.metro import SliderDialog
class StarPilotDeviceLayout(StarPilotPanel): class StarPilotDeviceLayout(StarPilotPanel):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.CATEGORIES = [
items = [ {"title": tr_noop("Screen Settings"), "panel": "screen", "icon": "toggle_icons/icon_light.png", "color": "#FA6800"},
button_item( {"title": tr_noop("Device Settings"), "panel": "device_settings", "icon": "toggle_icons/icon_device.png", "color": "#FA6800"},
tr_noop("Screen Controls"), {
lambda: tr("MANAGE"), "title": tr_noop("Device Shutdown"),
tr_noop("<b>Adjust screen brightness, timeout,</b> and other display settings."), "type": "value",
), "get_value": self._get_shutdown_timer,
button_item( "on_click": self._show_shutdown_selector,
tr_noop("Device Settings"), "color": "#FA6800",
lambda: tr("MANAGE"), },
tr_noop("<b>Configure device-specific options</b> like orientation and controls."), {
), "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")
)
@@ -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 import DialogResult
from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog, alert_dialog 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.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.panel import StarPilotPanel
from openpilot.selfdrive.ui.layouts.settings.starpilot.metro import SliderDialog
class StarPilotDrivingModelLayout(StarPilotPanel): class StarPilotDrivingModelLayout(StarPilotPanel):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self._toggle_items = {}
if PC: if PC:
self._model_dir = Path(os.path.expanduser("~/.comma/frogpilot/data/models")) 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_file_to_name = {}
self._model_series_map = {} self._model_series_map = {}
self._model_version_map = {} self._model_version_map = {}
self._current_model_name = "" self._current_model_name = tr("Default")
self._toggle_items["ModelRandomizer"] = toggle_item( self.CATEGORIES = [
tr_noop("Model Randomizer"), {"title": tr_noop("Ratings"), "type": "hub", "icon": "toggle_icons/icon_system.png", "on_click": self._on_scores_clicked, "color": "#1BA1E2"},
tr_noop("<b>Driving models are chosen at random each drive</b> and feedback prompts are used to find the model that best suits your needs."), {
self._params.get_bool("ModelRandomizer"), "title": tr_noop("Recovery Power"),
callback=self._on_model_randomizer_toggled, "type": "value",
) "icon": "toggle_icons/icon_longitudinal_tune.png",
"on_click": self._on_recovery_power_clicked,
self._toggle_items["AutomaticallyDownloadModels"] = toggle_item( "get_value": lambda: f"{self._params.get_float('RecoveryPower', default=1.0):.1f}",
tr_noop("Automatically Download New Models"), "visible": lambda: self._params.get_int("TuningLevel", default=1) >= 3,
tr_noop("<b>Automatically download new driving models</b> as they become available."), "color": "#1BA1E2",
self._params.get_bool("AutomaticallyDownloadModels"), },
callback=self._on_auto_download_toggled, {
) "title": tr_noop("Stop Distance"),
"type": "value",
self._select_model_btn = button_item( "icon": "toggle_icons/icon_longitudinal_tune.png",
tr_noop("Select Driving Model"), "on_click": self._on_stop_distance_clicked,
lambda: tr("SELECT"), "get_value": lambda: f"{self._params.get_float('StopDistance', default=6.0):.1f}",
tr_noop("<b>Select the active driving model.</b>"), "visible": lambda: self._params.get_int("TuningLevel", default=1) >= 3,
callback=self._on_select_model_clicked, "color": "#1BA1E2",
) },
self._download_model_btn = button_item(
tr_noop("Download Driving Models"),
lambda: tr("DOWNLOAD"),
tr_noop("<b>Download driving models to the device.</b>"),
callback=self._on_download_clicked,
)
self._delete_model_btn = button_item(
tr_noop("Delete Driving Models"),
lambda: tr("DELETE"),
tr_noop("<b>Delete driving models from the device.</b>"),
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("<b>Add or remove models</b> from the Model Randomizer's blacklist."),
callback=self._on_blacklist_clicked,
),
button_item(
tr_noop("Manage Model Ratings"),
lambda: tr("MANAGE"),
tr_noop("<b>Reset or view the saved ratings</b> for the driving models."),
callback=self._on_scores_clicked,
),
] ]
self._model_manager = ModelManager(self._params, self._params_memory) self._model_manager = ModelManager(self._params, self._params_memory)
self._download_thread = None self._download_thread = None
self._scroller = Scroller(items, line_separator=True, spacing=0)
self._update_model_metadata() self._update_model_metadata()
self._rebuild_grid()
def _render(self, rect: rl.Rectangle): def _render(self, rect: rl.Rectangle):
self._update_state() self._update_state()
@@ -104,50 +72,13 @@ class StarPilotDrivingModelLayout(StarPilotPanel):
super().show_event() super().show_event()
self._update_model_metadata() 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: def _is_model_installed(self, key: str) -> bool:
if not key: if not key:
return False return False
has_thneed = (self._model_dir / f"{key}.thneed").exists()
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
if has_thneed: if has_thneed:
return True return True
return (self._model_dir / f"{key}_driving_policy_tinygrad.pkl").exists()
return has_policy_meta and has_policy_tg and has_vision_meta and has_vision_tg
def _update_model_metadata(self): def _update_model_metadata(self):
available_models_raw = self._params.get("AvailableModels", encoding='utf-8') available_models_raw = self._params.get("AvailableModels", encoding='utf-8')
@@ -173,30 +104,27 @@ class StarPilotDrivingModelLayout(StarPilotPanel):
name = self._available_model_names[i].strip() name = self._available_model_names[i].strip()
if not key or not name: if not key or not name:
continue continue
series = self._available_model_series[i].strip() if i < len(self._available_model_series) else tr("Custom Series") 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_file_to_name[key] = name
self._model_series_map[key] = series self._model_series_map[key] = series
if i < len(self._available_model_versions): if i < len(self._available_model_versions):
version = self._available_model_versions[i].strip() v = self._available_model_versions[i].strip()
if version: if v:
self._model_version_map[key] = version self._model_version_map[key] = v
if i < len(released_dates): if i < len(released_dates):
date = released_dates[i].strip() d = released_dates[i].strip()
if date: if d:
self._model_released_dates[key] = date self._model_released_dates[key] = d
model_key = self._params.get("Model") or self._params.get("DrivingModel") model_key = self._params.get("Model") or self._params.get("DrivingModel")
if model_key: if model_key and isinstance(model_key, bytes):
if isinstance(model_key, bytes): model_key = model_key.decode()
model_key = model_key.decode()
if not model_key or not self._is_model_installed(model_key): 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 "" 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() model_key = model_key.decode()
self._current_model_name = self._model_file_to_name.get(model_key, "Default") 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): def _show_selection_dialog(self, title: str, options: dict[str, str] | list[str], current_val: str, on_confirm: Callable):
if not options: if not options:
@@ -204,9 +132,11 @@ class StarPilotDrivingModelLayout(StarPilotPanel):
return return
if isinstance(options, list): if isinstance(options, list):
def _on_close_list(res, val): def _on_close_list(res, val):
if res == DialogResult.CONFIRM: if res == DialogResult.CONFIRM:
on_confirm(val) on_confirm(val)
dialog = SelectionDialog(title, options, current_val, on_close=_on_close_list) dialog = SelectionDialog(title, options, current_val, on_close=_on_close_list)
gui_app.set_modal_overlay(dialog) gui_app.set_modal_overlay(dialog)
return return
@@ -219,15 +149,14 @@ class StarPilotDrivingModelLayout(StarPilotPanel):
grouped[series] = [] grouped[series] = []
grouped[series].append(name) grouped[series].append(name)
name_to_key[name] = key name_to_key[name] = key
for series in grouped: for series in grouped:
grouped[series].sort() grouped[series].sort()
sorted_series = sorted(grouped.keys()) sorted_series = sorted(grouped.keys())
if "StarPilot" in sorted_series: if "StarPilot" in sorted_series:
sorted_series.remove("StarPilot") sorted_series.remove("StarPilot")
sorted_series.insert(0, "StarPilot") sorted_series.insert(0, "StarPilot")
final_grouped = {s: grouped[s] for s in sorted_series} final_grouped = {s: grouped[s] for s in sorted_series}
def _on_close_grouped(res, val): 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()] comm_favs = [f.strip() for f in (self._params.get("CommunityFavorites", encoding='utf-8') or "").split(",") if f.strip()]
dialog = SelectionDialog( dialog = SelectionDialog(
title, title,
final_grouped, final_grouped,
current_val, current_val,
on_close=_on_close_grouped, on_close=_on_close_grouped,
model_released_dates=self._model_released_dates, model_released_dates=self._model_released_dates,
model_file_to_name=self._model_file_to_name, model_file_to_name=self._model_file_to_name,
user_favorites=user_favs, user_favorites=user_favs,
community_favorites=comm_favs, community_favorites=comm_favs,
on_favorite_toggled=_on_favorite_toggled on_favorite_toggled=_on_favorite_toggled,
) )
gui_app.set_modal_overlay(dialog) gui_app.set_modal_overlay(dialog)
@@ -268,20 +197,17 @@ class StarPilotDrivingModelLayout(StarPilotPanel):
self._params.put("Model", model_key) self._params.put("Model", model_key)
self._params.put("DrivingModel", model_key) self._params.put("DrivingModel", model_key)
self._params.put("DrivingModelName", installed_models[model_key]) self._params.put("DrivingModelName", installed_models[model_key])
model_version = self._model_version_map.get(model_key, "") mv = self._model_version_map.get(model_key, "")
if model_version: if mv:
self._params.put("ModelVersion", model_version) self._params.put("ModelVersion", mv)
self._params.put("DrivingModelVersion", model_version) self._params.put("DrivingModelVersion", mv)
self._update_model_metadata() self._update_model_metadata()
if ui_state.started: if ui_state.started:
reboot_dialog = ConfirmDialog( gui_app.set_modal_overlay(
tr("Reboot required to take effect. Reboot now?"), ConfirmDialog(
tr("Reboot"), tr("Reboot required. Reboot now?"), tr("Reboot"), tr("Cancel"), on_close=lambda res: HARDWARE.reboot() if res == DialogResult.CONFIRM else None
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) self._show_selection_dialog(tr("Select Driving Model"), installed_models, self._current_model_name, _on_confirm)
@@ -294,136 +220,109 @@ class StarPilotDrivingModelLayout(StarPilotPanel):
return return
not_installed = {k: v for k, v in self._model_file_to_name.items() if not self._is_model_installed(k)} 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._show_selection_dialog(tr("Select Model to Download"), not_installed, "", lambda mk: self._params_memory.put("ModelToDownload", mk))
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)
def _on_delete_clicked(self): def _on_delete_clicked(self):
installed = {k: v for k, v in self._model_file_to_name.items() if self._is_model_installed(k)} 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 "" dk = self._params.get_default_value("Model") or ""
if isinstance(default_key, bytes): if isinstance(dk, bytes):
default_key = default_key.decode() dk = dk.decode()
current_key = self._params.get("Model", encoding='utf-8') or "" ck = 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} deletable = {k: v for k, v in installed.items() if k != dk and k != ck}
def _on_confirm(model_key): def _on_confirm(mk):
def _execute_delete(confirm_res): def _execute_delete(res):
if confirm_res == DialogResult.CONFIRM: if res == DialogResult.CONFIRM:
for file in self._model_dir.iterdir(): for file in self._model_dir.iterdir():
if file.name.startswith(model_key): if file.name.startswith(mk):
file.unlink() file.unlink()
self._update_model_metadata() self._update_model_metadata()
confirm = ConfirmDialog( gui_app.set_modal_overlay(ConfirmDialog(tr(f"Delete '{deletable[mk]}'?"), tr("Delete"), on_close=_execute_delete))
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)
self._show_selection_dialog(tr("Select Model to Delete"), deletable, "", _on_confirm) self._show_selection_dialog(tr("Select Model to Delete"), deletable, "", _on_confirm)
def _on_blacklist_clicked(self): def _on_blacklist_clicked(self):
blacklisted = [m.strip() for m in (self._params.get("BlacklistedModels", encoding='utf-8') or "").split(",") if m.strip()] 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): def _on_action_selected(res, val):
if res == DialogResult.CONFIRM: if res == DialogResult.CONFIRM:
if val == tr("ADD"): if val == tr("ADD"):
blacklistable = {k: v for k, v in self._model_file_to_name.items() if k not in blacklisted} 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]))) self._show_selection_dialog(tr("Add to Blacklist"), blacklistable, "", lambda k: self._params.put("BlacklistedModels", ",".join(blacklisted + [k])))
elif val == tr("REMOVE"): elif val == tr("REMOVE"):
options = {k: self._model_file_to_name.get(k, k) for k in blacklisted} 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")
dialog = SelectionDialog( def _remove(k):
tr("Manage Blacklist"), blacklisted.remove(k)
[tr("ADD"), tr("REMOVE"), tr("RESET ALL")], self._params.put("BlacklistedModels", ",".join(blacklisted))
on_close=_on_action_selected
) self._show_selection_dialog(tr("Remove from Blacklist"), options, "", _remove)
gui_app.set_modal_overlay(dialog) 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): def _on_scores_clicked(self):
scores_raw = self._params.get("ModelDrivesAndScores", encoding='utf-8') or "" scores_raw = self._params.get("ModelDrivesAndScores", encoding='utf-8') or ""
if not scores_raw: if not scores_raw:
gui_app.set_modal_overlay(alert_dialog(tr("No model ratings found."))) gui_app.set_modal_overlay(alert_dialog(tr("No model ratings found.")))
return return
try: try:
scores = json.loads(scores_raw) scores = json.loads(scores_raw)
lines = [f"{k}: {v.get('Score', 0)}% ({v.get('Drives', 0)} drives)" for k, v in scores.items()] 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(ConfirmDialog("\n".join(lines), tr("Close"), rich=True))
gui_app.set_modal_overlay(confirm)
except: except:
pass pass
def _on_model_randomizer_toggled(self, state: bool): def _on_model_randomizer_toggled(self, state: bool):
self._params.put_bool("ModelRandomizer", state) self._params.put_bool("ModelRandomizer", state)
if state: if state:
not_installed = [k for k in self._model_file_to_name if not self._is_model_installed(k)] not_installed = [k for k in self._model_file_to_name if not self._is_model_installed(k)]
if not_installed: if not_installed:
def _on_download_confirm(res):
if res == DialogResult.CONFIRM: def _on_download_confirm(res):
self._params_memory.put_bool("DownloadAllModels", True) if res == DialogResult.CONFIRM:
self._params_memory.put("ModelDownloadProgress", "Downloading...") 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?"), gui_app.set_modal_overlay(ConfirmDialog(tr("Download all models for Randomizer?"), tr("Download All"), on_close=_on_download_confirm))
tr("Download All"),
on_close=_on_download_confirm def _on_recovery_power_clicked(self):
) def on_close(res, val):
gui_app.set_modal_overlay(confirm) 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): def _update_state(self):
if not self.is_visible:
return
model_to_download = self._params_memory.get("ModelToDownload", encoding='utf-8') or "" model_to_download = self._params_memory.get("ModelToDownload", encoding='utf-8') or ""
download_all = self._params_memory.get_bool("DownloadAllModels") 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) 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()): 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: def _download_task():
self._download_model_btn.action_item._text_source = tr("CANCEL") try:
self._download_model_btn.action_item._value_source = progress if progress else tr("Downloading...") if download_all:
else: self._model_manager.download_all_models()
self._download_model_btn.action_item._text_source = tr("DOWNLOAD") else:
parked = not ui_state.started self._model_manager.download_model(model_to_download)
online = ui_state.sm["deviceState"].networkType != 0 if ui_state.sm.valid.get("deviceState", False) else True except:
if not online: pass
self._download_model_btn.action_item._value_source = tr("Offline...") finally:
elif not parked: self._download_thread = None
self._download_model_btn.action_item._value_source = tr("Not parked")
else: self._download_thread = threading.Thread(target=_download_task, daemon=True)
self._download_model_btn.action_item._value_source = "" self._download_thread.start()
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!")
@@ -1,325 +1,126 @@
from __future__ import annotations from __future__ import annotations
from openpilot.selfdrive.ui.lib.starpilot_state import starpilot_state 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.lib.multilang import tr, tr_noop
from openpilot.system.ui.widgets import DialogResult from openpilot.system.ui.widgets import DialogResult
from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog 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.selection_dialog import SelectionDialog
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.panel import StarPilotPanel
from openpilot.selfdrive.ui.layouts.settings.starpilot.metro import SliderDialog
class StarPilotAdvancedLateralLayout(StarPilotPanel): class StarPilotAdvancedLateralLayout(StarPilotPanel):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.CATEGORIES = [
items = [ {"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"},
value_button_item( {"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"},
lambda: ( {"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"},
tr("Actuator Delay") {"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"},
+ (f" (Default: {starpilot_state.car_state.steerActuatorDelay:.2f})" if starpilot_state.car_state.steerActuatorDelay != 0 else "") {"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"},
"SteerDelay", {"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"},
min_val=0.01, {"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"},
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(
"<b>The time between openpilot's steering command and the vehicle's response.</b> 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(
"<b>Compensates for steering friction.</b> 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(
"<b>How strongly openpilot corrects lane position.</b> 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(
"<b>Maps steering torque to turning response.</b> 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(
"<b>The relationship between steering wheel rotation and road wheel angle.</b> 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("<b>Force-enable openpilot's live auto-tuning for \"Friction\" and \"Lateral Acceleration\".</b>"),
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("<b>Force-disable openpilot's live auto-tuning for \"Friction\" and \"Lateral Acceleration\" and use the set value instead.</b>"),
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("<b>Use torque-based steering control instead of angle-based control for smoother lane keeping, especially in curves.</b>"),
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._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): def _on_reboot_toggle(self, key, state):
self._params.put_bool(key, state) self._params.put_bool(key, state)
from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.selfdrive.ui.ui_state import ui_state
if ui_state.started: if ui_state.started:
from openpilot.system.ui.lib.application import gui_app dialog = ConfirmDialog(tr("Reboot required. Reboot now?"), tr("Reboot"), tr("Cancel"), on_close=lambda res: HARDWARE.reboot() if res == DialogResult.CONFIRM else None)
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(dialog)
class StarPilotAlwaysOnLateralLayout(StarPilotPanel): class StarPilotAlwaysOnLateralLayout(StarPilotPanel):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.CATEGORIES = [
items = [ {"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"},
toggle_item( {"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"},
tr_noop("Always On Lateral"), {"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"},
tr_noop("<b>openpilot's steering remains active even when the accelerator or brake pedals are pressed.</b>"),
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("<b>Enable \"Always On Lateral\" whenever \"LKAS\" is on, even when openpilot is not engaged.</b>"),
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("<b>Pause \"Always On Lateral\" below the set speed while the brake pedal is pressed.</b>"),
is_metric=True,
),
] ]
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): def _on_reboot_toggle(self, key, state):
self._params.put_bool(key, state) self._params.put_bool(key, state)
from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.selfdrive.ui.ui_state import ui_state
if ui_state.started: if ui_state.started:
from openpilot.system.ui.lib.application import gui_app 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 _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)
class StarPilotLaneChangesLayout(StarPilotPanel): class StarPilotLaneChangesLayout(StarPilotPanel):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.CATEGORIES = [
def _get_lane_change_labels(): {"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"},
labels = {0.0: tr("Instant")} {"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"},
for i in range(1, 51): {"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"},
val = i / 10.0 {"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"},
labels[val] = f"{val:.1f} seconds" if val != 1.0 else "1.0 second" {"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"},
return labels {"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"},
items = [
toggle_item(
tr_noop("Lane Changes"),
tr_noop("<b>Allow openpilot to change lanes.</b>"),
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("<b>When the turn signal is on, openpilot will automatically change lanes.</b> 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("<b>Delay between turn signal activation and the start of an automatic lane change.</b>"),
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("<b>Lowest speed at which openpilot will change lanes.</b>"),
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("<b>Prevent automatic lane changes into lanes narrower than the set width.</b>"),
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("<b>Limit automatic lane changes to one per turn-signal activation.</b>"),
self._params.get_bool("OneLaneChange"),
callback=lambda x: self._params.put_bool("OneLaneChange", x),
),
] ]
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): class StarPilotLateralTuneLayout(StarPilotPanel):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.CATEGORIES = [
items = [ {"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"},
toggle_item( {"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"},
tr_noop("Force Turn Desires Below Lane Change Speed"), {"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"},
tr_noop("<b>While driving below the minimum lane change speed with an active turn signal, instruct openpilot to turn left/right.</b>"),
self._params.get_bool("TurnDesires"),
callback=lambda x: self._params.put_bool("TurnDesires", x),
),
toggle_item(
tr_noop("Neural Network Feedforward (NNFF)"),
tr_noop(
"<b>Twilsonco's \"Neural Network FeedForward\" controller.</b> 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(
"<b>A lightweight version of Twilsonco's \"Neural Network FeedForward\" controller.</b> 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._scroller = Scroller(items, line_separator=True, spacing=0) self._rebuild_grid()
def _on_reboot_toggle(self, key, state): def _on_reboot_toggle(self, key, state):
self._params.put_bool(key, state) self._params.put_bool(key, state)
from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.selfdrive.ui.ui_state import ui_state
if ui_state.started: if ui_state.started:
from openpilot.system.ui.lib.application import gui_app 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 _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)
class StarPilotLateralQOLLayout(StarPilotPanel): class StarPilotLateralQOLLayout(StarPilotPanel):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.CATEGORIES = [
items = [ {"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"}
value_button_item(
tr_noop("Pause Steering Below"),
"PauseLateralSpeed",
min_val=0,
max_val=99,
step=1,
unit="mph",
description=tr_noop("<b>Pause steering below the set speed.</b>"),
sub_toggles=[("PauseLateralOnSignal", True)],
labels={0: tr_noop("Off")},
is_metric=True,
)
] ]
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): class StarPilotLateralLayout(StarPilotPanel):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self._sub_panels = { self._sub_panels = {
"advanced_lateral": StarPilotAdvancedLateralLayout(), "advanced_lateral": StarPilotAdvancedLateralLayout(),
"always_on_lateral": StarPilotAlwaysOnLateralLayout(), "always_on_lateral": StarPilotAlwaysOnLateralLayout(),
@@ -327,54 +128,14 @@ class StarPilotLateralLayout(StarPilotPanel):
"lateral_tune": StarPilotLateralTuneLayout(), "lateral_tune": StarPilotLateralTuneLayout(),
"qol": StarPilotLateralQOLLayout(), "qol": StarPilotLateralQOLLayout(),
} }
self.CATEGORIES = [
for name, panel in self._sub_panels.items(): {"title": tr_noop("Advanced Lateral Tuning"), "panel": "advanced_lateral", "icon": "toggle_icons/icon_advanced_lateral_tune.png", "color": "#1BA1E2"},
if hasattr(panel, 'set_navigate_callback'): {"title": tr_noop("Always On Lateral"), "panel": "always_on_lateral", "icon": "toggle_icons/icon_always_on_lateral.png", "color": "#1BA1E2"},
panel.set_navigate_callback(self._navigate_to) {"title": tr_noop("Lane Changes"), "panel": "lane_changes", "icon": "toggle_icons/icon_lane.png", "color": "#1BA1E2"},
if hasattr(panel, 'set_back_callback'): {"title": tr_noop("Lateral Tuning"), "panel": "lateral_tune", "icon": "toggle_icons/icon_lateral_tune.png", "color": "#1BA1E2"},
panel.set_back_callback(self._go_back) {"title": tr_noop("Quality of Life"), "panel": "qol", "icon": "toggle_icons/icon_quality_of_life.png", "color": "#1BA1E2"},
items = [
button_item(
tr_noop("Advanced Lateral Tuning"),
lambda: tr("MANAGE"),
tr_noop("<b>Advanced steering control changes to fine-tune how openpilot drives.</b>"),
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("<b>openpilot's steering remains active even when the accelerator or brake pedals are pressed.</b>"),
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("<b>Allow openpilot to change lanes.</b>"),
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("<b>Miscellaneous steering control changes</b> 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("<b>Steering control changes to fine-tune how openpilot drives.</b>"),
callback=lambda: self._navigate_to("qol"),
icon="toggle_icons/icon_quality_of_life.png",
starpilot_icon=True,
),
] ]
for name, panel in self._sub_panels.items():
self._scroller = Scroller(items, line_separator=True, spacing=0) 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()
File diff suppressed because it is too large Load Diff
@@ -5,8 +5,7 @@ import pyray as rl
from openpilot.common.params import Params from openpilot.common.params import Params
from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.lib.multilang import tr, tr_noop from openpilot.system.ui.lib.multilang import tr, tr_noop
from openpilot.system.ui.widgets.scroller_tici import Scroller from openpilot.system.ui.lib.application import MousePos
from openpilot.system.ui.widgets.list_view import multiple_button_item, category_buttons_item
from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import StarPilotPanelType, StarPilotPanelInfo from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import StarPilotPanelType, StarPilotPanelInfo
from openpilot.selfdrive.ui.layouts.settings.starpilot.sounds import StarPilotSoundsLayout 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.themes import StarPilotThemesLayout
from openpilot.selfdrive.ui.layouts.settings.starpilot.vehicle import StarPilotVehicleSettingsLayout 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.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" STARPILOT_ICONS_DIR = "toggle_icons"
class StarPilotLayout(Widget): class StarPilotLayout(Widget):
CATEGORIES = [ CATEGORIES = [
{ {
"title": "Alerts and Sounds", "title": "Alerts and Sounds",
"icon": "icon_sound.png", "icon": "icon_sound.png",
"desc": "<b>Adjust alert volumes and enable custom notifications.</b>", "desc": "Adjust alert volumes and enable custom notifications.",
"buttons": [("MANAGE", "SOUNDS", 0)], "buttons": [("MANAGE", "SOUNDS", 0)],
"color": "#FF0097",
}, },
{ {
"title": "Driving Controls", "title": "Driving Controls",
"icon": "icon_steering.png", "icon": "icon_steering.png",
"desc": "<b>Fine-tune custom StarPilot acceleration, braking, and steering controls.</b>", "desc": "Fine-tune custom StarPilot acceleration, braking, and steering controls.",
"buttons": [("DRIVING MODEL", "DRIVING_MODEL", 0), ("GAS / BRAKE", "LONGITUDINAL", 0), ("STEERING", "LATERAL", 0)], "buttons": [("DRIVING MODEL", "DRIVING_MODEL", 0), ("GAS / BRAKE", "LONGITUDINAL", 0), ("STEERING", "LATERAL", 0)],
"color": "#1BA1E2",
}, },
{ {
"title": "Navigation", "title": "Navigation",
"icon": "icon_navigate.png", "icon": "icon_navigate.png",
"desc": "<b>Download map data for the Speed Limit Controller.</b>", "desc": "Download map data for the Speed Limit Controller.",
"buttons": [("MAP DATA", "MAPS", 0), ("NAVIGATION", "NAVIGATION", 1)], "buttons": [("MAP DATA", "MAPS", 0), ("NAVIGATION", "NAVIGATION", 0)],
"color": "#8CBF26",
}, },
{ {
"title": "System Settings", "title": "System Settings",
"icon": "icon_system.png", "icon": "icon_system.png",
"desc": "<b>Manage backups, device settings, screen options, storage, and tools to keep StarPilot running smoothly.</b>", "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)], "buttons": [("DATA", "DATA", 0), ("DEVICE CONTROLS", "DEVICE", 0), ("UTILITIES", "UTILITIES", 0)],
"color": "#FA6800",
}, },
{ {
"title": "Theme and Appearance", "title": "Theme and Appearance",
"icon": "icon_display.png", "icon": "icon_display.png",
"desc": "<b>Customize the look of the driving screen and interface, including themes!</b>", "desc": "Customize the look of the driving screen and interface, including themes!",
"buttons": [("APPEARANCE", "VISUALS", 0), ("THEME", "THEMES", 0)], "buttons": [("APPEARANCE", "VISUALS", 0), ("THEME", "THEMES", 0), ("DEVELOPER", "DEVELOPER", 0)],
"color": "#A200FF",
}, },
{ {
"title": "Vehicle Settings", "title": "Vehicle Settings",
"icon": "icon_vehicle.png", "icon": "icon_vehicle.png",
"desc": "<b>Configure car-specific options and steering wheel button mappings.</b>", "desc": "Configure car-specific options and steering wheel button mappings.",
"buttons": [("VEHICLE SETTINGS", "VEHICLE", 0), ("WHEEL CONTROLS", "WHEEL", 1)], "buttons": [("VEHICLE SETTINGS", "VEHICLE", 0), ("WHEEL CONTROLS", "WHEEL", 0)],
"color": "#FFC40D",
}, },
] ]
@@ -70,18 +79,12 @@ class StarPilotLayout(Widget):
self._params = Params() self._params = Params()
self._current_panel = StarPilotPanelType.MAIN self._current_panel = StarPilotPanelType.MAIN
self._current_category_idx: int | None = None
self._depth_callback: Callable | None = None self._depth_callback: Callable | None = None
self._settings_layout = None self._settings_layout = None
self._panel_stack: list[tuple[StarPilotPanelType, str]] = [] self._panel_stack: list[tuple[StarPilotPanelType, str]] = []
self._sub_panel_callbacks: dict[str, Callable] = {} 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._panels = { self._panels = {
StarPilotPanelType.MAIN: StarPilotPanelInfo("", None), StarPilotPanelType.MAIN: StarPilotPanelInfo("", None),
@@ -98,78 +101,18 @@ class StarPilotLayout(Widget):
StarPilotPanelType.THEMES: StarPilotPanelInfo(tr_noop("Themes"), StarPilotThemesLayout()), StarPilotPanelType.THEMES: StarPilotPanelInfo(tr_noop("Themes"), StarPilotThemesLayout()),
StarPilotPanelType.VEHICLE: StarPilotPanelInfo(tr_noop("Vehicle Settings"), StarPilotVehicleSettingsLayout()), StarPilotPanelType.VEHICLE: StarPilotPanelInfo(tr_noop("Vehicle Settings"), StarPilotVehicleSettingsLayout()),
StarPilotPanelType.WHEEL: StarPilotPanelInfo(tr_noop("Wheel Controls"), StarPilotWheelLayout()), StarPilotPanelType.WHEEL: StarPilotPanelInfo(tr_noop("Wheel Controls"), StarPilotWheelLayout()),
StarPilotPanelType.DEVELOPER: StarPilotPanelInfo(tr_noop("Developer"), StarPilotDeveloperLayout()),
} }
self._setup_longitudinal_sub_panels() self._setup_longitudinal_sub_panels()
self._setup_sounds_sub_panels() self._setup_sounds_sub_panels()
self._setup_lateral_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 [ self._main_grid = TileGrid(columns=None, padding=20)
StarPilotPanelType.SOUNDS, self._rebuild_grid()
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)
def set_depth_callback(self, callback: Callable): def set_depth_callback(self, callback: Callable):
self._depth_callback = callback self._depth_callback = callback
@@ -180,14 +123,47 @@ class StarPilotLayout(Widget):
def navigate_back(self): def navigate_back(self):
if self._panel_stack: if self._panel_stack:
self._panel_stack.pop() self._panel_stack.pop()
if self._panel_stack:
self._update_sub_panel_visibility() self._update_sub_panel_visibility()
else: self._update_depth()
self._set_current_panel(StarPilotPanelType.MAIN) 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): def _push_sub_panel(self, sub_panel_name: str):
self._panel_stack.append((self._current_panel, sub_panel_name)) self._panel_stack.append((self._current_panel, sub_panel_name))
self._update_sub_panel_visibility() self._update_sub_panel_visibility()
self._update_depth()
def _update_sub_panel_visibility(self): def _update_sub_panel_visibility(self):
if self._current_panel == StarPilotPanelType.LONGITUDINAL: if self._current_panel == StarPilotPanelType.LONGITUDINAL:
@@ -202,6 +178,24 @@ class StarPilotLayout(Widget):
current_sub = self._get_current_sub_panel() current_sub = self._get_current_sub_panel()
if hasattr(sounds, '_navigate_to'): if hasattr(sounds, '_navigate_to'):
sounds._current_sub_panel = current_sub 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: def _get_current_sub_panel(self) -> str:
if self._panel_stack and self._panel_stack[-1][0] == self._current_panel: 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'): if lateral and hasattr(lateral, 'set_navigate_callback'):
lateral.set_navigate_callback(self._push_sub_panel) lateral.set_navigate_callback(self._push_sub_panel)
def _on_tuning_level_changed(self, index: int): def _setup_navigation_sub_panels(self):
self._params.put_nonblocking("TuningLevel", index) nav = self._panels[StarPilotPanelType.NAVIGATION].instance
if self._settings_layout: if nav and hasattr(nav, 'set_navigate_callback'):
self._settings_layout.refresh_developer_visibility() nav.set_navigate_callback(self._push_sub_panel)
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 _rebuild_main_scroller(self, tuning_level: int): def _setup_maps_sub_panels(self):
tuning_levels = [tr("Minimal"), tr("Standard"), tr("Advanced"), tr("Developer")] maps = self._panels[StarPilotPanelType.MAPS].instance
if maps and hasattr(maps, 'set_navigate_callback'):
maps.set_navigate_callback(self._push_sub_panel)
items = [ def _setup_developer_sub_panels(self):
multiple_button_item( developer = self._panels[StarPilotPanelType.DEVELOPER].instance
tr_noop("Tuning Level"), if developer and hasattr(developer, 'set_navigate_callback'):
tr_noop( developer.set_navigate_callback(self._push_sub_panel)
"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" def _rebuild_grid(self):
"Standard - Recommended for most users for a balanced experience\n" self._main_grid.clear()
"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,
),
]
panel_type_map = { panel_type_map = {
"SOUNDS": StarPilotPanelType.SOUNDS, "SOUNDS": StarPilotPanelType.SOUNDS,
@@ -268,28 +249,57 @@ class StarPilotLayout(Widget):
"THEMES": StarPilotPanelType.THEMES, "THEMES": StarPilotPanelType.THEMES,
"VEHICLE": StarPilotPanelType.VEHICLE, "VEHICLE": StarPilotPanelType.VEHICLE,
"WHEEL": StarPilotPanelType.WHEEL, "WHEEL": StarPilotPanelType.WHEEL,
"DEVELOPER": StarPilotPanelType.DEVELOPER,
} }
for cat in self.CATEGORIES: if self._current_category_idx is None:
filtered_buttons = [] # Main Categories Grid
for btn_label, panel_key, min_level in cat["buttons"]: for i, cat in enumerate(self.CATEGORIES):
if tuning_level >= min_level: visible_buttons = cat["buttons"]
panel_type = panel_type_map[panel_key] if not visible_buttons:
callback = lambda p=panel_type: self._set_current_panel(p) continue
filtered_buttons.append((tr(btn_label), callback))
if filtered_buttons: def on_click(idx=i):
full_icon_path = f"{STARPILOT_ICONS_DIR}/{cat['icon']}" cat_info = self.CATEGORIES[idx]
item = category_buttons_item( 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"]), title=tr(cat["title"]),
buttons=filtered_buttons, desc=tr(cat["desc"]),
description=tr(cat["desc"]), icon_path=f"{STARPILOT_ICONS_DIR}/{cat['icon']}",
icon=full_icon_path, on_click=on_click,
starpilot_icon=True, 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): def _set_current_panel(self, panel_type: StarPilotPanelType):
if panel_type != self._current_panel: if panel_type != self._current_panel:
@@ -298,14 +308,14 @@ class StarPilotLayout(Widget):
self._current_panel = panel_type self._current_panel = panel_type
if panel_type != StarPilotPanelType.MAIN: if panel_type != StarPilotPanelType.MAIN:
self._panels[panel_type].instance.show_event() self._panels[panel_type].instance.show_event()
else:
self._rebuild_grid()
depth = 1 if panel_type != StarPilotPanelType.MAIN else 0 self._update_depth()
if self._depth_callback:
self._depth_callback(depth)
def _render(self, rect: rl.Rectangle): def _render(self, rect: rl.Rectangle):
if self._current_panel == StarPilotPanelType.MAIN: if self._current_panel == StarPilotPanelType.MAIN:
self._main_scroller.render(rect) self._main_grid.render(rect)
else: else:
panel = self._panels[self._current_panel] panel = self._panels[self._current_panel]
if panel.instance: if panel.instance:
@@ -313,9 +323,7 @@ class StarPilotLayout(Widget):
def show_event(self): def show_event(self):
super().show_event() super().show_event()
if self._current_panel == StarPilotPanelType.MAIN: if self._current_panel != StarPilotPanelType.MAIN:
self._main_scroller.show_event()
else:
self._panels[self._current_panel].instance.show_event() self._panels[self._current_panel].instance.show_event()
def hide_event(self): def hide_event(self):
+153 -14
View File
@@ -1,25 +1,164 @@
from __future__ import annotations 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.lib.multilang import tr, tr_noop
from openpilot.system.ui.widgets.list_view import button_item from openpilot.system.ui.widgets import DialogResult
from openpilot.system.ui.widgets.scroller_tici import Scroller 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 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): class StarPilotMapsLayout(StarPilotPanel):
def __init__(self): def __init__(self):
super().__init__() 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 = [ self.CATEGORIES = [
button_item( {"title": tr_noop("Download Maps"), "type": "hub", "on_click": self._on_download, "icon": "toggle_icons/icon_map.png", "color": "#8CBF26"},
tr_noop("Download Map Data"), {"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"},
lambda: tr("DOWNLOAD"), {"title": tr_noop("Countries"), "panel": "countries", "icon": "toggle_icons/icon_map.png", "color": "#8CBF26"},
tr_noop("<b>Download map data</b> for the Speed Limit Controller."), {"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"},
button_item( {"title": tr_noop("Remove Maps"), "type": "hub", "on_click": self._on_remove, "icon": "toggle_icons/icon_map.png", "color": "#8CBF26"},
tr_noop("Manage Map Data"),
lambda: tr("MANAGE"),
tr_noop("<b>View or delete downloaded map data.</b>"),
),
] ]
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))
@@ -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
@@ -1,25 +1,134 @@
from __future__ import annotations 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.lib.multilang import tr, tr_noop
from openpilot.system.ui.widgets.list_view import toggle_item, button_item from openpilot.system.ui.widgets import DialogResult
from openpilot.system.ui.widgets.scroller_tici import Scroller 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 from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import StarPilotPanel
class StarPilotNavigationLayout(StarPilotPanel): class StarPilotNavigationLayout(StarPilotPanel):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self._sub_panels = {
items = [ "mapbox": StarPilotMapboxLayout(),
toggle_item( }
tr_noop("Use Live Map Data"), self.CATEGORIES = [
tr_noop("<b>Use live map data</b> for real-time navigation updates."), {"title": tr_noop("Mapbox Credentials"), "panel": "mapbox", "icon": "toggle_icons/icon_navigate.png", "color": "#8CBF26"},
False, {"title": tr_noop("Setup Instructions"), "type": "hub", "on_click": self._on_setup, "icon": "toggle_icons/icon_navigate.png", "color": "#8CBF26"},
), {
button_item( "title": tr_noop("Speed Limit Filler"),
tr_noop("Navigation Settings"), "type": "toggle",
lambda: tr("MANAGE"), "get_state": lambda: self._params.get_bool("SpeedLimitFiller"),
tr_noop("<b>Configure navigation-specific options</b> like route preferences."), "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))
@@ -6,8 +6,10 @@ from enum import IntEnum
import pyray as rl import pyray as rl
from openpilot.common.params import Params from openpilot.common.params import Params
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets import Widget
class StarPilotPanelType(IntEnum): class StarPilotPanelType(IntEnum):
MAIN = 0 MAIN = 0
SOUNDS = 1 SOUNDS = 1
@@ -23,23 +25,30 @@ class StarPilotPanelType(IntEnum):
THEMES = 11 THEMES = 11
VEHICLE = 12 VEHICLE = 12
WHEEL = 13 WHEEL = 13
DEVELOPER = 14
@dataclass @dataclass
class StarPilotPanelInfo: class StarPilotPanelInfo:
name: str name: str
instance: Widget instance: Widget
from openpilot.selfdrive.ui.layouts.settings.starpilot.metro import TileGrid, HubTile, ToggleTile, ValueTile
class StarPilotPanel(Widget): class StarPilotPanel(Widget):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self._params = Params() self._params = Params()
self._params_memory = Params(memory=True) self._params_memory = Params(memory=True)
self._tuning_levels: dict[str, int] = {}
self._navigate_callback: Callable | None = None self._navigate_callback: Callable | None = None
self._back_callback: Callable | None = None self._back_callback: Callable | None = None
self._current_sub_panel = "" self._current_sub_panel = ""
self._sub_panels: dict[str, Widget] = {} self._sub_panels: dict[str, Widget] = {}
self._scroller = None self._scroller = None
self._tile_grid = None
self.CATEGORIES = []
def set_navigate_callback(self, callback: Callable): def set_navigate_callback(self, callback: Callable):
self._navigate_callback = callback self._navigate_callback = callback
@@ -47,8 +56,42 @@ class StarPilotPanel(Widget):
def set_back_callback(self, callback: Callable): def set_back_callback(self, callback: Callable):
self._back_callback = callback self._back_callback = callback
def set_tuning_levels(self, levels: dict[str, int]): def _rebuild_grid(self):
self._tuning_levels = levels 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): def _navigate_to(self, sub_panel: str):
self._current_sub_panel = sub_panel self._current_sub_panel = sub_panel
@@ -63,11 +106,14 @@ class StarPilotPanel(Widget):
def _render(self, rect: rl.Rectangle): def _render(self, rect: rl.Rectangle):
if self._current_sub_panel and self._current_sub_panel in self._sub_panels: if self._current_sub_panel and self._current_sub_panel in self._sub_panels:
self._sub_panels[self._current_sub_panel].render(rect) self._sub_panels[self._current_sub_panel].render(rect)
elif self.CATEGORIES and self._tile_grid:
self._tile_grid.render(rect)
elif self._scroller: elif self._scroller:
self._scroller.render(rect) self._scroller.render(rect)
def show_event(self): def show_event(self):
super().show_event() super().show_event()
self._rebuild_grid()
if self._current_sub_panel and self._current_sub_panel in self._sub_panels: if self._current_sub_panel and self._current_sub_panel in self._sub_panels:
self._sub_panels[self._current_sub_panel].show_event() self._sub_panels[self._current_sub_panel].show_event()
elif self._scroller: elif self._scroller:
+116 -163
View File
@@ -4,12 +4,14 @@ from pathlib import Path
from openpilot.common.basedir import BASEDIR from openpilot.common.basedir import BASEDIR
from openpilot.frogpilot.common.frogpilot_variables import ACTIVE_THEME_PATH 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.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 import DialogResult
from openpilot.system.ui.widgets.scroller_tici import Scroller from openpilot.system.ui.widgets.selection_dialog import SelectionDialog
from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.selfdrive.ui.lib.starpilot_state import starpilot_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.panel import StarPilotPanel
from openpilot.selfdrive.ui.layouts.settings.starpilot.metro import TileGrid, ToggleTile, SliderDialog
class StarPilotSoundsLayout(StarPilotPanel): class StarPilotSoundsLayout(StarPilotPanel):
VOLUME_KEYS = [ VOLUME_KEYS = [
@@ -32,39 +34,36 @@ class StarPilotSoundsLayout(StarPilotPanel):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
items = [
button_item(
tr_noop("Alert Volume Controller"),
lambda: tr("MANAGE"),
tr_noop("<b>Set how loud each type of openpilot alert is</b> 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("<b>Optional StarPilot alerts</b> 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 = { self._sub_panels = {
"volume_control": StarPilotVolumeControlLayout(), "volume_control": StarPilotVolumeControlLayout(),
"custom_alerts": StarPilotCustomAlertsLayout(), "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(): for name, panel in self._sub_panels.items():
if hasattr(panel, 'set_navigate_callback'): if hasattr(panel, 'set_navigate_callback'):
panel.set_navigate_callback(self._navigate_to) panel.set_navigate_callback(self._navigate_to)
if hasattr(panel, 'set_back_callback'): if hasattr(panel, 'set_back_callback'):
panel.set_back_callback(self._go_back) panel.set_back_callback(self._go_back)
self._rebuild_grid()
def refresh_visibility(self): def refresh_visibility(self):
for panel in self._sub_panels.values(): for panel in self._sub_panels.values():
if hasattr(panel, 'refresh_visibility'): if hasattr(panel, 'refresh_visibility'):
@@ -72,53 +71,13 @@ class StarPilotSoundsLayout(StarPilotPanel):
class StarPilotVolumeControlLayout(StarPilotPanel): class StarPilotVolumeControlLayout(StarPilotPanel):
VOLUME_INFO = { VOLUME_INFO = {
"DisengageVolume": { "DisengageVolume": {"title": tr_noop("Disengage Volume"), "icon": "toggle_icons/icon_mute.png", "min": 0},
"title": tr_noop("Disengage Volume"), "EngageVolume": {"title": tr_noop("Engage Volume"), "icon": "toggle_icons/icon_green_light.png", "min": 0},
"desc": tr_noop( "PromptVolume": {"title": tr_noop("Prompt Volume"), "icon": "toggle_icons/icon_message.png", "min": 0},
"<b>Set the volume for alerts when openpilot disengages.</b><br><br>Examples include: \"Cruise Fault: Restart the Car\", \"Parking Brake Engaged\", \"Pedal Pressed\"." "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},
"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},
"EngageVolume": {
"title": tr_noop("Engage Volume"),
"desc": tr_noop("<b>Set the volume for the chime when openpilot engages</b>, such as after pressing the \"RESUME\" or \"SET\" steering wheel buttons."),
"min": 0,
},
"PromptVolume": {
"title": tr_noop("Prompt Volume"),
"desc": tr_noop(
"<b>Set the volume for prompts that need attention.</b><br><br>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(
"<b>Set the volume for prompts when openpilot detects driver distraction or unresponsiveness.</b><br><br>Examples include: \"Pay Attention\", \"Touch Steering Wheel\"."
),
"min": 0,
},
"RefuseVolume": {
"title": tr_noop("Refuse Volume"),
"desc": tr_noop(
"<b>Set the volume for alerts when openpilot refuses to engage.</b><br><br>Examples include: \"Brake Hold Active\", \"Door Open\", \"Seatbelt Unlatched\"."
),
"min": 0,
},
"WarningSoftVolume": {
"title": tr_noop("Warning Soft Volume"),
"desc": tr_noop(
"<b>Set the volume for softer warnings about potential risks.</b><br><br>Examples include: \"BRAKE! Risk of Collision\", \"Steering Temporarily Unavailable\"."
),
"min": 25,
},
"WarningImmediateVolume": {
"title": tr_noop("Warning Immediate Volume"),
"desc": tr_noop(
"<b>Set the volume for the loudest warnings that require urgent attention.</b><br><br>Examples include: \"DISENGAGE IMMEDIATELY — Driver Distracted\", \"DISENGAGE IMMEDIATELY — Driver Unresponsive\"."
),
"min": 25,
},
} }
_sound_player_process = None _sound_player_process = None
@@ -126,140 +85,126 @@ class StarPilotVolumeControlLayout(StarPilotPanel):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self._init_sound_player() self._init_sound_player()
volume_labels = {0.0: tr("Muted"), 101.0: tr("Auto")} self.CATEGORIES = []
for i in range(1, 101):
volume_labels[float(i)] = f"{i}%"
items = []
for key in StarPilotSoundsLayout.VOLUME_KEYS: for key in StarPilotSoundsLayout.VOLUME_KEYS:
info = self.VOLUME_INFO[key] info = self.VOLUME_INFO[key]
items.append(
value_button_item( def get_val(k=key):
info["title"], v = self._params.get_int(k, return_default=True, default=100)
key, if v == 0: return tr("Muted")
min_val=info["min"], if v == 101: return tr("Auto")
max_val=101, return f"{v}%"
step=1,
button_text="Test",
button_callback=lambda k=key: self._test_sound(k),
description=info["desc"],
labels=volume_labels,
)
)
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 @classmethod
def _init_sound_player(cls): def _init_sound_player(cls):
if cls._sound_player_process is not None: if cls._sound_player_process is not None: return
return
program = """ program = """
import numpy as np import numpy as np
import sounddevice as sd import sounddevice as sd
import sys import sys
import wave import wave
while True: while True:
try: try:
line = sys.stdin.readline() line = sys.stdin.readline()
if not line: if not line: break
break
path, volume = line.strip().split('|') path, volume = line.strip().split('|')
sound_file = wave.open(path, 'rb') sound_file = wave.open(path, 'rb')
audio = np.frombuffer(sound_file.readframes(sound_file.getnframes()), dtype=np.int16).astype(np.float32) / 32768.0 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.play(audio * float(volume), sound_file.getframerate())
sd.wait() sd.wait()
except Exception: except: pass
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( def _test_sound(self, key: str):
["python3", "-u", "-c", program], base_name = key.replace("Volume", "")
stdin=subprocess.PIPE, if ui_state.started:
stdout=subprocess.DEVNULL, self._params_memory.put("TestAlert", base_name[0].lower() + base_name[1:])
stderr=subprocess.DEVNULL, else:
) self._play_sound_offroad(key)
def _play_sound_offroad(self, key: str): def _play_sound_offroad(self, key: str):
base_name = key.replace("Volume", "") base_name = key.replace("Volume", "")
snake_case = "".join(["_" + c.lower() if c.isupper() else c for c in base_name]).lstrip("_") 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" stock_path = Path(BASEDIR) / "selfdrive" / "assets" / "sounds" / f"{snake_case}.wav"
theme_path = ACTIVE_THEME_PATH / "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 sound_path = theme_path if theme_path.exists() else stock_path
if not sound_path.exists(): if not sound_path.exists(): return
return
volume = self._params.get_int(key, return_default=True, default=100) / 100.0 volume = self._params.get_int(key, return_default=True, default=100) / 100.0
try: try:
self._sound_player_process.stdin.write(f"{sound_path}|{volume}\n".encode()) self._sound_player_process.stdin.write(f"{sound_path}|{volume}\n".encode())
self._sound_player_process.stdin.flush() self._sound_player_process.stdin.flush()
except Exception: except: pass
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)
class StarPilotCustomAlertsLayout(StarPilotPanel): class StarPilotCustomAlertsLayout(StarPilotPanel):
ALERT_INFO = { ALERT_INFO = {
"GoatScream": { "GoatScream": {"title": tr_noop("Goat Scream"), "icon": "toggle_icons/icon_sound.png"},
"title": tr_noop("Goat Scream"), "GreenLightAlert": {"title": tr_noop("Green Light"), "icon": "toggle_icons/icon_green_light.png"},
"desc": tr_noop( "LeadDepartingAlert": {"title": tr_noop("Lead Departure"), "icon": "toggle_icons/icon_steering.png"},
"<b>Play the infamous \"Goat Scream\" when the steering controller reaches its limit.</b> Based on the \"Turn Exceeds Steering Limit\" event." "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"},
},
"GreenLightAlert": {
"title": tr_noop("Green Light Alert"),
"desc": tr_noop(
"<b>Play an alert when the model predicts a red light has turned green.</b><br><br><i><b>Disclaimer</b>: 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.</i>"
),
},
"LeadDepartingAlert": {
"title": tr_noop("Lead Departing Alert"),
"desc": tr_noop("<b>Play an alert when the lead vehicle departs from a stop.</b>"),
},
"LoudBlindspotAlert": {
"title": tr_noop("Loud \"Car Detected in Blindspot\" Alert"),
"desc": tr_noop(
"<b>Play a louder alert if a vehicle is in the blind spot when attempting to change lanes.</b> Based on the \"Car Detected in Blindspot\" event."
),
},
"SpeedLimitChangedAlert": {
"title": tr_noop("Speed Limit Changed Alert"),
"desc": tr_noop("<b>Play an alert when the posted speed limit changes.</b>"),
},
} }
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self._toggle_items = {} self.CATEGORIES = []
for key in StarPilotSoundsLayout.CUSTOM_ALERTS_KEYS: for key in StarPilotSoundsLayout.CUSTOM_ALERTS_KEYS:
info = self.ALERT_INFO[key] info = self.ALERT_INFO[key]
self._toggle_items[key] = toggle_item( self.CATEGORIES.append({
info["title"], "title": info["title"],
info["desc"], "type": "toggle",
self._params.get_bool(key), "get_state": lambda k=key: self._params.get_bool(k),
callback=lambda s, k=key: self._params.put_bool(k, s), "set_state": lambda s, k=key: self._params.put_bool(k, s),
) "icon": info["icon"],
"color": "#FF0097",
self._scroller = Scroller(list(self._toggle_items.values()), line_separator=True, spacing=0) "key": key # Store for visibility check
})
self._rebuild_grid()
def refresh_visibility(self): def refresh_visibility(self):
current_level = int(self._params.get("TuningLevel", return_default=True, default="1") or "1") self._rebuild_grid()
for key, item in self._toggle_items.items():
min_level = self._tuning_levels.get(key, 0) def _rebuild_grid(self):
visible = current_level >= min_level # 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": if key == "LoudBlindspotAlert":
visible &= starpilot_state.car_state.hasBSM visible &= starpilot_state.car_state.hasBSM
@@ -268,4 +213,12 @@ class StarPilotCustomAlertsLayout(StarPilotPanel):
starpilot_state.car_state.hasOpenpilotLongitudinal and self._params.get_bool("SpeedLimitController") 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)
+150 -20
View File
@@ -1,30 +1,160 @@
from __future__ import annotations 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.lib.multilang import tr, tr_noop
from openpilot.system.ui.widgets.list_view import button_item from openpilot.system.ui.widgets import DialogResult
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.panel import StarPilotPanel
class StarPilotThemesLayout(StarPilotPanel): class StarPilotThemesLayout(StarPilotPanel):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
items = [ self._sub_panels = {
button_item( "personalize": StarPilotPersonalizeLayout(),
tr_noop("Select Theme"), }
lambda: tr("SELECT"),
tr_noop("<b>Select a theme</b> for the StarPilot interface."), self.CATEGORIES = [
), {
button_item( "title": tr_noop("Personalize openpilot"),
tr_noop("Holiday Themes"), "panel": "personalize",
lambda: tr("MANAGE"), "icon": "toggle_icons/icon_frog.png",
tr_noop("<b>Enable or disable holiday-themed visuals.</b>"), "color": "#A200FF",
), "desc": tr_noop("Customize the overall look and feel."),
button_item( },
tr_noop("Custom Theme"), {
lambda: tr("MANAGE"), "title": tr_noop("Holiday Themes"),
tr_noop("<b>Create or import a custom theme.</b>"), "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))
@@ -1,30 +1,163 @@
from __future__ import annotations 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.lib.multilang import tr, tr_noop
from openpilot.system.ui.widgets.list_view import button_item from openpilot.system.ui.widgets import DialogResult
from openpilot.system.ui.widgets.scroller_tici import Scroller 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 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): class StarPilotUtilitiesLayout(StarPilotPanel):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.CATEGORIES = [
items = [ {
button_item( "title": tr_noop("Debug Mode"),
tr_noop("Update StarPilot"), "type": "toggle",
lambda: tr("CHECK"), "get_state": lambda: self._params.get_bool("DebugMode"),
tr_noop("<b>Check for updates</b> and update StarPilot to the latest version."), "set_state": lambda s: self._params.put_bool("DebugMode", s),
), "color": "#FA6800",
button_item( },
tr_noop("Reset Settings"), {"title": tr_noop("Flash Panda"), "type": "hub", "on_click": self._on_flash_panda, "color": "#FA6800"},
lambda: tr("RESET"), {
tr_noop("<b>Reset all StarPilot settings</b> to their default values."), "title": tr_noop("Force Drive State"),
), "type": "value",
button_item( "get_value": self._get_force_drive_state,
tr_noop("View Logs"), "on_click": self._on_force_drive_state,
lambda: tr("VIEW"), "color": "#FA6800",
tr_noop("<b>View StarPilot logs</b> for debugging."), },
), {
"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))
@@ -1,20 +1,516 @@
from __future__ import annotations 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.lib.multilang import tr, tr_noop
from openpilot.system.ui.widgets.list_view import button_item from openpilot.system.ui.widgets import DialogResult
from openpilot.system.ui.widgets.scroller_tici import Scroller 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.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): class StarPilotVehicleSettingsLayout(StarPilotPanel):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self._sub_panels = {
"gm": StarPilotGMVehicleLayout(),
"hkg": StarPilotHKGVehicleLayout(),
"subaru": StarPilotSubaruVehicleLayout(),
"toyota": StarPilotToyotaVehicleLayout(),
"info": StarPilotVehicleInfoLayout(),
}
items = [ self.CATEGORIES = [
button_item( {
tr_noop("Vehicle Settings"), "title": tr_noop("Car Make"),
lambda: tr("MANAGE"), "type": "value",
tr_noop("<b>Configure car-specific options</b> like model name and features."), "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()
@@ -1,40 +1,509 @@
from __future__ import annotations 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.lib.multilang import tr, tr_noop
from openpilot.system.ui.widgets.list_view import button_item from openpilot.system.ui.widgets import DialogResult
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.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): class StarPilotVisualsLayout(StarPilotPanel):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self._sub_panels = {
items = [ "advanced": StarPilotAdvancedVisualsLayout(),
button_item( "widgets": StarPilotVisualWidgetsLayout(),
tr_noop("Advanced UI Controls"), "model": StarPilotModelUILayout(),
lambda: tr("MANAGE"), "navigation": StarPilotNavigationVisualsLayout(),
tr_noop("<b>Advanced visual changes</b> to fine-tune how the driving screen looks."), "qol": StarPilotVisualQOLLayout(),
), }
button_item( self.CATEGORIES = [
tr_noop("Driving Screen Widgets"), {"title": tr_noop("Advanced UI Controls"), "panel": "advanced", "icon": "toggle_icons/icon_advanced_device.png", "color": "#A200FF"},
lambda: tr("MANAGE"), {"title": tr_noop("Driving Screen Widgets"), "panel": "widgets", "icon": "toggle_icons/icon_display.png", "color": "#A200FF"},
tr_noop("<b>Custom StarPilot widgets</b> for the driving screen."), {"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"},
button_item( {"title": tr_noop("Quality of Life"), "panel": "qol", "icon": "toggle_icons/icon_quality_of_life.png", "color": "#A200FF"},
tr_noop("Model UI"),
lambda: tr("MANAGE"),
tr_noop("<b>Model visualizations</b> for the driving path, lane lines, path edges, and road edges."),
),
button_item(
tr_noop("Navigation Widgets"),
lambda: tr("MANAGE"),
tr_noop("<b>Speed limits, and other navigation widgets.</b>"),
),
button_item(
tr_noop("Quality of Life"),
lambda: tr("MANAGE"),
tr_noop("<b>Miscellaneous visual changes</b> to fine-tune how the driving screen looks."),
),
] ]
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))
@@ -1,20 +1,107 @@
from __future__ import annotations 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.lib.multilang import tr, tr_noop
from openpilot.system.ui.widgets.list_view import button_item from openpilot.system.ui.widgets import DialogResult
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.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): class StarPilotWheelLayout(StarPilotPanel):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.CATEGORIES = [
items = [ {
button_item( "title": tr_noop("Remap Cancel Button"),
tr_noop("Wheel Controls"), "type": "toggle",
lambda: tr("MANAGE"), "get_state": lambda: self._params.get_bool("RemapCancelToDistance"),
tr_noop("<b>Configure steering wheel button mappings</b> for custom controls."), "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)
+204 -14
View File
@@ -1,9 +1,12 @@
import pyray as rl import pyray as rl
import socket
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from collections.abc import Callable from collections.abc import Callable
from cereal import log from cereal import log
from openpilot.common.params import Params
from openpilot.selfdrive.ui.ui_state import ui_state 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.application import gui_app, FontWeight, MousePos, FONT_SCALE
from openpilot.system.ui.lib.multilang import tr, tr_noop from openpilot.system.ui.lib.multilang import tr, tr_noop
from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.lib.text_measure import measure_text_cached
@@ -18,6 +21,11 @@ FONT_SIZE = 35
SETTINGS_BTN = rl.Rectangle(50, 35, 200, 117) SETTINGS_BTN = rl.Rectangle(50, 35, 200, 117)
HOME_BTN = rl.Rectangle(60, 860, 180, 180) 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 ThermalStatus = log.DeviceState.ThermalStatus
NetworkType = log.DeviceState.NetworkType NetworkType = log.DeviceState.NetworkType
@@ -73,11 +81,34 @@ class Sidebar(Widget):
self._connect_status = MetricData(tr_noop("CONNECT"), tr_noop("OFFLINE"), Colors.WARNING) self._connect_status = MetricData(tr_noop("CONNECT"), tr_noop("OFFLINE"), Colors.WARNING)
self._recording_audio = False 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._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._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._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_img = gui_app.texture("icons/microphone.png", 30, 30)
self._mic_indicator_rect = rl.Rectangle(0, 0, 0, 0) 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_regular = gui_app.font(FontWeight.NORMAL)
self._font_bold = gui_app.font(FontWeight.SEMI_BOLD) self._font_bold = gui_app.font(FontWeight.SEMI_BOLD)
@@ -86,8 +117,7 @@ class Sidebar(Widget):
self._on_flag_click: Callable | None = None self._on_flag_click: Callable | None = None
self._open_settings_callback: Callable | None = None self._open_settings_callback: Callable | None = None
def set_callbacks(self, on_settings: Callable | None = None, on_flag: Callable | None = None, def set_callbacks(self, on_settings: Callable | None = None, on_flag: Callable | None = None, open_settings: Callable | None = None):
open_settings: Callable | None = None):
self._on_settings_click = on_settings self._on_settings_click = on_settings
self._on_flag_click = on_flag self._on_flag_click = on_flag
self._open_settings_callback = open_settings self._open_settings_callback = open_settings
@@ -102,6 +132,29 @@ class Sidebar(Widget):
def _update_state(self): def _update_state(self):
sm = ui_state.sm 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']: if not sm.updated['deviceState']:
return return
@@ -112,6 +165,19 @@ class Sidebar(Widget):
self._update_temperature_status(device_state) self._update_temperature_status(device_state)
self._update_connection_status(device_state) self._update_connection_status(device_state)
self._update_panda_status() 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): def _update_network_status(self, device_state):
self._net_type = NETWORK_TYPES.get(device_state.networkType.raw, tr_noop("Unknown")) self._net_type = NETWORK_TYPES.get(device_state.networkType.raw, tr_noop("Unknown"))
@@ -143,6 +209,82 @@ class Sidebar(Widget):
else: else:
self._panda_status.update(tr_noop("VEHICLE"), tr_noop("ONLINE"), Colors.GOOD) 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): def _handle_mouse_release(self, mouse_pos: MousePos):
if rl.check_collision_point_rec(mouse_pos, SETTINGS_BTN): if rl.check_collision_point_rec(mouse_pos, SETTINGS_BTN):
if self._on_settings_click: 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): elif self._recording_audio and rl.check_collision_point_rec(mouse_pos, self._mic_indicator_rect):
if self._open_settings_callback: if self._open_settings_callback:
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): def _draw_buttons(self, rect: rl.Rectangle):
mouse_pos = rl.get_mouse_position() mouse_pos = rl.get_mouse_position()
mouse_down = self.is_pressed and rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT) 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) 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 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) 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 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 # Microphone button
if self._recording_audio: 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 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_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), rl.draw_texture(
int(self._mic_indicator_rect.y + (self._mic_indicator_rect.height - self._mic_img.height) / 2), Colors.WHITE) 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): 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 # Signal strength dots
x_start = rect.x + 58 x_start = rect.x + 58
y_pos = rect.y + 196 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) 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): 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: for metric, y_offset in metrics:
self._draw_metric(rect, metric, rect.y + y_offset) self._draw_metric(rect, metric, rect.y + y_offset)
@@ -222,8 +415,5 @@ class Sidebar(Widget):
for text in labels: for text in labels:
text_size = measure_text_cached(self._font_bold, text, FONT_SIZE) text_size = measure_text_cached(self._font_bold, text, FONT_SIZE)
text_y += text_size.y text_y += text_size.y
text_pos = rl.Vector2( text_pos = rl.Vector2(metric_rect.x + 22 + (metric_rect.width - 22 - text_size.x) / 2, text_y)
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) rl.draw_text_ex(self._font_bold, text, text_pos, FONT_SIZE, 0, Colors.WHITE)
+31 -13
View File
@@ -32,9 +32,9 @@ SELFDRIVE_UNRESPONSIVE_TIMEOUT = 10 # Seconds
# Constants # Constants
ALERT_COLORS = { 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.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) self.font_bold: rl.Font = gui_app.font(FontWeight.BOLD)
# font size is set dynamically # font size is set dynamically
self._full_text1_label = Label("", font_size=0, font_weight=FontWeight.BOLD, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, self._full_text1_label = Label(
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, font_size=0,
text_alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP) 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: def get_alert(self, sm: messaging.SubMaster) -> Alert | None:
"""Generate the current alert based on selfdrive state.""" """Generate the current alert based on selfdrive state."""
ss = sm['selfdriveState'] ss = sm['selfdriveState']
t = ui_state.frogpilot_toggles
# Check if selfdriveState messages have stopped arriving # Check if selfdriveState messages have stopped arriving
recv_frame = sm.recv_frame['selfdriveState'] recv_frame = sm.recv_frame['selfdriveState']
@@ -93,12 +100,16 @@ class AlertRenderer(Widget):
# 1. Never received selfdriveState since going onroad # 1. Never received selfdriveState since going onroad
waiting_for_startup = recv_frame < ui_state.started_frame waiting_for_startup = recv_frame < ui_state.started_frame
if waiting_for_startup and time_since_onroad > 5: if waiting_for_startup and time_since_onroad > 5:
if t.get("force_onroad", False):
return None
return ALERT_STARTUP_PENDING return ALERT_STARTUP_PENDING
# 2. Lost communication with selfdriveState after receiving it # 2. Lost communication with selfdriveState after receiving it
if TICI and not waiting_for_startup: if TICI and not waiting_for_startup:
ss_missing = time.monotonic() - sm.recv_time['selfdriveState'] ss_missing = time.monotonic() - sm.recv_time['selfdriveState']
if ss_missing > SELFDRIVE_STATE_TIMEOUT: 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: if ss.enabled and (ss_missing - SELFDRIVE_STATE_TIMEOUT) < SELFDRIVE_UNRESPONSIVE_TIMEOUT:
return ALERT_CRITICAL_TIMEOUT return ALERT_CRITICAL_TIMEOUT
return ALERT_CRITICAL_REBOOT return ALERT_CRITICAL_REBOOT
@@ -107,12 +118,23 @@ class AlertRenderer(Widget):
if ss.alertSize == 0: if ss.alertSize == 0:
return None 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 # Don't get old alert
if recv_frame < ui_state.started_frame: if recv_frame < ui_state.started_frame:
return None return None
# Return current alert # 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): def _render(self, rect: rl.Rectangle):
alert = self.get_alert(ui_state.sm) alert = self.get_alert(ui_state.sm)
@@ -123,10 +145,7 @@ class AlertRenderer(Widget):
self._draw_background(alert_rect, alert) self._draw_background(alert_rect, alert)
text_rect = rl.Rectangle( text_rect = rl.Rectangle(
alert_rect.x + ALERT_PADDING, alert_rect.x + ALERT_PADDING, alert_rect.y + ALERT_PADDING, alert_rect.width - 2 * ALERT_PADDING, alert_rect.height - 2 * 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) self._draw_text(text_rect, alert)
@@ -135,8 +154,7 @@ class AlertRenderer(Widget):
return rect return rect
h = ALERT_HEIGHTS.get(size, rect.height) h = ALERT_HEIGHTS.get(size, rect.height)
return rl.Rectangle(rect.x + ALERT_MARGIN, rect.y + rect.height - h + ALERT_MARGIN, return rl.Rectangle(rect.x + ALERT_MARGIN, rect.y + rect.height - h + ALERT_MARGIN, rect.width - ALERT_MARGIN * 2, h - ALERT_MARGIN * 2)
rect.width - ALERT_MARGIN * 2, h - ALERT_MARGIN * 2)
def _draw_background(self, rect: rl.Rectangle, alert: Alert) -> None: def _draw_background(self, rect: rl.Rectangle, alert: Alert) -> None:
color = ALERT_COLORS.get(alert.status, ALERT_COLORS[AlertStatus.normal]) color = ALERT_COLORS.get(alert.status, ALERT_COLORS[AlertStatus.normal])
+19 -27
View File
@@ -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.hud_renderer import HudRenderer
from openpilot.selfdrive.ui.onroad.model_renderer import ModelRenderer from openpilot.selfdrive.ui.onroad.model_renderer import ModelRenderer
from openpilot.selfdrive.ui.onroad.cameraview import CameraView 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.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.camera import DEVICE_CAMERAS, DeviceCameraConfig, view_frame_from_device_frame
from openpilot.common.transformations.orientation import rot_from_euler from openpilot.common.transformations.orientation import rot_from_euler
@@ -48,6 +50,8 @@ class AugmentedRoadView(CameraView):
self._hud_renderer = HudRenderer() self._hud_renderer = HudRenderer()
self.alert_renderer = AlertRenderer() self.alert_renderer = AlertRenderer()
self.driver_state_renderer = DriverStateRenderer() self.driver_state_renderer = DriverStateRenderer()
self._frogpilot_overlay = FrogPilotOverlay(self.model_renderer, self._hud_renderer, self.driver_state_renderer)
self._developer_sidebar = DeveloperSidebar()
# debug # debug
self._pm = messaging.PubMaster(['uiDebug']) self._pm = messaging.PubMaster(['uiDebug'])
@@ -73,12 +77,7 @@ class AugmentedRoadView(CameraView):
# Enable scissor mode to clip all rendering within content rectangle boundaries # Enable scissor mode to clip all rendering within content rectangle boundaries
# This creates a rendering viewport that prevents graphics from drawing outside the border # This creates a rendering viewport that prevents graphics from drawing outside the border
rl.begin_scissor_mode( rl.begin_scissor_mode(int(self._content_rect.x), int(self._content_rect.y), int(self._content_rect.width), int(self._content_rect.height))
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 # Render the base camera view
super()._render(rect) super()._render(rect)
@@ -89,8 +88,15 @@ class AugmentedRoadView(CameraView):
self.alert_renderer.render(self._content_rect) self.alert_renderer.render(self._content_rect)
self.driver_state_renderer.render(self._content_rect) self.driver_state_renderer.render(self._content_rect)
# Custom UI extension point - add custom overlays here # StarPilot overlays (speed limits, compass, turn signals, etc.)
# Use self._content_rect for positioning within camera bounds 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 # End clipping region
rl.end_scissor_mode() rl.end_scissor_mode()
@@ -109,14 +115,13 @@ class AugmentedRoadView(CameraView):
def _handle_mouse_release(self, _): def _handle_mouse_release(self, _):
# We only call click callback on press if not interacting with HUD # 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): def _draw_border(self, rect: rl.Rectangle):
rl.draw_rectangle_lines_ex(rect, UI_BORDER_SIZE, rl.BLACK) rl.draw_rectangle_lines_ex(rect, UI_BORDER_SIZE, rl.BLACK)
border_roundness = 0.12 border_roundness = 0.12
border_color = BORDER_COLORS.get(ui_state.status, BORDER_COLORS[UIStatus.DISENGAGED]) 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, 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)
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) rl.draw_rectangle_rounded_lines_ex(border_rect, border_roundness, 10, UI_BORDER_SIZE, border_color)
def _switch_stream_if_needed(self, sm): def _switch_stream_if_needed(self, sm):
@@ -160,12 +165,7 @@ class AugmentedRoadView(CameraView):
def _calc_frame_matrix(self, rect: rl.Rectangle) -> np.ndarray: def _calc_frame_matrix(self, rect: rl.Rectangle) -> np.ndarray:
# Check if we can use cached matrix # Check if we can use cached matrix
cache_key = ( cache_key = (ui_state.sm.recv_frame['liveCalibration'], self._content_rect.width, self._content_rect.height, self.stream_type)
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: if cache_key == self._matrix_cache_key and self._cached_matrix is not None:
return self._cached_matrix return self._cached_matrix
@@ -202,17 +202,9 @@ class AugmentedRoadView(CameraView):
# Cache the computed transformation matrix to avoid recalculations # Cache the computed transformation matrix to avoid recalculations
self._matrix_cache_key = cache_key self._matrix_cache_key = cache_key
self._cached_matrix = np.array([ 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]])
[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([ 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]])
[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) self.model_renderer.set_transform(video_transform @ calib_transform)
return self._cached_matrix return self._cached_matrix
+52 -20
View File
@@ -10,17 +10,44 @@ from openpilot.system.ui.widgets import Widget
AlertSize = log.SelfdriveState.AlertSize AlertSize = log.SelfdriveState.AlertSize
# Default 3D coordinates for face keypoints as a NumPy array # Default 3D coordinates for face keypoints as a NumPy array
DEFAULT_FACE_KPTS_3D = np.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], [-5.98, -51.20, 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], [-17.64, -49.14, 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], [-23.81, -46.40, 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], [-29.98, -40.91, 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], [-32.04, -37.49, 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], [-34.10, -32.00, 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], [-36.16, -21.03, 8.00],
[-5.98, -51.20, 8.00], [-36.16, 6.40, 8.00],
], dtype=np.float32) [-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 # UI constants
BTN_SIZE = 192 BTN_SIZE = 192
@@ -39,6 +66,7 @@ ARC_ANGLES = np.linspace(0.0, np.pi, ARC_POINT_COUNT, dtype=np.float32)
@dataclass @dataclass
class ArcData: class ArcData:
"""Data structure for arc rendering parameters.""" """Data structure for arc rendering parameters."""
x: float x: float
y: float y: float
width: float width: float
@@ -78,8 +106,7 @@ class DriverStateRenderer(Widget):
self.engaged_color = rl.Color(26, 242, 66, 255) self.engaged_color = rl.Color(26, 242, 66, 255)
self.disengaged_color = rl.Color(139, 139, 139, 255) self.disengaged_color = rl.Color(139, 139, 139, 255)
self.set_visible(lambda: (ui_state.sm["selfdriveState"].alertSize == AlertSize.none and self.set_visible(lambda: ui_state.sm["selfdriveState"].alertSize == AlertSize.none and ui_state.sm.recv_frame["driverStateV2"] > ui_state.started_frame)
ui_state.sm.recv_frame["driverStateV2"] > ui_state.started_frame))
def _render(self, rect): def _render(self, rect):
# Set opacity based on active state # Set opacity based on active state
@@ -106,6 +133,15 @@ class DriverStateRenderer(Widget):
if self.v_arc_data: 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) 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): def _update_state(self):
"""Update the driver monitoring state based on model data""" """Update the driver monitoring state based on model data"""
sm = ui_state.sm sm = ui_state.sm
@@ -181,20 +217,16 @@ class DriverStateRenderer(Widget):
# Horizontal arc # Horizontal arc
h_width = abs(delta_x) h_width = abs(delta_x)
self.h_arc_data = self._calculate_arc_data( self.h_arc_data = self._calculate_arc_data(
delta_x, h_width, self.position_x, self.position_y - ARC_LENGTH / 2, 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
self.driver_pose_sins[1], self.driver_pose_diff[1], is_horizontal=True
) )
# Vertical arc # Vertical arc
v_height = abs(delta_y) v_height = abs(delta_y)
self.v_arc_data = self._calculate_arc_data( self.v_arc_data = self._calculate_arc_data(
delta_y, v_height, self.position_x - ARC_LENGTH / 2, self.position_y, 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
self.driver_pose_sins[0], self.driver_pose_diff[0], is_horizontal=False
) )
def _calculate_arc_data( def _calculate_arc_data(self, delta: float, size: float, x: float, y: float, sin_val: float, diff_val: float, is_horizontal: bool):
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.""" """Calculate arc data and pre-compute arc points."""
if size <= 0: if size <= 0:
return None return None
+74 -4
View File
@@ -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.lib.application import gui_app
from openpilot.system.ui.widgets import Widget 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): class ExpButton(Widget):
def __init__(self, button_size: int, icon_size: int): def __init__(self, button_size: int, icon_size: int):
super().__init__() super().__init__()
self._params = Params() self._params = Params()
self._params_memory = Params(memory=True)
self._experimental_mode: bool = False self._experimental_mode: bool = False
self._engageable: bool = False self._engageable: bool = False
self._icon_size = icon_size
# State hold mechanism # State hold mechanism
self._hold_duration = 2.0 # seconds 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._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_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) 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) self._rect = rl.Rectangle(0, 0, button_size, button_size)
def set_rect(self, rect: rl.Rectangle) -> None: def set_rect(self, rect: rl.Rectangle) -> None:
@@ -29,9 +41,22 @@ class ExpButton(Widget):
def _update_state(self) -> None: def _update_state(self) -> None:
selfdrive_state = ui_state.sm["selfdriveState"] 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 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, _): def _handle_mouse_release(self, _):
super()._handle_mouse_release(_) super()._handle_mouse_release(_)
if self._is_toggle_allowed(): if self._is_toggle_allowed():
@@ -45,12 +70,57 @@ class ExpButton(Widget):
def _render(self, rect: rl.Rectangle) -> None: def _render(self, rect: rl.Rectangle) -> None:
center_x = int(self._rect.x + self._rect.width // 2) center_x = int(self._rect.x + self._rect.width // 2)
center_y = int(self._rect.y + self._rect.height // 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 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 # Status-aware background color
rl.draw_circle(center_x, center_y, self._rect.width / 2, self._black_bg) bg = self._black_bg
rl.draw_texture(texture, center_x - texture.width // 2, center_y - texture.height // 2, self._white_color) 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): def _held_or_actual_mode(self):
now = time.monotonic() now = time.monotonic()
File diff suppressed because it is too large Load Diff
+170
View File
@@ -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)
+26 -6
View File
@@ -75,6 +75,8 @@ class HudRenderer(Widget):
def _update_state(self) -> None: def _update_state(self) -> None:
"""Update HUD state based on car state and controls state.""" """Update HUD state based on car state and controls state."""
sm = ui_state.sm sm = ui_state.sm
self._toggles = ui_state.frogpilot_toggles
if sm.recv_frame["carState"] < ui_state.started_frame: if sm.recv_frame["carState"] < ui_state.started_frame:
self.is_cruise_set = False self.is_cruise_set = False
self.set_speed = SET_SPEED_NA self.set_speed = SET_SPEED_NA
@@ -85,9 +87,7 @@ class HudRenderer(Widget):
car_state = sm['carState'] car_state = sm['carState']
v_cruise_cluster = car_state.vCruiseCluster v_cruise_cluster = car_state.vCruiseCluster
self.set_speed = ( self.set_speed = controls_state.vCruiseDEPRECATED if v_cruise_cluster == 0.0 else v_cruise_cluster
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_set = 0 < self.set_speed < SET_SPEED_NA
self.is_cruise_available = self.set_speed != -1 self.is_cruise_available = self.set_speed != -1
@@ -96,12 +96,17 @@ class HudRenderer(Widget):
v_ego_cluster = car_state.vEgoCluster v_ego_cluster = car_state.vEgoCluster
self.v_ego_cluster_seen = self.v_ego_cluster_seen or v_ego_cluster != 0.0 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 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) self.speed = max(0.0, v_ego * speed_conversion)
def _render(self, rect: rl.Rectangle) -> None: def _render(self, rect: rl.Rectangle) -> None:
"""Render HUD elements to the screen.""" """Render HUD elements to the screen."""
t = self._toggles
# Draw the header background # Draw the header background
rl.draw_rectangle_gradient_v( rl.draw_rectangle_gradient_v(
int(rect.x), int(rect.x),
@@ -112,10 +117,11 @@ class HudRenderer(Widget):
COLORS.HEADER_GRADIENT_END, 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_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_x = rect.x + rect.width - UI_CONFIG.border_size - UI_CONFIG.button_size
button_y = rect.y + UI_CONFIG.border_size button_y = rect.y + UI_CONFIG.border_size
@@ -124,6 +130,20 @@ class HudRenderer(Widget):
def user_interacting(self) -> bool: def user_interacting(self) -> bool:
return self._exp_button.is_pressed 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: def _draw_set_speed(self, rect: rl.Rectangle) -> None:
"""Draw the MAX speed indicator box.""" """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 set_speed_width = UI_CONFIG.set_speed_width_metric if ui_state.is_metric else UI_CONFIG.set_speed_width_imperial
+370 -57
View File
@@ -16,15 +16,15 @@ MIN_DRAW_DISTANCE = 10.0
MAX_DRAW_DISTANCE = 100.0 MAX_DRAW_DISTANCE = 100.0
THROTTLE_COLORS = [ THROTTLE_COLORS = [
rl.Color(13, 248, 122, 102), # HSLF(148/360, 0.94, 0.51, 0.4) 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, 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(114, 255, 92, 0), # HSLF(112/360, 1.0, 0.68, 0.0)
] ]
NO_THROTTLE_COLORS = [ 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, 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._prev_allow_throttle = True
self._lane_line_probs = np.zeros(4, dtype=np.float32) self._lane_line_probs = np.zeros(4, dtype=np.float32)
self._road_edge_stds = np.zeros(2, 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] self._path_offset_z = HEIGHT_INIT[0]
# Initialize ModelPoints objects # Initialize ModelPoints objects
self._path = ModelPoints() self._path = ModelPoints()
self._path_edge = ModelPoints()
self._adjacent_left = ModelPoints()
self._adjacent_right = ModelPoints()
self._lane_lines = [ModelPoints() for _ in range(4)] self._lane_lines = [ModelPoints() for _ in range(4)]
self._road_edges = [ModelPoints() for _ in range(2)] self._road_edges = [ModelPoints() for _ in range(2)]
self._acceleration_x = np.empty((0,), dtype=np.float32) 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._car_space_transform = np.zeros((3, 3), dtype=np.float32)
self._transform_dirty = True self._transform_dirty = True
self._clip_region = None self._clip_region = None
self._lead_data_raw = [None, None]
self._exp_gradient = Gradient( self._exp_gradient = Gradient(
start=(0.0, 1.0), # Bottom of path start=(0.0, 1.0), # Bottom of path
@@ -76,25 +81,85 @@ class ModelRenderer(Widget):
cp = messaging.log_from_bytes(car_params, car.CarParams) cp = messaging.log_from_bytes(car_params, car.CarParams)
self._longitudinal_control = cp.openpilotLongitudinalControl 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): def set_transform(self, transform: np.ndarray):
self._car_space_transform = transform.astype(np.float32) self._car_space_transform = transform.astype(np.float32)
self._transform_dirty = True 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): def _render(self, rect: rl.Rectangle):
sm = ui_state.sm 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 # Check if data is up-to-date
if (sm.recv_frame["liveCalibration"] < ui_state.started_frame or if sm.recv_frame["liveCalibration"] < ui_state.started_frame or sm.recv_frame["modelV2"] < ui_state.started_frame:
sm.recv_frame["modelV2"] < ui_state.started_frame):
return return
# Set up clipping region # Set up clipping region
self._clip_region = rl.Rectangle( self._clip_region = rl.Rectangle(rect.x - CLIP_MARGIN, rect.y - CLIP_MARGIN, rect.width + 2 * CLIP_MARGIN, rect.height + 2 * CLIP_MARGIN)
rect.x - CLIP_MARGIN, rect.y - CLIP_MARGIN, rect.width + 2 * CLIP_MARGIN, rect.height + 2 * CLIP_MARGIN
)
# Update state # Update state
self._experimental_mode = sm['selfdriveState'].experimentalMode 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'] live_calib = sm['liveCalibration']
self._path_offset_z = live_calib.height[0] if live_calib.height else HEIGHT_INIT[0] 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 lead_one = radar_state.leadOne if radar_state else None
render_lead_indicator = self._longitudinal_control and radar_state is not 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 # Update model data when needed
model_updated = sm.updated['modelV2'] model_updated = sm.updated['modelV2']
if model_updated or sm.updated['radarState'] or self._transform_dirty: 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. # the fixed number of lane/edge slots used by the UI.
for lane_line in self._lane_lines: for lane_line in self._lane_lines:
lane_line.raw_points = np.empty((0, 3), dtype=np.float32) 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 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: for road_edge in self._road_edges:
road_edge.raw_points = np.empty((0, 3), dtype=np.float32) 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 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) 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 = 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) 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 = 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) self._acceleration_x = np.array(model.acceleration.x, dtype=np.float32)
def _update_leads(self, radar_state, path_x_array): def _update_leads(self, radar_state, path_x_array):
"""Update positions of lead vehicles""" """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] 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): for i, lead_data in enumerate(leads):
if lead_data and lead_data.status: 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 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) idx = self._get_path_length_idx(path_x_array, d_rel)
@@ -170,20 +252,48 @@ class ModelRenderer(Widget):
if point: if point:
self._lead_vehicles[i] = self._update_lead_vehicle(d_rel, v_rel, point, self._rect) 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): def _update_model(self, lead, path_x_array):
"""Update model visualization data based on model message""" """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_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) max_idx = self._get_path_length_idx(self._lane_lines[0].raw_points[:, 0], max_distance)
# Update lane lines using raw points # Update lane lines using raw points
for i, lane_line in enumerate(self._lane_lines): for i, lane_line in enumerate(self._lane_lines):
lane_line.projected_points = self._map_line_to_polygon( 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)
lane_line.raw_points, 0.025 * self._lane_line_probs[i], 0.0, max_idx, max_distance
)
# Update road edges using raw points # Update road edges using raw points
for road_edge in self._road_edges: 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 # Update path using raw points
if lead and lead.status: 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_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) max_idx = self._get_path_length_idx(path_x_array, max_distance)
self._path.projected_points = self._map_line_to_polygon( 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)
self._path.raw_points, 0.9, 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() self._update_experimental_gradient()
@@ -242,6 +375,30 @@ class ModelRenderer(Widget):
stops=gradient_stops, 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): def _update_lead_vehicle(self, d_rel, v_rel, point, rect):
speed_buff, lead_buff = 10.0, 40.0 speed_buff, lead_buff = 10.0, 40.0
@@ -268,12 +425,24 @@ class ModelRenderer(Widget):
def _draw_lane_lines(self): def _draw_lane_lines(self):
"""Draw lane lines and road edges""" """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): for i, lane_line in enumerate(self._lane_lines):
if lane_line.projected_points.size == 0: if lane_line.projected_points.size == 0:
continue continue
alpha = np.clip(self._lane_line_probs[i], 0.0, 0.7) 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) draw_polygon(self._rect, lane_line.projected_points, color)
for i, road_edge in enumerate(self._road_edges): for i, road_edge in enumerate(self._road_edges):
@@ -289,11 +458,45 @@ class ModelRenderer(Widget):
if not self._path.projected_points.size: if not self._path.projected_points.size:
return 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 allow_throttle = sm['longitudinalPlan'].allowThrottle or not self._longitudinal_control
self._blend_filter.update(int(allow_throttle)) self._blend_filter.update(int(allow_throttle))
if self._experimental_mode: # FrogPilot: rainbow path (speed-based HSL gradient, integrated into path rendering)
# Draw with acceleration coloring 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: if len(self._exp_gradient.colors) > 1:
draw_polygon(self._rect, self._path.projected_points, gradient=self._exp_gradient) draw_polygon(self._rect, self._path.projected_points, gradient=self._exp_gradient)
else: else:
@@ -301,23 +504,51 @@ class ModelRenderer(Widget):
else: else:
# Blend throttle/no throttle colors based on transition # Blend throttle/no throttle colors based on transition
blend_factor = round(self._blend_filter.x * 100) / 100 blend_factor = round(self._blend_filter.x * 100) / 100
blended_colors = self._blend_colors(NO_THROTTLE_COLORS, THROTTLE_COLORS, blend_factor)
gradient = Gradient( # Custom path color override: use when not accelerating
start=(0.0, 1.0), # Bottom of path if path_color_override and blend_factor < 0.5:
end=(0.0, 0.0), # Top of path c = path_color_override
colors=blended_colors, draw_polygon(self._rect, self._path.projected_points, rl.Color(c.r, c.g, c.b, 102))
stops=[0.0, 0.5, 1.0], else:
) if blend_factor != self._blend_factor_cache:
draw_polygon(self._rect, self._path.projected_points, gradient=gradient) 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): 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 # Draw lead vehicles if available
for lead in self._lead_vehicles: for lead in self._lead_vehicles:
if not lead.glow or not lead.chevron: if not lead.glow or not lead.chevron:
continue continue
rl.draw_triangle_fan(lead.glow, len(lead.glow), rl.Color(218, 202, 37, 255)) rl.draw_triangle_fan(lead.glow, len(lead.glow), glow_color)
rl.draw_triangle_fan(lead.chevron, len(lead.chevron), rl.Color(201, 34, 49, lead.fill_alpha)) 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 @staticmethod
def _get_path_length_idx(pos_x_array: np.ndarray, path_distance: float) -> int: 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): def _map_to_screen(self, in_x, in_y, in_z):
"""Project a point in car space to screen space""" """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]) input_pt = np.array([in_x, in_y, in_z])
pt = self._car_space_transform @ input_pt pt = self._car_space_transform @ input_pt
@@ -349,7 +583,7 @@ class ModelRenderer(Widget):
return np.empty((0, 2), dtype=np.float32) return np.empty((0, 2), dtype=np.float32)
# Slice points and filter non-negative x-coordinates # 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) # Interpolate around max_idx so path end is smooth (max_distance is always >= p0.x)
if 0 < max_idx < line.shape[0] - 1: 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 y_min, y_max = clip.y, clip.y + clip.height
# Filter points within clip region # Filter points within clip region
left_in_clip = ( left_in_clip = (left_screen[0] >= x_min) & (left_screen[0] <= x_max) & (left_screen[1] >= y_min) & (left_screen[1] <= y_max)
(left_screen[0] >= x_min) & (left_screen[0] <= x_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_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 both_in_clip = left_in_clip & right_in_clip
if not np.any(both_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) 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 @staticmethod
def _hsla_to_color(h, s, l, a): def _hsla_to_color(h, s, l, a):
rgb = colorsys.hls_to_rgb(h, l, s) rgb = colorsys.hls_to_rgb(h, l, s)
return rl.Color( return rl.Color(int(rgb[0] * 255), int(rgb[1] * 255), int(rgb[2] * 255), int(a * 255))
int(rgb[0] * 255),
int(rgb[1] * 255),
int(rgb[2] * 255),
int(a * 255)
)
@staticmethod @staticmethod
def _blend_colors(begin_colors, end_colors, t): def _blend_colors(begin_colors, end_colors, t):
@@ -438,9 +743,17 @@ class ModelRenderer(Widget):
return begin_colors return begin_colors
inv_t = 1.0 - t inv_t = 1.0 - t
return [rl.Color( return [
int(inv_t * start.r + t * end.r), 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))
int(inv_t * start.g + t * end.g), for start, end in zip(begin_colors, end_colors, strict=True)
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)
+27 -2
View File
@@ -1,3 +1,4 @@
import json
import pyray as rl import pyray as rl
import numpy as np import numpy as np
import time import time
@@ -58,8 +59,19 @@ class UIState:
"rawAudioData", "rawAudioData",
"frogpilotCarState", "frogpilotCarState",
] ]
).extend(
[
"frogpilotPlan",
"frogpilotSelfdriveState",
"frogpilotRadarState",
"frogpilotDeviceState",
"mapdOut",
"liveTracks",
]
) )
self._frogpilot_toggles: dict = {}
self.prime_state = PrimeState() self.prime_state = PrimeState()
# UI Status tracking # UI Status tracking
@@ -150,8 +162,7 @@ class UIState:
self.always_on_dm = self.params.get_bool("AlwaysOnDM") self.always_on_dm = self.params.get_bool("AlwaysOnDM")
if self.sm.valid.get("frogpilotCarState", False): if self.sm.valid.get("frogpilotCarState", False):
frogpilot_car_state = self.sm["frogpilotCarState"] frogpilot_car_state = self.sm["frogpilotCarState"]
self.always_on_lateral_active = (not self.sm["selfdriveState"].enabled and self.always_on_lateral_active = not self.sm["selfdriveState"].enabled and frogpilot_car_state.alwaysOnLateralEnabled
frogpilot_car_state.alwaysOnLateralEnabled)
self.traffic_mode_enabled = frogpilot_car_state.trafficModeEnabled self.traffic_mode_enabled = frogpilot_car_state.trafficModeEnabled
else: else:
self.always_on_lateral_active = False 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 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: def _update_status(self) -> None:
if self.started and self.sm.updated["selfdriveState"]: if self.started and self.sm.updated["selfdriveState"]:
ss = self.sm["selfdriveState"] ss = self.sm["selfdriveState"]
+186
View File
@@ -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)
+183
View File
@@ -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)
+39 -1
View File
@@ -225,6 +225,7 @@ class GuiApplication:
self._modal_overlay = ModalOverlay() self._modal_overlay = ModalOverlay()
self._modal_overlay_shown = False self._modal_overlay_shown = False
self._modal_overlay_tick: Callable[[], None] | None = None self._modal_overlay_tick: Callable[[], None] | None = None
self._nav_stack: list = []
self._mouse = MouseState(self._scale) self._mouse = MouseState(self._scale)
self._mouse_events: list[MouseEvent] = [] self._mouse_events: list[MouseEvent] = []
@@ -369,6 +370,41 @@ class GuiApplication:
def set_modal_overlay_tick(self, tick_function: Callable | None): def set_modal_overlay_tick(self, tick_function: Callable | None):
self._modal_overlay_tick = tick_function 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): def set_should_render(self, should_render: bool):
self._should_render = should_render self._should_render = should_render
@@ -523,7 +559,9 @@ class GuiApplication:
rl.clear_background(rl.BLACK) rl.clear_background(rl.BLACK)
# Handle modal overlay rendering and input processing # 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 # Allow a Widget to still run a function while overlay is shown
if self._modal_overlay_tick is not None: if self._modal_overlay_tick is not None:
self._modal_overlay_tick() self._modal_overlay_tick()
+17
View File
@@ -34,6 +34,7 @@ class Widget(abc.ABC):
self._click_callback: Callable[[], None] | None = None self._click_callback: Callable[[], None] | None = None
self._multi_touch = False self._multi_touch = False
self.__was_awake = True self.__was_awake = True
self._children: list = []
@property @property
def rect(self) -> rl.Rectangle: def rect(self) -> rl.Rectangle:
@@ -180,9 +181,25 @@ class Widget(abc.ABC):
def show_event(self): def show_event(self):
"""Optionally handle show event. Parent must manually call this""" """Optionally handle show event. Parent must manually call this"""
for child in self._children:
child.show_event()
def hide_event(self): def hide_event(self):
"""Optionally handle hide event. Parent must manually call this""" """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 SWIPE_AWAY_THRESHOLD = 80 # px to dismiss after releasing
+19 -94
View File
@@ -1,118 +1,43 @@
import pyray as rl
from collections.abc import Callable 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 import Widget, DialogResult
from openpilot.system.ui.widgets.button import Button, ButtonStyle from openpilot.system.ui.widgets.keyboard import Keyboard
from openpilot.system.ui.widgets.label import Label, FontWeight
from openpilot.system.ui.widgets.keyboard import Keyboard, KeyboardLayout
MARGIN = 50
BUTTON_HEIGHT = 160
OUTER_MARGIN_X = 200
OUTER_MARGIN_Y = 150
BACKGROUND_COLOR = rl.Color(27, 27, 27, 255)
class InputDialog(Widget): class InputDialog(Widget):
def __init__(self, title: str, default_text: str = "", hint_text: str = "", on_close: Callable[[DialogResult, str], None] | None = None): def __init__(self, title: str, default_text: str = "", hint_text: str = "", on_close: Callable[[DialogResult, str], None] | None = None):
super().__init__() super().__init__()
self._title = title self._default_text = default_text
self._text = default_text
self._hint = hint_text
self._on_close = on_close self._on_close = on_close
self._dialog_result = DialogResult.NO_ACTION 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): self._keyboard = Keyboard(callback=self._on_keyboard_result)
if key == "\b": self._keyboard.set_title(title)
self._text = self._text[:-1] self._keyboard.set_text(default_text)
else:
self._text += key
def _on_keyboard_done(self): def _on_keyboard_result(self, result: DialogResult):
self._confirm_button_callback() if self._dialog_result != DialogResult.NO_ACTION:
return
def _cancel_button_callback(self): self._dialog_result = result
self._dialog_result = DialogResult.CANCEL
if self._on_close: if self._on_close:
self._on_close(self._dialog_result, self._text) self._on_close(result, self._keyboard.text)
def _confirm_button_callback(self):
self._dialog_result = DialogResult.CONFIRM
if self._on_close:
self._on_close(self._dialog_result, self._text)
@property @property
def result(self) -> DialogResult: def result(self) -> DialogResult:
return self._dialog_result return self._dialog_result
@property @property
def text(self) -> str: def text(self) -> str:
return self._text return self._keyboard.text
def show_event(self): def show_event(self):
super().show_event() super().show_event()
self._dialog_result = DialogResult.NO_ACTION 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): def _render(self, rect):
# Dim background self._keyboard.render(rect)
rl.draw_rectangle(0, 0, int(rect.width), int(rect.height), rl.Color(0, 0, 0, 200)) return self._dialog_result
# 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)
+49 -10
View File
@@ -1,12 +1,13 @@
from functools import partial from functools import partial
import time import time
from typing import Literal from typing import Literal
from collections.abc import Callable
import pyray as rl import pyray as rl
from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.multilang import tr 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.button import ButtonStyle, Button
from openpilot.system.ui.widgets.inputbox import InputBox from openpilot.system.ui.widgets.inputbox import InputBox
from openpilot.system.ui.widgets.label import Label from openpilot.system.ui.widgets.label import Label
@@ -58,7 +59,14 @@ KEYBOARD_LAYOUTS = {
class Keyboard(Widget): 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__() super().__init__()
self._layout_name: Literal["lowercase", "uppercase", "numbers", "specials"] = "lowercase" self._layout_name: Literal["lowercase", "uppercase", "numbers", "specials"] = "lowercase"
self._caps_lock = False self._caps_lock = False
@@ -71,6 +79,7 @@ class Keyboard(Widget):
self._input_box = InputBox(max_text_size) self._input_box = InputBox(max_text_size)
self._password_mode = password_mode self._password_mode = password_mode
self._show_password_toggle = show_password_toggle self._show_password_toggle = show_password_toggle
self._callback = callback
# Backspace key repeat tracking # Backspace key repeat tracking
self._backspace_pressed: bool = False self._backspace_pressed: bool = False
@@ -78,6 +87,8 @@ class Keyboard(Widget):
self._backspace_last_repeat: float = 0.0 self._backspace_last_repeat: float = 0.0
self._render_return_status = -1 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._cancel_button = Button(lambda: tr("Cancel"), self._cancel_button_callback)
self._eye_button = Button("", self._eye_button_callback, button_style=ButtonStyle.TRANSPARENT) self._eye_button = Button("", self._eye_button_callback, button_style=ButtonStyle.TRANSPARENT)
@@ -98,12 +109,18 @@ class Keyboard(Widget):
for _, key in enumerate(keys): for _, key in enumerate(keys):
if key in self._key_icons: if key in self._key_icons:
texture = self._key_icons[key] texture = self._key_icons[key]
self._all_keys[key] = Button("", partial(self._key_callback, key), icon=texture, self._all_keys[key] = Button(
button_style=ButtonStyle.PRIMARY if key == ENTER_KEY else ButtonStyle.KEYBOARD, multi_touch=True) "",
partial(self._key_callback, key),
icon=texture,
button_style=ButtonStyle.PRIMARY if key == ENTER_KEY else ButtonStyle.KEYBOARD,
multi_touch=True,
)
else: 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[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], self._all_keys[CAPS_LOCK_KEY] = Button(
button_style=ButtonStyle.KEYBOARD, multi_touch=True) "", 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): def set_text(self, text: str):
self._input_box.text = text self._input_box.text = text
@@ -122,20 +139,42 @@ class Keyboard(Widget):
self._title.set_text(title) self._title.set_text(title)
self._sub_title.set_text(sub_title) self._sub_title.set_text(sub_title)
def _eye_button_callback(self): def set_callback(self, callback: Callable[[DialogResult], None] | None):
self._password_mode = not self._password_mode 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): def _cancel_button_callback(self):
self.clear() 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): def _key_callback(self, k):
if k == ENTER_KEY: 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: else:
self.handle_key_press(k) self.handle_key_press(k)
def _render(self, rect: rl.Rectangle): 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) 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._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)) self._sub_title.render(rl.Rectangle(rect.x, rect.y + 95, rect.width, 60))