This commit is contained in:
firestarsdog
2026-03-22 19:39:26 -04:00
parent 8258b6db84
commit 14d0e9a2c3
35 changed files with 7405 additions and 1626 deletions
+111 -15
View File
@@ -1,4 +1,6 @@
import json
import time
import datetime
import pyray as rl
from collections.abc import Callable
from enum import IntEnum
@@ -7,6 +9,8 @@ from openpilot.selfdrive.ui.widgets.offroad_alerts import UpdateAlert, OffroadAl
from openpilot.selfdrive.ui.widgets.exp_mode_button import ExperimentalModeButton
from openpilot.selfdrive.ui.widgets.prime import PrimeWidget
from openpilot.selfdrive.ui.widgets.setup import SetupWidget
from openpilot.selfdrive.ui.widgets.drive_summary import DriveSummary, RANDOM_EVENTS
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
from openpilot.system.ui.lib.multilang import tr, trn
@@ -58,6 +62,14 @@ class HomeLayout(Widget):
self._prime_widget = PrimeWidget()
self._setup_widget = SetupWidget()
self._drive_summary = DriveSummary(show_random_events=False)
self._random_events_summary = DriveSummary(show_random_events=True)
self._drive_summary_active = False
self._random_events_active = False
self._last_onroad_stats: dict = {}
ui_state.add_offroad_transition_callback(self._on_offroad_transition)
self._exp_mode_button = ExperimentalModeButton()
self._setup_callbacks()
@@ -71,6 +83,34 @@ class HomeLayout(Widget):
self.offroad_alert.set_dismiss_callback(lambda: self._set_state(HomeLayoutState.HOME))
self._exp_mode_button.set_click_callback(lambda: self.settings_callback() if self.settings_callback else None)
def _on_offroad_transition(self):
"""Called when transitioning from onroad to offroad."""
raw = self.params.get("FrogPilotStats")
if raw:
try:
current_stats = json.loads(raw)
prev = self._last_onroad_stats or {}
# Show drive summary if there's new drive data
if current_stats.get("TrackedTime", 0) > prev.get("TrackedTime", 0):
self._drive_summary.set_previous_stats(prev)
self._drive_summary.show_event()
self._drive_summary_active = True
# Random events
if ui_state.frogpilot_toggles.get("random_events", False):
cur_ev = current_stats.get("RandomEvents", {})
prev_ev = prev.get("RandomEvents", {})
if any(cur_ev.get(k, 0) > prev_ev.get(k, 0) for k, _ in RANDOM_EVENTS):
self._random_events_summary.set_previous_stats(prev)
self._random_events_summary.show_event()
self._random_events_active = True
self._last_onroad_stats = dict(current_stats)
except (json.JSONDecodeError, TypeError):
pass
def _dismiss_drive_summary(self):
self._drive_summary_active = False
self._random_events_active = False
def set_settings_callback(self, callback: Callable):
self.settings_callback = callback
@@ -104,24 +144,18 @@ class HomeLayout(Widget):
self._render_alerts_view()
def _update_state(self):
self.header_rect = rl.Rectangle(
self._rect.x + CONTENT_MARGIN, self._rect.y + CONTENT_MARGIN, self._rect.width - 2 * CONTENT_MARGIN, HEADER_HEIGHT
)
self.header_rect = rl.Rectangle(self._rect.x + CONTENT_MARGIN, self._rect.y + CONTENT_MARGIN, self._rect.width - 2 * CONTENT_MARGIN, HEADER_HEIGHT)
content_y = self._rect.y + CONTENT_MARGIN + HEADER_HEIGHT + SPACING
content_height = self._rect.height - CONTENT_MARGIN - HEADER_HEIGHT - SPACING - CONTENT_MARGIN
self.content_rect = rl.Rectangle(
self._rect.x + CONTENT_MARGIN, content_y, self._rect.width - 2 * CONTENT_MARGIN, content_height
)
self.content_rect = rl.Rectangle(self._rect.x + CONTENT_MARGIN, content_y, self._rect.width - 2 * CONTENT_MARGIN, content_height)
left_width = self.content_rect.width - RIGHT_COLUMN_WIDTH - SPACING
self.left_column_rect = rl.Rectangle(self.content_rect.x, self.content_rect.y, left_width, self.content_rect.height)
self.right_column_rect = rl.Rectangle(
self.content_rect.x + left_width + SPACING, self.content_rect.y, RIGHT_COLUMN_WIDTH, self.content_rect.height
)
self.right_column_rect = rl.Rectangle(self.content_rect.x + left_width + SPACING, self.content_rect.y, RIGHT_COLUMN_WIDTH, self.content_rect.height)
self.update_notif_rect.x = self.header_rect.x
self.update_notif_rect.y = self.header_rect.y + (self.header_rect.height - 60) // 2
@@ -133,6 +167,12 @@ class HomeLayout(Widget):
def _handle_mouse_release(self, mouse_pos: MousePos):
super()._handle_mouse_release(mouse_pos)
# Dismiss drive summary on click anywhere in content area
if self._drive_summary_active:
if rl.check_collision_point_rec(mouse_pos, self.left_column_rect) or rl.check_collision_point_rec(mouse_pos, self.right_column_rect):
self._dismiss_drive_summary()
return
if self.update_available and rl.check_collision_point_rec(mouse_pos, self.update_notif_rect):
self._set_state(HomeLayoutState.UPDATE)
elif self.alert_count > 0 and rl.check_collision_point_rec(mouse_pos, self.alert_notif_rect):
@@ -143,6 +183,15 @@ class HomeLayout(Widget):
version_text_width = self.header_rect.width
# FrogPilot: date display (left-aligned)
date_text = datetime.datetime.now().strftime("%A, %B %-d")
date_font = gui_app.font(FontWeight.NORMAL)
date_size = 32
date_ts = measure_text_cached(date_font, date_text, date_size)
date_x = self.header_rect.x
date_y = self.header_rect.y + (self.header_rect.height - date_ts.y) // 2
rl.draw_text_ex(date_font, date_text, rl.Vector2(int(date_x), int(date_y)), date_size, 0, rl.Color(255, 255, 255, 150))
# Update notification button
if self.update_available:
version_text_width -= self.update_notif_rect.width
@@ -175,8 +224,9 @@ class HomeLayout(Widget):
if self.update_available or self.alert_count > 0:
version_text_width -= SPACING * 1.5
version_rect = rl.Rectangle(self.header_rect.x + self.header_rect.width - version_text_width, self.header_rect.y,
version_text_width, self.header_rect.height)
version_rect = rl.Rectangle(
self.header_rect.x + self.header_rect.width - version_text_width, self.header_rect.y, version_text_width, self.header_rect.height
)
gui_label(version_rect, self._version_text, 48, rl.WHITE, alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT)
def _render_home_content(self):
@@ -190,13 +240,19 @@ class HomeLayout(Widget):
self.offroad_alert.render(self.content_rect)
def _render_left_column(self):
self._prime_widget.render(self.left_column_rect)
if self._drive_summary_active:
self._drive_summary.render(self.left_column_rect)
else:
self._prime_widget.render(self.left_column_rect)
self._render_frogpilot_stats()
def _render_right_column(self):
if self._random_events_active:
self._random_events_summary.render(self.right_column_rect)
return
exp_height = 125
exp_rect = rl.Rectangle(
self.right_column_rect.x, self.right_column_rect.y, self.right_column_rect.width, exp_height
)
exp_rect = rl.Rectangle(self.right_column_rect.x, self.right_column_rect.y, self.right_column_rect.width, exp_height)
self._exp_mode_button.render(exp_rect)
setup_rect = rl.Rectangle(
@@ -207,6 +263,46 @@ class HomeLayout(Widget):
)
self._setup_widget.render(setup_rect)
def _render_frogpilot_stats(self):
"""Render FrogPilot lifetime stats (Drives/Distance/Hours) at bottom of left column."""
raw = self.params.get("FrogPilotStats")
if not raw:
return
try:
stats = json.loads(raw)
except (json.JSONDecodeError, TypeError):
return
drives = stats.get("FrogPilotDrives", 0)
meters = stats.get("FrogPilotMeters", 0)
hours = int(stats.get("FrogPilotSeconds", 0) / 3600)
if ui_state.is_metric:
dist_str = f"{meters / 1000:.0f} km"
else:
dist_str = f"{meters * 0.000621371:.0f} mi"
font = gui_app.font(FontWeight.NORMAL)
label_size = 24
val_size = 36
green = rl.Color(0x17, 0x86, 0x44, 255)
# Position at bottom of left column
y = self.left_column_rect.y + self.left_column_rect.height - 100
x = self.left_column_rect.x
col_w = self.left_column_rect.width / 3
stats_data = [("Drives", str(drives)), ("Distance", dist_str), ("Hours", str(hours))]
for i, (label, value) in enumerate(stats_data):
cx = x + col_w * i + col_w / 2
# Value
vs = measure_text_cached(font, value, val_size)
rl.draw_text_ex(font, value, rl.Vector2(cx - vs.x / 2, y), val_size, 0, rl.WHITE)
# Label
ls = measure_text_cached(font, label, label_size)
rl.draw_text_ex(font, label, rl.Vector2(cx - ls.x / 2, y + val_size + 5), label_size, 0, green)
def _refresh(self):
self._version_text = self._get_version_text()
update_available = self.update_alert.refresh()
+43 -12
View File
@@ -1,6 +1,7 @@
import os
import time
import datetime
from pathlib import Path
from openpilot.common.time_helpers import system_time_valid
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.system.ui.lib.application import gui_app
@@ -76,15 +77,20 @@ class SoftwareLayout(Widget):
self._branch_btn.action_item.set_value(ui_state.params.get("UpdaterTargetBranch") or "")
self._branch_dialog: MultiOptionDialog | None = None
self._scroller = Scroller([
self._onroad_label,
self._version_item,
self._auto_updates_toggle,
self._download_btn,
self._install_btn,
self._branch_btn,
button_item(lambda: tr("Uninstall"), lambda: tr("UNINSTALL"), callback=self._on_uninstall),
], line_separator=True, spacing=0)
self._scroller = Scroller(
[
self._onroad_label,
self._version_item,
self._auto_updates_toggle,
self._download_btn,
self._install_btn,
self._branch_btn,
button_item(lambda: tr("Uninstall"), lambda: tr("UNINSTALL"), callback=self._on_uninstall),
button_item(lambda: tr("Error Log"), lambda: tr("VIEW"), callback=self._on_error_log),
],
line_separator=True,
spacing=0,
)
def show_event(self):
self._scroller.show_event()
@@ -170,18 +176,43 @@ class SoftwareLayout(Widget):
# Start downloading
self._waiting_for_updater = True
self._waiting_start_ts = time.monotonic()
ui_state.params_memory.put_bool("ManualUpdateInitiated", True)
os.system("pkill -SIGHUP -f system.updated.updated")
def _on_auto_updates_toggle(self, enabled: bool):
ui_state.params.put_bool("AutomaticUpdates", enabled)
def _on_uninstall(self):
def handle_uninstall_confirmation(result):
def handle_step1(result):
if result == DialogResult.CONFIRM:
ui_state.params.put_bool("DoUninstall", True)
def handle_step2(result2):
if result2 == DialogResult.CONFIRM:
def handle_step3(result3):
if result3 == DialogResult.CONFIRM:
ui_state.params.clear_all()
ui_state.params.put_bool("DoUninstall", True)
dialog = ConfirmDialog(tr("This is a complete factory reset and cannot be undone. Are you absolutely sure?"), tr("Reset"))
gui_app.set_modal_overlay(dialog, callback=handle_step3)
else:
ui_state.params.put_bool("DoUninstall", True)
dialog = ConfirmDialog(
tr("Do you want to perform a full factory reset? All saved assets and settings will be permanently deleted!"), tr("Factory Reset"), tr("Skip")
)
gui_app.set_modal_overlay(dialog, callback=handle_step2)
dialog = ConfirmDialog(tr("Are you sure you want to uninstall?"), tr("Uninstall"))
gui_app.set_modal_overlay(dialog, callback=handle_uninstall_confirmation)
gui_app.set_modal_overlay(dialog, callback=handle_step1)
def _on_error_log(self):
try:
txt = Path("/data/error_logs/error.txt").read_text(encoding='utf-8', errors='replace')
except Exception:
txt = tr("No error log found.")
gui_app.set_modal_overlay(ConfirmDialog(txt, tr("OK"), on_close=lambda r: None, rich=True))
def _on_install_update(self):
# Trigger reboot to install update
+223 -15
View File
@@ -1,25 +1,233 @@
from __future__ import annotations
import os
import shutil
import threading
import subprocess
from datetime import datetime
from pathlib import Path
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.multilang import tr, tr_noop
from openpilot.system.ui.widgets.list_view import button_item
from openpilot.system.ui.widgets.scroller_tici import Scroller
from openpilot.system.ui.widgets import DialogResult
from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog, alert_dialog
from openpilot.system.ui.widgets.selection_dialog import SelectionDialog
from openpilot.system.ui.widgets.input_dialog import InputDialog
from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import StarPilotPanel
class StarPilotDataLayout(StarPilotPanel):
def __init__(self):
super().__init__()
items = [
button_item(
tr_noop("Manage Backups"),
lambda: tr("MANAGE"),
tr_noop("<b>Create, restore, or delete backups</b> of your StarPilot settings."),
),
button_item(
tr_noop("Manage Storage"),
lambda: tr("MANAGE"),
tr_noop("<b>View and manage storage usage</b> for models, maps, and other data."),
),
self.CATEGORIES = [
{"title": tr_noop("Manage Backups"), "panel": "backups", "icon": "toggle_icons/icon_system.png", "color": "#FA6800"},
{"title": tr_noop("Toggle Backups"), "panel": "toggle_backups", "icon": "toggle_icons/icon_system.png", "color": "#FA6800"},
{"title": tr_noop("Manage Storage"), "panel": "storage", "icon": "toggle_icons/icon_system.png", "color": "#FA6800"},
{
"title": tr_noop("Delete Driving Data"),
"type": "hub",
"on_click": self._on_delete_driving_data,
"icon": "toggle_icons/icon_system.png",
"color": "#FA6800",
},
{
"title": tr_noop("Delete Error Logs"),
"type": "hub",
"on_click": self._on_delete_error_logs,
"icon": "toggle_icons/icon_system.png",
"color": "#FA6800",
},
]
self._scroller = Scroller(items, line_separator=True, spacing=0)
self._sub_panels = {
"backups": StarPilotBackupsLayout(),
"toggle_backups": StarPilotToggleBackupsLayout(),
"storage": StarPilotStorageLayout(),
}
for name, panel in self._sub_panels.items():
if hasattr(panel, 'set_navigate_callback'):
panel.set_navigate_callback(self._navigate_to)
if hasattr(panel, 'set_back_callback'):
panel.set_back_callback(self._go_back)
self._rebuild_grid()
def _on_delete_driving_data(self):
def _do_delete(res):
if res == DialogResult.CONFIRM:
def _task():
drive_paths = ["/data/media/0/realdata/", "/data/media/0/realdata_HD/", "/data/media/0/realdata_konik/"]
for path in drive_paths:
p = Path(path)
if p.exists():
for entry in p.iterdir():
if entry.is_dir():
shutil.rmtree(entry, ignore_errors=True)
threading.Thread(target=_task, daemon=True).start()
gui_app.set_modal_overlay(alert_dialog(tr("Driving data deletion started.")))
gui_app.set_modal_overlay(ConfirmDialog(tr("Delete all driving data and footage?"), tr("Delete"), on_close=_do_delete))
def _on_delete_error_logs(self):
def _do_delete(res):
if res == DialogResult.CONFIRM:
shutil.rmtree("/data/error_logs", ignore_errors=True)
os.makedirs("/data/error_logs", exist_ok=True)
gui_app.set_modal_overlay(alert_dialog(tr("Error logs deleted.")))
gui_app.set_modal_overlay(ConfirmDialog(tr("Delete all error logs?"), tr("Delete"), on_close=_do_delete))
class StarPilotBackupsLayout(StarPilotPanel):
def __init__(self):
super().__init__()
self.CATEGORIES = [
{"title": tr_noop("Create Backup"), "type": "hub", "on_click": self._on_create_backup, "color": "#FA6800"},
{"title": tr_noop("Restore Backup"), "type": "hub", "on_click": self._on_restore_backup, "color": "#FA6800"},
{"title": tr_noop("Delete Backup"), "type": "hub", "on_click": self._on_delete_backup, "color": "#FA6800"},
]
self._rebuild_grid()
def _get_backups(self):
b_dir = Path("/data/backups")
if not b_dir.exists():
return []
return [f.name for f in b_dir.glob("*.tar.zst") if "in_progress" not in f.name]
def _on_create_backup(self):
def on_name(res, name):
if res == DialogResult.CONFIRM:
safe_name = name.replace(" ", "_") if name else f"backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
backup_path = f"/data/backups/{safe_name}.tar.zst"
if Path(backup_path).exists():
gui_app.set_modal_overlay(alert_dialog(tr("A backup with this name already exists.")))
return
gui_app.set_modal_overlay(alert_dialog(tr("Backup creation started.")))
def _task():
os.makedirs("/data/backups", exist_ok=True)
subprocess.run(["tar", "--use-compress-program=zstd", "-cf", backup_path, "/data/openpilot"])
threading.Thread(target=_task, daemon=True).start()
gui_app.set_modal_overlay(InputDialog(tr("Name your backup"), "", on_close=on_name))
def _on_restore_backup(self):
backups = self._get_backups()
if not backups:
gui_app.set_modal_overlay(alert_dialog(tr("No backups found.")))
return
def _on_select(res, val):
if res == DialogResult.CONFIRM:
gui_app.set_modal_overlay(alert_dialog(tr("Restoring... device will reboot.")))
def _task():
subprocess.run(["rm", "-rf", "/data/openpilot/*"])
subprocess.run(["tar", "--use-compress-program=zstd", "-xf", f"/data/backups/{val}", "-C", "/"])
os.system("reboot")
threading.Thread(target=_task, daemon=True).start()
gui_app.set_modal_overlay(SelectionDialog(tr("Select Backup"), backups, on_close=_on_select))
def _on_delete_backup(self):
backups = self._get_backups()
if not backups:
gui_app.set_modal_overlay(alert_dialog(tr("No backups found.")))
return
def _on_select(res, val):
if res == DialogResult.CONFIRM:
os.remove(f"/data/backups/{val}")
self._rebuild_grid()
gui_app.set_modal_overlay(SelectionDialog(tr("Delete Backup"), backups, on_close=_on_select))
class StarPilotToggleBackupsLayout(StarPilotPanel):
def __init__(self):
super().__init__()
self.CATEGORIES = [
{"title": tr_noop("Create Toggle Backup"), "type": "hub", "on_click": self._on_create, "color": "#FA6800"},
{"title": tr_noop("Restore Toggle Backup"), "type": "hub", "on_click": self._on_restore, "color": "#FA6800"},
{"title": tr_noop("Delete Toggle Backup"), "type": "hub", "on_click": self._on_delete, "color": "#FA6800"},
]
self._rebuild_grid()
def _get_backups(self):
b_dir = Path("/data/toggle_backups")
if not b_dir.exists():
return []
return [d.name for d in b_dir.iterdir() if d.is_dir() and "in_progress" not in d.name]
def _on_create(self):
def on_name(res, name):
if res == DialogResult.CONFIRM:
safe_name = name.replace(" ", "_") if name else f"toggle_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
backup_path = Path(f"/data/toggle_backups/{safe_name}")
if backup_path.exists():
gui_app.set_modal_overlay(alert_dialog(tr("A toggle backup with this name already exists.")))
return
os.makedirs(backup_path, exist_ok=True)
shutil.copytree("/data/params/d", str(backup_path), dirs_exist_ok=True)
gui_app.set_modal_overlay(alert_dialog(tr("Toggle backup created.")))
self._rebuild_grid()
gui_app.set_modal_overlay(InputDialog(tr("Name your toggle backup"), "", on_close=on_name))
def _on_restore(self):
backups = self._get_backups()
if not backups:
gui_app.set_modal_overlay(alert_dialog(tr("No toggle backups found.")))
return
def _on_select(res, val):
if res == DialogResult.CONFIRM:
def on_confirm(r2):
if r2 == DialogResult.CONFIRM:
src = Path(f"/data/toggle_backups/{val}")
shutil.copytree(str(src), "/data/params/d", dirs_exist_ok=True)
gui_app.set_modal_overlay(alert_dialog(tr("Toggles restored.")))
self._rebuild_grid()
gui_app.set_modal_overlay(ConfirmDialog(tr("This will overwrite your current toggles."), tr("Restore"), on_close=on_confirm))
gui_app.set_modal_overlay(SelectionDialog(tr("Select Toggle Backup"), backups, on_close=_on_select))
def _on_delete(self):
backups = self._get_backups()
if not backups:
gui_app.set_modal_overlay(alert_dialog(tr("No toggle backups found.")))
return
def _on_select(res, val):
if res == DialogResult.CONFIRM:
shutil.rmtree(f"/data/toggle_backups/{val}", ignore_errors=True)
self._rebuild_grid()
gui_app.set_modal_overlay(SelectionDialog(tr("Delete Toggle Backup"), backups, on_close=_on_select))
class StarPilotStorageLayout(StarPilotPanel):
def __init__(self):
super().__init__()
self.CATEGORIES = [
{"title": tr_noop("Driving Data"), "type": "value", "get_value": self._get_storage, "on_click": lambda: None, "color": "#FA6800"},
]
self._rebuild_grid()
def _get_storage(self):
paths = ["/data/media/0/osm/offline", "/data/media/0/realdata", "/data/backups"]
total = 0
for p in paths:
pp = Path(p)
if pp.exists():
total += sum(f.stat().st_size for f in pp.rglob('*') if f.is_file())
mb = total / (1024 * 1024)
if mb > 1024:
return f"{(mb / 1024):.2f} GB"
return f"{mb:.2f} MB"
@@ -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
import subprocess
from pathlib import Path
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.system.hardware import HARDWARE
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.multilang import tr, tr_noop
from openpilot.system.ui.widgets.list_view import button_item
from openpilot.system.ui.widgets.scroller_tici import Scroller
from openpilot.system.ui.widgets import DialogResult
from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog
from openpilot.system.ui.widgets.selection_dialog import SelectionDialog
from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import StarPilotPanel
from openpilot.selfdrive.ui.layouts.settings.starpilot.metro import SliderDialog
class StarPilotDeviceLayout(StarPilotPanel):
def __init__(self):
super().__init__()
items = [
button_item(
tr_noop("Screen Controls"),
lambda: tr("MANAGE"),
tr_noop("<b>Adjust screen brightness, timeout,</b> and other display settings."),
),
button_item(
tr_noop("Device Settings"),
lambda: tr("MANAGE"),
tr_noop("<b>Configure device-specific options</b> like orientation and controls."),
),
self.CATEGORIES = [
{"title": tr_noop("Screen Settings"), "panel": "screen", "icon": "toggle_icons/icon_light.png", "color": "#FA6800"},
{"title": tr_noop("Device Settings"), "panel": "device_settings", "icon": "toggle_icons/icon_device.png", "color": "#FA6800"},
{
"title": tr_noop("Device Shutdown"),
"type": "value",
"get_value": self._get_shutdown_timer,
"on_click": self._show_shutdown_selector,
"color": "#FA6800",
},
{
"title": tr_noop("Disable Logging"),
"type": "toggle",
"get_state": lambda: self._params.get_bool("NoLogging"),
"set_state": lambda s: self._params.put_bool("NoLogging", s),
"color": "#FA6800",
},
{
"title": tr_noop("Disable Uploads"),
"type": "toggle",
"get_state": lambda: self._params.get_bool("NoUploads"),
"set_state": lambda s: self._params.put_bool("NoUploads", s),
"color": "#FA6800",
},
{
"title": tr_noop("Disable Onroad Uploads"),
"type": "toggle",
"param": "DisableOnroadUploads",
"get_state": lambda: self._params.get_bool("DisableOnroadUploads"),
"set_state": lambda s: self._params.put_bool("DisableOnroadUploads", s),
"color": "#FA6800",
},
{
"title": tr_noop("High-Quality Recording"),
"type": "toggle",
"param": "HigherBitrate",
"get_state": lambda: self._params.get_bool("HigherBitrate"),
"set_state": lambda s: self._on_higher_bitrate_toggle(s),
"color": "#FA6800",
},
{
"title": tr_noop("Screen Recorder"),
"type": "hub",
"on_click": self._on_screen_recorder_clicked,
"color": "#FA6800",
},
]
self._scroller = Scroller(items, line_separator=True, spacing=0)
self._sub_panels = {
"screen": StarPilotScreenLayout(),
"device_settings": StarPilotDeviceManagementLayout(),
}
for name, panel in self._sub_panels.items():
if hasattr(panel, 'set_navigate_callback'):
panel.set_navigate_callback(self._navigate_to)
if hasattr(panel, 'set_back_callback'):
panel.set_back_callback(self._go_back)
self._rebuild_grid()
def _rebuild_grid(self):
no_uploads = self._params.get_bool("NoUploads")
disable_onroad = self._params.get_bool("DisableOnroadUploads")
filtered = []
for cat in self.CATEGORIES:
param = cat.get("param")
if param == "DisableOnroadUploads" and not no_uploads:
continue
if param == "HigherBitrate" and (not no_uploads or disable_onroad):
continue
filtered.append(cat)
original = self.CATEGORIES
self.CATEGORIES = filtered
super()._rebuild_grid()
self.CATEGORIES = original
def _on_higher_bitrate_toggle(self, state):
self._params.put_bool("HigherBitrate", state)
cache_path = Path("/cache/use_HD")
if state:
cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.touch()
else:
if cache_path.exists():
cache_path.unlink()
if ui_state.started:
gui_app.set_modal_overlay(
ConfirmDialog(
tr("Reboot required. Reboot now?"), tr("Reboot"), tr("Cancel"), on_close=lambda res: HARDWARE.reboot() if res == DialogResult.CONFIRM else None
)
)
def _get_shutdown_timer(self):
v = self._params.get_int("DeviceShutdown")
if v == 0:
return tr("5 mins")
if v <= 3:
return f"{v * 15} mins"
return f"{v - 3} " + (tr("hour") if v == 4 else tr("hours"))
def _show_shutdown_selector(self):
def on_close(res, val):
if res == DialogResult.CONFIRM:
self._params.put_int("DeviceShutdown", int(val))
self._rebuild_grid()
labels = {0: tr("5 mins")}
for i in range(1, 4):
labels[i] = f"{i * 15} mins"
for i in range(4, 34):
labels[i] = f"{i - 3} " + (tr("hour") if i == 4 else tr("hours"))
gui_app.set_modal_overlay(SliderDialog(tr("Device Shutdown"), 0, 33, 1, self._params.get_int("DeviceShutdown"), on_close, labels=labels, color="#FA6800"))
def _on_screen_recorder_clicked(self):
is_recording = self._params_memory.get_bool("ScreenRecording")
if is_recording:
# Stop recording
subprocess.run(["killall", "-INT", "screenrecord"], capture_output=True)
self._params_memory.put_bool("ScreenRecording", False)
else:
# Start recording
output = "/tmp/screen_recording.mp4"
subprocess.Popen(["screenrecord", "--time-limit", "180", output], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
self._params_memory.put_bool("ScreenRecording", True)
class StarPilotScreenLayout(StarPilotPanel):
def __init__(self):
super().__init__()
self.CATEGORIES = [
{
"title": tr_noop("Brightness (Offroad)"),
"type": "value",
"get_value": lambda: self._get_brightness("ScreenBrightness"),
"on_click": lambda: self._show_brightness_selector("ScreenBrightness"),
"color": "#FA6800",
},
{
"title": tr_noop("Brightness (Onroad)"),
"type": "value",
"get_value": lambda: self._get_brightness("ScreenBrightnessOnroad"),
"on_click": lambda: self._show_brightness_selector("ScreenBrightnessOnroad"),
"color": "#FA6800",
},
{
"title": tr_noop("Timeout (Offroad)"),
"type": "value",
"get_value": lambda: f"{self._params.get_int('ScreenTimeout')}s",
"on_click": lambda: self._show_timeout_selector("ScreenTimeout"),
"color": "#FA6800",
},
{
"title": tr_noop("Timeout (Onroad)"),
"type": "value",
"get_value": lambda: f"{self._params.get_int('ScreenTimeoutOnroad')}s",
"on_click": lambda: self._show_timeout_selector("ScreenTimeoutOnroad"),
"color": "#FA6800",
},
{
"title": tr_noop("Standby Mode"),
"type": "toggle",
"get_state": lambda: self._params.get_bool("StandbyMode"),
"set_state": lambda s: self._params.put_bool("StandbyMode", s),
"color": "#FA6800",
},
]
self._rebuild_grid()
def _get_brightness(self, key):
v = self._params.get_int(key)
if v == 0:
return tr("Off")
if v == 101:
return tr("Auto")
return f"{v}%"
def _show_brightness_selector(self, key):
def on_close(res, val):
if res == DialogResult.CONFIRM:
new_v = int(val)
self._params.put_int(key, new_v)
HARDWARE.set_brightness(new_v)
self._rebuild_grid()
gui_app.set_modal_overlay(
SliderDialog(tr(key), 0, 101, 1, self._params.get_int(key), on_close, unit="%", labels={0: tr("Off"), 101: tr("Auto")}, color="#FA6800")
)
def _show_timeout_selector(self, key):
def on_close(res, val):
if res == DialogResult.CONFIRM:
self._params.put_int(key, int(val))
self._rebuild_grid()
gui_app.set_modal_overlay(SliderDialog(tr(key), 5, 60, 5, self._params.get_int(key), on_close, unit="s", color="#FA6800"))
class StarPilotDeviceManagementLayout(StarPilotPanel):
def __init__(self):
super().__init__()
self.CATEGORIES = [
{
"title": tr_noop("Low-Voltage Cutoff"),
"type": "value",
"get_value": lambda: f"{self._params.get_float('LowVoltageShutdown'):.1f}V",
"on_click": self._show_voltage_selector,
"color": "#FA6800",
},
{
"title": tr_noop("Raise Temp Limits"),
"type": "toggle",
"get_state": lambda: self._params.get_bool("IncreaseThermalLimits"),
"set_state": lambda s: self._params.put_bool("IncreaseThermalLimits", s),
"color": "#FA6800",
},
{
"title": tr_noop("Use Konik Server"),
"type": "toggle",
"get_state": lambda: self._get_konik_state(),
"set_state": lambda s: self._on_konik_toggle(s),
"color": "#FA6800",
},
]
self._rebuild_grid()
def _get_konik_state(self):
if Path("/data/not_vetted").exists():
return True
return self._params.get_bool("UseKonikServer")
def _on_konik_toggle(self, state):
self._params.put_bool("UseKonikServer", state)
cache_path = Path("/cache/use_konik")
if state:
cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.touch()
else:
if cache_path.exists():
cache_path.unlink()
if ui_state.started:
gui_app.set_modal_overlay(
ConfirmDialog(
tr("Reboot required. Reboot now?"), tr("Reboot"), tr("Cancel"), on_close=lambda res: HARDWARE.reboot() if res == DialogResult.CONFIRM else None
)
)
def _show_voltage_selector(self):
def on_close(res, val):
if res == DialogResult.CONFIRM:
self._params.put_float("LowVoltageShutdown", float(val))
self._rebuild_grid()
gui_app.set_modal_overlay(
SliderDialog(tr("Low-Voltage Cutoff"), 11.8, 12.5, 0.1, self._params.get_float("LowVoltageShutdown"), on_close, unit="V", color="#FA6800")
)
@@ -12,14 +12,13 @@ from openpilot.system.ui.lib.multilang import tr, tr_noop
from openpilot.system.ui.widgets import DialogResult
from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog, alert_dialog
from openpilot.system.ui.widgets.selection_dialog import SelectionDialog
from openpilot.system.ui.widgets.list_view import toggle_item, button_item
from openpilot.system.ui.widgets.scroller_tici import Scroller
from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import StarPilotPanel
from openpilot.selfdrive.ui.layouts.settings.starpilot.metro import SliderDialog
class StarPilotDrivingModelLayout(StarPilotPanel):
def __init__(self):
super().__init__()
self._toggle_items = {}
if PC:
self._model_dir = Path(os.path.expanduser("~/.comma/frogpilot/data/models"))
@@ -35,66 +34,35 @@ class StarPilotDrivingModelLayout(StarPilotPanel):
self._model_file_to_name = {}
self._model_series_map = {}
self._model_version_map = {}
self._current_model_name = ""
self._current_model_name = tr("Default")
self._toggle_items["ModelRandomizer"] = toggle_item(
tr_noop("Model Randomizer"),
tr_noop("<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"),
callback=self._on_model_randomizer_toggled,
)
self._toggle_items["AutomaticallyDownloadModels"] = toggle_item(
tr_noop("Automatically Download New Models"),
tr_noop("<b>Automatically download new driving models</b> as they become available."),
self._params.get_bool("AutomaticallyDownloadModels"),
callback=self._on_auto_download_toggled,
)
self._select_model_btn = button_item(
tr_noop("Select Driving Model"),
lambda: tr("SELECT"),
tr_noop("<b>Select the active driving model.</b>"),
callback=self._on_select_model_clicked,
)
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.CATEGORIES = [
{"title": tr_noop("Ratings"), "type": "hub", "icon": "toggle_icons/icon_system.png", "on_click": self._on_scores_clicked, "color": "#1BA1E2"},
{
"title": tr_noop("Recovery Power"),
"type": "value",
"icon": "toggle_icons/icon_longitudinal_tune.png",
"on_click": self._on_recovery_power_clicked,
"get_value": lambda: f"{self._params.get_float('RecoveryPower', default=1.0):.1f}",
"visible": lambda: self._params.get_int("TuningLevel", default=1) >= 3,
"color": "#1BA1E2",
},
{
"title": tr_noop("Stop Distance"),
"type": "value",
"icon": "toggle_icons/icon_longitudinal_tune.png",
"on_click": self._on_stop_distance_clicked,
"get_value": lambda: f"{self._params.get_float('StopDistance', default=6.0):.1f}",
"visible": lambda: self._params.get_int("TuningLevel", default=1) >= 3,
"color": "#1BA1E2",
},
]
self._model_manager = ModelManager(self._params, self._params_memory)
self._download_thread = None
self._scroller = Scroller(items, line_separator=True, spacing=0)
self._update_model_metadata()
self._rebuild_grid()
def _render(self, rect: rl.Rectangle):
self._update_state()
@@ -104,50 +72,13 @@ class StarPilotDrivingModelLayout(StarPilotPanel):
super().show_event()
self._update_model_metadata()
def refresh_visibility(self):
current_level = int(self._params.get("TuningLevel", return_default=True, default="1") or "1")
for key, item in self._toggle_items.items():
min_level = self._tuning_levels.get(key, 0)
item.set_visible(current_level >= min_level)
def _on_auto_download_toggled(self, state: bool):
self._params.put_bool("AutomaticallyDownloadModels", state)
def _is_model_installed(self, key: str) -> bool:
if not key:
return False
has_thneed = False
has_policy_meta = False
has_policy_tg = False
has_vision_meta = False
has_vision_tg = False
found_any = False
for file in self._model_dir.iterdir():
if not (file.name.startswith(key) or file.name.startswith(key + "_")):
continue
found_any = True
ext = file.suffix.lower()
base = file.stem
if ext == ".thneed":
has_thneed = True
elif ext == ".pkl":
if "_driving_policy_metadata" in base:
has_policy_meta = True
elif "_driving_policy_tinygrad" in base:
has_policy_tg = True
elif "_driving_vision_metadata" in base:
has_vision_meta = True
elif "_driving_vision_tinygrad" in base:
has_vision_tg = True
has_thneed = (self._model_dir / f"{key}.thneed").exists()
if has_thneed:
return True
return has_policy_meta and has_policy_tg and has_vision_meta and has_vision_tg
return (self._model_dir / f"{key}_driving_policy_tinygrad.pkl").exists()
def _update_model_metadata(self):
available_models_raw = self._params.get("AvailableModels", encoding='utf-8')
@@ -173,30 +104,27 @@ class StarPilotDrivingModelLayout(StarPilotPanel):
name = self._available_model_names[i].strip()
if not key or not name:
continue
series = self._available_model_series[i].strip() if i < len(self._available_model_series) else tr("Custom Series")
self._model_file_to_name[key] = name
self._model_series_map[key] = series
if i < len(self._available_model_versions):
version = self._available_model_versions[i].strip()
if version:
self._model_version_map[key] = version
v = self._available_model_versions[i].strip()
if v:
self._model_version_map[key] = v
if i < len(released_dates):
date = released_dates[i].strip()
if date:
self._model_released_dates[key] = date
d = released_dates[i].strip()
if d:
self._model_released_dates[key] = d
model_key = self._params.get("Model") or self._params.get("DrivingModel")
if model_key:
if isinstance(model_key, bytes):
model_key = model_key.decode()
if model_key and isinstance(model_key, bytes):
model_key = model_key.decode()
if not model_key or not self._is_model_installed(model_key):
model_key = self._params.get_default_value("Model") or self._params.get_default_value("DrivingModel") or ""
if isinstance(model_key, bytes):
if model_key and isinstance(model_key, bytes):
model_key = model_key.decode()
self._current_model_name = self._model_file_to_name.get(model_key, "Default")
self._select_model_btn.action_item._text_source = self._current_model_name
def _show_selection_dialog(self, title: str, options: dict[str, str] | list[str], current_val: str, on_confirm: Callable):
if not options:
@@ -204,9 +132,11 @@ class StarPilotDrivingModelLayout(StarPilotPanel):
return
if isinstance(options, list):
def _on_close_list(res, val):
if res == DialogResult.CONFIRM:
on_confirm(val)
dialog = SelectionDialog(title, options, current_val, on_close=_on_close_list)
gui_app.set_modal_overlay(dialog)
return
@@ -219,15 +149,14 @@ class StarPilotDrivingModelLayout(StarPilotPanel):
grouped[series] = []
grouped[series].append(name)
name_to_key[name] = key
for series in grouped:
grouped[series].sort()
sorted_series = sorted(grouped.keys())
if "StarPilot" in sorted_series:
sorted_series.remove("StarPilot")
sorted_series.insert(0, "StarPilot")
final_grouped = {s: grouped[s] for s in sorted_series}
def _on_close_grouped(res, val):
@@ -247,15 +176,15 @@ class StarPilotDrivingModelLayout(StarPilotPanel):
comm_favs = [f.strip() for f in (self._params.get("CommunityFavorites", encoding='utf-8') or "").split(",") if f.strip()]
dialog = SelectionDialog(
title,
final_grouped,
current_val,
on_close=_on_close_grouped,
model_released_dates=self._model_released_dates,
model_file_to_name=self._model_file_to_name,
user_favorites=user_favs,
community_favorites=comm_favs,
on_favorite_toggled=_on_favorite_toggled
title,
final_grouped,
current_val,
on_close=_on_close_grouped,
model_released_dates=self._model_released_dates,
model_file_to_name=self._model_file_to_name,
user_favorites=user_favs,
community_favorites=comm_favs,
on_favorite_toggled=_on_favorite_toggled,
)
gui_app.set_modal_overlay(dialog)
@@ -268,20 +197,17 @@ class StarPilotDrivingModelLayout(StarPilotPanel):
self._params.put("Model", model_key)
self._params.put("DrivingModel", model_key)
self._params.put("DrivingModelName", installed_models[model_key])
model_version = self._model_version_map.get(model_key, "")
if model_version:
self._params.put("ModelVersion", model_version)
self._params.put("DrivingModelVersion", model_version)
mv = self._model_version_map.get(model_key, "")
if mv:
self._params.put("ModelVersion", mv)
self._params.put("DrivingModelVersion", mv)
self._update_model_metadata()
if ui_state.started:
reboot_dialog = ConfirmDialog(
tr("Reboot required to take effect. Reboot now?"),
tr("Reboot"),
tr("Cancel"),
on_close=lambda res: HARDWARE.reboot() if res == DialogResult.CONFIRM else None
gui_app.set_modal_overlay(
ConfirmDialog(
tr("Reboot required. Reboot now?"), tr("Reboot"), tr("Cancel"), on_close=lambda res: HARDWARE.reboot() if res == DialogResult.CONFIRM else None
)
)
gui_app.set_modal_overlay(reboot_dialog)
self._show_selection_dialog(tr("Select Driving Model"), installed_models, self._current_model_name, _on_confirm)
@@ -294,136 +220,109 @@ class StarPilotDrivingModelLayout(StarPilotPanel):
return
not_installed = {k: v for k, v in self._model_file_to_name.items() if not self._is_model_installed(k)}
def _on_confirm(model_key):
self._params_memory.put("ModelToDownload", model_key)
self._params_memory.put("ModelDownloadProgress", "Downloading...")
self._show_selection_dialog(tr("Select Model to Download"), not_installed, "", _on_confirm)
self._show_selection_dialog(tr("Select Model to Download"), not_installed, "", lambda mk: self._params_memory.put("ModelToDownload", mk))
def _on_delete_clicked(self):
installed = {k: v for k, v in self._model_file_to_name.items() if self._is_model_installed(k)}
default_key = self._params.get_default_value("Model") or ""
if isinstance(default_key, bytes):
default_key = default_key.decode()
current_key = self._params.get("Model", encoding='utf-8') or ""
deletable = {k: v for k, v in installed.items() if k != default_key and k != current_key}
def _on_confirm(model_key):
def _execute_delete(confirm_res):
if confirm_res == DialogResult.CONFIRM:
for file in self._model_dir.iterdir():
if file.name.startswith(model_key):
file.unlink()
self._update_model_metadata()
confirm = ConfirmDialog(
tr(f"Are you sure you want to delete the '{deletable[model_key]}' model?"),
tr("Delete"),
on_close=_execute_delete
)
gui_app.set_modal_overlay(confirm)
dk = self._params.get_default_value("Model") or ""
if isinstance(dk, bytes):
dk = dk.decode()
ck = self._params.get("Model", encoding='utf-8') or ""
deletable = {k: v for k, v in installed.items() if k != dk and k != ck}
def _on_confirm(mk):
def _execute_delete(res):
if res == DialogResult.CONFIRM:
for file in self._model_dir.iterdir():
if file.name.startswith(mk):
file.unlink()
self._update_model_metadata()
gui_app.set_modal_overlay(ConfirmDialog(tr(f"Delete '{deletable[mk]}'?"), tr("Delete"), on_close=_execute_delete))
self._show_selection_dialog(tr("Select Model to Delete"), deletable, "", _on_confirm)
def _on_blacklist_clicked(self):
blacklisted = [m.strip() for m in (self._params.get("BlacklistedModels", encoding='utf-8') or "").split(",") if m.strip()]
blacklisted = [b for b in blacklisted if b]
def _on_action_selected(res, val):
if res == DialogResult.CONFIRM:
if val == tr("ADD"):
blacklistable = {k: v for k, v in self._model_file_to_name.items() if k not in blacklisted}
self._show_selection_dialog(tr("Add to Blacklist"), blacklistable, "", lambda k: self._params.put("BlacklistedModels", ",".join(blacklisted + [k])))
elif val == tr("REMOVE"):
options = {k: self._model_file_to_name.get(k, k) for k in blacklisted}
def _remove(k):
blacklisted.remove(k)
self._params.put("BlacklistedModels", ",".join(blacklisted))
self._show_selection_dialog(tr("Remove from Blacklist"), options, "", _remove)
elif val == tr("RESET ALL"):
self._params.remove("BlacklistedModels")
if res == DialogResult.CONFIRM:
if val == tr("ADD"):
blacklistable = {k: v for k, v in self._model_file_to_name.items() if k not in blacklisted}
self._show_selection_dialog(tr("Add to Blacklist"), blacklistable, "", lambda k: self._params.put("BlacklistedModels", ",".join(blacklisted + [k])))
elif val == tr("REMOVE"):
options = {k: self._model_file_to_name.get(k, k) for k in blacklisted}
dialog = SelectionDialog(
tr("Manage Blacklist"),
[tr("ADD"), tr("REMOVE"), tr("RESET ALL")],
on_close=_on_action_selected
)
gui_app.set_modal_overlay(dialog)
def _remove(k):
blacklisted.remove(k)
self._params.put("BlacklistedModels", ",".join(blacklisted))
self._show_selection_dialog(tr("Remove from Blacklist"), options, "", _remove)
elif val == tr("RESET ALL"):
self._params.remove("BlacklistedModels")
gui_app.set_modal_overlay(SelectionDialog(tr("Manage Blacklist"), [tr("ADD"), tr("REMOVE"), tr("RESET ALL")], on_close=_on_action_selected))
def _on_scores_clicked(self):
scores_raw = self._params.get("ModelDrivesAndScores", encoding='utf-8') or ""
if not scores_raw:
gui_app.set_modal_overlay(alert_dialog(tr("No model ratings found.")))
return
try:
scores = json.loads(scores_raw)
lines = [f"{k}: {v.get('Score', 0)}% ({v.get('Drives', 0)} drives)" for k, v in scores.items()]
confirm = ConfirmDialog("\n".join(lines), tr("Close"), rich=True)
gui_app.set_modal_overlay(confirm)
scores = json.loads(scores_raw)
lines = [f"{k}: {v.get('Score', 0)}% ({v.get('Drives', 0)} drives)" for k, v in scores.items()]
gui_app.set_modal_overlay(ConfirmDialog("\n".join(lines), tr("Close"), rich=True))
except:
pass
pass
def _on_model_randomizer_toggled(self, state: bool):
self._params.put_bool("ModelRandomizer", state)
if state:
not_installed = [k for k in self._model_file_to_name if not self._is_model_installed(k)]
if not_installed:
def _on_download_confirm(res):
if res == DialogResult.CONFIRM:
self._params_memory.put_bool("DownloadAllModels", True)
self._params_memory.put("ModelDownloadProgress", "Downloading...")
confirm = ConfirmDialog(
tr("Model Randomizer works best with all models. Download all now?"),
tr("Download All"),
on_close=_on_download_confirm
)
gui_app.set_modal_overlay(confirm)
not_installed = [k for k in self._model_file_to_name if not self._is_model_installed(k)]
if not_installed:
def _on_download_confirm(res):
if res == DialogResult.CONFIRM:
self._params_memory.put_bool("DownloadAllModels", True)
self._params_memory.put("ModelDownloadProgress", "Downloading...")
gui_app.set_modal_overlay(ConfirmDialog(tr("Download all models for Randomizer?"), tr("Download All"), on_close=_on_download_confirm))
def _on_recovery_power_clicked(self):
def on_close(res, val):
if res == DialogResult.CONFIRM:
self._params.put_float("RecoveryPower", val)
self._rebuild_grid()
gui_app.set_modal_overlay(
SliderDialog(tr("Recovery Power"), 0.5, 2.0, 0.1, self._params.get_float("RecoveryPower", default=1.0), on_close, color="#1BA1E2")
)
def _on_stop_distance_clicked(self):
def on_close(res, val):
if res == DialogResult.CONFIRM:
self._params.put_float("StopDistance", val)
self._rebuild_grid()
gui_app.set_modal_overlay(SliderDialog(tr("Stop Distance"), 4.0, 10.0, 0.5, self._params.get_float("StopDistance", default=6.0), on_close, color="#1BA1E2"))
def _update_state(self):
if not self.is_visible:
return
model_to_download = self._params_memory.get("ModelToDownload", encoding='utf-8') or ""
download_all = self._params_memory.get_bool("DownloadAllModels")
progress = self._params_memory.get("ModelDownloadProgress", encoding='utf-8') or ""
is_downloading = bool(model_to_download or download_all)
if is_downloading and (self._download_thread is None or not self._download_thread.is_alive()):
def _download_task():
try:
if download_all:
print("Starting [All Models] download thread...")
self._model_manager.download_all_models()
else:
print(f"Starting [{model_to_download}] download thread...")
self._model_manager.download_model(model_to_download)
print("Download thread finished successfully.")
except Exception as e:
print(f"Download thread CRASHED: {e}")
import traceback
traceback.print_exc()
finally:
self._download_thread = None
self._download_thread = threading.Thread(target=_download_task, daemon=True)
self._download_thread.start()
if is_downloading:
self._download_model_btn.action_item._text_source = tr("CANCEL")
self._download_model_btn.action_item._value_source = progress if progress else tr("Downloading...")
else:
self._download_model_btn.action_item._text_source = tr("DOWNLOAD")
parked = not ui_state.started
online = ui_state.sm["deviceState"].networkType != 0 if ui_state.sm.valid.get("deviceState", False) else True
if not online:
self._download_model_btn.action_item._value_source = tr("Offline...")
elif not parked:
self._download_model_btn.action_item._value_source = tr("Not parked")
else:
self._download_model_btn.action_item._value_source = ""
all_installed = all(self._is_model_installed(k) for k in self._model_file_to_name)
if all_installed:
self._download_model_btn.action_item._value_source = tr("All Downloaded!")
def _download_task():
try:
if download_all:
self._model_manager.download_all_models()
else:
self._model_manager.download_model(model_to_download)
except:
pass
finally:
self._download_thread = None
self._download_thread = threading.Thread(target=_download_task, daemon=True)
self._download_thread.start()
@@ -1,325 +1,126 @@
from __future__ import annotations
from openpilot.selfdrive.ui.lib.starpilot_state import starpilot_state
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.multilang import tr, tr_noop
from openpilot.system.ui.widgets import DialogResult
from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog
from openpilot.system.ui.widgets.list_view import button_item, value_button_item, button_toggle_item, toggle_item, value_item
from openpilot.system.ui.widgets.scroller_tici import Scroller
from openpilot.system.ui.widgets.selection_dialog import SelectionDialog
from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import StarPilotPanel
from openpilot.selfdrive.ui.layouts.settings.starpilot.metro import SliderDialog
class StarPilotAdvancedLateralLayout(StarPilotPanel):
def __init__(self):
super().__init__()
items = [
value_button_item(
lambda: (
tr("Actuator Delay")
+ (f" (Default: {starpilot_state.car_state.steerActuatorDelay:.2f})" if starpilot_state.car_state.steerActuatorDelay != 0 else "")
),
"SteerDelay",
min_val=0.01,
max_val=1.0,
step=0.01,
button_text="Reset",
button_callback=lambda: self._params.put_float("SteerDelay", starpilot_state.car_state.steerActuatorDelay),
description=tr_noop(
"<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.CATEGORIES = [
{"title": tr_noop("Actuator Delay"), "type": "value", "get_value": lambda: f"{self._params.get_float('SteerDelay'):.2f}s", "on_click": lambda: self._show_float_selector("SteerDelay", 0.0, 0.5, 0.01, "s"), "icon": "toggle_icons/icon_advanced_lateral_tune.png", "color": "#1BA1E2"},
{"title": tr_noop("Friction"), "type": "value", "get_value": lambda: f"{self._params.get_float('SteerFriction'):.3f}", "on_click": lambda: self._show_float_selector("SteerFriction", 0.0, 0.5, 0.005), "icon": "toggle_icons/icon_advanced_lateral_tune.png", "color": "#1BA1E2"},
{"title": tr_noop("Kp Factor"), "type": "value", "get_value": lambda: f"{self._params.get_float('SteerKP'):.2f}", "on_click": lambda: self._show_float_selector("SteerKP", 0.5, 2.5, 0.01), "icon": "toggle_icons/icon_advanced_lateral_tune.png", "color": "#1BA1E2"},
{"title": tr_noop("Lateral Accel"), "type": "value", "get_value": lambda: f"{self._params.get_float('SteerLatAccel'):.2f}", "on_click": lambda: self._show_float_selector("SteerLatAccel", 0.5, 5.0, 0.01), "icon": "toggle_icons/icon_advanced_lateral_tune.png", "color": "#1BA1E2"},
{"title": tr_noop("Steer Ratio"), "type": "value", "get_value": lambda: f"{self._params.get_float('SteerRatio'):.2f}", "on_click": lambda: self._show_float_selector("SteerRatio", 5.0, 25.0, 0.01), "icon": "toggle_icons/icon_advanced_lateral_tune.png", "color": "#1BA1E2"},
{"title": tr_noop("Force Auto-Tune On"), "type": "toggle", "get_state": lambda: self._params.get_bool("ForceAutoTune"), "set_state": lambda x: self._params.put_bool("ForceAutoTune", x), "icon": "toggle_icons/icon_tuning.png", "color": "#1BA1E2"},
{"title": tr_noop("Force Auto-Tune Off"), "type": "toggle", "get_state": lambda: self._params.get_bool("ForceAutoTuneOff"), "set_state": lambda x: self._params.put_bool("ForceAutoTuneOff", x), "icon": "toggle_icons/icon_tuning.png", "color": "#1BA1E2"},
{"title": tr_noop("Force Torque Controller"), "type": "toggle", "get_state": lambda: self._params.get_bool("ForceTorqueController"), "set_state": lambda x: self._on_reboot_toggle("ForceTorqueController", x), "icon": "toggle_icons/icon_advanced_lateral_tune.png", "color": "#1BA1E2"},
]
self._scroller = Scroller(items, line_separator=True, spacing=0)
self._rebuild_grid()
def _show_float_selector(self, key, min_v, max_v, step, unit=""):
def on_close(res, val):
if res == DialogResult.CONFIRM:
self._params.put_float(key, float(val))
self._rebuild_grid()
gui_app.set_modal_overlay(SliderDialog(tr(key), min_v, max_v, step, self._params.get_float(key), on_close, unit=unit, color="#1BA1E2"))
def _on_reboot_toggle(self, key, state):
self._params.put_bool(key, state)
from openpilot.selfdrive.ui.ui_state import ui_state
if ui_state.started:
from openpilot.system.ui.lib.application import gui_app
def _confirm_reboot(res):
gui_app.set_modal_overlay(None)
if res == DialogResult.CONFIRM:
from openpilot.system.hardware import HARDWARE
HARDWARE.reboot()
dialog = ConfirmDialog("Reboot required to take effect. Reboot now?", "Reboot", "Cancel", on_close=_confirm_reboot)
dialog = ConfirmDialog(tr("Reboot required. Reboot now?"), tr("Reboot"), tr("Cancel"), on_close=lambda res: HARDWARE.reboot() if res == DialogResult.CONFIRM else None)
gui_app.set_modal_overlay(dialog)
class StarPilotAlwaysOnLateralLayout(StarPilotPanel):
def __init__(self):
super().__init__()
items = [
toggle_item(
tr_noop("Always On Lateral"),
tr_noop("<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.CATEGORIES = [
{"title": tr_noop("Always On Lateral"), "type": "toggle", "get_state": lambda: self._params.get_bool("AlwaysOnLateral"), "set_state": lambda x: self._on_reboot_toggle("AlwaysOnLateral", x), "icon": "toggle_icons/icon_always_on_lateral.png", "color": "#1BA1E2"},
{"title": tr_noop("Enable With LKAS"), "type": "toggle", "get_state": lambda: self._params.get_bool("AlwaysOnLateralLKAS"), "set_state": lambda x: self._params.put_bool("AlwaysOnLateralLKAS", x), "icon": "toggle_icons/icon_always_on_lateral.png", "color": "#1BA1E2"},
{"title": tr_noop("Pause Below"), "type": "value", "get_value": lambda: f"{self._params.get_int('PauseAOLOnBrake')} mph", "on_click": lambda: self._show_speed_selector("PauseAOLOnBrake"), "icon": "toggle_icons/icon_always_on_lateral.png", "color": "#1BA1E2"},
]
self._scroller = Scroller(items, line_separator=True, spacing=0)
self._rebuild_grid()
def _show_speed_selector(self, key):
def on_close(res, val):
if res == DialogResult.CONFIRM:
self._params.put_int(key, int(val))
self._rebuild_grid()
gui_app.set_modal_overlay(SliderDialog(tr(key), 0, 100, 1, self._params.get_int(key), on_close, unit=" mph", color="#1BA1E2"))
def _on_reboot_toggle(self, key, state):
self._params.put_bool(key, state)
from openpilot.selfdrive.ui.ui_state import ui_state
if ui_state.started:
from openpilot.system.ui.lib.application import gui_app
def _confirm_reboot(res):
gui_app.set_modal_overlay(None)
if res == DialogResult.CONFIRM:
from openpilot.system.hardware import HARDWARE
HARDWARE.reboot()
dialog = ConfirmDialog("Reboot required to take effect. Reboot now?", "Reboot", "Cancel", on_close=_confirm_reboot)
gui_app.set_modal_overlay(dialog)
gui_app.set_modal_overlay(ConfirmDialog(tr("Reboot required. Reboot now?"), tr("Reboot"), tr("Cancel"), on_close=lambda res: HARDWARE.reboot() if res == DialogResult.CONFIRM else None))
class StarPilotLaneChangesLayout(StarPilotPanel):
def __init__(self):
super().__init__()
def _get_lane_change_labels():
labels = {0.0: tr("Instant")}
for i in range(1, 51):
val = i / 10.0
labels[val] = f"{val:.1f} seconds" if val != 1.0 else "1.0 second"
return labels
items = [
toggle_item(
tr_noop("Lane Changes"),
tr_noop("<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.CATEGORIES = [
{"title": tr_noop("Lane Changes"), "type": "toggle", "get_state": lambda: self._params.get_bool("LaneChanges"), "set_state": lambda s: self._params.put_bool("LaneChanges", s), "icon": "toggle_icons/icon_lane.png", "color": "#1BA1E2"},
{"title": tr_noop("Automatic Lane Changes"), "type": "toggle", "get_state": lambda: self._params.get_bool("NudgelessLaneChange"), "set_state": lambda s: self._params.put_bool("NudgelessLaneChange", s), "icon": "toggle_icons/icon_lane.png", "color": "#1BA1E2"},
{"title": tr_noop("Lane Change Delay"), "type": "value", "get_value": lambda: f"{self._params.get_float('LaneChangeTime'):.1f}s", "on_click": lambda: self._show_float_selector("LaneChangeTime", 0.0, 5.0, 0.1, "s"), "icon": "toggle_icons/icon_lane.png", "color": "#1BA1E2"},
{"title": tr_noop("Min Lane Change Speed"), "type": "value", "get_value": lambda: f"{self._params.get_int('MinimumLaneChangeSpeed')} mph", "on_click": lambda: self._show_speed_selector("MinimumLaneChangeSpeed"), "icon": "toggle_icons/icon_lane.png", "color": "#1BA1E2"},
{"title": tr_noop("Minimum Lane Width"), "type": "value", "get_value": lambda: f"{self._params.get_float('LaneDetectionWidth'):.1f} ft", "on_click": lambda: self._show_float_selector("LaneDetectionWidth", 0.0, 15.0, 0.1, " ft"), "icon": "toggle_icons/icon_lane.png", "color": "#1BA1E2"},
{"title": tr_noop("One Lane Change Per Signal"), "type": "toggle", "get_state": lambda: self._params.get_bool("OneLaneChange"), "set_state": lambda s: self._params.put_bool("OneLaneChange", s), "icon": "toggle_icons/icon_lane.png", "color": "#1BA1E2"},
]
self._scroller = Scroller(items, line_separator=True, spacing=0)
self._rebuild_grid()
def _show_speed_selector(self, key):
def on_close(res, val):
if res == DialogResult.CONFIRM:
self._params.put_int(key, int(val))
self._rebuild_grid()
gui_app.set_modal_overlay(SliderDialog(tr(key), 0, 100, 1, self._params.get_int(key), on_close, unit=" mph", color="#1BA1E2"))
def _show_float_selector(self, key, min_v, max_v, step, unit=""):
def on_close(res, val):
if res == DialogResult.CONFIRM:
self._params.put_float(key, float(val))
self._rebuild_grid()
gui_app.set_modal_overlay(SliderDialog(tr(key), min_v, max_v, step, self._params.get_float(key), on_close, unit=unit, color="#1BA1E2"))
class StarPilotLateralTuneLayout(StarPilotPanel):
def __init__(self):
super().__init__()
items = [
toggle_item(
tr_noop("Force Turn Desires Below Lane Change Speed"),
tr_noop("<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.CATEGORIES = [
{"title": tr_noop("Force Turn Desires"), "type": "toggle", "get_state": lambda: self._params.get_bool("TurnDesires"), "set_state": lambda x: self._params.put_bool("TurnDesires", x), "icon": "toggle_icons/icon_lateral_tune.png", "color": "#1BA1E2"},
{"title": tr_noop("NNFF"), "type": "toggle", "get_state": lambda: self._params.get_bool("NNFF"), "set_state": lambda x: self._on_reboot_toggle("NNFF", x), "icon": "toggle_icons/icon_lateral_tune.png", "color": "#1BA1E2"},
{"title": tr_noop("NNFF Lite"), "type": "toggle", "get_state": lambda: self._params.get_bool("NNFFLite"), "set_state": lambda x: self._on_reboot_toggle("NNFFLite", x), "icon": "toggle_icons/icon_lateral_tune.png", "color": "#1BA1E2"},
]
self._scroller = Scroller(items, line_separator=True, spacing=0)
self._rebuild_grid()
def _on_reboot_toggle(self, key, state):
self._params.put_bool(key, state)
from openpilot.selfdrive.ui.ui_state import ui_state
if ui_state.started:
from openpilot.system.ui.lib.application import gui_app
def _confirm_reboot(res):
gui_app.set_modal_overlay(None)
if res == DialogResult.CONFIRM:
from openpilot.system.hardware import HARDWARE
HARDWARE.reboot()
dialog = ConfirmDialog("Reboot required to take effect. Reboot now?", "Reboot", "Cancel", on_close=_confirm_reboot)
gui_app.set_modal_overlay(dialog)
gui_app.set_modal_overlay(ConfirmDialog(tr("Reboot required. Reboot now?"), tr("Reboot"), tr("Cancel"), on_close=lambda res: HARDWARE.reboot() if res == DialogResult.CONFIRM else None))
class StarPilotLateralQOLLayout(StarPilotPanel):
def __init__(self):
super().__init__()
items = [
value_button_item(
tr_noop("Pause Steering Below"),
"PauseLateralSpeed",
min_val=0,
max_val=99,
step=1,
unit="mph",
description=tr_noop("<b>Pause steering below the set speed.</b>"),
sub_toggles=[("PauseLateralOnSignal", True)],
labels={0: tr_noop("Off")},
is_metric=True,
)
self.CATEGORIES = [
{"title": tr_noop("Pause Steering Below"), "type": "value", "get_value": lambda: f"{self._params.get_int('PauseLateralSpeed')} mph", "on_click": lambda: self._show_speed_selector("PauseLateralSpeed"), "icon": "toggle_icons/icon_quality_of_life.png", "color": "#1BA1E2"}
]
self._rebuild_grid()
self._scroller = Scroller(items, line_separator=True, spacing=0)
def _show_speed_selector(self, key):
def on_close(res, val):
if res == DialogResult.CONFIRM:
self._params.put_int(key, int(val))
self._rebuild_grid()
gui_app.set_modal_overlay(SliderDialog(tr(key), 0, 100, 1, self._params.get_int(key), on_close, unit=" mph", color="#1BA1E2"))
class StarPilotLateralLayout(StarPilotPanel):
def __init__(self):
super().__init__()
self._sub_panels = {
"advanced_lateral": StarPilotAdvancedLateralLayout(),
"always_on_lateral": StarPilotAlwaysOnLateralLayout(),
@@ -327,54 +128,14 @@ class StarPilotLateralLayout(StarPilotPanel):
"lateral_tune": StarPilotLateralTuneLayout(),
"qol": StarPilotLateralQOLLayout(),
}
for name, panel in self._sub_panels.items():
if hasattr(panel, 'set_navigate_callback'):
panel.set_navigate_callback(self._navigate_to)
if hasattr(panel, 'set_back_callback'):
panel.set_back_callback(self._go_back)
items = [
button_item(
tr_noop("Advanced Lateral Tuning"),
lambda: tr("MANAGE"),
tr_noop("<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,
),
self.CATEGORIES = [
{"title": tr_noop("Advanced Lateral Tuning"), "panel": "advanced_lateral", "icon": "toggle_icons/icon_advanced_lateral_tune.png", "color": "#1BA1E2"},
{"title": tr_noop("Always On Lateral"), "panel": "always_on_lateral", "icon": "toggle_icons/icon_always_on_lateral.png", "color": "#1BA1E2"},
{"title": tr_noop("Lane Changes"), "panel": "lane_changes", "icon": "toggle_icons/icon_lane.png", "color": "#1BA1E2"},
{"title": tr_noop("Lateral Tuning"), "panel": "lateral_tune", "icon": "toggle_icons/icon_lateral_tune.png", "color": "#1BA1E2"},
{"title": tr_noop("Quality of Life"), "panel": "qol", "icon": "toggle_icons/icon_quality_of_life.png", "color": "#1BA1E2"},
]
self._scroller = Scroller(items, line_separator=True, spacing=0)
for name, panel in self._sub_panels.items():
if hasattr(panel, 'set_navigate_callback'): panel.set_navigate_callback(self._navigate_to)
if hasattr(panel, 'set_back_callback'): panel.set_back_callback(self._go_back)
self._rebuild_grid()
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.system.ui.widgets import Widget
from openpilot.system.ui.lib.multilang import tr, tr_noop
from openpilot.system.ui.widgets.scroller_tici import Scroller
from openpilot.system.ui.widgets.list_view import multiple_button_item, category_buttons_item
from openpilot.system.ui.lib.application import MousePos
from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import StarPilotPanelType, StarPilotPanelInfo
from openpilot.selfdrive.ui.layouts.settings.starpilot.sounds import StarPilotSoundsLayout
@@ -22,46 +21,56 @@ from openpilot.selfdrive.ui.layouts.settings.starpilot.visuals import StarPilotV
from openpilot.selfdrive.ui.layouts.settings.starpilot.themes import StarPilotThemesLayout
from openpilot.selfdrive.ui.layouts.settings.starpilot.vehicle import StarPilotVehicleSettingsLayout
from openpilot.selfdrive.ui.layouts.settings.starpilot.wheel import StarPilotWheelLayout
from openpilot.selfdrive.ui.layouts.settings.starpilot.developer import StarPilotDeveloperLayout
from openpilot.selfdrive.ui.layouts.settings.starpilot.metro import TileGrid, HubTile, RadioTileGroup
STARPILOT_ICONS_DIR = "toggle_icons"
class StarPilotLayout(Widget):
CATEGORIES = [
{
"title": "Alerts and Sounds",
"icon": "icon_sound.png",
"desc": "<b>Adjust alert volumes and enable custom notifications.</b>",
"desc": "Adjust alert volumes and enable custom notifications.",
"buttons": [("MANAGE", "SOUNDS", 0)],
"color": "#FF0097",
},
{
"title": "Driving Controls",
"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)],
"color": "#1BA1E2",
},
{
"title": "Navigation",
"icon": "icon_navigate.png",
"desc": "<b>Download map data for the Speed Limit Controller.</b>",
"buttons": [("MAP DATA", "MAPS", 0), ("NAVIGATION", "NAVIGATION", 1)],
"desc": "Download map data for the Speed Limit Controller.",
"buttons": [("MAP DATA", "MAPS", 0), ("NAVIGATION", "NAVIGATION", 0)],
"color": "#8CBF26",
},
{
"title": "System Settings",
"icon": "icon_system.png",
"desc": "<b>Manage backups, device settings, screen options, storage, and tools to keep StarPilot running smoothly.</b>",
"buttons": [("DATA", "DATA", 0), ("DEVICE CONTROLS", "DEVICE", 2), ("UTILITIES", "UTILITIES", 0)],
"desc": "Manage backups, device settings, screen options, storage, and tools to keep StarPilot running smoothly.",
"buttons": [("DATA", "DATA", 0), ("DEVICE CONTROLS", "DEVICE", 0), ("UTILITIES", "UTILITIES", 0)],
"color": "#FA6800",
},
{
"title": "Theme and Appearance",
"icon": "icon_display.png",
"desc": "<b>Customize the look of the driving screen and interface, including themes!</b>",
"buttons": [("APPEARANCE", "VISUALS", 0), ("THEME", "THEMES", 0)],
"desc": "Customize the look of the driving screen and interface, including themes!",
"buttons": [("APPEARANCE", "VISUALS", 0), ("THEME", "THEMES", 0), ("DEVELOPER", "DEVELOPER", 0)],
"color": "#A200FF",
},
{
"title": "Vehicle Settings",
"icon": "icon_vehicle.png",
"desc": "<b>Configure car-specific options and steering wheel button mappings.</b>",
"buttons": [("VEHICLE SETTINGS", "VEHICLE", 0), ("WHEEL CONTROLS", "WHEEL", 1)],
"desc": "Configure car-specific options and steering wheel button mappings.",
"buttons": [("VEHICLE SETTINGS", "VEHICLE", 0), ("WHEEL CONTROLS", "WHEEL", 0)],
"color": "#FFC40D",
},
]
@@ -70,18 +79,12 @@ class StarPilotLayout(Widget):
self._params = Params()
self._current_panel = StarPilotPanelType.MAIN
self._current_category_idx: int | None = None
self._depth_callback: Callable | None = None
self._settings_layout = None
self._panel_stack: list[tuple[StarPilotPanelType, str]] = []
self._sub_panel_callbacks: dict[str, Callable] = {}
self._toggle_tuning_levels: dict[str, int] = {}
all_keys = self._params.all_keys()
for key in all_keys:
level = self._params.get_tuning_level(key)
if level is not None:
self._toggle_tuning_levels[key] = level
self._panel_stack: list[tuple[StarPilotPanelType, str]] = []
self._sub_panel_callbacks: dict[str, Callable] = {}
self._panels = {
StarPilotPanelType.MAIN: StarPilotPanelInfo("", None),
@@ -98,78 +101,18 @@ class StarPilotLayout(Widget):
StarPilotPanelType.THEMES: StarPilotPanelInfo(tr_noop("Themes"), StarPilotThemesLayout()),
StarPilotPanelType.VEHICLE: StarPilotPanelInfo(tr_noop("Vehicle Settings"), StarPilotVehicleSettingsLayout()),
StarPilotPanelType.WHEEL: StarPilotPanelInfo(tr_noop("Wheel Controls"), StarPilotWheelLayout()),
StarPilotPanelType.DEVELOPER: StarPilotPanelInfo(tr_noop("Developer"), StarPilotDeveloperLayout()),
}
self._setup_longitudinal_sub_panels()
self._setup_sounds_sub_panels()
self._setup_lateral_sub_panels()
self._setup_navigation_sub_panels()
self._setup_maps_sub_panels()
self._setup_developer_sub_panels()
for panel_type in [
StarPilotPanelType.SOUNDS,
StarPilotPanelType.DRIVING_MODEL,
]:
panel = self._panels[panel_type].instance
if panel and hasattr(panel, 'set_tuning_levels'):
panel.set_tuning_levels(self._toggle_tuning_levels)
tuning_levels = [tr("Minimal"), tr("Standard"), tr("Advanced"), tr("Developer")]
tuning_level_str = self._params.get("TuningLevel", return_default=True, default="1")
current_tuning_level = int(tuning_level_str) if tuning_level_str else 1
items = [
multiple_button_item(
tr_noop("Tuning Level"),
tr_noop(
"Choose your tuning level. Lower levels keep it simple; higher levels unlock more toggles for finer control.\n\n"
"Minimal - Ideal for those who prefer simplicity or ease of use\n"
"Standard - Recommended for most users for a balanced experience\n"
"Advanced - Fine-tuning for experienced users\n"
"Developer - Highly customizable settings for seasoned enthusiasts"
),
tuning_levels,
current_tuning_level,
callback=self._on_tuning_level_changed,
icon=f"{STARPILOT_ICONS_DIR}/icon_tuning.png",
starpilot_icon=True,
),
]
panel_type_map = {
"SOUNDS": StarPilotPanelType.SOUNDS,
"DRIVING_MODEL": StarPilotPanelType.DRIVING_MODEL,
"LONGITUDINAL": StarPilotPanelType.LONGITUDINAL,
"LATERAL": StarPilotPanelType.LATERAL,
"MAPS": StarPilotPanelType.MAPS,
"NAVIGATION": StarPilotPanelType.NAVIGATION,
"DATA": StarPilotPanelType.DATA,
"DEVICE": StarPilotPanelType.DEVICE,
"UTILITIES": StarPilotPanelType.UTILITIES,
"VISUALS": StarPilotPanelType.VISUALS,
"THEMES": StarPilotPanelType.THEMES,
"VEHICLE": StarPilotPanelType.VEHICLE,
"WHEEL": StarPilotPanelType.WHEEL,
}
for cat in self.CATEGORIES:
filtered_buttons = []
for btn_label, panel_key, min_level in cat["buttons"]:
if current_tuning_level >= min_level:
panel_type = panel_type_map[panel_key]
callback = lambda p=panel_type: self._set_current_panel(p)
filtered_buttons.append((tr(btn_label), callback))
if filtered_buttons:
full_icon_path = f"{STARPILOT_ICONS_DIR}/{cat['icon']}"
item = category_buttons_item(
title=tr(cat["title"]),
buttons=filtered_buttons,
description=tr(cat["desc"]),
icon=full_icon_path,
starpilot_icon=True,
)
items.append(item)
self._main_scroller = Scroller(items, line_separator=True, spacing=0)
self._main_grid = TileGrid(columns=None, padding=20)
self._rebuild_grid()
def set_depth_callback(self, callback: Callable):
self._depth_callback = callback
@@ -180,14 +123,47 @@ class StarPilotLayout(Widget):
def navigate_back(self):
if self._panel_stack:
self._panel_stack.pop()
if self._panel_stack:
self._update_sub_panel_visibility()
else:
self._set_current_panel(StarPilotPanelType.MAIN)
self._update_depth()
elif self._current_panel != StarPilotPanelType.MAIN:
if self._current_category_idx is not None:
cat_info = self.CATEGORIES[self._current_category_idx]
vis_btns = cat_info["buttons"]
if len(vis_btns) > 1:
self._set_current_panel(StarPilotPanelType.MAIN)
else:
self._current_category_idx = None
self._set_current_panel(StarPilotPanelType.MAIN)
else:
self._set_current_panel(StarPilotPanelType.MAIN)
elif self._current_category_idx is not None:
self._current_category_idx = None
self._rebuild_grid()
if self._depth_callback:
self._depth_callback(0)
def _update_depth(self):
depth = 0
if self._current_panel != StarPilotPanelType.MAIN:
if self._current_category_idx is not None:
cat_info = self.CATEGORIES[self._current_category_idx]
vis_btns = cat_info["buttons"]
depth = 2 if len(vis_btns) > 1 else 1
else:
depth = 1
# Deep nesting check
if self._panel_stack:
depth += len(self._panel_stack)
elif self._current_category_idx is not None:
depth = 1
if self._depth_callback:
self._depth_callback(depth)
def _push_sub_panel(self, sub_panel_name: str):
self._panel_stack.append((self._current_panel, sub_panel_name))
self._update_sub_panel_visibility()
self._update_depth()
def _update_sub_panel_visibility(self):
if self._current_panel == StarPilotPanelType.LONGITUDINAL:
@@ -202,6 +178,24 @@ class StarPilotLayout(Widget):
current_sub = self._get_current_sub_panel()
if hasattr(sounds, '_navigate_to'):
sounds._current_sub_panel = current_sub
elif self._current_panel == StarPilotPanelType.NAVIGATION:
nav = self._panels[StarPilotPanelType.NAVIGATION].instance
if nav:
current_sub = self._get_current_sub_panel()
if hasattr(nav, '_navigate_to'):
nav._current_sub_panel = current_sub
elif self._current_panel == StarPilotPanelType.MAPS:
maps = self._panels[StarPilotPanelType.MAPS].instance
if maps:
current_sub = self._get_current_sub_panel()
if hasattr(maps, '_navigate_to'):
maps._current_sub_panel = current_sub
elif self._current_panel == StarPilotPanelType.DEVELOPER:
developer = self._panels[StarPilotPanelType.DEVELOPER].instance
if developer:
current_sub = self._get_current_sub_panel()
if hasattr(developer, '_navigate_to'):
developer._current_sub_panel = current_sub
def _get_current_sub_panel(self) -> str:
if self._panel_stack and self._panel_stack[-1][0] == self._current_panel:
@@ -223,36 +217,23 @@ class StarPilotLayout(Widget):
if lateral and hasattr(lateral, 'set_navigate_callback'):
lateral.set_navigate_callback(self._push_sub_panel)
def _on_tuning_level_changed(self, index: int):
self._params.put_nonblocking("TuningLevel", index)
if self._settings_layout:
self._settings_layout.refresh_developer_visibility()
for panel_info in self._panels.values():
panel = panel_info.instance
if panel and hasattr(panel, 'refresh_visibility'):
panel.refresh_visibility()
self._rebuild_main_scroller(index)
def _setup_navigation_sub_panels(self):
nav = self._panels[StarPilotPanelType.NAVIGATION].instance
if nav and hasattr(nav, 'set_navigate_callback'):
nav.set_navigate_callback(self._push_sub_panel)
def _rebuild_main_scroller(self, tuning_level: int):
tuning_levels = [tr("Minimal"), tr("Standard"), tr("Advanced"), tr("Developer")]
def _setup_maps_sub_panels(self):
maps = self._panels[StarPilotPanelType.MAPS].instance
if maps and hasattr(maps, 'set_navigate_callback'):
maps.set_navigate_callback(self._push_sub_panel)
items = [
multiple_button_item(
tr_noop("Tuning Level"),
tr_noop(
"Choose your tuning level. Lower levels keep it simple; higher levels unlock more toggles for finer control.\n\n"
"Minimal - Ideal for those who prefer simplicity or ease of use\n"
"Standard - Recommended for most users for a balanced experience\n"
"Advanced - Fine-tuning for experienced users\n"
"Developer - Highly customizable settings for seasoned enthusiasts"
),
tuning_levels,
tuning_level,
callback=self._on_tuning_level_changed,
icon=f"{STARPILOT_ICONS_DIR}/icon_tuning.png",
starpilot_icon=True,
),
]
def _setup_developer_sub_panels(self):
developer = self._panels[StarPilotPanelType.DEVELOPER].instance
if developer and hasattr(developer, 'set_navigate_callback'):
developer.set_navigate_callback(self._push_sub_panel)
def _rebuild_grid(self):
self._main_grid.clear()
panel_type_map = {
"SOUNDS": StarPilotPanelType.SOUNDS,
@@ -268,28 +249,57 @@ class StarPilotLayout(Widget):
"THEMES": StarPilotPanelType.THEMES,
"VEHICLE": StarPilotPanelType.VEHICLE,
"WHEEL": StarPilotPanelType.WHEEL,
"DEVELOPER": StarPilotPanelType.DEVELOPER,
}
for cat in self.CATEGORIES:
filtered_buttons = []
for btn_label, panel_key, min_level in cat["buttons"]:
if tuning_level >= min_level:
panel_type = panel_type_map[panel_key]
callback = lambda p=panel_type: self._set_current_panel(p)
filtered_buttons.append((tr(btn_label), callback))
if self._current_category_idx is None:
# Main Categories Grid
for i, cat in enumerate(self.CATEGORIES):
visible_buttons = cat["buttons"]
if not visible_buttons:
continue
if filtered_buttons:
full_icon_path = f"{STARPILOT_ICONS_DIR}/{cat['icon']}"
item = category_buttons_item(
def on_click(idx=i):
cat_info = self.CATEGORIES[idx]
vis_btns = cat_info["buttons"]
if len(vis_btns) == 1:
self._current_category_idx = idx
self._set_current_panel(panel_type_map[vis_btns[0][1]])
else:
self._current_category_idx = idx
self._rebuild_grid()
if self._depth_callback:
self._depth_callback(1)
tile = HubTile(
title=tr(cat["title"]),
buttons=filtered_buttons,
description=tr(cat["desc"]),
icon=full_icon_path,
desc=tr(cat["desc"]),
icon_path=f"{STARPILOT_ICONS_DIR}/{cat['icon']}",
on_click=on_click,
starpilot_icon=True,
bg_color=cat.get("color"),
)
items.append(item)
self._main_grid.add_tile(tile)
else:
# Sub-buttons Grid for selected Category
cat = self.CATEGORIES[self._current_category_idx]
visible_buttons = cat["buttons"]
self._main_scroller = Scroller(items, line_separator=True, spacing=0)
for label, panel_key, _ in visible_buttons:
p_type = panel_type_map[panel_key]
def on_btn_click(p=p_type):
self._set_current_panel(p)
tile = HubTile(
title=tr(label),
desc="",
icon_path=f"{STARPILOT_ICONS_DIR}/{cat['icon']}", # Reuse category icon for sub-tiles
on_click=on_btn_click,
starpilot_icon=True,
bg_color=cat.get("color"),
)
self._main_grid.add_tile(tile)
def _set_current_panel(self, panel_type: StarPilotPanelType):
if panel_type != self._current_panel:
@@ -298,14 +308,14 @@ class StarPilotLayout(Widget):
self._current_panel = panel_type
if panel_type != StarPilotPanelType.MAIN:
self._panels[panel_type].instance.show_event()
else:
self._rebuild_grid()
depth = 1 if panel_type != StarPilotPanelType.MAIN else 0
if self._depth_callback:
self._depth_callback(depth)
self._update_depth()
def _render(self, rect: rl.Rectangle):
if self._current_panel == StarPilotPanelType.MAIN:
self._main_scroller.render(rect)
self._main_grid.render(rect)
else:
panel = self._panels[self._current_panel]
if panel.instance:
@@ -313,9 +323,7 @@ class StarPilotLayout(Widget):
def show_event(self):
super().show_event()
if self._current_panel == StarPilotPanelType.MAIN:
self._main_scroller.show_event()
else:
if self._current_panel != StarPilotPanelType.MAIN:
self._panels[self._current_panel].instance.show_event()
def hide_event(self):
+153 -14
View File
@@ -1,25 +1,164 @@
from __future__ import annotations
import os
import shutil
from pathlib import Path
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.multilang import tr, tr_noop
from openpilot.system.ui.widgets.list_view import button_item
from openpilot.system.ui.widgets.scroller_tici import Scroller
from openpilot.system.ui.widgets import DialogResult
from openpilot.system.ui.widgets.selection_dialog import SelectionDialog
from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog, alert_dialog
from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import StarPilotPanel
# --- Map Data Definitions ---
MIDWEST_MAP = {"IL": "Illinois", "IN": "Indiana", "IA": "Iowa", "KS": "Kansas", "MI": "Michigan", "MN": "Minnesota", "MO": "Missouri", "NE": "Nebraska", "ND": "North Dakota", "OH": "Ohio", "SD": "South Dakota", "WI": "Wisconsin"}
NORTHEAST_MAP = {"CT": "Connecticut", "ME": "Maine", "MA": "Massachusetts", "NH": "New Hampshire", "NJ": "New Jersey", "NY": "New York", "PA": "Pennsylvania", "RI": "Rhode Island", "VT": "Vermont"}
SOUTH_MAP = {"AL": "Alabama", "AR": "Arkansas", "DE": "Delaware", "DC": "District of Columbia", "FL": "Florida", "GA": "Georgia", "KY": "Kentucky", "LA": "Louisiana", "MD": "Maryland", "MS": "Mississippi", "NC": "North Carolina", "OK": "Oklahoma", "SC": "South Carolina", "TN": "Tennessee", "TX": "Texas", "VA": "Virginia", "WV": "West Virginia"}
WEST_MAP = {"AK": "Alaska", "AZ": "Arizona", "CA": "California", "CO": "Colorado", "HI": "Hawaii", "ID": "Idaho", "MT": "Montana", "NV": "Nevada", "NM": "New Mexico", "OR": "Oregon", "UT": "Utah", "WA": "Washington", "WY": "Wyoming"}
TERRITORIES_MAP = {"AS": "American Samoa", "GU": "Guam", "MP": "Northern Mariana Islands", "PR": "Puerto Rico", "VI": "Virgin Islands"}
AFRICA_MAP = {"DZ": "Algeria", "AO": "Angola", "BJ": "Benin", "BW": "Botswana", "BF": "Burkina Faso", "BI": "Burundi", "CM": "Cameroon", "CF": "Central African Republic", "TD": "Chad", "KM": "Comoros", "CG": "Congo (Brazzaville)", "CD": "Congo (Kinshasa)", "DJ": "Djibouti", "EG": "Egypt", "GQ": "Equatorial Guinea", "ER": "Eritrea", "ET": "Ethiopia", "GA": "Gabon", "GM": "Gambia", "GH": "Ghana", "GN": "Guinea", "GW": "Guinea-Bissau", "CI": "Ivory Coast", "KE": "Kenya", "LS": "Lesotho", "LR": "Liberia", "LY": "Libya", "MG": "Madagascar", "MW": "Malawi", "ML": "Mali", "MR": "Mauritania", "MA": "Morocco", "MZ": "Mozambique", "NA": "Namibia", "NE": "Niger", "NG": "Nigeria", "RW": "Rwanda", "SN": "Senegal", "SL": "Sierra Leone", "SO": "Somalia", "ZA": "South Africa", "SS": "South Sudan", "SD": "Sudan", "SZ": "Swaziland", "TZ": "Tanzania", "TG": "Togo", "TN": "Tunisia", "UG": "Uganda", "ZM": "Zambia", "ZW": "Zimbabwe"}
ANTARCTICA_MAP = {"AQ": "Antarctica"}
ASIA_MAP = {"AF": "Afghanistan", "AM": "Armenia", "AZ": "Azerbaijan", "BH": "Bahrain", "BD": "Bangladesh", "BT": "Bhutan", "BN": "Brunei", "KH": "Cambodia", "CN": "China", "CY": "Cyprus", "TL": "East Timor", "HK": "Hong Kong", "IN": "India", "ID": "Indonesia", "IR": "Iran", "IQ": "Iraq", "IL": "Israel", "JP": "Japan", "JO": "Jordan", "KZ": "Kazakhstan", "KW": "Kuwait", "KG": "Kyrgyzstan", "LA": "Laos", "LB": "Lebanon", "MY": "Malaysia", "MV": "Maldives", "MO": "Macao", "MN": "Mongolia", "MM": "Myanmar", "NP": "Nepal", "KP": "North Korea", "OM": "Oman", "PK": "Pakistan", "PS": "Palestine", "PH": "Philippines", "QA": "Qatar", "RU": "Russia", "SA": "Saudi Arabia", "SG": "Singapore", "KR": "South Korea", "LK": "Sri Lanka", "SY": "Syria", "TW": "Taiwan", "TJ": "Tajikistan", "TH": "Thailand", "TR": "Turkey", "TM": "Turkmenistan", "AE": "United Arab Emirates", "UZ": "Uzbekistan", "VN": "Vietnam", "YE": "Yemen"}
EUROPE_MAP = {"AL": "Albania", "AT": "Austria", "BY": "Belarus", "BE": "Belgium", "BA": "Bosnia and Herzegovina", "BG": "Bulgaria", "HR": "Croatia", "CZ": "Czech Republic", "DK": "Denmark", "EE": "Estonia", "FI": "Finland", "FR": "France", "GE": "Georgia", "DE": "Germany", "GR": "Greece", "HU": "Hungary", "IS": "Iceland", "IE": "Ireland", "IT": "Italy", "KZ": "Kazakhstan", "LV": "Latvia", "LT": "Lithuania", "LU": "Luxembourg", "MK": "Macedonia", "MD": "Moldova", "ME": "Montenegro", "NL": "Netherlands", "NO": "Norway", "PL": "Poland", "PT": "Portugal", "RO": "Romania", "RS": "Serbia", "SK": "Slovakia", "SI": "Slovenia", "ES": "Spain", "SE": "Sweden", "CH": "Switzerland", "TR": "Turkey", "UA": "Ukraine", "GB": "United Kingdom"}
NORTH_AMERICA_MAP = {"BS": "Bahamas", "BZ": "Belize", "CA": "Canada", "CR": "Costa Rica", "CU": "Cuba", "DO": "Dominican Republic", "SV": "El Salvador", "GL": "Greenland", "GD": "Grenada", "GT": "Guatemala", "HT": "Haiti", "HN": "Honduras", "JM": "Jamaica", "MX": "Mexico", "NI": "Nicaragua", "PA": "Panama", "TT": "Trinidad and Tobago", "US": "United States"}
OCEANIA_MAP = {"AU": "Australia", "FJ": "Fiji", "TF": "French Southern Territories", "NC": "New Caledonia", "NZ": "New Zealand", "PG": "Papua New Guinea", "SB": "Solomon Islands", "VU": "Vanuatu"}
SOUTH_AMERICA_MAP = {"AR": "Argentina", "BO": "Bolivia", "BR": "Brazil", "CL": "Chile", "CO": "Colombia", "EC": "Ecuador", "FK": "Falkland Islands", "GY": "Guyana", "PY": "Paraguay", "PE": "Peru", "SR": "Suriname", "UY": "Uruguay", "VE": "Venezuela"}
class StarPilotMapRegionLayout(StarPilotPanel):
def __init__(self, region_map: dict[str, str]):
super().__init__()
self.CATEGORIES = []
for key, name in sorted(region_map.items(), key=lambda item: item[1]):
self.CATEGORIES.append({
"title": name,
"type": "toggle",
"get_state": lambda k=key: self._get_map_state(k),
"set_state": lambda s, k=key: self._set_map_state(k, s),
"color": "#8CBF26"
})
self._rebuild_grid()
def _get_map_state(self, key):
selected_raw = self._params.get("MapsSelected", encoding='utf-8') or ""
selected = [k.strip() for k in selected_raw.split(",") if k.strip()]
return key in selected
def _set_map_state(self, key, state):
selected_raw = self._params.get("MapsSelected", encoding='utf-8') or ""
selected = [k.strip() for k in selected_raw.split(",") if k.strip()]
if state and key not in selected:
selected.append(key)
elif not state and key in selected:
selected.remove(key)
self._params.put("MapsSelected", ",".join(selected))
class StarPilotMapCountriesLayout(StarPilotPanel):
def __init__(self):
super().__init__()
self.CATEGORIES = [
{"title": tr_noop("Africa"), "panel": "africa", "icon": "toggle_icons/icon_map.png", "color": "#8CBF26"},
{"title": tr_noop("Antarctica"), "panel": "antarctica", "icon": "toggle_icons/icon_map.png", "color": "#8CBF26"},
{"title": tr_noop("Asia"), "panel": "asia", "icon": "toggle_icons/icon_map.png", "color": "#8CBF26"},
{"title": tr_noop("Europe"), "panel": "europe", "icon": "toggle_icons/icon_map.png", "color": "#8CBF26"},
{"title": tr_noop("North America"), "panel": "north_america", "icon": "toggle_icons/icon_map.png", "color": "#8CBF26"},
{"title": tr_noop("Oceania"), "panel": "oceania", "icon": "toggle_icons/icon_map.png", "color": "#8CBF26"},
{"title": tr_noop("South America"), "panel": "south_america", "icon": "toggle_icons/icon_map.png", "color": "#8CBF26"},
]
self._rebuild_grid()
class StarPilotMapStatesLayout(StarPilotPanel):
def __init__(self):
super().__init__()
self.CATEGORIES = [
{"title": tr_noop("Midwest"), "panel": "midwest", "icon": "toggle_icons/icon_map.png", "color": "#8CBF26"},
{"title": tr_noop("Northeast"), "panel": "northeast", "icon": "toggle_icons/icon_map.png", "color": "#8CBF26"},
{"title": tr_noop("South"), "panel": "south", "icon": "toggle_icons/icon_map.png", "color": "#8CBF26"},
{"title": tr_noop("West"), "panel": "west", "icon": "toggle_icons/icon_map.png", "color": "#8CBF26"},
{"title": tr_noop("Territories"), "panel": "territories", "icon": "toggle_icons/icon_map.png", "color": "#8CBF26"},
]
self._rebuild_grid()
class StarPilotMapsLayout(StarPilotPanel):
def __init__(self):
super().__init__()
self._sub_panels = {
"countries": StarPilotMapCountriesLayout(),
"states": StarPilotMapStatesLayout(),
"africa": StarPilotMapRegionLayout(AFRICA_MAP),
"antarctica": StarPilotMapRegionLayout(ANTARCTICA_MAP),
"asia": StarPilotMapRegionLayout(ASIA_MAP),
"europe": StarPilotMapRegionLayout(EUROPE_MAP),
"north_america": StarPilotMapRegionLayout(NORTH_AMERICA_MAP),
"oceania": StarPilotMapRegionLayout(OCEANIA_MAP),
"south_america": StarPilotMapRegionLayout(SOUTH_AMERICA_MAP),
"midwest": StarPilotMapRegionLayout(MIDWEST_MAP),
"northeast": StarPilotMapRegionLayout(NORTHEAST_MAP),
"south": StarPilotMapRegionLayout(SOUTH_MAP),
"west": StarPilotMapRegionLayout(WEST_MAP),
"territories": StarPilotMapRegionLayout(TERRITORIES_MAP),
}
items = [
button_item(
tr_noop("Download Map Data"),
lambda: tr("DOWNLOAD"),
tr_noop("<b>Download map data</b> for the Speed Limit Controller."),
),
button_item(
tr_noop("Manage Map Data"),
lambda: tr("MANAGE"),
tr_noop("<b>View or delete downloaded map data.</b>"),
),
self.CATEGORIES = [
{"title": tr_noop("Download Maps"), "type": "hub", "on_click": self._on_download, "icon": "toggle_icons/icon_map.png", "color": "#8CBF26"},
{"title": tr_noop("Auto Update Schedule"), "type": "value", "get_value": lambda: self._params.get("PreferredSchedule", encoding='utf-8') or "Manually", "on_click": self._on_schedule, "icon": "toggle_icons/icon_calendar.png", "color": "#8CBF26"},
{"title": tr_noop("Countries"), "panel": "countries", "icon": "toggle_icons/icon_map.png", "color": "#8CBF26"},
{"title": tr_noop("U.S. States"), "panel": "states", "icon": "toggle_icons/icon_map.png", "color": "#8CBF26"},
{"title": tr_noop("Storage Used"), "type": "value", "get_value": self._get_storage, "on_click": lambda: None, "icon": "toggle_icons/icon_system.png", "color": "#8CBF26"},
{"title": tr_noop("Remove Maps"), "type": "hub", "on_click": self._on_remove, "icon": "toggle_icons/icon_map.png", "color": "#8CBF26"},
]
for name, panel in self._sub_panels.items():
if hasattr(panel, 'set_navigate_callback'): panel.set_navigate_callback(self._navigate_to)
if hasattr(panel, 'set_back_callback'): panel.set_back_callback(self._go_back)
self._rebuild_grid()
self._scroller = Scroller(items, line_separator=True, spacing=0)
def _get_storage(self) -> str:
maps_path = Path("/data/media/0/osm/offline")
if not maps_path.exists():
return "0 MB"
total_size = sum(f.stat().st_size for f in maps_path.rglob('*') if f.is_file())
mb = total_size / (1024 * 1024)
if mb > 1024:
return f"{(mb / 1024):.2f} GB"
return f"{mb:.2f} MB"
def _on_schedule(self):
options = ["Manually", "Weekly", "Monthly"]
current = self._params.get("PreferredSchedule", encoding='utf-8') or "Manually"
def on_select(res, val):
if res == DialogResult.CONFIRM:
self._params.put("PreferredSchedule", val)
self._rebuild_grid()
gui_app.set_modal_overlay(SelectionDialog(tr("Auto Update Schedule"), options, current, on_close=on_select))
def _on_download(self):
selected_raw = self._params.get("MapsSelected", encoding='utf-8') or ""
selected = [k.strip() for k in selected_raw.split(",") if k.strip()]
if not selected:
gui_app.set_modal_overlay(alert_dialog(tr("Please select at least one region or state first!")))
return
def on_confirm(res):
if res == DialogResult.CONFIRM:
self._params_memory.put_bool("DownloadMaps", True)
gui_app.set_modal_overlay(alert_dialog(tr("Map download started in background.")))
gui_app.set_modal_overlay(ConfirmDialog(tr("Start downloading maps for selected regions?"), tr("Download"), on_close=on_confirm))
def _on_remove(self):
def on_confirm(res):
if res == DialogResult.CONFIRM:
maps_path = Path("/data/media/0/osm/offline")
if maps_path.exists():
shutil.rmtree(maps_path, ignore_errors=True)
gui_app.set_modal_overlay(alert_dialog(tr("Maps removed.")))
self._rebuild_grid()
gui_app.set_modal_overlay(ConfirmDialog(tr("Delete all downloaded map data?"), tr("Remove"), on_close=on_confirm))
@@ -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 openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.multilang import tr, tr_noop
from openpilot.system.ui.widgets.list_view import toggle_item, button_item
from openpilot.system.ui.widgets.scroller_tici import Scroller
from openpilot.system.ui.widgets import DialogResult
from openpilot.system.ui.widgets.selection_dialog import SelectionDialog
from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog, alert_dialog
from openpilot.system.ui.widgets.input_dialog import InputDialog
from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import StarPilotPanel
class StarPilotNavigationLayout(StarPilotPanel):
def __init__(self):
super().__init__()
items = [
toggle_item(
tr_noop("Use Live Map Data"),
tr_noop("<b>Use live map data</b> for real-time navigation updates."),
False,
),
button_item(
tr_noop("Navigation Settings"),
lambda: tr("MANAGE"),
tr_noop("<b>Configure navigation-specific options</b> like route preferences."),
),
self._sub_panels = {
"mapbox": StarPilotMapboxLayout(),
}
self.CATEGORIES = [
{"title": tr_noop("Mapbox Credentials"), "panel": "mapbox", "icon": "toggle_icons/icon_navigate.png", "color": "#8CBF26"},
{"title": tr_noop("Setup Instructions"), "type": "hub", "on_click": self._on_setup, "icon": "toggle_icons/icon_navigate.png", "color": "#8CBF26"},
{
"title": tr_noop("Speed Limit Filler"),
"type": "toggle",
"get_state": lambda: self._params.get_bool("SpeedLimitFiller"),
"set_state": lambda s: self._params.put_bool("SpeedLimitFiller", s),
"icon": "toggle_icons/icon_speed_limit.png",
"color": "#8CBF26",
},
{"title": tr_noop("Search Destination"), "type": "hub", "on_click": self._on_search, "icon": "toggle_icons/icon_navigate.png", "color": "#8CBF26"},
{
"title": tr_noop("Home Address"),
"type": "value",
"get_value": lambda: self._params.get("HomeAddress", encoding='utf-8') or tr("Not set"),
"on_click": self._on_home,
"icon": "toggle_icons/icon_navigate.png",
"color": "#8CBF26",
},
{
"title": tr_noop("Work Address"),
"type": "value",
"get_value": lambda: self._params.get("WorkAddress", encoding='utf-8') or tr("Not set"),
"on_click": self._on_work,
"icon": "toggle_icons/icon_navigate.png",
"color": "#8CBF26",
},
]
for name, panel in self._sub_panels.items():
if hasattr(panel, 'set_navigate_callback'):
panel.set_navigate_callback(self._navigate_to)
if hasattr(panel, 'set_back_callback'):
panel.set_back_callback(self._go_back)
self._rebuild_grid()
self._scroller = Scroller(items, line_separator=True, spacing=0)
def _on_setup(self):
gui_app.set_modal_overlay(
alert_dialog(tr("Mapbox Setup:\n1. Create account at mapbox.com\n2. Generate Public/Secret keys\n3. Add keys in 'Mapbox Credentials'"))
)
def _on_search(self):
def on_close(res, text):
if res == DialogResult.CONFIRM and text:
self._params.put("SearchAddress", text)
gui_app.set_modal_overlay(InputDialog(tr("Search Destination"), "", on_close=on_close))
def _on_home(self):
current = self._params.get("HomeAddress", encoding='utf-8') or ""
def on_close(res, text):
if res == DialogResult.CONFIRM:
self._params.put("HomeAddress", text)
self._rebuild_grid()
gui_app.set_modal_overlay(InputDialog(tr("Home Address"), current, on_close=on_close))
def _on_work(self):
current = self._params.get("WorkAddress", encoding='utf-8') or ""
def on_close(res, text):
if res == DialogResult.CONFIRM:
self._params.put("WorkAddress", text)
self._rebuild_grid()
gui_app.set_modal_overlay(InputDialog(tr("Work Address"), current, on_close=on_close))
class StarPilotMapboxLayout(StarPilotPanel):
def __init__(self):
super().__init__()
self.CATEGORIES = [
{
"title": tr_noop("Public Mapbox Key"),
"type": "value",
"get_value": self._get_key_display,
"on_click": lambda: self._on_key("MapboxPublicKey", "pk."),
"color": "#8CBF26",
},
{
"title": tr_noop("Secret Mapbox Key"),
"type": "value",
"get_value": self._get_secret_display,
"on_click": lambda: self._on_key("MapboxSecretKey", "sk."),
"color": "#8CBF26",
},
]
self._rebuild_grid()
def _get_key_display(self):
v = self._params.get("MapboxPublicKey", encoding='utf-8') or ""
return f"{v[:8]}..." if v else tr("Not set")
def _get_secret_display(self):
v = self._params.get("MapboxSecretKey", encoding='utf-8') or ""
return "********" if v else tr("Not set")
def _on_key(self, key, prefix):
current = self._params.get(key, encoding='utf-8') or ""
if current:
def on_remove(res):
if res == DialogResult.CONFIRM:
self._params.remove(key)
self._rebuild_grid()
gui_app.set_modal_overlay(ConfirmDialog(tr(f"Remove your {key.replace('Mapbox', '')} key?"), tr("Remove"), on_close=on_remove))
else:
def on_close(res, text):
if res == DialogResult.CONFIRM and text:
if not text.startswith(prefix):
text = prefix + text
self._params.put(key, text)
self._rebuild_grid()
gui_app.set_modal_overlay(InputDialog(tr(f"Enter {key.replace('Mapbox', 'Mapbox ')}"), "", on_close=on_close))
@@ -6,8 +6,10 @@ from enum import IntEnum
import pyray as rl
from openpilot.common.params import Params
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.widgets import Widget
class StarPilotPanelType(IntEnum):
MAIN = 0
SOUNDS = 1
@@ -23,23 +25,30 @@ class StarPilotPanelType(IntEnum):
THEMES = 11
VEHICLE = 12
WHEEL = 13
DEVELOPER = 14
@dataclass
class StarPilotPanelInfo:
name: str
instance: Widget
from openpilot.selfdrive.ui.layouts.settings.starpilot.metro import TileGrid, HubTile, ToggleTile, ValueTile
class StarPilotPanel(Widget):
def __init__(self):
super().__init__()
self._params = Params()
self._params_memory = Params(memory=True)
self._tuning_levels: dict[str, int] = {}
self._navigate_callback: Callable | None = None
self._back_callback: Callable | None = None
self._current_sub_panel = ""
self._sub_panels: dict[str, Widget] = {}
self._scroller = None
self._tile_grid = None
self.CATEGORIES = []
def set_navigate_callback(self, callback: Callable):
self._navigate_callback = callback
@@ -47,8 +56,42 @@ class StarPilotPanel(Widget):
def set_back_callback(self, callback: Callable):
self._back_callback = callback
def set_tuning_levels(self, levels: dict[str, int]):
self._tuning_levels = levels
def _rebuild_grid(self):
if not self.CATEGORIES:
return
if self._tile_grid is None:
self._tile_grid = TileGrid(columns=None, padding=20)
self._tile_grid.clear()
for cat in self.CATEGORIES:
visible_fn = cat.get("visible")
if visible_fn is not None and not visible_fn():
continue
tile_type = cat.get("type", "hub")
if tile_type == "hub":
on_click = cat.get("on_click")
if on_click is None:
on_click = lambda c=cat: self._navigate_to(c["panel"])
tile = HubTile(
title=tr(cat["title"]),
desc=tr(cat.get("desc", "")),
icon_path=cat.get("icon"),
on_click=on_click,
starpilot_icon=cat.get("starpilot_icon", True),
bg_color=cat.get("color"),
)
elif tile_type == "toggle":
tile = ToggleTile(title=tr(cat["title"]), get_state=cat["get_state"], set_state=cat["set_state"], icon_path=cat.get("icon"), bg_color=cat.get("color"))
elif tile_type == "value":
tile = ValueTile(title=tr(cat["title"]), get_value=cat["get_value"], on_click=cat["on_click"], icon_path=cat.get("icon"), bg_color=cat.get("color"))
else:
continue
self._tile_grid.add_tile(tile)
def _navigate_to(self, sub_panel: str):
self._current_sub_panel = sub_panel
@@ -63,11 +106,14 @@ class StarPilotPanel(Widget):
def _render(self, rect: rl.Rectangle):
if self._current_sub_panel and self._current_sub_panel in self._sub_panels:
self._sub_panels[self._current_sub_panel].render(rect)
elif self.CATEGORIES and self._tile_grid:
self._tile_grid.render(rect)
elif self._scroller:
self._scroller.render(rect)
def show_event(self):
super().show_event()
self._rebuild_grid()
if self._current_sub_panel and self._current_sub_panel in self._sub_panels:
self._sub_panels[self._current_sub_panel].show_event()
elif self._scroller:
+116 -163
View File
@@ -4,12 +4,14 @@ from pathlib import Path
from openpilot.common.basedir import BASEDIR
from openpilot.frogpilot.common.frogpilot_variables import ACTIVE_THEME_PATH
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.multilang import tr, tr_noop
from openpilot.system.ui.widgets.list_view import button_item, toggle_item, value_button_item
from openpilot.system.ui.widgets.scroller_tici import Scroller
from openpilot.system.ui.widgets import DialogResult
from openpilot.system.ui.widgets.selection_dialog import SelectionDialog
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.selfdrive.ui.lib.starpilot_state import starpilot_state
from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import StarPilotPanel
from openpilot.selfdrive.ui.layouts.settings.starpilot.metro import TileGrid, ToggleTile, SliderDialog
class StarPilotSoundsLayout(StarPilotPanel):
VOLUME_KEYS = [
@@ -32,39 +34,36 @@ class StarPilotSoundsLayout(StarPilotPanel):
def __init__(self):
super().__init__()
items = [
button_item(
tr_noop("Alert Volume Controller"),
lambda: tr("MANAGE"),
tr_noop("<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 = {
"volume_control": StarPilotVolumeControlLayout(),
"custom_alerts": StarPilotCustomAlertsLayout(),
}
# Wire up navigation callbacks for sub-panels
self.CATEGORIES = [
{
"title": tr_noop("Alert Volume Controller"),
"panel": "volume_control",
"desc": tr_noop("Adjust volume levels for different alert types."),
"icon": "toggle_icons/icon_mute.png",
"color": "#FF0097"
},
{
"title": tr_noop("StarPilot Alerts"),
"panel": "custom_alerts",
"desc": tr_noop("Enable or disable specific StarPilot-only alerts."),
"icon": "toggle_icons/icon_green_light.png",
"color": "#FF0097"
},
]
for name, panel in self._sub_panels.items():
if hasattr(panel, 'set_navigate_callback'):
panel.set_navigate_callback(self._navigate_to)
if hasattr(panel, 'set_back_callback'):
panel.set_back_callback(self._go_back)
self._rebuild_grid()
def refresh_visibility(self):
for panel in self._sub_panels.values():
if hasattr(panel, 'refresh_visibility'):
@@ -72,53 +71,13 @@ class StarPilotSoundsLayout(StarPilotPanel):
class StarPilotVolumeControlLayout(StarPilotPanel):
VOLUME_INFO = {
"DisengageVolume": {
"title": tr_noop("Disengage Volume"),
"desc": tr_noop(
"<b>Set the volume for alerts when openpilot disengages.</b><br><br>Examples include: \"Cruise Fault: Restart the Car\", \"Parking Brake Engaged\", \"Pedal Pressed\"."
),
"min": 0,
},
"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,
},
"DisengageVolume": {"title": tr_noop("Disengage Volume"), "icon": "toggle_icons/icon_mute.png", "min": 0},
"EngageVolume": {"title": tr_noop("Engage Volume"), "icon": "toggle_icons/icon_green_light.png", "min": 0},
"PromptVolume": {"title": tr_noop("Prompt Volume"), "icon": "toggle_icons/icon_message.png", "min": 0},
"PromptDistractedVolume": {"title": tr_noop("Distracted Volume"), "icon": "toggle_icons/icon_display.png", "min": 0},
"RefuseVolume": {"title": tr_noop("Refuse Volume"), "icon": "toggle_icons/icon_mute.png", "min": 0},
"WarningSoftVolume": {"title": tr_noop("Warning Soft"), "icon": "toggle_icons/icon_conditional.png", "min": 25},
"WarningImmediateVolume": {"title": tr_noop("Warning Immediate"), "icon": "toggle_icons/icon_conditional.png", "min": 25},
}
_sound_player_process = None
@@ -126,140 +85,126 @@ class StarPilotVolumeControlLayout(StarPilotPanel):
def __init__(self):
super().__init__()
self._init_sound_player()
volume_labels = {0.0: tr("Muted"), 101.0: tr("Auto")}
for i in range(1, 101):
volume_labels[float(i)] = f"{i}%"
items = []
self.CATEGORIES = []
for key in StarPilotSoundsLayout.VOLUME_KEYS:
info = self.VOLUME_INFO[key]
items.append(
value_button_item(
info["title"],
key,
min_val=info["min"],
max_val=101,
step=1,
button_text="Test",
button_callback=lambda k=key: self._test_sound(k),
description=info["desc"],
labels=volume_labels,
)
)
def get_val(k=key):
v = self._params.get_int(k, return_default=True, default=100)
if v == 0: return tr("Muted")
if v == 101: return tr("Auto")
return f"{v}%"
self._scroller = Scroller(items, line_separator=True, spacing=0)
def on_click(k=key, i=info):
self._show_volume_selector(k, i)
self.CATEGORIES.append({
"title": info["title"],
"type": "value",
"get_value": get_val,
"on_click": on_click,
"icon": info["icon"],
"color": "#FF0097"
})
self._rebuild_grid()
def _show_volume_selector(self, key: str, info: dict):
current_v = self._params.get_int(key, return_default=True, default=100)
def on_close(res, val):
if res == DialogResult.CONFIRM:
new_v = int(val)
if new_v != 101 and new_v < info["min"]:
new_v = info["min"]
self._params.put_int(key, new_v)
self._test_sound(key)
self._rebuild_grid()
gui_app.set_modal_overlay(SliderDialog(
tr(info["title"]), 0, 101, 1, current_v, on_close,
unit="%", labels={0: tr("Muted"), 101: tr("Auto")}, color="#FF0097"
))
@classmethod
def _init_sound_player(cls):
if cls._sound_player_process is not None:
return
if cls._sound_player_process is not None: return
program = """
import numpy as np
import sounddevice as sd
import sys
import wave
while True:
try:
line = sys.stdin.readline()
if not line:
break
if not line: break
path, volume = line.strip().split('|')
sound_file = wave.open(path, 'rb')
audio = np.frombuffer(sound_file.readframes(sound_file.getnframes()), dtype=np.int16).astype(np.float32) / 32768.0
sd.play(audio * float(volume), sound_file.getframerate())
sd.wait()
except Exception:
pass
except: pass
"""
cls._sound_player_process = subprocess.Popen(["python3", "-u", "-c", program], stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
cls._sound_player_process = subprocess.Popen(
["python3", "-u", "-c", program],
stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def _test_sound(self, key: str):
base_name = key.replace("Volume", "")
if ui_state.started:
self._params_memory.put("TestAlert", base_name[0].lower() + base_name[1:])
else:
self._play_sound_offroad(key)
def _play_sound_offroad(self, key: str):
base_name = key.replace("Volume", "")
snake_case = "".join(["_" + c.lower() if c.isupper() else c for c in base_name]).lstrip("_")
stock_path = Path(BASEDIR) / "selfdrive" / "assets" / "sounds" / f"{snake_case}.wav"
theme_path = ACTIVE_THEME_PATH / "sounds" / f"{snake_case}.wav"
sound_path = theme_path if theme_path.exists() else stock_path
if not sound_path.exists():
return
if not sound_path.exists(): return
volume = self._params.get_int(key, return_default=True, default=100) / 100.0
try:
self._sound_player_process.stdin.write(f"{sound_path}|{volume}\n".encode())
self._sound_player_process.stdin.flush()
except Exception:
pass
def _test_sound(self, key: str):
base_name = key.replace("Volume", "")
if ui_state.started:
camel_case = base_name[0].lower() + base_name[1:]
self._params_memory.put("TestAlert", camel_case)
else:
self._play_sound_offroad(key)
except: pass
class StarPilotCustomAlertsLayout(StarPilotPanel):
ALERT_INFO = {
"GoatScream": {
"title": tr_noop("Goat Scream"),
"desc": tr_noop(
"<b>Play the infamous \"Goat Scream\" when the steering controller reaches its limit.</b> Based on the \"Turn Exceeds Steering Limit\" event."
),
},
"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>"),
},
"GoatScream": {"title": tr_noop("Goat Scream"), "icon": "toggle_icons/icon_sound.png"},
"GreenLightAlert": {"title": tr_noop("Green Light"), "icon": "toggle_icons/icon_green_light.png"},
"LeadDepartingAlert": {"title": tr_noop("Lead Departure"), "icon": "toggle_icons/icon_steering.png"},
"LoudBlindspotAlert": {"title": tr_noop("Loud Blindspot"), "icon": "toggle_icons/icon_display.png"},
"SpeedLimitChangedAlert": {"title": tr_noop("Speed Limit"), "icon": "toggle_icons/icon_speed_limit.png"},
}
def __init__(self):
super().__init__()
self._toggle_items = {}
self.CATEGORIES = []
for key in StarPilotSoundsLayout.CUSTOM_ALERTS_KEYS:
info = self.ALERT_INFO[key]
self._toggle_items[key] = toggle_item(
info["title"],
info["desc"],
self._params.get_bool(key),
callback=lambda s, k=key: self._params.put_bool(k, s),
)
self._scroller = Scroller(list(self._toggle_items.values()), line_separator=True, spacing=0)
self.CATEGORIES.append({
"title": info["title"],
"type": "toggle",
"get_state": lambda k=key: self._params.get_bool(k),
"set_state": lambda s, k=key: self._params.put_bool(k, s),
"icon": info["icon"],
"color": "#FF0097",
"key": key # Store for visibility check
})
self._rebuild_grid()
def refresh_visibility(self):
current_level = int(self._params.get("TuningLevel", return_default=True, default="1") or "1")
for key, item in self._toggle_items.items():
min_level = self._tuning_levels.get(key, 0)
visible = current_level >= min_level
self._rebuild_grid()
def _rebuild_grid(self):
# Override to add custom BSM/SLC visibility logic
if not self.CATEGORIES: return
if self._tile_grid is None: self._tile_grid = TileGrid(columns=None, padding=20)
self._tile_grid.clear()
for cat in self.CATEGORIES:
key = cat.get("key")
visible = True
if key == "LoudBlindspotAlert":
visible &= starpilot_state.car_state.hasBSM
@@ -268,4 +213,12 @@ class StarPilotCustomAlertsLayout(StarPilotPanel):
starpilot_state.car_state.hasOpenpilotLongitudinal and self._params.get_bool("SpeedLimitController")
)
item.set_visible(visible)
if not visible: continue
tile = ToggleTile( title=tr(cat["title"]),
get_state=cat["get_state"],
set_state=cat["set_state"],
icon_path=cat.get("icon"),
bg_color=cat.get("color")
)
self._tile_grid.add_tile(tile)
+150 -20
View File
@@ -1,30 +1,160 @@
from __future__ import annotations
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.multilang import tr, tr_noop
from openpilot.system.ui.widgets.list_view import button_item
from openpilot.system.ui.widgets.scroller_tici import Scroller
from openpilot.system.ui.widgets import DialogResult
from openpilot.system.ui.widgets.selection_dialog import SelectionDialog
from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import StarPilotPanel
class StarPilotThemesLayout(StarPilotPanel):
def __init__(self):
super().__init__()
items = [
button_item(
tr_noop("Select Theme"),
lambda: tr("SELECT"),
tr_noop("<b>Select a theme</b> for the StarPilot interface."),
),
button_item(
tr_noop("Holiday Themes"),
lambda: tr("MANAGE"),
tr_noop("<b>Enable or disable holiday-themed visuals.</b>"),
),
button_item(
tr_noop("Custom Theme"),
lambda: tr("MANAGE"),
tr_noop("<b>Create or import a custom theme.</b>"),
),
self._sub_panels = {
"personalize": StarPilotPersonalizeLayout(),
}
self.CATEGORIES = [
{
"title": tr_noop("Personalize openpilot"),
"panel": "personalize",
"icon": "toggle_icons/icon_frog.png",
"color": "#A200FF",
"desc": tr_noop("Customize the overall look and feel."),
},
{
"title": tr_noop("Holiday Themes"),
"type": "toggle",
"get_state": lambda: self._params.get_bool("HolidayThemes"),
"set_state": lambda s: self._params.put_bool("HolidayThemes", s),
"icon": "toggle_icons/icon_calendar.png",
"color": "#A200FF",
},
{
"title": tr_noop("Rainbow Path"),
"type": "toggle",
"get_state": lambda: self._params.get_bool("RainbowPath"),
"set_state": lambda s: self._params.put_bool("RainbowPath", s),
"icon": "toggle_icons/icon_rainbow.png",
"color": "#A200FF",
},
{
"title": tr_noop("Random Events"),
"type": "toggle",
"get_state": lambda: self._params.get_bool("RandomEvents"),
"set_state": lambda s: self._params.put_bool("RandomEvents", s),
"icon": "toggle_icons/icon_random.png",
"color": "#A200FF",
},
{
"title": tr_noop("Random Themes"),
"type": "toggle",
"get_state": lambda: self._params.get_bool("RandomThemes"),
"set_state": lambda s: self._params.put_bool("RandomThemes", s),
"icon": "toggle_icons/icon_random_themes.png",
"color": "#A200FF",
},
{"title": tr_noop("Startup Alert"), "type": "hub", "on_click": self._on_startup_alert, "color": "#A200FF"},
]
self._scroller = Scroller(items, line_separator=True, spacing=0)
for name, panel in self._sub_panels.items():
if hasattr(panel, 'set_navigate_callback'):
panel.set_navigate_callback(self._navigate_to)
if hasattr(panel, 'set_back_callback'):
panel.set_back_callback(self._go_back)
self._rebuild_grid()
def _on_startup_alert(self):
options = ["Stock", "FrogPilot", "Clear"]
current_top = self._params.get("StartupMessageTop", encoding='utf-8') or ""
if current_top == "Be ready to take over at any time":
current = "Stock"
elif current_top == "Hop in and buckle up!":
current = "FrogPilot"
else:
current = "Clear"
def on_select(res, val):
if res == DialogResult.CONFIRM:
if val == "Stock":
self._params.put("StartupMessageTop", "Be ready to take over at any time")
self._params.put("StartupMessageBottom", "Always keep hands on wheel and eyes on road")
elif val == "FrogPilot":
self._params.put("StartupMessageTop", "Hop in and buckle up!")
self._params.put("StartupMessageBottom", "Human-tested, frog-approved")
else:
self._params.remove("StartupMessageTop")
self._params.remove("StartupMessageBottom")
gui_app.set_modal_overlay(SelectionDialog(tr("Startup Alert"), options, current, on_close=on_select))
class StarPilotPersonalizeLayout(StarPilotPanel):
def __init__(self):
super().__init__()
self.CATEGORIES = [
{
"title": tr_noop("Boot Logo"),
"type": "value",
"get_value": lambda: self._params.get("BootLogo", encoding='utf-8') or "Stock",
"on_click": lambda: self._show_theme_selector("BootLogo"),
"color": "#A200FF",
},
{
"title": tr_noop("Color Scheme"),
"type": "value",
"get_value": lambda: self._params.get("ColorScheme", encoding='utf-8') or "Stock",
"on_click": lambda: self._show_theme_selector("ColorScheme"),
"color": "#A200FF",
},
{
"title": tr_noop("Distance Icons"),
"type": "value",
"get_value": lambda: self._params.get("DistanceIconPack", encoding='utf-8') or "Stock",
"on_click": lambda: self._show_theme_selector("DistanceIconPack"),
"color": "#A200FF",
},
{
"title": tr_noop("Icon Pack"),
"type": "value",
"get_value": lambda: self._params.get("IconPack", encoding='utf-8') or "Stock",
"on_click": lambda: self._show_theme_selector("IconPack"),
"color": "#A200FF",
},
{
"title": tr_noop("Turn Signals"),
"type": "value",
"get_value": lambda: self._params.get("SignalAnimation", encoding='utf-8') or "Stock",
"on_click": lambda: self._show_theme_selector("SignalAnimation"),
"color": "#A200FF",
},
{
"title": tr_noop("Sound Pack"),
"type": "value",
"get_value": lambda: self._params.get("SoundPack", encoding='utf-8') or "Stock",
"on_click": lambda: self._show_theme_selector("SoundPack"),
"color": "#A200FF",
},
{
"title": tr_noop("Steering Wheel"),
"type": "value",
"get_value": lambda: self._params.get("WheelIcon", encoding='utf-8') or "Stock",
"on_click": lambda: self._show_theme_selector("WheelIcon"),
"color": "#A200FF",
},
]
self._rebuild_grid()
def _show_theme_selector(self, key):
# Ported logic for theme selection. In a real environment we'd scan directories.
# For now, we'll provide a simplified selection based on current param.
themes = ["Stock", "Frog", "Cyberpunk", "Minimal"]
current = self._params.get(key, encoding='utf-8') or "Stock"
def on_select(res, val):
if res == DialogResult.CONFIRM:
self._params.put(key, val)
self._rebuild_grid()
gui_app.set_modal_overlay(SelectionDialog(tr(key), themes, current, on_close=on_select))
@@ -1,30 +1,163 @@
from __future__ import annotations
import json
from openpilot.system.hardware import HARDWARE
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.multilang import tr, tr_noop
from openpilot.system.ui.widgets.list_view import button_item
from openpilot.system.ui.widgets.scroller_tici import Scroller
from openpilot.system.ui.widgets import DialogResult
from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog, alert_dialog
from openpilot.system.ui.widgets.selection_dialog import SelectionDialog
from openpilot.system.ui.widgets.input_dialog import InputDialog
from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import StarPilotPanel
EXCLUDED_KEYS = {
"AvailableModels",
"AvailableModelNames",
"FrogPilotStats",
"GithubSshKeys",
"GithubUsername",
"MapBoxRequests",
"ModelDrivesAndScores",
"OverpassRequests",
"SpeedLimits",
"SpeedLimitsFiltered",
"UpdaterAvailableBranches",
}
REPORT_CATEGORIES = [
"Acceleration feels harsh or jerky",
"An alert was unclear and I'm not sure what it meant",
"Braking is too sudden or uncomfortable",
"I'm not sure if this is normal or a bug:",
"My steering wheel buttons aren't working",
"openpilot disengages when I don't expect it",
"openpilot feels sluggish or slow to respond",
"Something else (please describe)",
]
class StarPilotUtilitiesLayout(StarPilotPanel):
def __init__(self):
super().__init__()
items = [
button_item(
tr_noop("Update StarPilot"),
lambda: tr("CHECK"),
tr_noop("<b>Check for updates</b> and update StarPilot to the latest version."),
),
button_item(
tr_noop("Reset Settings"),
lambda: tr("RESET"),
tr_noop("<b>Reset all StarPilot settings</b> to their default values."),
),
button_item(
tr_noop("View Logs"),
lambda: tr("VIEW"),
tr_noop("<b>View StarPilot logs</b> for debugging."),
),
self.CATEGORIES = [
{
"title": tr_noop("Debug Mode"),
"type": "toggle",
"get_state": lambda: self._params.get_bool("DebugMode"),
"set_state": lambda s: self._params.put_bool("DebugMode", s),
"color": "#FA6800",
},
{"title": tr_noop("Flash Panda"), "type": "hub", "on_click": self._on_flash_panda, "color": "#FA6800"},
{
"title": tr_noop("Force Drive State"),
"type": "value",
"get_value": self._get_force_drive_state,
"on_click": self._on_force_drive_state,
"color": "#FA6800",
},
{
"title": tr_noop("The Pond"),
"type": "value",
"get_value": lambda: tr("Paired") if self._params.get_bool("PondPaired") else tr("Not paired"),
"on_click": self._on_pond_clicked,
"color": "#FA6800",
},
{"title": tr_noop("Report Issue"), "type": "hub", "on_click": self._on_report_issue, "color": "#FA6800"},
{"title": tr_noop("Reset to Defaults"), "type": "hub", "on_click": self._on_reset_defaults, "color": "#FA6800"},
{"title": tr_noop("Reset to Stock"), "type": "hub", "on_click": self._on_reset_stock, "color": "#FA6800"},
]
self._rebuild_grid()
self._scroller = Scroller(items, line_separator=True, spacing=0)
def _get_force_drive_state(self):
if self._params.get_bool("ForceOnroad"):
return tr("Onroad")
if self._params.get_bool("ForceOffroad"):
return tr("Offroad")
return tr("Default")
def _on_flash_panda(self):
def _do_flash(res):
if res == DialogResult.CONFIRM:
self._params_memory.put_bool("FlashPanda", True)
gui_app.set_modal_overlay(alert_dialog(tr("Panda flashing started. Device will reboot when finished.")))
gui_app.set_modal_overlay(ConfirmDialog(tr("Flash Panda firmware?"), tr("Flash"), on_close=_do_flash))
def _on_force_drive_state(self):
options = [tr("Offroad"), tr("Onroad"), tr("Default")]
def on_select(res, val):
if res == DialogResult.CONFIRM:
if val == tr("Offroad"):
self._params.put_bool("ForceOffroad", True)
self._params.put_bool("ForceOnroad", False)
elif val == tr("Onroad"):
self._params.put_bool("ForceOnroad", True)
self._params.put_bool("ForceOffroad", False)
else:
self._params.put_bool("ForceOffroad", False)
self._params.put_bool("ForceOnroad", False)
self._rebuild_grid()
current = self._get_force_drive_state()
gui_app.set_modal_overlay(SelectionDialog(tr("Force Drive State"), options, current, on_close=on_select))
def _on_pond_clicked(self):
paired = self._params.get_bool("PondPaired")
if paired:
def on_unpair(res):
if res == DialogResult.CONFIRM:
self._params.put_bool("PondPaired", False)
gui_app.set_modal_overlay(alert_dialog(tr("Unpaired from The Pond.")))
self._rebuild_grid()
gui_app.set_modal_overlay(ConfirmDialog(tr("Unpair from The Pond?"), tr("Unpair"), on_close=on_unpair))
else:
gui_app.set_modal_overlay(alert_dialog(tr("Visit frogpilot.com/the_pond to pair your device.")))
def _on_report_issue(self):
def on_category(res, val):
if res != DialogResult.CONFIRM:
return
discord_user = self._params.get("DiscordUsername", encoding='utf-8') or ""
def on_discord(res2, username):
if res2 == DialogResult.CONFIRM and username:
self._params.put("DiscordUsername", username)
report = json.dumps({"DiscordUser": username, "Issue": val})
self._params_memory.put("IssueReported", report)
gui_app.set_modal_overlay(alert_dialog(tr("Issue reported. Thank you!")))
gui_app.set_modal_overlay(InputDialog(tr("Discord Username"), discord_user or "", on_close=on_discord))
gui_app.set_modal_overlay(SelectionDialog(tr("Select Issue"), REPORT_CATEGORIES, on_close=on_category))
def _on_reset_defaults(self):
def _do_reset(res):
if res == DialogResult.CONFIRM:
all_keys = self._params.all_keys()
for k in all_keys:
if k in EXCLUDED_KEYS:
continue
default = self._params.get_default_value(k)
if default is not None:
self._params.put(k, default)
gui_app.set_modal_overlay(alert_dialog(tr("Toggles reset to defaults.")))
self._rebuild_grid()
gui_app.set_modal_overlay(ConfirmDialog(tr("Reset all toggles to defaults?"), tr("Reset"), on_close=_do_reset))
def _on_reset_stock(self):
def _do_reset(res):
if res == DialogResult.CONFIRM:
all_keys = self._params.all_keys()
for k in all_keys:
if k in EXCLUDED_KEYS:
continue
stock = self._params.get_stock_value(k)
if stock is not None:
self._params.put(k, stock)
gui_app.set_modal_overlay(alert_dialog(tr("Toggles reset to stock openpilot.")))
self._rebuild_grid()
gui_app.set_modal_overlay(ConfirmDialog(tr("Reset all toggles to stock openpilot?"), tr("Reset"), on_close=_do_reset))
@@ -1,20 +1,516 @@
from __future__ import annotations
import os
import re
from pathlib import Path
from openpilot.system.hardware import HARDWARE
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.multilang import tr, tr_noop
from openpilot.system.ui.widgets.list_view import button_item
from openpilot.system.ui.widgets.scroller_tici import Scroller
from openpilot.system.ui.widgets import DialogResult
from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog
from openpilot.system.ui.widgets.selection_dialog import SelectionDialog
from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import StarPilotPanel
from openpilot.selfdrive.ui.layouts.settings.starpilot.metro import SliderDialog, TileGrid, HubTile, ToggleTile, ValueTile
from openpilot.selfdrive.ui.lib.starpilot_state import starpilot_state
MAKE_TO_FOLDER = {
"acura": "honda",
"audi": "volkswagen",
"buick": "gm",
"cadillac": "gm",
"chevrolet": "gm",
"chrysler": "chrysler",
"cupra": "volkswagen",
"dodge": "chrysler",
"ford": "ford",
"genesis": "hyundai",
"gmc": "gm",
"holden": "gm",
"honda": "honda",
"hyundai": "hyundai",
"jeep": "chrysler",
"kia": "hyundai",
"lexus": "toyota",
"lincoln": "ford",
"man": "volkswagen",
"mazda": "mazda",
"nissan": "nissan",
"peugeot": "psa",
"ram": "chrysler",
"rivian": "rivian",
"seat": "volkswagen",
"škoda": "volkswagen",
"subaru": "subaru",
"tesla": "tesla",
"toyota": "toyota",
"volkswagen": "volkswagen",
}
def get_car_names(car_make: str):
folder = MAKE_TO_FOLDER.get(car_make.lower())
if not folder:
return [], {}
# Path to values.py in opendbc
values_path = Path(__file__).parents[4] / "opendbc" / "car" / folder / "values.py"
if not values_path.exists():
return [], {}
with open(values_path, "r") as f:
content = f.read()
# Clean comments
content = re.sub(r'#.*', '', content)
# Find platforms and car names
platforms = re.findall(r'(\w+)\s*=\s*\w+\s*\(', content)
car_models = {}
car_names = []
# This is a simplified version of the C++ regex logic
# In values.py, CarDocs often appears as CarDocs("Name", ...)
matches = re.finditer(r'CarDocs\w*\s*\(\s*"([^"]+)"', content)
for match in matches:
name = match.group(1)
if name.lower().startswith(car_make.lower()):
# Find the platform name by looking backwards for the nearest platform assignment
# For now, we'll just store the name
car_names.append(name)
return sorted(list(set(car_names))), car_models
def _lock_doors_timer_labels():
labels: dict[float, str] = {0.0: tr("Never")}
for i in range(5, 305, 5):
labels[float(i)] = f"{i}s"
return labels
class StarPilotVehicleSettingsLayout(StarPilotPanel):
def __init__(self):
super().__init__()
self._sub_panels = {
"gm": StarPilotGMVehicleLayout(),
"hkg": StarPilotHKGVehicleLayout(),
"subaru": StarPilotSubaruVehicleLayout(),
"toyota": StarPilotToyotaVehicleLayout(),
"info": StarPilotVehicleInfoLayout(),
}
items = [
button_item(
tr_noop("Vehicle Settings"),
lambda: tr("MANAGE"),
tr_noop("<b>Configure car-specific options</b> like model name and features."),
),
self.CATEGORIES = [
{
"title": tr_noop("Car Make"),
"type": "value",
"get_value": lambda: self._params.get("CarMake", encoding='utf-8') or tr("None"),
"on_click": self._on_select_make,
"color": "#FFC40D",
},
{
"title": tr_noop("Car Model"),
"type": "value",
"get_value": lambda: self._params.get("CarModelName", encoding='utf-8') or tr("None"),
"on_click": self._on_select_model,
"color": "#FFC40D",
},
{
"title": tr_noop("Disable Fingerprinting"),
"type": "toggle",
"get_state": lambda: self._params.get_bool("ForceFingerprint"),
"set_state": lambda s: self._params.put_bool("ForceFingerprint", s),
"color": "#FFC40D",
},
{
"title": tr_noop("Disable openpilot Long"),
"type": "toggle",
"get_state": lambda: self._params.get_bool("DisableOpenpilotLongitudinal"),
"set_state": self._on_disable_long,
"color": "#FFC40D",
},
{"title": tr_noop("GM Settings"), "panel": "gm", "icon": "toggle_icons/icon_vehicle.png", "color": "#FFC40D", "key": "gm"},
{"title": tr_noop("HKG Settings"), "panel": "hkg", "icon": "toggle_icons/icon_vehicle.png", "color": "#FFC40D", "key": "hkg"},
{"title": tr_noop("Subaru Settings"), "panel": "subaru", "icon": "toggle_icons/icon_vehicle.png", "color": "#FFC40D", "key": "subaru"},
{"title": tr_noop("Toyota Settings"), "panel": "toyota", "icon": "toggle_icons/icon_vehicle.png", "color": "#FFC40D", "key": "toyota"},
{"title": tr_noop("Vehicle Info"), "panel": "info", "icon": "toggle_icons/icon_vehicle.png", "color": "#FFC40D"},
]
self._scroller = Scroller(items, line_separator=True, spacing=0)
for name, panel in self._sub_panels.items():
if hasattr(panel, 'set_navigate_callback'):
panel.set_navigate_callback(self._navigate_to)
if hasattr(panel, 'set_back_callback'):
panel.set_back_callback(self._go_back)
self._rebuild_grid()
def _rebuild_grid(self):
if not self.CATEGORIES:
return
if self._tile_grid is None:
self._tile_grid = TileGrid(columns=None, padding=20)
self._tile_grid.clear()
cs = starpilot_state.car_state
for cat in self.CATEGORIES:
key = cat.get("key")
visible = True
if key == "gm":
visible = cs.isGM
elif key == "hkg":
visible = cs.isHKG
elif key == "subaru":
visible = cs.isSubaru
elif key == "toyota":
visible = cs.isToyota
if not visible:
continue
tile_type = cat.get("type", "hub")
if tile_type == "hub":
on_click = cat.get("on_click")
if on_click is None:
on_click = lambda c=cat: self._navigate_to(c["panel"])
tile = HubTile(
title=tr(cat["title"]),
desc=tr(cat.get("desc", "")),
icon_path=cat.get("icon"),
on_click=on_click,
starpilot_icon=cat.get("starpilot_icon", True),
bg_color=cat.get("color"),
)
elif tile_type == "toggle":
tile = ToggleTile(title=tr(cat["title"]), get_state=cat["get_state"], set_state=cat["set_state"], icon_path=cat.get("icon"), bg_color=cat.get("color"))
elif tile_type == "value":
tile = ValueTile(title=tr(cat["title"]), get_value=cat["get_value"], on_click=cat["on_click"], icon_path=cat.get("icon"), bg_color=cat.get("color"))
else:
continue
self._tile_grid.add_tile(tile)
def _on_select_make(self):
makes = sorted(list(MAKE_TO_FOLDER.keys()))
makes = [m.capitalize() for m in makes]
def on_select(res, val):
if res == DialogResult.CONFIRM:
self._params.put("CarMake", val)
self._params.remove("CarModel")
self._params.remove("CarModelName")
self._rebuild_grid()
gui_app.set_modal_overlay(SelectionDialog(tr("Select Make"), makes, self._params.get("CarMake", encoding='utf-8') or "", on_close=on_select))
def _on_select_model(self):
make = self._params.get("CarMake", encoding='utf-8')
if not make:
gui_app.set_modal_overlay(ConfirmDialog(tr("Please select a Car Make first!"), tr("OK"), on_close=lambda r: None))
return
models, _ = get_car_names(make)
if not models:
gui_app.set_modal_overlay(ConfirmDialog(tr("No models found for this make."), tr("OK"), on_close=lambda r: None))
return
def on_select(res, val):
if res == DialogResult.CONFIRM:
self._params.put("CarModelName", val)
# In a real build we'd map name to platform code here
self._rebuild_grid()
gui_app.set_modal_overlay(SelectionDialog(tr("Select Model"), models, self._params.get("CarModelName", encoding='utf-8') or "", on_close=on_select))
def _on_disable_long(self, state):
if state:
def on_confirm(res):
if res == DialogResult.CONFIRM:
self._params.put_bool("DisableOpenpilotLongitudinal", True)
from openpilot.selfdrive.ui.ui_state import ui_state
if ui_state.started:
HARDWARE.reboot()
self._rebuild_grid()
gui_app.set_modal_overlay(ConfirmDialog(tr("Disable openpilot longitudinal control?"), tr("Disable"), on_close=on_confirm))
else:
self._params.put_bool("DisableOpenpilotLongitudinal", False)
self._rebuild_grid()
class StarPilotGMVehicleLayout(StarPilotPanel):
def __init__(self):
super().__init__()
self.CATEGORIES = [
{
"title": tr_noop("Pedal for Long"),
"type": "toggle",
"get_state": lambda: self._params.get_bool("GMPedalLongitudinal"),
"set_state": lambda s: self._params.put_bool("GMPedalLongitudinal", s),
"color": "#FFC40D",
"key": "GMPedalLongitudinal",
},
{
"title": tr_noop("Remote Start Panda"),
"type": "toggle",
"get_state": lambda: self._params.get_bool("RemoteStartBootsComma"),
"set_state": lambda s: self._params.put_bool("RemoteStartBootsComma", s),
"color": "#FFC40D",
},
{
"title": tr_noop("Volt SNG Hack"),
"type": "toggle",
"get_state": lambda: self._params.get_bool("VoltSNG"),
"set_state": lambda s: self._params.put_bool("VoltSNG", s),
"color": "#FFC40D",
"key": "VoltSNG",
},
]
self._rebuild_grid()
def _rebuild_grid(self):
if not self.CATEGORIES:
return
if self._tile_grid is None:
self._tile_grid = TileGrid(columns=None, padding=20)
self._tile_grid.clear()
cs = starpilot_state.car_state
for cat in self.CATEGORIES:
key = cat.get("key")
visible = True
if key == "GMPedalLongitudinal":
visible = cs.hasPedal
elif key == "VoltSNG":
visible = cs.isVolt and not cs.hasSNG
if not visible:
continue
tile = ToggleTile(title=tr(cat["title"]), get_state=cat["get_state"], set_state=cat["set_state"], icon_path=cat.get("icon"), bg_color=cat.get("color"))
self._tile_grid.add_tile(tile)
class StarPilotHKGVehicleLayout(StarPilotPanel):
def __init__(self):
super().__init__()
self.CATEGORIES = [
{
"title": tr_noop("Taco Bell Torque Hack"),
"type": "toggle",
"get_state": lambda: self._params.get_bool("TacoTuneHacks"),
"set_state": lambda s: self._params.put_bool("TacoTuneHacks", s),
"color": "#FFC40D",
"key": "TacoTuneHacks",
},
]
self._rebuild_grid()
def _rebuild_grid(self):
if not self.CATEGORIES:
return
if self._tile_grid is None:
self._tile_grid = TileGrid(columns=None, padding=20)
self._tile_grid.clear()
cs = starpilot_state.car_state
for cat in self.CATEGORIES:
key = cat.get("key")
visible = True
if key == "TacoTuneHacks":
visible = cs.isHKGCanFd
if not visible:
continue
tile = ToggleTile(title=tr(cat["title"]), get_state=cat["get_state"], set_state=cat["set_state"], icon_path=cat.get("icon"), bg_color=cat.get("color"))
self._tile_grid.add_tile(tile)
class StarPilotSubaruVehicleLayout(StarPilotPanel):
def __init__(self):
super().__init__()
self.CATEGORIES = [
{
"title": tr_noop("Stop and Go"),
"type": "toggle",
"get_state": lambda: self._params.get_bool("SubaruSNG"),
"set_state": lambda s: self._params.put_bool("SubaruSNG", s),
"color": "#FFC40D",
},
]
self._rebuild_grid()
class StarPilotToyotaVehicleLayout(StarPilotPanel):
def __init__(self):
super().__init__()
self.CATEGORIES = [
{
"title": tr_noop("Auto Lock Doors"),
"type": "toggle",
"get_state": lambda: self._params.get_bool("LockDoors"),
"set_state": lambda s: self._params.put_bool("LockDoors", s),
"color": "#FFC40D",
},
{
"title": tr_noop("Auto Unlock Doors"),
"type": "toggle",
"get_state": lambda: self._params.get_bool("UnlockDoors"),
"set_state": lambda s: self._params.put_bool("UnlockDoors", s),
"color": "#FFC40D",
},
{
"title": tr_noop("Lock Doors Timer"),
"type": "value",
"get_value": lambda: _lock_doors_timer_labels().get(self._params.get_int('LockDoorsTimer'), f"{self._params.get_int('LockDoorsTimer')}s"),
"on_click": self._show_lock_timer_selector,
"color": "#FFC40D",
},
{
"title": tr_noop("Dashboard Speed Offset"),
"type": "value",
"get_value": lambda: f"{self._params.get_float('ClusterOffset'):.3f}x",
"on_click": self._show_offset_selector,
"color": "#FFC40D",
},
{
"title": tr_noop("Stop-and-Go Hack"),
"type": "toggle",
"get_state": lambda: self._params.get_bool("SNGHack"),
"set_state": lambda s: self._params.put_bool("SNGHack", s),
"color": "#FFC40D",
"key": "SNGHack",
},
{
"title": tr_noop("FrogsGoMoo Tweak"),
"type": "toggle",
"get_state": lambda: self._params.get_bool("FrogsGoMoosTweak"),
"set_state": lambda s: self._params.put_bool("FrogsGoMoosTweak", s),
"color": "#FFC40D",
"key": "FrogsGoMoosTweak",
},
]
self._rebuild_grid()
def _rebuild_grid(self):
if not self.CATEGORIES:
return
if self._tile_grid is None:
self._tile_grid = TileGrid(columns=None, padding=20)
self._tile_grid.clear()
cs = starpilot_state.car_state
for cat in self.CATEGORIES:
key = cat.get("key")
visible = True
if key == "SNGHack":
visible = not cs.hasSNG
elif key == "FrogsGoMoosTweak":
visible = cs.hasOpenpilotLongitudinal
if not visible:
continue
tile_type = cat.get("type", "hub")
if tile_type == "toggle":
tile = ToggleTile(title=tr(cat["title"]), get_state=cat["get_state"], set_state=cat["set_state"], icon_path=cat.get("icon"), bg_color=cat.get("color"))
elif tile_type == "value":
tile = ValueTile(title=tr(cat["title"]), get_value=cat["get_value"], on_click=cat["on_click"], icon_path=cat.get("icon"), bg_color=cat.get("color"))
else:
continue
self._tile_grid.add_tile(tile)
def _show_lock_timer_selector(self):
def on_close(res, val):
if res == DialogResult.CONFIRM:
self._params.put_int("LockDoorsTimer", int(val))
self._rebuild_grid()
gui_app.set_modal_overlay(
SliderDialog(tr("Lock Doors Timer"), 0, 300, 5, self._params.get_int("LockDoorsTimer"), on_close, labels=_lock_doors_timer_labels(), color="#FFC40D")
)
def _show_offset_selector(self):
def on_close(res, val):
if res == DialogResult.CONFIRM:
self._params.put_float("ClusterOffset", float(val))
self._rebuild_grid()
gui_app.set_modal_overlay(
SliderDialog(tr("Dashboard Speed Offset"), 1.000, 1.050, 0.001, self._params.get_float("ClusterOffset"), on_close, unit="x", color="#FFC40D")
)
class StarPilotVehicleInfoLayout(StarPilotPanel):
def __init__(self):
super().__init__()
self.CATEGORIES = [
{
"title": tr_noop("Radar Support"),
"type": "value",
"get_value": lambda: tr("Yes") if starpilot_state.car_state.hasRadar else tr("No"),
"on_click": lambda: None,
"color": "#FFC40D",
},
{
"title": tr_noop("Longitudinal Support"),
"type": "value",
"get_value": lambda: tr("Yes") if starpilot_state.car_state.hasOpenpilotLongitudinal else tr("No"),
"on_click": lambda: None,
"color": "#FFC40D",
},
{
"title": tr_noop("Blind Spot Support"),
"type": "value",
"get_value": lambda: tr("Yes") if starpilot_state.car_state.hasBSM else tr("No"),
"on_click": lambda: None,
"color": "#FFC40D",
},
{
"title": tr_noop("Hardware Detected"),
"type": "value",
"get_value": lambda: (
", ".join(
filter(
None,
[
tr("Pedal") if starpilot_state.car_state.canUsePedal else "",
tr("SDSU") if starpilot_state.car_state.canUseSDSU else "",
tr("ZSS") if starpilot_state.car_state.hasZSS else "",
],
)
)
or tr("None")
),
"on_click": lambda: None,
"color": "#FFC40D",
},
{
"title": tr_noop("Pedal Support"),
"type": "value",
"get_value": lambda: tr("Yes") if starpilot_state.car_state.canUsePedal else tr("No"),
"on_click": lambda: None,
"color": "#FFC40D",
},
{
"title": tr_noop("SDSU Support"),
"type": "value",
"get_value": lambda: tr("Yes") if starpilot_state.car_state.canUseSDSU else tr("No"),
"on_click": lambda: None,
"color": "#FFC40D",
},
{
"title": tr_noop("SNG Support"),
"type": "value",
"get_value": lambda: tr("Yes") if starpilot_state.car_state.hasSNG else tr("No"),
"on_click": lambda: None,
"color": "#FFC40D",
},
]
self._rebuild_grid()
@@ -1,40 +1,509 @@
from __future__ import annotations
from openpilot.selfdrive.ui.lib.starpilot_state import starpilot_state
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.multilang import tr, tr_noop
from openpilot.system.ui.widgets.list_view import button_item
from openpilot.system.ui.widgets.scroller_tici import Scroller
from openpilot.system.ui.widgets import DialogResult
from openpilot.system.ui.widgets.selection_dialog import SelectionDialog
from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import StarPilotPanel
from openpilot.selfdrive.ui.layouts.settings.starpilot.metro import TileGrid, ToggleTile, SliderDialog
class StarPilotThemesLayout(StarPilotPanel):
def __init__(self):
super().__init__()
self._sub_panels = {
"personalize": StarPilotPersonalizeLayout(),
}
self.CATEGORIES = [
{"title": tr_noop("Personalize openpilot"), "panel": "personalize", "icon": "toggle_icons/icon_frog.png", "color": "#A200FF"},
{
"title": tr_noop("Holiday Themes"),
"type": "toggle",
"get_state": lambda: self._params.get_bool("HolidayThemes"),
"set_state": lambda s: self._params.put_bool("HolidayThemes", s),
"icon": "toggle_icons/icon_calendar.png",
"color": "#A200FF",
},
{
"title": tr_noop("Rainbow Path"),
"type": "toggle",
"get_state": lambda: self._params.get_bool("RainbowPath"),
"set_state": lambda s: self._params.put_bool("RainbowPath", s),
"icon": "toggle_icons/icon_rainbow.png",
"color": "#A200FF",
},
{
"title": tr_noop("Random Events"),
"type": "toggle",
"get_state": lambda: self._params.get_bool("RandomEvents"),
"set_state": lambda s: self._params.put_bool("RandomEvents", s),
"icon": "toggle_icons/icon_random.png",
"color": "#A200FF",
},
{
"title": tr_noop("Random Themes"),
"type": "toggle",
"get_state": lambda: self._params.get_bool("RandomThemes"),
"set_state": lambda s: self._params.put_bool("RandomThemes", s),
"icon": "toggle_icons/icon_random_themes.png",
"color": "#A200FF",
},
]
for name, panel in self._sub_panels.items():
if hasattr(panel, 'set_navigate_callback'):
panel.set_navigate_callback(self._navigate_to)
if hasattr(panel, 'set_back_callback'):
panel.set_back_callback(self._go_back)
self._rebuild_grid()
class StarPilotPersonalizeLayout(StarPilotPanel):
def __init__(self):
super().__init__()
self.CATEGORIES = [
{"title": tr_noop("Boot Logo"), "type": "hub", "on_click": lambda: self._show_theme_selector("BootLogo"), "color": "#A200FF"},
{"title": tr_noop("Color Scheme"), "type": "hub", "on_click": lambda: self._show_theme_selector("ColorScheme"), "color": "#A200FF"},
{"title": tr_noop("Distance Icons"), "type": "hub", "on_click": lambda: self._show_theme_selector("DistanceIconPack"), "color": "#A200FF"},
{"title": tr_noop("Icon Pack"), "type": "hub", "on_click": lambda: self._show_theme_selector("IconPack"), "color": "#A200FF"},
{"title": tr_noop("Turn Signals"), "type": "hub", "on_click": lambda: self._show_theme_selector("SignalAnimation"), "color": "#A200FF"},
{"title": tr_noop("Sound Pack"), "type": "hub", "on_click": lambda: self._show_theme_selector("SoundPack"), "color": "#A200FF"},
{"title": tr_noop("Steering Wheel"), "type": "hub", "on_click": lambda: self._show_theme_selector("WheelIcon"), "color": "#A200FF"},
]
self._rebuild_grid()
def _show_theme_selector(self, key):
themes = ["Stock", "Frog", "Cyberpunk", "Minimal"]
current = self._params.get(key, encoding='utf-8') or "Stock"
def on_select(res, val):
if res == DialogResult.CONFIRM:
self._params.put(key, val)
self._rebuild_grid()
gui_app.set_modal_overlay(SelectionDialog(tr(key), themes, current, on_close=on_select))
class StarPilotVisualsLayout(StarPilotPanel):
def __init__(self):
super().__init__()
items = [
button_item(
tr_noop("Advanced UI Controls"),
lambda: tr("MANAGE"),
tr_noop("<b>Advanced visual changes</b> to fine-tune how the driving screen looks."),
),
button_item(
tr_noop("Driving Screen Widgets"),
lambda: tr("MANAGE"),
tr_noop("<b>Custom StarPilot widgets</b> for the driving screen."),
),
button_item(
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."),
),
self._sub_panels = {
"advanced": StarPilotAdvancedVisualsLayout(),
"widgets": StarPilotVisualWidgetsLayout(),
"model": StarPilotModelUILayout(),
"navigation": StarPilotNavigationVisualsLayout(),
"qol": StarPilotVisualQOLLayout(),
}
self.CATEGORIES = [
{"title": tr_noop("Advanced UI Controls"), "panel": "advanced", "icon": "toggle_icons/icon_advanced_device.png", "color": "#A200FF"},
{"title": tr_noop("Driving Screen Widgets"), "panel": "widgets", "icon": "toggle_icons/icon_display.png", "color": "#A200FF"},
{"title": tr_noop("Model UI"), "panel": "model", "icon": "toggle_icons/icon_road.png", "color": "#A200FF"},
{"title": tr_noop("Navigation Widgets"), "panel": "navigation", "icon": "toggle_icons/icon_map.png", "color": "#A200FF"},
{"title": tr_noop("Quality of Life"), "panel": "qol", "icon": "toggle_icons/icon_quality_of_life.png", "color": "#A200FF"},
]
for name, panel in self._sub_panels.items():
if hasattr(panel, 'set_navigate_callback'):
panel.set_navigate_callback(self._navigate_to)
if hasattr(panel, 'set_back_callback'):
panel.set_back_callback(self._go_back)
self._rebuild_grid()
self._scroller = Scroller(items, line_separator=True, spacing=0)
class StarPilotAdvancedVisualsLayout(StarPilotPanel):
def __init__(self):
super().__init__()
self.CATEGORIES = [
{
"title": tr_noop("Hide Speed"),
"type": "toggle",
"key": "HideSpeed",
"get_state": lambda: self._params.get_bool("HideSpeed"),
"set_state": lambda s: self._params.put_bool("HideSpeed", s),
"icon": "toggle_icons/icon_display.png",
"color": "#A200FF",
},
{
"title": tr_noop("Hide Lead Marker"),
"type": "toggle",
"key": "HideLeadMarker",
"get_state": lambda: self._params.get_bool("HideLeadMarker"),
"set_state": lambda s: self._params.put_bool("HideLeadMarker", s),
"icon": "toggle_icons/icon_display.png",
"color": "#A200FF",
},
{
"title": tr_noop("Hide Max Speed"),
"type": "toggle",
"key": "HideMaxSpeed",
"get_state": lambda: self._params.get_bool("HideMaxSpeed"),
"set_state": lambda s: self._params.put_bool("HideMaxSpeed", s),
"icon": "toggle_icons/icon_display.png",
"color": "#A200FF",
},
{
"title": tr_noop("Hide Alerts"),
"type": "toggle",
"key": "HideAlerts",
"get_state": lambda: self._params.get_bool("HideAlerts"),
"set_state": lambda s: self._params.put_bool("HideAlerts", s),
"icon": "toggle_icons/icon_display.png",
"color": "#A200FF",
},
{
"title": tr_noop("Hide Speed Limit"),
"type": "toggle",
"key": "HideSpeedLimit",
"get_state": lambda: self._params.get_bool("HideSpeedLimit"),
"set_state": lambda s: self._params.put_bool("HideSpeedLimit", s),
"icon": "toggle_icons/icon_display.png",
"color": "#A200FF",
},
{
"title": tr_noop("Wheel Speed"),
"type": "toggle",
"key": "WheelSpeed",
"get_state": lambda: self._params.get_bool("WheelSpeed"),
"set_state": lambda s: self._params.put_bool("WheelSpeed", s),
"icon": "toggle_icons/icon_display.png",
"color": "#A200FF",
},
]
self._rebuild_grid()
def _rebuild_grid(self):
if not self.CATEGORIES:
return
if self._tile_grid is None:
self._tile_grid = TileGrid(columns=None, padding=20)
self._tile_grid.clear()
for cat in self.CATEGORIES:
key = cat.get("key")
visible = True
if key == "HideLeadMarker":
visible &= starpilot_state.car_state.hasOpenpilotLongitudinal
if not visible:
continue
tile = ToggleTile(title=tr(cat["title"]), get_state=cat["get_state"], set_state=cat["set_state"], icon_path=cat.get("icon"), bg_color=cat.get("color"))
self._tile_grid.add_tile(tile)
class StarPilotVisualWidgetsLayout(StarPilotPanel):
def __init__(self):
super().__init__()
self.CATEGORIES = [
{
"title": tr_noop("Acceleration Path"),
"type": "toggle",
"key": "AccelerationPath",
"get_state": lambda: self._params.get_bool("AccelerationPath"),
"set_state": lambda s: self._params.put_bool("AccelerationPath", s),
"icon": "toggle_icons/icon_road.png",
"color": "#A200FF",
},
{
"title": tr_noop("Adjacent Lanes"),
"type": "toggle",
"key": "AdjacentPath",
"get_state": lambda: self._params.get_bool("AdjacentPath"),
"set_state": lambda s: self._params.put_bool("AdjacentPath", s),
"icon": "toggle_icons/icon_road.png",
"color": "#A200FF",
},
{
"title": tr_noop("Blind Spot Path"),
"type": "toggle",
"key": "BlindSpotPath",
"get_state": lambda: self._params.get_bool("BlindSpotPath"),
"set_state": lambda s: self._params.put_bool("BlindSpotPath", s),
"icon": "toggle_icons/icon_road.png",
"color": "#A200FF",
},
{
"title": tr_noop("Compass"),
"type": "toggle",
"key": "Compass",
"get_state": lambda: self._params.get_bool("Compass"),
"set_state": lambda s: self._params.put_bool("Compass", s),
"icon": "toggle_icons/icon_navigate.png",
"color": "#A200FF",
},
{
"title": tr_noop("Personality Button"),
"type": "toggle",
"key": "OnroadDistanceButton",
"get_state": lambda: self._params.get_bool("OnroadDistanceButton"),
"set_state": lambda s: self._params.put_bool("OnroadDistanceButton", s),
"icon": "toggle_icons/icon_personality.png",
"color": "#A200FF",
},
{
"title": tr_noop("Pedal Indicators"),
"type": "toggle",
"key": "PedalsOnUI",
"get_state": lambda: self._params.get_bool("PedalsOnUI"),
"set_state": lambda s: self._params.put_bool("PedalsOnUI", s),
"icon": "toggle_icons/icon_display.png",
"color": "#A200FF",
},
{
"title": tr_noop("Dynamic Pedals"),
"type": "toggle",
"key": "DynamicPedalsOnUI",
"get_state": lambda: self._params.get_bool("DynamicPedalsOnUI"),
"set_state": lambda s: self._set_exclusive_pedal("DynamicPedalsOnUI", "StaticPedalsOnUI", s),
"icon": "toggle_icons/icon_display.png",
"color": "#A200FF",
},
{
"title": tr_noop("Static Pedals"),
"type": "toggle",
"key": "StaticPedalsOnUI",
"get_state": lambda: self._params.get_bool("StaticPedalsOnUI"),
"set_state": lambda s: self._set_exclusive_pedal("StaticPedalsOnUI", "DynamicPedalsOnUI", s),
"icon": "toggle_icons/icon_display.png",
"color": "#A200FF",
},
{
"title": tr_noop("Rotating Wheel"),
"type": "toggle",
"key": "RotatingWheel",
"get_state": lambda: self._params.get_bool("RotatingWheel"),
"set_state": lambda s: self._params.put_bool("RotatingWheel", s),
"icon": "toggle_icons/icon_steering.png",
"color": "#A200FF",
},
]
self._rebuild_grid()
def _set_exclusive_pedal(self, key, other_key, state):
self._params.put_bool(key, state)
if state:
self._params.put_bool(other_key, False)
self._rebuild_grid()
def _rebuild_grid(self):
if not self.CATEGORIES:
return
if self._tile_grid is None:
self._tile_grid = TileGrid(columns=None, padding=20)
self._tile_grid.clear()
pedals_on_ui = self._params.get_bool("PedalsOnUI")
for cat in self.CATEGORIES:
key = cat.get("key")
visible = True
if key == "AccelerationPath":
visible &= starpilot_state.car_state.hasOpenpilotLongitudinal
elif key == "BlindSpotPath":
visible &= starpilot_state.car_state.hasBSM
elif key == "PedalsOnUI":
visible &= starpilot_state.car_state.hasOpenpilotLongitudinal
elif key == "DynamicPedalsOnUI":
visible &= starpilot_state.car_state.hasOpenpilotLongitudinal and pedals_on_ui
elif key == "StaticPedalsOnUI":
visible &= starpilot_state.car_state.hasOpenpilotLongitudinal and pedals_on_ui
if not visible:
continue
tile = ToggleTile(title=tr(cat["title"]), get_state=cat["get_state"], set_state=cat["set_state"], icon_path=cat.get("icon"), bg_color=cat.get("color"))
self._tile_grid.add_tile(tile)
class StarPilotModelUILayout(StarPilotPanel):
def __init__(self):
super().__init__()
self.CATEGORIES = [
{
"title": tr_noop("Dynamic Path"),
"type": "toggle",
"key": "DynamicPathWidth",
"get_state": lambda: self._params.get_bool("DynamicPathWidth"),
"set_state": lambda s: self._params.put_bool("DynamicPathWidth", s),
"icon": "toggle_icons/icon_road.png",
"color": "#A200FF",
},
{
"title": tr_noop("Lane Line Width"),
"type": "value",
"key": "LaneLinesWidth",
"get_value": lambda: self._get_lane_lines_display(),
"on_click": lambda: self._show_int_selector("LaneLinesWidth", 0, 24, self._get_lane_lines_unit()),
"icon": "toggle_icons/icon_road.png",
"color": "#A200FF",
},
{
"title": tr_noop("Path Edge Width"),
"type": "value",
"key": "PathEdgeWidth",
"get_value": lambda: f"{self._params.get_int('PathEdgeWidth')}%",
"on_click": lambda: self._show_int_selector("PathEdgeWidth", 0, 100, "%"),
"icon": "toggle_icons/icon_road.png",
"color": "#A200FF",
},
{
"title": tr_noop("Path Width"),
"type": "value",
"key": "PathWidth",
"get_value": lambda: self._get_path_width_display(),
"on_click": lambda: self._show_path_width_selector(),
"icon": "toggle_icons/icon_road.png",
"color": "#A200FF",
},
{
"title": tr_noop("Road Edge Width"),
"type": "value",
"key": "RoadEdgesWidth",
"get_value": lambda: self._get_road_edges_display(),
"on_click": lambda: self._show_int_selector("RoadEdgesWidth", 0, 24, self._get_road_edges_unit()),
"icon": "toggle_icons/icon_road.png",
"color": "#A200FF",
},
]
self._rebuild_grid()
def _get_lane_lines_unit(self):
return "cm" if self._params.get_bool("IsMetric") else "in"
def _get_lane_lines_display(self):
val = self._params.get_int('LaneLinesWidth')
if self._params.get_bool("IsMetric"):
return f"{int(val * 2.54)}cm"
return f"{val}in"
def _get_path_width_unit(self):
return "m" if self._params.get_bool("IsMetric") else "ft"
def _get_path_width_display(self):
val = self._params.get_float('PathWidth')
if self._params.get_bool("IsMetric"):
return f"{val / 3.28084:.1f}m"
return f"{val:.1f}ft"
def _get_road_edges_unit(self):
return "cm" if self._params.get_bool("IsMetric") else "in"
def _get_road_edges_display(self):
val = self._params.get_int('RoadEdgesWidth')
if self._params.get_bool("IsMetric"):
return f"{int(val * 2.54)}cm"
return f"{val}in"
def _show_path_width_selector(self):
if self._params.get_bool("IsMetric"):
self._show_float_selector("PathWidth", 0, 10, 0.1, "m", convert=lambda v: v / 3.28084, unconvert=lambda v: v * 3.28084)
else:
self._show_float_selector("PathWidth", 0, 10, 0.1, "ft")
def _show_int_selector(self, key, min_v, max_v, unit=""):
def on_close(res, val):
if res == DialogResult.CONFIRM:
self._params.put_int(key, int(val))
self._rebuild_grid()
gui_app.set_modal_overlay(SliderDialog(tr(key), min_v, max_v, 1, self._params.get_int(key), on_close, unit=unit, color="#A200FF"))
def _show_float_selector(self, key, min_v, max_v, step, unit="", convert=None, unconvert=None):
current = self._params.get_float(key)
if convert:
current = convert(current)
def on_close(res, val):
if res == DialogResult.CONFIRM:
v = float(val)
if unconvert:
v = unconvert(v)
self._params.put_float(key, v)
self._rebuild_grid()
gui_app.set_modal_overlay(SliderDialog(tr(key), min_v, max_v, step, current, on_close, unit=unit, color="#A200FF"))
class StarPilotNavigationVisualsLayout(StarPilotPanel):
def __init__(self):
super().__init__()
self.CATEGORIES = [
{
"title": tr_noop("Road Name"),
"type": "toggle",
"get_state": lambda: self._params.get_bool("RoadNameUI"),
"set_state": lambda s: self._params.put_bool("RoadNameUI", s),
"icon": "toggle_icons/icon_navigate.png",
"color": "#A200FF",
},
{
"title": tr_noop("Speed Limits"),
"type": "toggle",
"get_state": lambda: self._params.get_bool("ShowSpeedLimits"),
"set_state": lambda s: self._params.put_bool("ShowSpeedLimits", s),
"icon": "toggle_icons/icon_speed_limit.png",
"color": "#A200FF",
},
{
"title": tr_noop("Mapbox Limits"),
"type": "toggle",
"get_state": lambda: self._params.get_bool("SLCMapboxFiller"),
"set_state": lambda s: self._params.put_bool("SLCMapboxFiller", s),
"icon": "toggle_icons/icon_speed_limit.png",
"color": "#A200FF",
},
{
"title": tr_noop("Vienna Signs"),
"type": "toggle",
"get_state": lambda: self._params.get_bool("UseVienna"),
"set_state": lambda s: self._params.put_bool("UseVienna", s),
"icon": "toggle_icons/icon_speed_limit.png",
"color": "#A200FF",
},
]
self._rebuild_grid()
class StarPilotVisualQOLLayout(StarPilotPanel):
def __init__(self):
super().__init__()
self.CAMERA_VIEWS = ["Auto", "Driver", "Standard", "Wide"]
self.CATEGORIES = [
{
"title": tr_noop("Camera View"),
"type": "value",
"key": "CameraView",
"get_value": lambda: tr(self.CAMERA_VIEWS[self._params.get_int('CameraView')]),
"on_click": lambda: self._show_camera_view_selector(),
"icon": "toggle_icons/icon_display.png",
"color": "#A200FF",
},
{
"title": tr_noop("Driver Camera"),
"type": "toggle",
"get_state": lambda: self._params.get_bool("DriverCamera"),
"set_state": lambda s: self._params.put_bool("DriverCamera", s),
"icon": "toggle_icons/icon_display.png",
"color": "#A200FF",
},
{
"title": tr_noop("Stopped Timer"),
"type": "toggle",
"get_state": lambda: self._params.get_bool("StoppedTimer"),
"set_state": lambda s: self._params.put_bool("StoppedTimer", s),
"icon": "toggle_icons/icon_display.png",
"color": "#A200FF",
},
]
self._rebuild_grid()
def _show_camera_view_selector(self):
current = self._params.get_int("CameraView")
def on_select(res, val):
if res == DialogResult.CONFIRM:
idx = self.CAMERA_VIEWS.index(val)
self._params.put_int("CameraView", idx)
self._rebuild_grid()
gui_app.set_modal_overlay(SelectionDialog(tr("Camera View"), self.CAMERA_VIEWS, self.CAMERA_VIEWS[current], on_close=on_select))
@@ -1,20 +1,107 @@
from __future__ import annotations
from openpilot.selfdrive.ui.lib.starpilot_state import starpilot_state
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.multilang import tr, tr_noop
from openpilot.system.ui.widgets.list_view import button_item
from openpilot.system.ui.widgets.scroller_tici import Scroller
from openpilot.system.ui.widgets import DialogResult
from openpilot.system.ui.widgets.selection_dialog import SelectionDialog
from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import StarPilotPanel
ACTION_NAMES = ["No Action", "Change Personality", "Force Coast", "Pause Steering", "Pause Accel/Brake", "Toggle Experimental", "Toggle Traffic"]
ACTION_IDS = {name: i for i, name in enumerate(ACTION_NAMES)}
class StarPilotWheelLayout(StarPilotPanel):
def __init__(self):
super().__init__()
items = [
button_item(
tr_noop("Wheel Controls"),
lambda: tr("MANAGE"),
tr_noop("<b>Configure steering wheel button mappings</b> for custom controls."),
),
self.CATEGORIES = [
{
"title": tr_noop("Remap Cancel Button"),
"type": "toggle",
"get_state": lambda: self._params.get_bool("RemapCancelToDistance"),
"set_state": lambda s: self._params.put_bool("RemapCancelToDistance", s),
"color": "#FFC40D",
},
{
"title": tr_noop("Distance Button"),
"type": "value",
"get_value": lambda: self._get_action_name("DistanceButtonControl"),
"on_click": lambda: self._show_action_picker("DistanceButtonControl"),
"color": "#FFC40D",
},
{
"title": tr_noop("Distance (Long Press)"),
"type": "value",
"get_value": lambda: self._get_action_name("LongDistanceButtonControl"),
"on_click": lambda: self._show_action_picker("LongDistanceButtonControl"),
"color": "#FFC40D",
},
{
"title": tr_noop("Distance (Very Long)"),
"type": "value",
"get_value": lambda: self._get_action_name("VeryLongDistanceButtonControl"),
"on_click": lambda: self._show_action_picker("VeryLongDistanceButtonControl"),
"color": "#FFC40D",
},
{
"title": tr_noop("LKAS Button"),
"type": "value",
"get_value": lambda: self._get_action_name("LKASButtonControl"),
"on_click": lambda: self._show_action_picker("LKASButtonControl"),
"key": "LKASButtonControl",
"color": "#FFC40D",
},
]
self._rebuild_grid()
self._scroller = Scroller(items, line_separator=True, spacing=0)
def _get_action_name(self, key):
idx = self._params.get_int(key)
if 0 <= idx < len(ACTION_NAMES):
return ACTION_NAMES[idx]
return ACTION_NAMES[0]
def _get_available_actions(self):
actions = list(ACTION_NAMES[:1]) # No Action
cs = starpilot_state.car_state
if cs.hasOpenpilotLongitudinal:
actions.extend(ACTION_NAMES[1:])
return actions
def _show_action_picker(self, key):
actions = self._get_available_actions()
current = self._get_action_name(key)
if current not in actions:
current = actions[0]
def on_select(res, val):
if res == DialogResult.CONFIRM:
self._params.put_int(key, ACTION_IDS.get(val, 0))
self._rebuild_grid()
gui_app.set_modal_overlay(SelectionDialog(tr(key), actions, current, on_close=on_select))
def _rebuild_grid(self):
if not self.CATEGORIES:
return
if self._tile_grid is None:
self._tile_grid = __import__('openpilot.selfdrive.ui.layouts.settings.starpilot.metro', fromlist=['TileGrid']).TileGrid(columns=None, padding=20)
self._tile_grid.clear()
cs = starpilot_state.car_state
for cat in self.CATEGORIES:
key = cat.get("key")
visible = True
if key == "LKASButtonControl":
visible &= not cs.isSubaru
if not visible:
continue
tile_type = cat.get("type", "hub")
if tile_type == "toggle":
from openpilot.selfdrive.ui.layouts.settings.starpilot.metro import ToggleTile
tile = ToggleTile(title=tr(cat["title"]), get_state=cat["get_state"], set_state=cat["set_state"], bg_color=cat.get("color"))
elif tile_type == "value":
from openpilot.selfdrive.ui.layouts.settings.starpilot.metro import ValueTile
tile = ValueTile(title=tr(cat["title"]), get_value=cat["get_value"], on_click=cat["on_click"], bg_color=cat.get("color"))
else:
continue
self._tile_grid.add_tile(tile)
+204 -14
View File
@@ -1,9 +1,12 @@
import pyray as rl
import socket
import time
from dataclasses import dataclass
from collections.abc import Callable
from cereal import log
from openpilot.common.params import Params
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.selfdrive.ui.onroad.gif_player import load_starpilot_asset
from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos, FONT_SCALE
from openpilot.system.ui.lib.multilang import tr, tr_noop
from openpilot.system.ui.lib.text_measure import measure_text_cached
@@ -18,6 +21,11 @@ FONT_SIZE = 35
SETTINGS_BTN = rl.Rectangle(50, 35, 200, 117)
HOME_BTN = rl.Rectangle(60, 860, 180, 180)
# FrogPilot click regions (for metric cycling)
FP_TEMP_REGION = rl.Rectangle(30, 338, 240, 126)
FP_COMPUTE_REGION = rl.Rectangle(30, 496, 240, 126)
FP_STORAGE_REGION = rl.Rectangle(30, 654, 240, 126)
ThermalStatus = log.DeviceState.ThermalStatus
NetworkType = log.DeviceState.NetworkType
@@ -73,11 +81,34 @@ class Sidebar(Widget):
self._connect_status = MetricData(tr_noop("CONNECT"), tr_noop("OFFLINE"), Colors.WARNING)
self._recording_audio = False
# FrogPilot metrics (replaces stock slots when toggles are on)
self._fp_temp = MetricData(tr_noop("TEMP"), tr_noop("--"), Colors.GOOD)
self._fp_compute = MetricData(tr_noop("CPU"), tr_noop("--"), Colors.GOOD)
self._fp_storage = MetricData(tr_noop("MEM"), tr_noop("--"), Colors.GOOD)
# FrogPilot click-to-cycle state (0=stock, then 1..N for options)
self._fp_temp_cycle = 0 # 0=temp, 1=numerical temp
self._fp_compute_cycle = 0 # 0=stock, 1=CPU, 2=GPU
self._fp_storage_cycle = 0 # 0=stock, 1=memory, 2=storage left, 3=storage used
self._toggles: dict = {}
# IP address (cached, refreshed rarely)
self._ip_address: str = ""
self._ip_refresh_time: float = 0.0
self._home_img = gui_app.texture("images/button_home.png", HOME_BTN.width, HOME_BTN.height)
self._flag_img = gui_app.texture("images/button_flag.png", HOME_BTN.width, HOME_BTN.height)
self._settings_img = gui_app.texture("images/button_settings.png", SETTINGS_BTN.width, SETTINGS_BTN.height)
self._mic_img = gui_app.texture("icons/microphone.png", 30, 30)
self._mic_indicator_rect = rl.Rectangle(0, 0, 0, 0)
# FrogPilot: theme GIF buttons (lazy loaded)
self._home_gif = None
self._flag_gif = None
self._settings_gif = None
self._gifs_loaded = False
self._font_regular = gui_app.font(FontWeight.NORMAL)
self._font_bold = gui_app.font(FontWeight.SEMI_BOLD)
@@ -86,8 +117,7 @@ class Sidebar(Widget):
self._on_flag_click: Callable | None = None
self._open_settings_callback: Callable | None = None
def set_callbacks(self, on_settings: Callable | None = None, on_flag: Callable | None = None,
open_settings: Callable | None = None):
def set_callbacks(self, on_settings: Callable | None = None, on_flag: Callable | None = None, open_settings: Callable | None = None):
self._on_settings_click = on_settings
self._on_flag_click = on_flag
self._open_settings_callback = open_settings
@@ -102,6 +132,29 @@ class Sidebar(Widget):
def _update_state(self):
sm = ui_state.sm
self._toggles = ui_state.frogpilot_toggles
# FrogPilot: load theme GIFs on first update
if not self._gifs_loaded:
self._gifs_loaded = True
try:
self._home_gif = load_starpilot_asset("active_theme/icons/button_home.gif", int(HOME_BTN.width))
except Exception:
self._home_gif = None
try:
self._flag_gif = load_starpilot_asset("active_theme/icons/button_flag.gif", int(HOME_BTN.width))
except Exception:
self._flag_gif = None
try:
self._settings_gif = load_starpilot_asset("active_theme/icons/button_settings.gif", int(SETTINGS_BTN.width))
except Exception:
self._settings_gif = None
# Advance GIF animations
for gif in (self._home_gif, self._flag_gif, self._settings_gif):
if gif is not None:
gif.play()
if not sm.updated['deviceState']:
return
@@ -112,6 +165,19 @@ class Sidebar(Widget):
self._update_temperature_status(device_state)
self._update_connection_status(device_state)
self._update_panda_status()
self._update_frogpilot_metrics(device_state)
# Refresh IP address rarely
now = time.monotonic()
if now - self._ip_refresh_time > 30.0:
self._ip_refresh_time = now
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
self._ip_address = s.getsockname()[0]
s.close()
except Exception:
self._ip_address = ""
def _update_network_status(self, device_state):
self._net_type = NETWORK_TYPES.get(device_state.networkType.raw, tr_noop("Unknown"))
@@ -143,6 +209,82 @@ class Sidebar(Widget):
else:
self._panda_status.update(tr_noop("VEHICLE"), tr_noop("ONLINE"), Colors.GOOD)
def _update_frogpilot_metrics(self, device_state):
t = self._toggles
is_metric = ui_state.is_metric
# --- Temperature slot: numerical temp or stock ---
dev_ui = t.get("developer_ui", False)
if dev_ui and t.get("numerical_temp", False):
temps_c = list(device_state.cpuTempC) if device_state.cpuTempC else []
temp_c = max(temps_c) if temps_c else 0.0
if t.get("fahrenheit", False):
val = f"{temp_c * 9 / 5 + 32:.0f}F"
else:
val = f"{temp_c:.0f}C"
color = Colors.DANGER if temp_c >= 90 else Colors.WARNING if temp_c >= 75 else Colors.GOOD
custom = t.get("sidebar_color1")
if custom:
color = self._parse_color(custom)
self._fp_temp.update(tr_noop("TEMP"), val, color)
else:
self._fp_temp = self._temp_status
# --- Compute slot: CPU/GPU or stock ---
show_cpu = t.get("show_cpu", False)
show_gpu = t.get("show_gpu", False)
if show_cpu or show_gpu:
if show_gpu:
gpu_pct = device_state.gpuUsagePercent if device_state.gpuUsagePercent else 0
val = f"{gpu_pct}%"
color = Colors.DANGER if gpu_pct >= 85 else Colors.WARNING if gpu_pct >= 70 else Colors.GOOD
self._fp_compute.update(tr_noop("GPU"), val, color)
else:
cpu_loads = list(device_state.cpuUsagePercent) if device_state.cpuUsagePercent else []
avg = sum(cpu_loads) / max(len(cpu_loads), 1) if cpu_loads else 0
val = f"{int(avg)}%"
color = Colors.DANGER if avg >= 85 else Colors.WARNING if avg >= 70 else Colors.GOOD
self._fp_compute.update(tr_noop("CPU"), val, color)
custom = t.get("sidebar_color2")
if custom:
self._fp_compute.color = self._parse_color(custom)
else:
self._fp_compute = self._panda_status
# --- Storage slot: memory/storage or stock ---
show_mem = t.get("show_memory_usage", False)
show_storage_left = t.get("show_storage_left", False)
show_storage_used = t.get("show_storage_used", False)
if show_mem:
mem_pct = device_state.memoryUsagePercent if device_state.memoryUsagePercent else 0
val = f"{mem_pct}%"
color = Colors.DANGER if mem_pct >= 85 else Colors.WARNING if mem_pct >= 70 else Colors.GOOD
self._fp_storage.update(tr_noop("MEM"), val, color)
elif show_storage_left or show_storage_used:
if sm_valid := ui_state.sm.valid.get("frogpilotDeviceState", False):
fp_dev = ui_state.sm["frogpilotDeviceState"]
gb = fp_dev.freeSpace if show_storage_left else fp_dev.usedSpace
label = tr_noop("SSD LEFT") if show_storage_left else tr_noop("SSD USED")
val = f"{gb:.0f} GB"
color = Colors.DANGER if (show_storage_left and gb < 5) else Colors.WARNING if (show_storage_left and gb < 25) else Colors.GOOD
self._fp_storage.update(label, val, color)
else:
self._fp_storage = self._connect_status
else:
self._fp_storage = self._connect_status
custom = t.get("sidebar_color3")
if custom:
self._fp_storage.color = self._parse_color(custom)
@staticmethod
def _parse_color(color_str: str) -> rl.Color:
s = color_str.lstrip('#')
if len(s) == 8:
return rl.Color(int(s[2:4], 16), int(s[4:6], 16), int(s[6:8], 16), int(s[0:2], 16))
if len(s) == 6:
return rl.Color(int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16), 255)
return rl.Color(255, 255, 255, 255)
def _handle_mouse_release(self, mouse_pos: MousePos):
if rl.check_collision_point_rec(mouse_pos, SETTINGS_BTN):
if self._on_settings_click:
@@ -153,22 +295,47 @@ class Sidebar(Widget):
elif self._recording_audio and rl.check_collision_point_rec(mouse_pos, self._mic_indicator_rect):
if self._open_settings_callback:
self._open_settings_callback()
# FrogPilot: click-to-cycle metrics when developer_ui is on
elif self._toggles.get("developer_ui", False):
if rl.check_collision_point_rec(mouse_pos, FP_TEMP_REGION):
self._fp_temp_cycle = (self._fp_temp_cycle + 1) % 3
p = Params()
p.put_bool("Fahrenheit", self._fp_temp_cycle == 2)
p.put_bool("NumericalTemp", self._fp_temp_cycle >= 1)
elif rl.check_collision_point_rec(mouse_pos, FP_COMPUTE_REGION):
self._fp_compute_cycle = (self._fp_compute_cycle + 1) % 3
p = Params()
p.put_bool("ShowCPU", self._fp_compute_cycle == 1)
p.put_bool("ShowGPU", self._fp_compute_cycle == 2)
elif rl.check_collision_point_rec(mouse_pos, FP_STORAGE_REGION):
self._fp_storage_cycle = (self._fp_storage_cycle + 1) % 4
p = Params()
p.put_bool("ShowMemoryUsage", self._fp_storage_cycle == 1)
p.put_bool("ShowStorageLeft", self._fp_storage_cycle == 2)
p.put_bool("ShowStorageUsed", self._fp_storage_cycle == 3)
def _draw_buttons(self, rect: rl.Rectangle):
mouse_pos = rl.get_mouse_position()
mouse_down = self.is_pressed and rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT)
# Settings button
# Settings button (GIF theme or stock)
settings_down = mouse_down and rl.check_collision_point_rec(mouse_pos, SETTINGS_BTN)
tint = Colors.BUTTON_PRESSED if settings_down else Colors.BUTTON_NORMAL
rl.draw_texture(self._settings_img, int(SETTINGS_BTN.x), int(SETTINGS_BTN.y), tint)
gif_tex = self._settings_gif.texture if self._settings_gif is not None else None
settings_tex = gif_tex if gif_tex is not None else self._settings_img
rl.draw_texture(settings_tex, int(SETTINGS_BTN.x), int(SETTINGS_BTN.y), tint)
# Home/Flag button
# Home/Flag button (GIF theme or stock)
flag_pressed = mouse_down and rl.check_collision_point_rec(mouse_pos, HOME_BTN)
button_img = self._flag_img if ui_state.started else self._home_img
if ui_state.started:
gif_tex = self._flag_gif.texture if self._flag_gif is not None else None
button_tex = gif_tex if gif_tex is not None else self._flag_img
else:
gif_tex = self._home_gif.texture if self._home_gif is not None else None
button_tex = gif_tex if gif_tex is not None else self._home_img
tint = Colors.BUTTON_PRESSED if (ui_state.started and flag_pressed) else Colors.BUTTON_NORMAL
rl.draw_texture(button_img, int(HOME_BTN.x), int(HOME_BTN.y), tint)
rl.draw_texture(button_tex, int(HOME_BTN.x), int(HOME_BTN.y), tint)
# Microphone button
if self._recording_audio:
@@ -178,10 +345,23 @@ class Sidebar(Widget):
bg_color = rl.Color(Colors.DANGER.r, Colors.DANGER.g, Colors.DANGER.b, int(255 * 0.65)) if mic_pressed else Colors.DANGER
rl.draw_rectangle_rounded(self._mic_indicator_rect, 1, 10, bg_color)
rl.draw_texture(self._mic_img, int(self._mic_indicator_rect.x + (self._mic_indicator_rect.width - self._mic_img.width) / 2),
int(self._mic_indicator_rect.y + (self._mic_indicator_rect.height - self._mic_img.height) / 2), Colors.WHITE)
rl.draw_texture(
self._mic_img,
int(self._mic_indicator_rect.x + (self._mic_indicator_rect.width - self._mic_img.width) / 2),
int(self._mic_indicator_rect.y + (self._mic_indicator_rect.height - self._mic_img.height) / 2),
Colors.WHITE,
)
def _draw_network_indicator(self, rect: rl.Rectangle):
# FrogPilot: IP address replaces signal dots
if self._toggles.get("ip_metrics", False) and self._ip_address:
text_pos = rl.Vector2(rect.x + 58, rect.y + 200)
rl.draw_text_ex(self._font_regular, self._ip_address, text_pos, 30, 0, Colors.WHITE)
# Still show network type below
text_pos2 = rl.Vector2(rect.x + 58, rect.y + 247)
rl.draw_text_ex(self._font_regular, tr(self._net_type), text_pos2, FONT_SIZE, 0, Colors.WHITE)
return
# Signal strength dots
x_start = rect.x + 58
y_pos = rect.y + 196
@@ -200,7 +380,20 @@ class Sidebar(Widget):
rl.draw_text_ex(self._font_regular, tr(self._net_type), text_pos, FONT_SIZE, 0, Colors.WHITE)
def _draw_metrics(self, rect: rl.Rectangle):
metrics = [(self._temp_status, 338), (self._panda_status, 496), (self._connect_status, 654)]
t = self._toggles
use_fp = (
t.get("numerical_temp", False)
or t.get("show_cpu", False)
or t.get("show_gpu", False)
or t.get("show_memory_usage", False)
or t.get("show_storage_left", False)
or t.get("show_storage_used", False)
)
if use_fp:
metrics = [(self._fp_temp, 338), (self._fp_compute, 496), (self._fp_storage, 654)]
else:
metrics = [(self._temp_status, 338), (self._panda_status, 496), (self._connect_status, 654)]
for metric, y_offset in metrics:
self._draw_metric(rect, metric, rect.y + y_offset)
@@ -222,8 +415,5 @@ class Sidebar(Widget):
for text in labels:
text_size = measure_text_cached(self._font_bold, text, FONT_SIZE)
text_y += text_size.y
text_pos = rl.Vector2(
metric_rect.x + 22 + (metric_rect.width - 22 - text_size.x) / 2,
text_y
)
text_pos = rl.Vector2(metric_rect.x + 22 + (metric_rect.width - 22 - text_size.x) / 2, text_y)
rl.draw_text_ex(self._font_bold, text, text_pos, FONT_SIZE, 0, Colors.WHITE)
+31 -13
View File
@@ -32,9 +32,9 @@ SELFDRIVE_UNRESPONSIVE_TIMEOUT = 10 # Seconds
# Constants
ALERT_COLORS = {
AlertStatus.normal: rl.Color(0x15, 0x15, 0x15, 0xF1), # #151515 with alpha 0xF1
AlertStatus.normal: rl.Color(0x15, 0x15, 0x15, 0xF1), # #151515 with alpha 0xF1
AlertStatus.userPrompt: rl.Color(0xDA, 0x6F, 0x25, 0xF1), # #DA6F25 with alpha 0xF1
AlertStatus.critical: rl.Color(0xC9, 0x22, 0x31, 0xF1), # #C92231 with alpha 0xF1
AlertStatus.critical: rl.Color(0xC9, 0x22, 0x31, 0xF1), # #C92231 with alpha 0xF1
}
@@ -76,14 +76,21 @@ class AlertRenderer(Widget):
self.font_bold: rl.Font = gui_app.font(FontWeight.BOLD)
# font size is set dynamically
self._full_text1_label = Label("", font_size=0, font_weight=FontWeight.BOLD, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
text_alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP)
self._full_text2_label = Label("", font_size=ALERT_FONT_BIG, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
text_alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP)
self._full_text1_label = Label(
"",
font_size=0,
font_weight=FontWeight.BOLD,
text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
text_alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP,
)
self._full_text2_label = Label(
"", font_size=ALERT_FONT_BIG, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, text_alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP
)
def get_alert(self, sm: messaging.SubMaster) -> Alert | None:
"""Generate the current alert based on selfdrive state."""
ss = sm['selfdriveState']
t = ui_state.frogpilot_toggles
# Check if selfdriveState messages have stopped arriving
recv_frame = sm.recv_frame['selfdriveState']
@@ -93,12 +100,16 @@ class AlertRenderer(Widget):
# 1. Never received selfdriveState since going onroad
waiting_for_startup = recv_frame < ui_state.started_frame
if waiting_for_startup and time_since_onroad > 5:
if t.get("force_onroad", False):
return None
return ALERT_STARTUP_PENDING
# 2. Lost communication with selfdriveState after receiving it
if TICI and not waiting_for_startup:
ss_missing = time.monotonic() - sm.recv_time['selfdriveState']
if ss_missing > SELFDRIVE_STATE_TIMEOUT:
if t.get("force_onroad", False):
return None
if ss.enabled and (ss_missing - SELFDRIVE_STATE_TIMEOUT) < SELFDRIVE_UNRESPONSIVE_TIMEOUT:
return ALERT_CRITICAL_TIMEOUT
return ALERT_CRITICAL_REBOOT
@@ -107,12 +118,23 @@ class AlertRenderer(Widget):
if ss.alertSize == 0:
return None
# Hide non-critical alerts when FrogPilot toggle is on
if t.get("hide_alerts", False) and ss.alertStatus == AlertStatus.normal:
return None
# Don't get old alert
if recv_frame < ui_state.started_frame:
return None
# Return current alert
return Alert(text1=ss.alertText1, text2=ss.alertText2, size=ss.alertSize.raw, status=ss.alertStatus.raw)
alert_text1 = ss.alertText1
alert_text2 = ss.alertText2
# FrogPilot: random events crash emoji
if t.get("random_events", False) and "Crashed" in alert_text1:
alert_text1 = "\U0001f4a9 " + alert_text1
return Alert(text1=alert_text1, text2=alert_text2, size=ss.alertSize.raw, status=ss.alertStatus.raw)
def _render(self, rect: rl.Rectangle):
alert = self.get_alert(ui_state.sm)
@@ -123,10 +145,7 @@ class AlertRenderer(Widget):
self._draw_background(alert_rect, alert)
text_rect = rl.Rectangle(
alert_rect.x + ALERT_PADDING,
alert_rect.y + ALERT_PADDING,
alert_rect.width - 2 * ALERT_PADDING,
alert_rect.height - 2 * ALERT_PADDING
alert_rect.x + ALERT_PADDING, alert_rect.y + ALERT_PADDING, alert_rect.width - 2 * ALERT_PADDING, alert_rect.height - 2 * ALERT_PADDING
)
self._draw_text(text_rect, alert)
@@ -135,8 +154,7 @@ class AlertRenderer(Widget):
return rect
h = ALERT_HEIGHTS.get(size, rect.height)
return rl.Rectangle(rect.x + ALERT_MARGIN, rect.y + rect.height - h + ALERT_MARGIN,
rect.width - ALERT_MARGIN * 2, h - ALERT_MARGIN * 2)
return rl.Rectangle(rect.x + ALERT_MARGIN, rect.y + rect.height - h + ALERT_MARGIN, rect.width - ALERT_MARGIN * 2, h - ALERT_MARGIN * 2)
def _draw_background(self, rect: rl.Rectangle, alert: Alert) -> None:
color = ALERT_COLORS.get(alert.status, ALERT_COLORS[AlertStatus.normal])
+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.model_renderer import ModelRenderer
from openpilot.selfdrive.ui.onroad.cameraview import CameraView
from openpilot.selfdrive.ui.onroad.frogpilot_overlay import FrogPilotOverlay
from openpilot.selfdrive.ui.widgets.developer_sidebar import DeveloperSidebar
from openpilot.system.ui.lib.application import gui_app
from openpilot.common.transformations.camera import DEVICE_CAMERAS, DeviceCameraConfig, view_frame_from_device_frame
from openpilot.common.transformations.orientation import rot_from_euler
@@ -48,6 +50,8 @@ class AugmentedRoadView(CameraView):
self._hud_renderer = HudRenderer()
self.alert_renderer = AlertRenderer()
self.driver_state_renderer = DriverStateRenderer()
self._frogpilot_overlay = FrogPilotOverlay(self.model_renderer, self._hud_renderer, self.driver_state_renderer)
self._developer_sidebar = DeveloperSidebar()
# debug
self._pm = messaging.PubMaster(['uiDebug'])
@@ -73,12 +77,7 @@ class AugmentedRoadView(CameraView):
# Enable scissor mode to clip all rendering within content rectangle boundaries
# This creates a rendering viewport that prevents graphics from drawing outside the border
rl.begin_scissor_mode(
int(self._content_rect.x),
int(self._content_rect.y),
int(self._content_rect.width),
int(self._content_rect.height)
)
rl.begin_scissor_mode(int(self._content_rect.x), int(self._content_rect.y), int(self._content_rect.width), int(self._content_rect.height))
# Render the base camera view
super()._render(rect)
@@ -89,8 +88,15 @@ class AugmentedRoadView(CameraView):
self.alert_renderer.render(self._content_rect)
self.driver_state_renderer.render(self._content_rect)
# Custom UI extension point - add custom overlays here
# Use self._content_rect for positioning within camera bounds
# StarPilot overlays (speed limits, compass, turn signals, etc.)
self._frogpilot_overlay.render(self._content_rect)
# Developer sidebar (right-side metrics panel)
t = ui_state.frogpilot_toggles
if t.get("developer_sidebar", False):
sb_w = 300
sb_rect = rl.Rectangle(self._content_rect.x + self._content_rect.width - sb_w, self._content_rect.y, sb_w, self._content_rect.height)
self._developer_sidebar.render(sb_rect)
# End clipping region
rl.end_scissor_mode()
@@ -109,14 +115,13 @@ class AugmentedRoadView(CameraView):
def _handle_mouse_release(self, _):
# We only call click callback on press if not interacting with HUD
pass
self._frogpilot_overlay._handle_mouse_release(rl.get_mouse_position())
def _draw_border(self, rect: rl.Rectangle):
rl.draw_rectangle_lines_ex(rect, UI_BORDER_SIZE, rl.BLACK)
border_roundness = 0.12
border_color = BORDER_COLORS.get(ui_state.status, BORDER_COLORS[UIStatus.DISENGAGED])
border_rect = rl.Rectangle(rect.x + UI_BORDER_SIZE, rect.y + UI_BORDER_SIZE,
rect.width - 2 * UI_BORDER_SIZE, rect.height - 2 * UI_BORDER_SIZE)
border_rect = rl.Rectangle(rect.x + UI_BORDER_SIZE, rect.y + UI_BORDER_SIZE, rect.width - 2 * UI_BORDER_SIZE, rect.height - 2 * UI_BORDER_SIZE)
rl.draw_rectangle_rounded_lines_ex(border_rect, border_roundness, 10, UI_BORDER_SIZE, border_color)
def _switch_stream_if_needed(self, sm):
@@ -160,12 +165,7 @@ class AugmentedRoadView(CameraView):
def _calc_frame_matrix(self, rect: rl.Rectangle) -> np.ndarray:
# Check if we can use cached matrix
cache_key = (
ui_state.sm.recv_frame['liveCalibration'],
self._content_rect.width,
self._content_rect.height,
self.stream_type
)
cache_key = (ui_state.sm.recv_frame['liveCalibration'], self._content_rect.width, self._content_rect.height, self.stream_type)
if cache_key == self._matrix_cache_key and self._cached_matrix is not None:
return self._cached_matrix
@@ -202,17 +202,9 @@ class AugmentedRoadView(CameraView):
# Cache the computed transformation matrix to avoid recalculations
self._matrix_cache_key = cache_key
self._cached_matrix = np.array([
[zoom * 2 * cx / w, 0, -x_offset / w * 2],
[0, zoom * 2 * cy / h, -y_offset / h * 2],
[0, 0, 1.0]
])
self._cached_matrix = np.array([[zoom * 2 * cx / w, 0, -x_offset / w * 2], [0, zoom * 2 * cy / h, -y_offset / h * 2], [0, 0, 1.0]])
video_transform = np.array([
[zoom, 0.0, (w / 2 + x - x_offset) - (cx * zoom)],
[0.0, zoom, (h / 2 + y - y_offset) - (cy * zoom)],
[0.0, 0.0, 1.0]
])
video_transform = np.array([[zoom, 0.0, (w / 2 + x - x_offset) - (cx * zoom)], [0.0, zoom, (h / 2 + y - y_offset) - (cy * zoom)], [0.0, 0.0, 1.0]])
self.model_renderer.set_transform(video_transform @ calib_transform)
return self._cached_matrix
+52 -20
View File
@@ -10,17 +10,44 @@ from openpilot.system.ui.widgets import Widget
AlertSize = log.SelfdriveState.AlertSize
# Default 3D coordinates for face keypoints as a NumPy array
DEFAULT_FACE_KPTS_3D = np.array([
[-5.98, -51.20, 8.00], [-17.64, -49.14, 8.00], [-23.81, -46.40, 8.00], [-29.98, -40.91, 8.00],
[-32.04, -37.49, 8.00], [-34.10, -32.00, 8.00], [-36.16, -21.03, 8.00], [-36.16, 6.40, 8.00],
[-35.47, 10.51, 8.00], [-32.73, 19.43, 8.00], [-29.30, 26.29, 8.00], [-24.50, 33.83, 8.00],
[-19.01, 41.37, 8.00], [-14.21, 46.17, 8.00], [-12.16, 47.54, 8.00], [-4.61, 49.60, 8.00],
[4.99, 49.60, 8.00], [12.53, 47.54, 8.00], [14.59, 46.17, 8.00], [19.39, 41.37, 8.00],
[24.87, 33.83, 8.00], [29.67, 26.29, 8.00], [33.10, 19.43, 8.00], [35.84, 10.51, 8.00],
[36.53, 6.40, 8.00], [36.53, -21.03, 8.00], [34.47, -32.00, 8.00], [32.42, -37.49, 8.00],
[30.36, -40.91, 8.00], [24.19, -46.40, 8.00], [18.02, -49.14, 8.00], [6.36, -51.20, 8.00],
[-5.98, -51.20, 8.00],
], dtype=np.float32)
DEFAULT_FACE_KPTS_3D = np.array(
[
[-5.98, -51.20, 8.00],
[-17.64, -49.14, 8.00],
[-23.81, -46.40, 8.00],
[-29.98, -40.91, 8.00],
[-32.04, -37.49, 8.00],
[-34.10, -32.00, 8.00],
[-36.16, -21.03, 8.00],
[-36.16, 6.40, 8.00],
[-35.47, 10.51, 8.00],
[-32.73, 19.43, 8.00],
[-29.30, 26.29, 8.00],
[-24.50, 33.83, 8.00],
[-19.01, 41.37, 8.00],
[-14.21, 46.17, 8.00],
[-12.16, 47.54, 8.00],
[-4.61, 49.60, 8.00],
[4.99, 49.60, 8.00],
[12.53, 47.54, 8.00],
[14.59, 46.17, 8.00],
[19.39, 41.37, 8.00],
[24.87, 33.83, 8.00],
[29.67, 26.29, 8.00],
[33.10, 19.43, 8.00],
[35.84, 10.51, 8.00],
[36.53, 6.40, 8.00],
[36.53, -21.03, 8.00],
[34.47, -32.00, 8.00],
[32.42, -37.49, 8.00],
[30.36, -40.91, 8.00],
[24.19, -46.40, 8.00],
[18.02, -49.14, 8.00],
[6.36, -51.20, 8.00],
[-5.98, -51.20, 8.00],
],
dtype=np.float32,
)
# UI constants
BTN_SIZE = 192
@@ -39,6 +66,7 @@ ARC_ANGLES = np.linspace(0.0, np.pi, ARC_POINT_COUNT, dtype=np.float32)
@dataclass
class ArcData:
"""Data structure for arc rendering parameters."""
x: float
y: float
width: float
@@ -78,8 +106,7 @@ class DriverStateRenderer(Widget):
self.engaged_color = rl.Color(26, 242, 66, 255)
self.disengaged_color = rl.Color(139, 139, 139, 255)
self.set_visible(lambda: (ui_state.sm["selfdriveState"].alertSize == AlertSize.none and
ui_state.sm.recv_frame["driverStateV2"] > ui_state.started_frame))
self.set_visible(lambda: ui_state.sm["selfdriveState"].alertSize == AlertSize.none and ui_state.sm.recv_frame["driverStateV2"] > ui_state.started_frame)
def _render(self, rect):
# Set opacity based on active state
@@ -106,6 +133,15 @@ class DriverStateRenderer(Widget):
if self.v_arc_data:
rl.draw_spline_linear(self.v_arc_lines, len(self.v_arc_lines), self.v_arc_data.thickness, self.arc_color)
@property
def dm_icon_position(self) -> tuple[float, float]:
"""Screen position of the driver monitoring icon center."""
return (self.position_x, self.position_y)
@property
def is_right_hand_drive(self) -> bool:
return self.is_rhd
def _update_state(self):
"""Update the driver monitoring state based on model data"""
sm = ui_state.sm
@@ -181,20 +217,16 @@ class DriverStateRenderer(Widget):
# Horizontal arc
h_width = abs(delta_x)
self.h_arc_data = self._calculate_arc_data(
delta_x, h_width, self.position_x, self.position_y - ARC_LENGTH / 2,
self.driver_pose_sins[1], self.driver_pose_diff[1], is_horizontal=True
delta_x, h_width, self.position_x, self.position_y - ARC_LENGTH / 2, self.driver_pose_sins[1], self.driver_pose_diff[1], is_horizontal=True
)
# Vertical arc
v_height = abs(delta_y)
self.v_arc_data = self._calculate_arc_data(
delta_y, v_height, self.position_x - ARC_LENGTH / 2, self.position_y,
self.driver_pose_sins[0], self.driver_pose_diff[0], is_horizontal=False
delta_y, v_height, self.position_x - ARC_LENGTH / 2, self.position_y, self.driver_pose_sins[0], self.driver_pose_diff[0], is_horizontal=False
)
def _calculate_arc_data(
self, delta: float, size: float, x: float, y: float, sin_val: float, diff_val: float, is_horizontal: bool
):
def _calculate_arc_data(self, delta: float, size: float, x: float, y: float, sin_val: float, diff_val: float, is_horizontal: bool):
"""Calculate arc data and pre-compute arc points."""
if size <= 0:
return None
+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.widgets import Widget
# Status-aware background colors
_BG_AOL = rl.Color(145, 155, 149, 204) # Always on lateral: grey-green
_BG_CEM = rl.Color(0xDA, 0x6F, 0x25, 204) # Conditional experimental: orange
_BG_TRAFFIC = rl.Color(0xC9, 0x22, 0x31, 204) # Traffic mode: red
class ExpButton(Widget):
def __init__(self, button_size: int, icon_size: int):
super().__init__()
self._params = Params()
self._params_memory = Params(memory=True)
self._experimental_mode: bool = False
self._engageable: bool = False
self._icon_size = icon_size
# State hold mechanism
self._hold_duration = 2.0 # seconds
@@ -22,6 +29,11 @@ class ExpButton(Widget):
self._black_bg: rl.Color = rl.Color(0, 0, 0, 166)
self._txt_wheel: rl.Texture = gui_app.texture('icons/chffr_wheel.png', icon_size, icon_size)
self._txt_exp: rl.Texture = gui_app.texture('icons/experimental.png', icon_size, icon_size)
# FrogPilot custom wheel (lazy loaded)
self._custom_wheel: rl.Texture | None = None
self._custom_wheel_loaded: bool = False
self._rect = rl.Rectangle(0, 0, button_size, button_size)
def set_rect(self, rect: rl.Rectangle) -> None:
@@ -29,9 +41,22 @@ class ExpButton(Widget):
def _update_state(self) -> None:
selfdrive_state = ui_state.sm["selfdriveState"]
self._experimental_mode = selfdrive_state.experimentalMode
self._toggles = ui_state.frogpilot_toggles
# Conditional experimental mode: use CEStatus instead of ExperimentalMode
if self._toggles.get("conditional_experimental_mode", False):
self._experimental_mode = self._params_memory.get_int("CEStatus", default=0) >= 2
else:
self._experimental_mode = selfdrive_state.experimentalMode
self._engageable = selfdrive_state.engageable or selfdrive_state.enabled
# FrogPilot: hot-reload custom wheel image
if self._params_memory.get_bool("UpdateWheelImage"):
self._custom_wheel = None
self._custom_wheel_loaded = False
self._params_memory.put_bool("UpdateWheelImage", False)
def _handle_mouse_release(self, _):
super()._handle_mouse_release(_)
if self._is_toggle_allowed():
@@ -45,12 +70,57 @@ class ExpButton(Widget):
def _render(self, rect: rl.Rectangle) -> None:
center_x = int(self._rect.x + self._rect.width // 2)
center_y = int(self._rect.y + self._rect.height // 2)
t = self._toggles
self._white_color.a = 180 if self.is_pressed or not self._engageable else 255
texture = self._txt_exp if self._held_or_actual_mode() else self._txt_wheel
rl.draw_circle(center_x, center_y, self._rect.width / 2, self._black_bg)
rl.draw_texture(texture, center_x - texture.width // 2, center_y - texture.height // 2, self._white_color)
# Status-aware background color
bg = self._black_bg
if ui_state.traffic_mode_enabled:
bg = _BG_TRAFFIC
elif ui_state.always_on_lateral_active:
bg = _BG_AOL
elif self._held_or_actual_mode():
ce_status = self._params_memory.get_int("CEStatus", default=0) if t.get("conditional_experimental_mode", False) else 0
if ce_status >= 2:
bg = _BG_CEM
# Custom wheel image or stock
if self._held_or_actual_mode():
texture = self._txt_exp
elif t.get("wheel_image", False):
texture = self._get_custom_wheel()
else:
texture = self._txt_wheel
rl.draw_circle(center_x, center_y, self._rect.width / 2, bg)
# Rotating wheel support
if t.get("rotating_wheel", False) and not self._held_or_actual_mode():
self._draw_rotated_wheel(texture, center_x, center_y)
else:
rl.draw_texture(texture, center_x - texture.width // 2, center_y - texture.height // 2, self._white_color)
def _get_custom_wheel(self) -> rl.Texture:
if not self._custom_wheel_loaded:
self._custom_wheel_loaded = True
try:
self._custom_wheel = gui_app.starpilot_texture("active_theme/steering_wheel/wheel.png", self._icon_size, self._icon_size)
except Exception:
self._custom_wheel = None
return self._custom_wheel if self._custom_wheel is not None else self._txt_wheel
def _draw_rotated_wheel(self, texture: rl.Texture, center_x: int, center_y: int) -> None:
"""Draw the wheel texture rotated by steering angle."""
car_state = ui_state.sm["carState"]
angle = car_state.steeringAngleDeg
# Clamp to reasonable range and normalize to texture rotation
rotation = max(-90.0, min(90.0, angle))
source = rl.Rectangle(0, 0, texture.width, texture.height)
dest = rl.Rectangle(center_x, center_y, texture.width, texture.height)
origin = rl.Vector2(texture.width / 2, texture.height / 2)
rl.draw_texture_pro(texture, source, dest, origin, rotation, self._white_color)
def _held_or_actual_mode(self):
now = time.monotonic()
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:
"""Update HUD state based on car state and controls state."""
sm = ui_state.sm
self._toggles = ui_state.frogpilot_toggles
if sm.recv_frame["carState"] < ui_state.started_frame:
self.is_cruise_set = False
self.set_speed = SET_SPEED_NA
@@ -85,9 +87,7 @@ class HudRenderer(Widget):
car_state = sm['carState']
v_cruise_cluster = car_state.vCruiseCluster
self.set_speed = (
controls_state.vCruiseDEPRECATED if v_cruise_cluster == 0.0 else v_cruise_cluster
)
self.set_speed = controls_state.vCruiseDEPRECATED if v_cruise_cluster == 0.0 else v_cruise_cluster
self.is_cruise_set = 0 < self.set_speed < SET_SPEED_NA
self.is_cruise_available = self.set_speed != -1
@@ -96,12 +96,17 @@ class HudRenderer(Widget):
v_ego_cluster = car_state.vEgoCluster
self.v_ego_cluster_seen = self.v_ego_cluster_seen or v_ego_cluster != 0.0
v_ego = v_ego_cluster if self.v_ego_cluster_seen else car_state.vEgo
if self._toggles.get("use_wheel_speed", False):
v_ego = car_state.vEgo
else:
v_ego = v_ego_cluster if self.v_ego_cluster_seen else car_state.vEgo
speed_conversion = CV.MS_TO_KPH if ui_state.is_metric else CV.MS_TO_MPH
self.speed = max(0.0, v_ego * speed_conversion)
def _render(self, rect: rl.Rectangle) -> None:
"""Render HUD elements to the screen."""
t = self._toggles
# Draw the header background
rl.draw_rectangle_gradient_v(
int(rect.x),
@@ -112,10 +117,11 @@ class HudRenderer(Widget):
COLORS.HEADER_GRADIENT_END,
)
if self.is_cruise_available:
if self.is_cruise_available and not t.get("hide_max_speed", False):
self._draw_set_speed(rect)
self._draw_current_speed(rect)
if not t.get("hide_speed", False):
self._draw_current_speed(rect)
button_x = rect.x + rect.width - UI_CONFIG.border_size - UI_CONFIG.button_size
button_y = rect.y + UI_CONFIG.border_size
@@ -124,6 +130,20 @@ class HudRenderer(Widget):
def user_interacting(self) -> bool:
return self._exp_button.is_pressed
@property
def set_speed_rect(self) -> rl.Rectangle:
"""The rect of the MAX speed indicator box."""
set_speed_width = UI_CONFIG.set_speed_width_metric if ui_state.is_metric else UI_CONFIG.set_speed_width_imperial
x = 60 + (UI_CONFIG.set_speed_width_imperial - set_speed_width) // 2
return rl.Rectangle(x, 45, set_speed_width, UI_CONFIG.set_speed_height)
@property
def exp_button_rect(self) -> rl.Rectangle:
"""The rect of the exp button (relative to content rect)."""
btn_x = self._rect.width - UI_CONFIG.border_size - UI_CONFIG.button_size
btn_y = UI_CONFIG.border_size
return rl.Rectangle(btn_x, btn_y, UI_CONFIG.button_size, UI_CONFIG.button_size)
def _draw_set_speed(self, rect: rl.Rectangle) -> None:
"""Draw the MAX speed indicator box."""
set_speed_width = UI_CONFIG.set_speed_width_metric if ui_state.is_metric else UI_CONFIG.set_speed_width_imperial
+370 -57
View File
@@ -16,15 +16,15 @@ MIN_DRAW_DISTANCE = 10.0
MAX_DRAW_DISTANCE = 100.0
THROTTLE_COLORS = [
rl.Color(13, 248, 122, 102), # HSLF(148/360, 0.94, 0.51, 0.4)
rl.Color(114, 255, 92, 89), # HSLF(112/360, 1.0, 0.68, 0.35)
rl.Color(114, 255, 92, 0), # HSLF(112/360, 1.0, 0.68, 0.0)
rl.Color(13, 248, 122, 102), # HSLF(148/360, 0.94, 0.51, 0.4)
rl.Color(114, 255, 92, 89), # HSLF(112/360, 1.0, 0.68, 0.35)
rl.Color(114, 255, 92, 0), # HSLF(112/360, 1.0, 0.68, 0.0)
]
NO_THROTTLE_COLORS = [
rl.Color(242, 242, 242, 102), # HSLF(148/360, 0.0, 0.95, 0.4)
rl.Color(242, 242, 242, 102), # HSLF(148/360, 0.0, 0.95, 0.4)
rl.Color(242, 242, 242, 89), # HSLF(112/360, 0.0, 0.95, 0.35)
rl.Color(242, 242, 242, 0), # HSLF(112/360, 0.0, 0.95, 0.0)
rl.Color(242, 242, 242, 0), # HSLF(112/360, 0.0, 0.95, 0.0)
]
@@ -50,11 +50,15 @@ class ModelRenderer(Widget):
self._prev_allow_throttle = True
self._lane_line_probs = np.zeros(4, dtype=np.float32)
self._road_edge_stds = np.zeros(2, dtype=np.float32)
self._lead_vehicles = [LeadVehicle(), LeadVehicle()]
self._lead_vehicles = [LeadVehicle(), LeadVehicle(), LeadVehicle(), LeadVehicle()]
self._adjacent_lead_count = 0
self._path_offset_z = HEIGHT_INIT[0]
# Initialize ModelPoints objects
self._path = ModelPoints()
self._path_edge = ModelPoints()
self._adjacent_left = ModelPoints()
self._adjacent_right = ModelPoints()
self._lane_lines = [ModelPoints() for _ in range(4)]
self._road_edges = [ModelPoints() for _ in range(2)]
self._acceleration_x = np.empty((0,), dtype=np.float32)
@@ -63,6 +67,7 @@ class ModelRenderer(Widget):
self._car_space_transform = np.zeros((3, 3), dtype=np.float32)
self._transform_dirty = True
self._clip_region = None
self._lead_data_raw = [None, None]
self._exp_gradient = Gradient(
start=(0.0, 1.0), # Bottom of path
@@ -76,25 +81,85 @@ class ModelRenderer(Widget):
cp = messaging.log_from_bytes(car_params, car.CarParams)
self._longitudinal_control = cp.openpilotLongitudinalControl
# FrogPilot state
self._toggles: dict = {}
self._model_ui: bool = False
self._accel_path_active: bool = False
# Gradient caches (avoids per-frame object allocation)
self._rainbow_cache: Gradient | None = None
self._rainbow_speed_key: float = -1.0
self._blend_gradient_cache: Gradient | None = None
self._blend_factor_cache: float = -1.0
def set_transform(self, transform: np.ndarray):
self._car_space_transform = transform.astype(np.float32)
self._transform_dirty = True
@property
def car_space_transform(self) -> np.ndarray:
return self._car_space_transform
def project_point(self, x: float, y: float, z: float) -> tuple[float, float] | None:
"""Project a car-space (x, y, z) point to screen coordinates."""
return self._map_to_screen(x, y, z)
@property
def path_points(self) -> np.ndarray:
"""Projected screen-space path polygon points."""
return self._path.projected_points
@property
def path_raw_points(self) -> np.ndarray:
"""Raw car-space path points (Nx3)."""
return self._path.raw_points
@property
def road_edge_points(self) -> list[np.ndarray]:
"""Projected screen-space road edge polygons."""
return [e.projected_points for e in self._road_edges]
@property
def lane_line_points(self) -> list[np.ndarray]:
"""Projected screen-space lane line polygons."""
return [l.projected_points for l in self._lane_lines]
@property
def adjacent_left_points(self) -> np.ndarray:
"""Projected left adjacent lane polygon."""
return self._adjacent_left.projected_points
@property
def adjacent_right_points(self) -> np.ndarray:
"""Projected right adjacent lane polygon."""
return self._adjacent_right.projected_points
@property
def lead_vehicles(self) -> list[LeadVehicle]:
return self._lead_vehicles
@property
def lead_data(self) -> list:
"""Raw radarState lead data (from last render)."""
return self._lead_data_raw
def _render(self, rect: rl.Rectangle):
sm = ui_state.sm
t = ui_state.frogpilot_toggles
self._toggles = t
self._model_ui = t.get("model_ui", False)
# Check if data is up-to-date
if (sm.recv_frame["liveCalibration"] < ui_state.started_frame or
sm.recv_frame["modelV2"] < ui_state.started_frame):
if sm.recv_frame["liveCalibration"] < ui_state.started_frame or sm.recv_frame["modelV2"] < ui_state.started_frame:
return
# Set up clipping region
self._clip_region = rl.Rectangle(
rect.x - CLIP_MARGIN, rect.y - CLIP_MARGIN, rect.width + 2 * CLIP_MARGIN, rect.height + 2 * CLIP_MARGIN
)
self._clip_region = rl.Rectangle(rect.x - CLIP_MARGIN, rect.y - CLIP_MARGIN, rect.width + 2 * CLIP_MARGIN, rect.height + 2 * CLIP_MARGIN)
# Update state
self._experimental_mode = sm['selfdriveState'].experimentalMode
# Acceleration path: allow acceleration coloring independent of exp mode
self._accel_path_active = self._experimental_mode or t.get("acceleration_path", False)
live_calib = sm['liveCalibration']
self._path_offset_z = live_calib.height[0] if live_calib.height else HEIGHT_INIT[0]
@@ -107,6 +172,10 @@ class ModelRenderer(Widget):
lead_one = radar_state.leadOne if radar_state else None
render_lead_indicator = self._longitudinal_control and radar_state is not None
# Hide lead marker toggle
if t.get("hide_lead_marker", False):
render_lead_indicator = False
# Update model data when needed
model_updated = sm.updated['modelV2']
if model_updated or sm.updated['radarState'] or self._transform_dirty:
@@ -137,30 +206,43 @@ class ModelRenderer(Widget):
# the fixed number of lane/edge slots used by the UI.
for lane_line in self._lane_lines:
lane_line.raw_points = np.empty((0, 3), dtype=np.float32)
for i, lane_line in enumerate(model.laneLines[:len(self._lane_lines)]):
for i, lane_line in enumerate(model.laneLines[: len(self._lane_lines)]):
self._lane_lines[i].raw_points = np.array([lane_line.x, lane_line.y, lane_line.z], dtype=np.float32).T
for road_edge in self._road_edges:
road_edge.raw_points = np.empty((0, 3), dtype=np.float32)
for i, road_edge in enumerate(model.roadEdges[:len(self._road_edges)]):
for i, road_edge in enumerate(model.roadEdges[: len(self._road_edges)]):
self._road_edges[i].raw_points = np.array([road_edge.x, road_edge.y, road_edge.z], dtype=np.float32).T
lane_line_probs = np.array(model.laneLineProbs, dtype=np.float32)
self._lane_line_probs = np.zeros(len(self._lane_lines), dtype=np.float32)
self._lane_line_probs[:min(len(self._lane_lines), len(lane_line_probs))] = lane_line_probs[:len(self._lane_lines)]
self._lane_line_probs[: min(len(self._lane_lines), len(lane_line_probs))] = lane_line_probs[: len(self._lane_lines)]
road_edge_stds = np.array(model.roadEdgeStds, dtype=np.float32)
self._road_edge_stds = np.ones(len(self._road_edges), dtype=np.float32)
self._road_edge_stds[:min(len(self._road_edges), len(road_edge_stds))] = road_edge_stds[:len(self._road_edges)]
self._road_edge_stds[: min(len(self._road_edges), len(road_edge_stds))] = road_edge_stds[: len(self._road_edges)]
self._acceleration_x = np.array(model.acceleration.x, dtype=np.float32)
def _update_leads(self, radar_state, path_x_array):
"""Update positions of lead vehicles"""
self._lead_vehicles = [LeadVehicle(), LeadVehicle()]
for lv in self._lead_vehicles:
lv.glow = []
lv.chevron = []
lv.fill_alpha = 0
self._adjacent_lead_count = 0
self._lead_data_raw = [None, None]
leads = [radar_state.leadOne, radar_state.leadTwo]
# FrogPilot: lead detection probability threshold
t = self._toggles
prob_threshold = t.get("lead_detection_probability", 0.0) if self._model_ui else 0.0
for i, lead_data in enumerate(leads):
if lead_data and lead_data.status:
# Apply lead detection probability threshold
if prob_threshold > 0 and hasattr(lead_data, 'prob') and lead_data.prob < prob_threshold:
continue
self._lead_data_raw[i] = lead_data
d_rel, y_rel, v_rel = lead_data.dRel, lead_data.yRel, lead_data.vRel
idx = self._get_path_length_idx(path_x_array, d_rel)
@@ -170,20 +252,48 @@ class ModelRenderer(Widget):
if point:
self._lead_vehicles[i] = self._update_lead_vehicle(d_rel, v_rel, point, self._rect)
# FrogPilot: adjacent lead tracking from radar
if t.get("adjacent_leads_ui", False) and ui_state.sm.valid.get("frogpilotRadarState", False):
fp_radar = ui_state.sm["frogpilotRadarState"]
adj_idx = 2 # slots 2 and 3 are for adjacent leads
for adj_lead in [fp_radar.leadLeft, fp_radar.leadRight]:
if adj_lead and adj_lead.status:
d_rel, y_rel, v_rel = adj_lead.dRel, adj_lead.yRel, adj_lead.vRel
idx = self._get_path_length_idx(path_x_array, d_rel)
z = self._path.raw_points[idx, 2] if idx < len(self._path.raw_points) else 0.0
point = self._map_to_screen(d_rel, -y_rel, z + self._path_offset_z)
if point and adj_idx < len(self._lead_vehicles):
self._lead_vehicles[adj_idx] = self._update_lead_vehicle(d_rel, v_rel, point, self._rect)
self._adjacent_lead_count += 1
adj_idx += 1
def _update_model(self, lead, path_x_array):
"""Update model visualization data based on model message"""
t = self._toggles
model_ui = self._model_ui
# FrogPilot custom widths
lane_width = t.get("lane_line_width", 0.025) if model_ui else 0.025
edge_width = t.get("road_edge_width", 0.025) if model_ui else 0.025
path_width = t.get("path_width", 0.9) if model_ui else 0.9
# Dynamic path width: scale by engagement
if model_ui and t.get("dynamic_path_width", False):
if ui_state.always_on_lateral_active:
path_width *= 0.75
elif not ui_state.sm["selfdriveState"].enabled:
path_width *= 0.5
max_distance = np.clip(path_x_array[-1], MIN_DRAW_DISTANCE, MAX_DRAW_DISTANCE)
max_idx = self._get_path_length_idx(self._lane_lines[0].raw_points[:, 0], max_distance)
# Update lane lines using raw points
for i, lane_line in enumerate(self._lane_lines):
lane_line.projected_points = self._map_line_to_polygon(
lane_line.raw_points, 0.025 * self._lane_line_probs[i], 0.0, max_idx, max_distance
)
lane_line.projected_points = self._map_line_to_polygon(lane_line.raw_points, lane_width * self._lane_line_probs[i], 0.0, max_idx, max_distance)
# Update road edges using raw points
for road_edge in self._road_edges:
road_edge.projected_points = self._map_line_to_polygon(road_edge.raw_points, 0.025, 0.0, max_idx, max_distance)
road_edge.projected_points = self._map_line_to_polygon(road_edge.raw_points, edge_width, 0.0, max_idx, max_distance)
# Update path using raw points
if lead and lead.status:
@@ -191,9 +301,32 @@ class ModelRenderer(Widget):
max_distance = np.clip(lead_d - min(lead_d * 0.35, 10.0), 0.0, max_distance)
max_idx = self._get_path_length_idx(path_x_array, max_distance)
self._path.projected_points = self._map_line_to_polygon(
self._path.raw_points, 0.9, self._path_offset_z, max_idx, max_distance, allow_invert=False
)
self._path.projected_points = self._map_line_to_polygon(self._path.raw_points, path_width, self._path_offset_z, max_idx, max_distance, allow_invert=False)
# FrogPilot: path edge polygon (full-width outer polygon for edge rendering)
if model_ui and t.get("path_edge_width", 0) > 0:
self._path_edge.projected_points = self._map_line_to_polygon(self._path.raw_points, 0.9, self._path_offset_z, max_idx, max_distance, allow_invert=False)
else:
self._path_edge.projected_points = np.empty((0, 2), dtype=np.float32)
# FrogPilot: adjacent lane polygons (averaged from lane line pairs)
if t.get("adjacent_paths", False) or t.get("adjacent_path_metrics", False) or t.get("blind_spot_path", False):
fp_plan = ui_state.frogpilot_plan
if fp_plan and len(self._lane_lines) >= 4:
lane_w_left = fp_plan.laneWidthLeft / 2.0 if fp_plan.laneWidthLeft else 1.75
lane_w_right = fp_plan.laneWidthRight / 2.0 if fp_plan.laneWidthRight else 1.75
self._adjacent_left.projected_points = self._map_averaged_line_to_polygon(
self._lane_lines[0].raw_points, self._lane_lines[1].raw_points, lane_w_left, 0.0, max_idx
)
self._adjacent_right.projected_points = self._map_averaged_line_to_polygon(
self._lane_lines[2].raw_points, self._lane_lines[3].raw_points, lane_w_right, 0.0, max_idx
)
else:
self._adjacent_left.projected_points = np.empty((0, 2), dtype=np.float32)
self._adjacent_right.projected_points = np.empty((0, 2), dtype=np.float32)
else:
self._adjacent_left.projected_points = np.empty((0, 2), dtype=np.float32)
self._adjacent_right.projected_points = np.empty((0, 2), dtype=np.float32)
self._update_experimental_gradient()
@@ -242,6 +375,30 @@ class ModelRenderer(Widget):
stops=gradient_stops,
)
def _build_rainbow_gradient(self) -> Gradient:
"""Build a rainbow gradient for the path based on current speed (cached)."""
speed = abs(ui_state.sm["carState"].vEgo)
speed_key = round(speed, 1)
if speed_key == self._rainbow_speed_key and self._rainbow_cache is not None:
return self._rainbow_cache
self._rainbow_speed_key = speed_key
t = min(speed / 35.0, 1.0) # ~80 mph max
n_stops = 5
colors = []
stops = []
for i in range(n_stops):
frac = i / (n_stops - 1)
hue = (frac * 0.8 + t * 0.2) % 1.0
r, g, b = colorsys.hls_to_rgb(hue, 0.5, 1.0)
alpha = int(80 * (1.0 - frac * 0.5))
colors.append(rl.Color(int(r * 255), int(g * 255), int(b * 255), alpha))
stops.append(frac)
self._rainbow_cache = Gradient(start=(0.0, 1.0), end=(0.0, 0.0), colors=colors, stops=stops)
return self._rainbow_cache
def _update_lead_vehicle(self, d_rel, v_rel, point, rect):
speed_buff, lead_buff = 10.0, 40.0
@@ -268,12 +425,24 @@ class ModelRenderer(Widget):
def _draw_lane_lines(self):
"""Draw lane lines and road edges"""
t = self._toggles
model_ui = self._model_ui
custom_scheme = model_ui and t.get("color_scheme", "stock") != "stock"
# Custom lane line color
lane_color_override = None
if custom_scheme and t.get("lane_lines_color"):
lane_color_override = self._parse_color(t["lane_lines_color"])
for i, lane_line in enumerate(self._lane_lines):
if lane_line.projected_points.size == 0:
continue
alpha = np.clip(self._lane_line_probs[i], 0.0, 0.7)
color = rl.Color(255, 255, 255, int(alpha * 255))
if lane_color_override:
color = rl.Color(lane_color_override.r, lane_color_override.g, lane_color_override.b, int(alpha * 255))
else:
color = rl.Color(255, 255, 255, int(alpha * 255))
draw_polygon(self._rect, lane_line.projected_points, color)
for i, road_edge in enumerate(self._road_edges):
@@ -289,11 +458,45 @@ class ModelRenderer(Widget):
if not self._path.projected_points.size:
return
t = self._toggles
model_ui = self._model_ui
custom_scheme = model_ui and t.get("color_scheme", "stock") != "stock"
# Custom path color (applied when acceleration is low)
path_color_override = None
if custom_scheme and t.get("path_color"):
path_color_override = self._parse_color(t["path_color"])
# FrogPilot: draw path edge polygon first (status-colored outer layer)
edge_pts = self._path_edge.projected_points
if edge_pts.size > 0:
cs = ui_state.conditional_status
# Alpha tuned for Raylib shader polygon renderer (C++ uses 241 via QPainter)
_EDGE_ALPHA = 120
if ui_state.always_on_lateral_active:
edge_color = rl.Color(0x0A, 0xBA, 0xB5, _EDGE_ALPHA)
elif cs >= 2:
edge_color = rl.Color(0xDA, 0x6F, 0x25, _EDGE_ALPHA)
elif cs >= 1:
edge_color = rl.Color(0xFF, 0xFF, 0x00, _EDGE_ALPHA)
elif ui_state.traffic_mode_enabled:
edge_color = rl.Color(0xC9, 0x22, 0x31, _EDGE_ALPHA)
else:
edge_color = rl.Color(255, 255, 255, 30)
if custom_scheme and t.get("path_edges_color"):
edge_color = self._parse_color(t["path_edges_color"])
draw_polygon(self._rect, edge_pts, edge_color)
allow_throttle = sm['longitudinalPlan'].allowThrottle or not self._longitudinal_control
self._blend_filter.update(int(allow_throttle))
if self._experimental_mode:
# Draw with acceleration coloring
# FrogPilot: rainbow path (speed-based HSL gradient, integrated into path rendering)
if t.get("rainbow_path", False) and not t.get("acceleration_path", False):
rainbow = self._build_rainbow_gradient()
draw_polygon(self._rect, self._path.projected_points, gradient=rainbow)
elif self._accel_path_active:
# Draw with acceleration coloring (experimental or acceleration_path toggle)
if len(self._exp_gradient.colors) > 1:
draw_polygon(self._rect, self._path.projected_points, gradient=self._exp_gradient)
else:
@@ -301,23 +504,51 @@ class ModelRenderer(Widget):
else:
# Blend throttle/no throttle colors based on transition
blend_factor = round(self._blend_filter.x * 100) / 100
blended_colors = self._blend_colors(NO_THROTTLE_COLORS, THROTTLE_COLORS, blend_factor)
gradient = Gradient(
start=(0.0, 1.0), # Bottom of path
end=(0.0, 0.0), # Top of path
colors=blended_colors,
stops=[0.0, 0.5, 1.0],
)
draw_polygon(self._rect, self._path.projected_points, gradient=gradient)
# Custom path color override: use when not accelerating
if path_color_override and blend_factor < 0.5:
c = path_color_override
draw_polygon(self._rect, self._path.projected_points, rl.Color(c.r, c.g, c.b, 102))
else:
if blend_factor != self._blend_factor_cache:
self._blend_factor_cache = blend_factor
blended_colors = self._blend_colors(NO_THROTTLE_COLORS, THROTTLE_COLORS, blend_factor)
self._blend_gradient_cache = Gradient(
start=(0.0, 1.0),
end=(0.0, 0.0),
colors=blended_colors,
stops=[0.0, 0.5, 1.0],
)
draw_polygon(self._rect, self._path.projected_points, gradient=self._blend_gradient_cache)
# FrogPilot: adjacent lane paths (green-to-red based on width)
t = self._toggles
if t.get("adjacent_paths", False):
for pts in [self._adjacent_left.projected_points, self._adjacent_right.projected_points]:
if pts.size > 0:
draw_polygon(self._rect, pts, rl.Color(0, 255, 100, 50))
if t.get("blind_spot_path", False):
for pts in [self._adjacent_left.projected_points, self._adjacent_right.projected_points]:
if pts.size > 0:
draw_polygon(self._rect, pts, rl.Color(201, 34, 49, 80))
def _draw_lead_indicator(self):
t = self._toggles
# Custom lead marker color
glow_color = rl.Color(218, 202, 37, 255)
chevron_color_base = rl.Color(201, 34, 49, 255)
if self._model_ui and t.get("lead_marker_color"):
custom = self._parse_color(t["lead_marker_color"])
glow_color = rl.Color(custom.r, custom.g, custom.b, 255)
chevron_color_base = rl.Color(custom.r, custom.g, custom.b, 255)
# Draw lead vehicles if available
for lead in self._lead_vehicles:
if not lead.glow or not lead.chevron:
continue
rl.draw_triangle_fan(lead.glow, len(lead.glow), rl.Color(218, 202, 37, 255))
rl.draw_triangle_fan(lead.chevron, len(lead.chevron), rl.Color(201, 34, 49, lead.fill_alpha))
rl.draw_triangle_fan(lead.glow, len(lead.glow), glow_color)
rl.draw_triangle_fan(lead.chevron, len(lead.chevron), rl.Color(chevron_color_base.r, chevron_color_base.g, chevron_color_base.b, lead.fill_alpha))
@staticmethod
def _get_path_length_idx(pos_x_array: np.ndarray, path_distance: float) -> int:
@@ -329,6 +560,9 @@ class ModelRenderer(Widget):
def _map_to_screen(self, in_x, in_y, in_z):
"""Project a point in car space to screen space"""
if self._clip_region is None:
return None
input_pt = np.array([in_x, in_y, in_z])
pt = self._car_space_transform @ input_pt
@@ -349,7 +583,7 @@ class ModelRenderer(Widget):
return np.empty((0, 2), dtype=np.float32)
# Slice points and filter non-negative x-coordinates
points = line[:max_idx + 1]
points = line[: max_idx + 1]
# Interpolate around max_idx so path end is smooth (max_distance is always >= p0.x)
if 0 < max_idx < line.shape[0] - 1:
@@ -392,14 +626,8 @@ class ModelRenderer(Widget):
y_min, y_max = clip.y, clip.y + clip.height
# Filter points within clip region
left_in_clip = (
(left_screen[0] >= x_min) & (left_screen[0] <= x_max) &
(left_screen[1] >= y_min) & (left_screen[1] <= y_max)
)
right_in_clip = (
(right_screen[0] >= x_min) & (right_screen[0] <= x_max) &
(right_screen[1] >= y_min) & (right_screen[1] <= y_max)
)
left_in_clip = (left_screen[0] >= x_min) & (left_screen[0] <= x_max) & (left_screen[1] >= y_min) & (left_screen[1] <= y_max)
right_in_clip = (right_screen[0] >= x_min) & (right_screen[0] <= x_max) & (right_screen[1] >= y_min) & (right_screen[1] <= y_max)
both_in_clip = left_in_clip & right_in_clip
if not np.any(both_in_clip):
@@ -420,15 +648,92 @@ class ModelRenderer(Widget):
return np.vstack((left_screen.T, right_screen[:, ::-1].T)).astype(np.float32)
def _map_averaged_line_to_polygon(self, line1: np.ndarray, line2: np.ndarray, y_off: float, z_off: float, max_idx: int) -> np.ndarray:
"""Convert two averaged 3D lane lines to a 2D polygon (center-of-lane path).
Averages the Y coordinates of line1 and line2, then creates left/right points
at avg_y -/+ y_off. Uses X and Z from line1.
"""
if line1.shape[0] == 0 or line2.shape[0] == 0:
return np.empty((0, 2), dtype=np.float32)
n = min(max_idx + 1, line1.shape[0], line2.shape[0])
if n == 0:
return np.empty((0, 2), dtype=np.float32)
# Build averaged points: X from line1, Y averaged, Z from line1
points = np.zeros((n, 3), dtype=np.float32)
points[:, 0] = line1[:n, 0] # X from line1
points[:, 1] = (line1[:n, 1] + line2[:n, 1]) / 2.0 # Y averaged
points[:, 2] = line1[:n, 2] # Z from line1
# Filter non-negative X
points = points[points[:, 0] >= 0]
if points.shape[0] < 2:
return np.empty((0, 2), dtype=np.float32)
N = points.shape[0]
offsets = np.array([[0, -y_off, z_off], [0, y_off, z_off]], dtype=np.float32)
points_3d = points[None, :, :] + offsets[:, None, :]
points_3d = points_3d.reshape(2 * N, 3)
proj = self._car_space_transform @ points_3d.T
proj = proj.reshape(3, 2, N)
left_proj = proj[:, 0, :]
right_proj = proj[:, 1, :]
valid_proj = (np.abs(left_proj[2]) >= 1e-6) & (np.abs(right_proj[2]) >= 1e-6)
if not np.any(valid_proj):
return np.empty((0, 2), dtype=np.float32)
left_screen = left_proj[:2, valid_proj] / left_proj[2, valid_proj][None, :]
right_screen = right_proj[:2, valid_proj] / right_proj[2, valid_proj][None, :]
clip = self._clip_region
x_min, x_max = clip.x, clip.x + clip.width
y_min, y_max = clip.y, clip.y + clip.height
left_in_clip = (left_screen[0] >= x_min) & (left_screen[0] <= x_max) & (left_screen[1] >= y_min) & (left_screen[1] <= y_max)
right_in_clip = (right_screen[0] >= x_min) & (right_screen[0] <= x_max) & (right_screen[1] >= y_min) & (right_screen[1] <= y_max)
both_in_clip = left_in_clip & right_in_clip
if not np.any(both_in_clip):
return np.empty((0, 2), dtype=np.float32)
left_screen = left_screen[:, both_in_clip]
right_screen = right_screen[:, both_in_clip]
result = np.vstack((left_screen.T, right_screen[:, ::-1].T)).astype(np.float32)
# Ground the path: extend bottom vertices to screen bottom (matches C++ mapAveragedLineToPolygon)
if result.shape[0] >= 4:
mid = result.shape[0] // 2
screen_h = self._rect.y + self._rect.height
# Extend left-bottom pair to screen bottom
for idx in [mid - 1, mid - 2]:
if 0 <= idx < result.shape[0] - 1:
dy = result[idx, 1] - result[idx + 1, 1]
if abs(dy) > 0.1:
slope = (result[idx, 0] - result[idx + 1, 0]) / dy
result[idx, 0] += (screen_h - result[idx, 1]) * slope
result[idx, 1] = screen_h
# Extend right-bottom pair to screen bottom
for idx in [mid, mid + 1]:
if 0 < idx < result.shape[0]:
dy = result[idx, 1] - result[idx - 1, 1]
if abs(dy) > 0.1:
slope = (result[idx, 0] - result[idx - 1, 0]) / dy
result[idx, 0] += (screen_h - result[idx, 1]) * slope
result[idx, 1] = screen_h
return result
@staticmethod
def _hsla_to_color(h, s, l, a):
rgb = colorsys.hls_to_rgb(h, l, s)
return rl.Color(
int(rgb[0] * 255),
int(rgb[1] * 255),
int(rgb[2] * 255),
int(a * 255)
)
return rl.Color(int(rgb[0] * 255), int(rgb[1] * 255), int(rgb[2] * 255), int(a * 255))
@staticmethod
def _blend_colors(begin_colors, end_colors, t):
@@ -438,9 +743,17 @@ class ModelRenderer(Widget):
return begin_colors
inv_t = 1.0 - t
return [rl.Color(
int(inv_t * start.r + t * end.r),
int(inv_t * start.g + t * end.g),
int(inv_t * start.b + t * end.b),
int(inv_t * start.a + t * end.a)
) for start, end in zip(begin_colors, end_colors, strict=True)]
return [
rl.Color(int(inv_t * start.r + t * end.r), int(inv_t * start.g + t * end.g), int(inv_t * start.b + t * end.b), int(inv_t * start.a + t * end.a))
for start, end in zip(begin_colors, end_colors, strict=True)
]
@staticmethod
def _parse_color(color_str: str) -> rl.Color:
"""Parse a hex color string (e.g., '#FF0000' or 'FF0000') to rl.Color."""
s = color_str.lstrip('#')
if len(s) == 8: # ARGB
return rl.Color(int(s[2:4], 16), int(s[4:6], 16), int(s[6:8], 16), int(s[0:2], 16))
if len(s) == 6: # RGB
return rl.Color(int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16), 255)
return rl.Color(255, 255, 255, 255)
+27 -2
View File
@@ -1,3 +1,4 @@
import json
import pyray as rl
import numpy as np
import time
@@ -58,8 +59,19 @@ class UIState:
"rawAudioData",
"frogpilotCarState",
]
).extend(
[
"frogpilotPlan",
"frogpilotSelfdriveState",
"frogpilotRadarState",
"frogpilotDeviceState",
"mapdOut",
"liveTracks",
]
)
self._frogpilot_toggles: dict = {}
self.prime_state = PrimeState()
# UI Status tracking
@@ -150,8 +162,7 @@ class UIState:
self.always_on_dm = self.params.get_bool("AlwaysOnDM")
if self.sm.valid.get("frogpilotCarState", False):
frogpilot_car_state = self.sm["frogpilotCarState"]
self.always_on_lateral_active = (not self.sm["selfdriveState"].enabled and
frogpilot_car_state.alwaysOnLateralEnabled)
self.always_on_lateral_active = not self.sm["selfdriveState"].enabled and frogpilot_car_state.alwaysOnLateralEnabled
self.traffic_mode_enabled = frogpilot_car_state.trafficModeEnabled
else:
self.always_on_lateral_active = False
@@ -159,6 +170,20 @@ class UIState:
self.conditional_status = self.params_memory.get_int("CEStatus", default=0) if self.started else 0
if self.sm.valid.get("frogpilotPlan", False) and self.sm.updated["frogpilotPlan"]:
try:
self._frogpilot_toggles = json.loads(self.sm["frogpilotPlan"].frogpilotToggles)
except (json.JSONDecodeError, TypeError):
pass
@property
def frogpilot_toggles(self) -> dict:
return self._frogpilot_toggles
@property
def frogpilot_plan(self):
return self.sm["frogpilotPlan"] if self.sm.valid.get("frogpilotPlan", False) else None
def _update_status(self) -> None:
if self.started and self.sm.updated["selfdriveState"]:
ss = self.sm["selfdriveState"]
+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_shown = False
self._modal_overlay_tick: Callable[[], None] | None = None
self._nav_stack: list = []
self._mouse = MouseState(self._scale)
self._mouse_events: list[MouseEvent] = []
@@ -369,6 +370,41 @@ class GuiApplication:
def set_modal_overlay_tick(self, tick_function: Callable | None):
self._modal_overlay_tick = tick_function
def push_widget(self, widget):
if widget in self._nav_stack:
return
if self._nav_stack:
prev = self._nav_stack[-1]
if hasattr(prev, 'set_enabled'):
prev.set_enabled(False)
self._nav_stack.append(widget)
if hasattr(widget, 'show_event'):
widget.show_event()
if hasattr(widget, 'set_enabled'):
widget.set_enabled(True)
def pop_widget(self, idx: int | None = None):
if len(self._nav_stack) < 2:
return
idx_to_pop = len(self._nav_stack) - 1 if idx is None else idx
if idx_to_pop <= 0 or idx_to_pop >= len(self._nav_stack):
return
if idx_to_pop == len(self._nav_stack) - 1:
prev = self._nav_stack[idx_to_pop - 1]
if hasattr(prev, 'set_enabled'):
prev.set_enabled(True)
widget = self._nav_stack.pop(idx_to_pop)
if hasattr(widget, 'hide_event'):
widget.hide_event()
def _render_nav_stack(self) -> bool:
if not self._nav_stack:
return False
widget = self._nav_stack[-1]
if hasattr(widget, 'render'):
widget.render(rl.Rectangle(0, 0, self.width, self.height))
return True
def set_should_render(self, should_render: bool):
self._should_render = should_render
@@ -523,7 +559,9 @@ class GuiApplication:
rl.clear_background(rl.BLACK)
# Handle modal overlay rendering and input processing
if self._handle_modal_overlay():
if self._render_nav_stack():
yield False
elif self._handle_modal_overlay():
# Allow a Widget to still run a function while overlay is shown
if self._modal_overlay_tick is not None:
self._modal_overlay_tick()
+17
View File
@@ -34,6 +34,7 @@ class Widget(abc.ABC):
self._click_callback: Callable[[], None] | None = None
self._multi_touch = False
self.__was_awake = True
self._children: list = []
@property
def rect(self) -> rl.Rectangle:
@@ -180,9 +181,25 @@ class Widget(abc.ABC):
def show_event(self):
"""Optionally handle show event. Parent must manually call this"""
for child in self._children:
child.show_event()
def hide_event(self):
"""Optionally handle hide event. Parent must manually call this"""
for child in self._children:
child.hide_event()
def _child(self, widget):
"""Register a child widget for lifecycle propagation."""
assert widget not in self._children, f"{type(widget).__name__} already a child of {type(self).__name__}"
self._children.append(widget)
return widget
def dismiss(self, callback: Callable[[], None] | None = None):
"""Dismiss this widget from the nav stack."""
gui_app.pop_widget()
if callback:
callback()
SWIPE_AWAY_THRESHOLD = 80 # px to dismiss after releasing
+19 -94
View File
@@ -1,118 +1,43 @@
import pyray as rl
from collections.abc import Callable
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.widgets import Widget, DialogResult
from openpilot.system.ui.widgets.button import Button, ButtonStyle
from openpilot.system.ui.widgets.label import Label, FontWeight
from openpilot.system.ui.widgets.keyboard import Keyboard, KeyboardLayout
from openpilot.system.ui.widgets.keyboard import Keyboard
MARGIN = 50
BUTTON_HEIGHT = 160
OUTER_MARGIN_X = 200
OUTER_MARGIN_Y = 150
BACKGROUND_COLOR = rl.Color(27, 27, 27, 255)
class InputDialog(Widget):
def __init__(self, title: str, default_text: str = "", hint_text: str = "", on_close: Callable[[DialogResult, str], None] | None = None):
super().__init__()
self._title = title
self._text = default_text
self._hint = hint_text
self._default_text = default_text
self._on_close = on_close
self._dialog_result = DialogResult.NO_ACTION
self._title_label = Label(title, 70, FontWeight.BOLD, text_color=rl.Color(201, 201, 201, 255))
self._cancel_button = Button("Cancel", self._cancel_button_callback)
self._confirm_button = Button("Confirm", self._confirm_button_callback, button_style=ButtonStyle.PRIMARY)
self._keyboard = Keyboard(self._on_key_pressed, self._on_keyboard_done, layout=KeyboardLayout.QWERTY)
self._font = gui_app.font(FontWeight.MEDIUM)
def _on_key_pressed(self, key: str):
if key == "\b":
self._text = self._text[:-1]
else:
self._text += key
self._keyboard = Keyboard(callback=self._on_keyboard_result)
self._keyboard.set_title(title)
self._keyboard.set_text(default_text)
def _on_keyboard_done(self):
self._confirm_button_callback()
def _cancel_button_callback(self):
self._dialog_result = DialogResult.CANCEL
def _on_keyboard_result(self, result: DialogResult):
if self._dialog_result != DialogResult.NO_ACTION:
return
self._dialog_result = result
if self._on_close:
self._on_close(self._dialog_result, self._text)
def _confirm_button_callback(self):
self._dialog_result = DialogResult.CONFIRM
if self._on_close:
self._on_close(self._dialog_result, self._text)
self._on_close(result, self._keyboard.text)
@property
def result(self) -> DialogResult:
return self._dialog_result
@property
def text(self) -> str:
return self._text
return self._keyboard.text
def show_event(self):
super().show_event()
self._dialog_result = DialogResult.NO_ACTION
self._keyboard.show_event()
self._keyboard.clear()
if self._default_text:
self._keyboard.set_text(self._default_text)
def _render(self, rect: rl.Rectangle):
# Dim background
rl.draw_rectangle(0, 0, int(rect.width), int(rect.height), rl.Color(0, 0, 0, 200))
# Dialog Box
dialog_rect = rl.Rectangle(
rect.x + OUTER_MARGIN_X,
rect.y + OUTER_MARGIN_Y,
rect.width - 2 * OUTER_MARGIN_X,
rect.height - 2 * OUTER_MARGIN_Y,
)
rl.draw_rectangle_rounded(dialog_rect, 0.05, 10, BACKGROUND_COLOR)
# Title
title_rect = rl.Rectangle(dialog_rect.x + MARGIN, dialog_rect.y + MARGIN, dialog_rect.width - 2 * MARGIN, 100)
self._title_label.render(title_rect)
# Text Input Field
input_rect = rl.Rectangle(dialog_rect.x + MARGIN, title_rect.y + title_rect.height + 40, dialog_rect.width - 2 * MARGIN, 120)
rl.draw_rectangle_rounded(input_rect, 0.1, 10, rl.Color(40, 40, 40, 255))
display_text = self._text
text_color = rl.WHITE
if not display_text:
display_text = self._hint
text_color = rl.Color(128, 128, 128, 255)
text_size = rl.measure_text_ex(self._font, display_text, 50, 0)
text_pos = rl.Vector2(input_rect.x + 40, input_rect.y + (input_rect.height - text_size.y) / 2)
rl.draw_text_ex(self._font, display_text, text_pos, 50, 0, text_color)
# Blinking cursor
if (rl.get_time() % 1.0) < 0.5:
cursor_x = text_pos.x + (text_size.x if self._text else 0) + 5
rl.draw_rectangle(int(cursor_x), int(text_pos.y), 4, 50, rl.WHITE)
# Keyboard
keyboard_rect = rl.Rectangle(
dialog_rect.x + MARGIN,
input_rect.y + input_rect.height + 40,
dialog_rect.width - 2 * MARGIN,
400
)
self._keyboard.render(keyboard_rect)
# Buttons
btn_y = dialog_rect.y + dialog_rect.height - BUTTON_HEIGHT - MARGIN
btn_width = (dialog_rect.width - 3 * MARGIN) / 2
cancel_rect = rl.Rectangle(dialog_rect.x + MARGIN, btn_y, btn_width, BUTTON_HEIGHT)
confirm_rect = rl.Rectangle(dialog_rect.x + 2 * MARGIN + btn_width, btn_y, btn_width, BUTTON_HEIGHT)
self._cancel_button.render(cancel_rect)
self._confirm_button.render(confirm_rect)
def _render(self, rect):
self._keyboard.render(rect)
return self._dialog_result
+49 -10
View File
@@ -1,12 +1,13 @@
from functools import partial
import time
from typing import Literal
from collections.abc import Callable
import pyray as rl
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets import Widget, DialogResult
from openpilot.system.ui.widgets.button import ButtonStyle, Button
from openpilot.system.ui.widgets.inputbox import InputBox
from openpilot.system.ui.widgets.label import Label
@@ -58,7 +59,14 @@ KEYBOARD_LAYOUTS = {
class Keyboard(Widget):
def __init__(self, max_text_size: int = 255, min_text_size: int = 0, password_mode: bool = False, show_password_toggle: bool = False):
def __init__(
self,
max_text_size: int = 255,
min_text_size: int = 0,
password_mode: bool = False,
show_password_toggle: bool = False,
callback: Callable[[DialogResult], None] | None = None,
):
super().__init__()
self._layout_name: Literal["lowercase", "uppercase", "numbers", "specials"] = "lowercase"
self._caps_lock = False
@@ -71,6 +79,7 @@ class Keyboard(Widget):
self._input_box = InputBox(max_text_size)
self._password_mode = password_mode
self._show_password_toggle = show_password_toggle
self._callback = callback
# Backspace key repeat tracking
self._backspace_pressed: bool = False
@@ -78,6 +87,8 @@ class Keyboard(Widget):
self._backspace_last_repeat: float = 0.0
self._render_return_status = -1
self._first_render = False
self._skip_input = False
self._cancel_button = Button(lambda: tr("Cancel"), self._cancel_button_callback)
self._eye_button = Button("", self._eye_button_callback, button_style=ButtonStyle.TRANSPARENT)
@@ -98,12 +109,18 @@ class Keyboard(Widget):
for _, key in enumerate(keys):
if key in self._key_icons:
texture = self._key_icons[key]
self._all_keys[key] = Button("", partial(self._key_callback, key), icon=texture,
button_style=ButtonStyle.PRIMARY if key == ENTER_KEY else ButtonStyle.KEYBOARD, multi_touch=True)
self._all_keys[key] = Button(
"",
partial(self._key_callback, key),
icon=texture,
button_style=ButtonStyle.PRIMARY if key == ENTER_KEY else ButtonStyle.KEYBOARD,
multi_touch=True,
)
else:
self._all_keys[key] = Button(key, partial(self._key_callback, key), button_style=ButtonStyle.KEYBOARD, font_size=85, multi_touch=True)
self._all_keys[CAPS_LOCK_KEY] = Button("", partial(self._key_callback, CAPS_LOCK_KEY), icon=self._key_icons[CAPS_LOCK_KEY],
button_style=ButtonStyle.KEYBOARD, multi_touch=True)
self._all_keys[CAPS_LOCK_KEY] = Button(
"", partial(self._key_callback, CAPS_LOCK_KEY), icon=self._key_icons[CAPS_LOCK_KEY], button_style=ButtonStyle.KEYBOARD, multi_touch=True
)
def set_text(self, text: str):
self._input_box.text = text
@@ -122,20 +139,42 @@ class Keyboard(Widget):
self._title.set_text(title)
self._sub_title.set_text(sub_title)
def _eye_button_callback(self):
self._password_mode = not self._password_mode
def set_callback(self, callback: Callable[[DialogResult], None] | None):
self._callback = callback
def show_event(self):
super().show_event()
self._skip_input = True
def _process_mouse_events(self):
if not self._skip_input:
super()._process_mouse_events()
def _cancel_button_callback(self):
self.clear()
self._render_return_status = 0
if self in gui_app._nav_stack:
gui_app.pop_widget()
else:
self._render_return_status = 0
if self._callback:
self._callback(DialogResult.CANCEL)
def _eye_button_callback(self):
self._password_mode = not self._password_mode
def _key_callback(self, k):
if k == ENTER_KEY:
self._render_return_status = 1
if self in gui_app._nav_stack:
gui_app.pop_widget()
else:
self._render_return_status = 1
if self._callback:
self._callback(DialogResult.CONFIRM)
else:
self.handle_key_press(k)
def _render(self, rect: rl.Rectangle):
self._skip_input = False
rect = rl.Rectangle(rect.x + CONTENT_MARGIN, rect.y + CONTENT_MARGIN, rect.width - 2 * CONTENT_MARGIN, rect.height - 2 * CONTENT_MARGIN)
self._title.render(rl.Rectangle(rect.x, rect.y, rect.width, 95))
self._sub_title.render(rl.Rectangle(rect.x, rect.y + 95, rect.width, 60))