We do a little vibe coding

This commit is contained in:
James
2026-01-19 21:58:52 -07:00
parent 5758e61a6d
commit 483f967636
17 changed files with 11080 additions and 0 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,425 @@
from enum import IntEnum
from pathlib import Path
import os
from openpilot.common.params import Params
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.widgets import Widget
from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog, alert_dialog, DialogResult
from openpilot.system.ui.widgets.list_view import ListItem, ToggleAction, ButtonAction, TextAction, ITEM_TEXT_VALUE_COLOR
from openpilot.system.ui.widgets.scroller_tici import Scroller
from openpilot.frogpilot.common.frogpilot_variables import update_frogpilot_toggles
from openpilot.frogpilot.system.ui.widgets.frogpilot_controls import (
FrogPilotButtonControl,
FrogPilotButtonToggleControl,
FrogPilotConfirmationDialog,
FrogPilotManageControl,
FrogPilotParamValueControl,
)
DEVICE_MANAGEMENT_KEYS = {
"DeviceShutdown",
"HigherBitrate",
"IncreaseThermalLimits",
"LowVoltageShutdown",
"NoLogging",
"NoUploads",
"UseKonikServer",
}
SCREEN_KEYS = {
"ScreenBrightness",
"ScreenBrightnessOnroad",
"ScreenRecorder",
"ScreenTimeout",
"ScreenTimeoutOnroad",
"StandbyMode",
}
NOT_VETTED_PATH = Path("/data/openpilot/not_vetted")
USE_HD_PATH = Path("/cache/use_HD")
USE_KONIK_PATH = Path("/cache/use_konik")
class SubPanel(IntEnum):
MAIN = 0
DEVICE_MANAGEMENT = 1
SCREEN = 2
def build_shutdown_labels():
labels = {}
for i in range(34):
if i == 0:
labels[i] = "5 mins"
elif i <= 3:
labels[i] = f"{i * 15} mins"
elif i == 4:
labels[i] = "1 hour"
else:
labels[i] = f"{i - 3} hours"
return labels
def build_brightness_labels(include_off=False):
labels = {}
if include_off:
labels[0] = "Screen Off"
for i in range(1, 101):
labels[i] = f"{i}%"
labels[101] = "Auto"
return labels
class FrogPilotDevicePanel(Widget):
def __init__(self):
super().__init__()
self._current_panel = SubPanel.MAIN
self._is_recording = False
self._params = Params()
self._params_memory = Params("", True)
self._started = False
self._toggles = {}
self._tuning_level = 0
# Pending dialog action tracking
self._pending_action = None # "warning_toggle", "reboot_toggle"
self._pending_data = {}
self._build_main_panel()
self._build_device_management_panel()
self._build_screen_panel()
ui_state.add_offroad_transition_callback(self._update_toggles)
def _build_main_panel(self):
self._device_management_control = FrogPilotManageControl(
"DeviceManagement",
"Device Settings",
"<b>Settings that control how the device runs, powers off, and manages driving data.</b>",
"../../frogpilot/assets/toggle_icons/icon_device.png",
)
self._device_management_control.set_manage_callback(self._open_device_management)
self._screen_management_control = FrogPilotManageControl(
"ScreenManagement",
"Screen Settings",
"<b>Settings that control screen brightness, screen recording, and timeout duration.</b>",
"../../frogpilot/assets/toggle_icons/icon_light.png",
)
self._screen_management_control.set_manage_callback(self._open_screen_panel)
main_items = [
self._device_management_control,
self._screen_management_control,
]
self._main_scroller = Scroller(main_items, line_separator=True, spacing=0)
def _build_device_management_panel(self):
shutdown_labels = build_shutdown_labels()
self._device_shutdown_control = FrogPilotParamValueControl(
"DeviceShutdown",
"Device Shutdown Timer",
"<b>Keep the device on for the set amount of time after a drive</b> before it shuts down automatically.",
"",
min_value=0,
max_value=33,
value_labels=shutdown_labels,
)
self._no_logging_item = ListItem(
title="Disable Logging",
description="<b>WARNING: This will prevent your drives from being recorded and all data will be unobtainable!</b><br><br><b>Prevent the device from saving driving data.</b>",
action_item=ToggleAction(
initial_state=self._params.get_bool("NoLogging"),
callback=lambda state: self._on_warning_toggle("NoLogging", state, "This will prevent your drives from being recorded. Are you sure?"),
),
)
self._no_uploads_control = FrogPilotButtonToggleControl(
"NoUploads",
"Disable Uploads",
"<b>WARNING: This will prevent your drives from being uploaded to comma connect which will impact debugging and official support from comma!</b><br><br><b>Prevent the device from uploading driving data.</b>",
"",
button_params=["DisableOnroadUploads"],
button_texts=["Disable Onroad Only"],
)
self._no_uploads_control.set_toggle_callback(lambda state: self._on_warning_toggle("NoUploads", state, "This will prevent uploads to comma connect. Are you sure?"))
self._no_uploads_control.set_button_click_callback(lambda _: self._update_toggles())
self._higher_bitrate_item = ListItem(
title="High-Quality Recording",
description="<b>Save drive footage in higher video quality.</b>",
action_item=ToggleAction(
initial_state=self._params.get_bool("HigherBitrate"),
callback=lambda state: self._on_reboot_toggle("HigherBitrate", state, USE_HD_PATH),
),
)
self._low_voltage_control = FrogPilotParamValueControl(
"LowVoltageShutdown",
"Low-Voltage Cutoff",
"<b>While parked, if the battery voltage falls below the set level, the device shuts down</b> to prevent excessive battery drain.",
"",
min_value=11.8,
max_value=12.5,
label=" volts",
interval=0.1,
)
self._thermal_limits_item = ListItem(
title="Raise Temperature Limits",
description="<b>WARNING: Running at higher temperatures may damage your device!</b><br><br><b>Allow the device to run at higher temperatures</b> before throttling or shutting down. Use only if you understand the risks!",
action_item=ToggleAction(
initial_state=self._params.get_bool("IncreaseThermalLimits"),
callback=lambda state: self._on_warning_toggle("IncreaseThermalLimits", state, "This may damage your device. Are you sure?"),
),
)
self._use_konik_item = ListItem(
title="Use Konik Server",
description="<b>Upload driving data to \"stable.konik.ai\" instead of \"connect.comma.ai\".</b>",
action_item=ToggleAction(
initial_state=self._params.get_bool("UseKonikServer") or NOT_VETTED_PATH.is_file(),
callback=lambda state: self._on_reboot_toggle("UseKonikServer", state, USE_KONIK_PATH),
enabled=lambda: not NOT_VETTED_PATH.is_file(),
),
)
device_items = [
self._device_shutdown_control,
self._no_logging_item,
self._no_uploads_control,
self._higher_bitrate_item,
self._low_voltage_control,
self._thermal_limits_item,
self._use_konik_item,
]
self._toggles["DeviceShutdown"] = self._device_shutdown_control
self._toggles["NoLogging"] = self._no_logging_item
self._toggles["NoUploads"] = self._no_uploads_control
self._toggles["HigherBitrate"] = self._higher_bitrate_item
self._toggles["LowVoltageShutdown"] = self._low_voltage_control
self._toggles["IncreaseThermalLimits"] = self._thermal_limits_item
self._toggles["UseKonikServer"] = self._use_konik_item
self._device_management_scroller = Scroller(device_items, line_separator=True, spacing=0)
def _build_screen_panel(self):
offroad_brightness_labels = build_brightness_labels(include_off=False)
self._screen_brightness_control = FrogPilotParamValueControl(
"ScreenBrightness",
"Screen Brightness (Offroad)",
"<b>The screen brightness while not driving.</b>",
"",
min_value=1,
max_value=101,
value_labels=offroad_brightness_labels,
fast_increase=True,
)
self._screen_brightness_control.set_value_changed_callback(self._on_offroad_brightness_changed)
onroad_brightness_labels = build_brightness_labels(include_off=True)
self._screen_brightness_onroad_control = FrogPilotParamValueControl(
"ScreenBrightnessOnroad",
"Screen Brightness (Onroad)",
"<b>The screen brightness while driving.</b>",
"",
min_value=0,
max_value=101,
value_labels=onroad_brightness_labels,
fast_increase=True,
)
self._screen_brightness_onroad_control.set_value_changed_callback(self._on_onroad_brightness_changed)
self._screen_recorder_control = FrogPilotButtonControl(
"ScreenRecorder",
"Screen Recorder",
"<b>Add a button to the driving screen to record the display.</b>",
"",
button_texts=["Start Recording", "Stop Recording"],
checkable=True,
)
self._screen_recorder_control.set_button_click_callback(self._on_screen_recorder_click)
self._screen_recorder_control.set_visible_button(1, False)
self._screen_timeout_control = FrogPilotParamValueControl(
"ScreenTimeout",
"Screen Timeout (Offroad)",
"<b>How long the screen stays on after being tapped while not driving.</b>",
"",
min_value=5,
max_value=60,
label=" seconds",
interval=5,
)
self._screen_timeout_onroad_control = FrogPilotParamValueControl(
"ScreenTimeoutOnroad",
"Screen Timeout (Onroad)",
"<b>How long the screen stays on after being tapped while driving.</b>",
"",
min_value=5,
max_value=60,
label=" seconds",
interval=5,
)
self._standby_mode_item = ListItem(
title="Standby Mode",
description="<b>Turn the screen off while driving and automatically wake it up for alerts or engagement state changes.</b>",
action_item=ToggleAction(
initial_state=self._params.get_bool("StandbyMode"),
callback=lambda state: self._simple_toggle("StandbyMode", state),
),
)
screen_items = [
self._screen_brightness_control,
self._screen_brightness_onroad_control,
self._screen_recorder_control,
self._screen_timeout_control,
self._screen_timeout_onroad_control,
self._standby_mode_item,
]
self._toggles["ScreenBrightness"] = self._screen_brightness_control
self._toggles["ScreenBrightnessOnroad"] = self._screen_brightness_onroad_control
self._toggles["ScreenRecorder"] = self._screen_recorder_control
self._toggles["ScreenTimeout"] = self._screen_timeout_control
self._toggles["ScreenTimeoutOnroad"] = self._screen_timeout_onroad_control
self._toggles["StandbyMode"] = self._standby_mode_item
self._screen_scroller = Scroller(screen_items, line_separator=True, spacing=0)
def _simple_toggle(self, param: str, state: bool):
self._params.put_bool(param, state)
update_frogpilot_toggles()
def _on_warning_toggle(self, param: str, state: bool, warning_message: str):
if state:
self._pending_action = "warning_toggle"
self._pending_data = {"param": param}
gui_app.set_modal_overlay(ConfirmDialog(warning_message, "Confirm", "Cancel"))
else:
self._params.put_bool(param, False)
update_frogpilot_toggles()
self._update_toggles()
def _on_reboot_toggle(self, param: str, state: bool, cache_path: Path):
self._params.put_bool(param, state)
if state:
cache_path.touch(exist_ok=True)
else:
if cache_path.exists():
cache_path.unlink()
update_frogpilot_toggles()
self._pending_action = "reboot_toggle"
self._pending_data = {}
gui_app.set_modal_overlay(ConfirmDialog(
"Reboot required to take effect.",
"Reboot Now",
"Reboot Later",
))
def handle_dialog_result(self, result: DialogResult, selection: str = ""):
"""Handle dialog results for pending actions."""
action = self._pending_action
self._pending_action = None
if action == "warning_toggle":
if result == DialogResult.CONFIRM:
param = self._pending_data.get("param")
if param:
self._params.put_bool(param, True)
update_frogpilot_toggles()
self._update_toggles()
self._pending_data = {}
elif action == "reboot_toggle":
if result == DialogResult.CONFIRM:
HARDWARE.reboot()
self._pending_data = {}
def _on_offroad_brightness_changed(self, value: float):
if not self._started:
brightness = int(value) if value <= 100 else 50
HARDWARE.set_brightness(brightness)
def _on_onroad_brightness_changed(self, value: float):
if self._started:
brightness = int(value) if value <= 100 else 50
HARDWARE.set_brightness(brightness)
def _on_screen_recorder_click(self, button_id: int):
if button_id == 0:
# Start Recording - enable the screen recording environment variable
self._is_recording = True
self._screen_recorder_control.set_checked_button(1)
self._screen_recorder_control.set_visible_button(0, False)
self._screen_recorder_control.set_visible_button(1, True)
# Set params to trigger screen recording
self._params_memory.put_bool("RecordScreen", True)
else:
# Stop Recording - disable the screen recording
self._is_recording = False
self._screen_recorder_control.clear_checked_buttons()
self._screen_recorder_control.set_visible_button(0, True)
self._screen_recorder_control.set_visible_button(1, False)
# Clear params to stop screen recording
self._params_memory.put_bool("RecordScreen", False)
def _open_device_management(self):
self._current_panel = SubPanel.DEVICE_MANAGEMENT
def _open_screen_panel(self):
self._current_panel = SubPanel.SCREEN
def _close_sub_panel(self):
self._current_panel = SubPanel.MAIN
def _update_toggles(self):
self._tuning_level = self._params.get_int("TuningLevel") or 0
device_management_enabled = self._params.get_bool("DeviceManagement")
no_uploads_enabled = self._params.get_bool("NoUploads")
disable_onroad_only = self._params.get_bool("DisableOnroadUploads")
higher_bitrate_visible = device_management_enabled and no_uploads_enabled and not disable_onroad_only
if hasattr(self._higher_bitrate_item, 'set_visible'):
self._higher_bitrate_item.set_visible(higher_bitrate_visible)
if NOT_VETTED_PATH.is_file():
self._params.put_bool("UseKonikServer", True)
def _update_state(self):
self._started = ui_state.started
def show_event(self):
super().show_event()
self._main_scroller.show_event()
self._update_toggles()
def hide_event(self):
super().hide_event()
self._current_panel = SubPanel.MAIN
def _render(self, rect):
if self._current_panel == SubPanel.DEVICE_MANAGEMENT:
self._device_management_scroller.render(rect)
elif self._current_panel == SubPanel.SCREEN:
self._screen_scroller.render(rect)
else:
self._main_scroller.render(rect)
+407
View File
@@ -0,0 +1,407 @@
import json
from cereal import car, custom, log, messaging
from enum import IntEnum
from openpilot.common.params import Params
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
from openpilot.system.ui.widgets.confirm_dialog import alert_dialog
from openpilot.system.ui.widgets.list_view import button_item, multiple_button_item
from openpilot.system.ui.widgets.scroller_tici import Scroller
from openpilot.frogpilot.common.frogpilot_variables import (
TUNING_LEVELS,
nnff_supported,
update_frogpilot_toggles,
)
from openpilot.frogpilot.ui.layouts.settings.data_settings import FrogPilotDataPanel
from openpilot.frogpilot.ui.layouts.settings.device_settings import FrogPilotDevicePanel
from openpilot.frogpilot.ui.layouts.settings.lateral_settings import FrogPilotLateralPanel
from openpilot.frogpilot.ui.layouts.settings.longitudinal_settings import FrogPilotLongitudinalPanel
from openpilot.frogpilot.ui.layouts.settings.model_settings import FrogPilotModelPanel
from openpilot.frogpilot.ui.layouts.settings.sounds_settings import FrogPilotSoundsPanel
from openpilot.frogpilot.ui.layouts.settings.theme_settings import FrogPilotThemePanel
from openpilot.frogpilot.ui.layouts.settings.utilities import FrogPilotUtilitiesPanel
from openpilot.frogpilot.ui.layouts.settings.vehicle_settings import FrogPilotVehiclesPanel
from openpilot.frogpilot.ui.layouts.settings.visual_settings import FrogPilotVisualsPanel
TUNING_BUTTON_WIDTH = 180
class SubPanel(IntEnum):
NONE = 0
DATA = 1
DEVICE = 2
LATERAL = 3
LONGITUDINAL = 4
MODEL = 5
SOUNDS = 6
THEME = 7
UTILITIES = 8
VEHICLES = 9
VISUALS = 10
class FrogPilotLayout(Widget):
def __init__(self):
super().__init__()
self._params = Params()
self._can_use_pedal = False
self._can_use_sdsu = False
self._car_make = ""
self._car_model = ""
self._current_subpanel = SubPanel.NONE
self._force_open_descriptions = False
self._friction = 0.0
self._frogpilot_toggle_levels = {}
self._has_alpha_longitudinal = False
self._has_auto_tune = True
self._has_bsm = True
self._has_dash_speed_limits = True
self._has_nnff_log = True
self._has_openpilot_longitudinal = True
self._has_pcm_cruise = False
self._has_pedal = False
self._has_radar = True
self._has_sdsu = False
self._has_sng = False
self._has_zss = False
self._is_angle_car = False
self._is_bolt = False
self._is_frogs_go_moo = False
self._is_gm = True
self._is_hkg = True
self._is_hkg_canfd = True
self._is_subaru = False
self._is_torque_car = False
self._is_toyota = True
self._is_tsk = False
self._is_volt = True
self._lat_accel_factor = 0.0
self._lkas_allowed_for_aol = False
self._longitudinal_actuator_delay = 0.0
self._openpilot_longitudinal_control_disabled = False
self._shown_descriptions = {}
self._start_accel = 0.0
self._steer_actuator_delay = 0.0
self._steer_kp = 1.0
self._steer_ratio = 0.0
self._stop_accel = 0.0
self._stopping_decel_rate = 0.0
self._tuning_level = self._params.get_int("TuningLevel") or 0
self._v_ego_starting = 0.0
self._v_ego_stopping = 0.0
self._subpanels = {
SubPanel.DATA: FrogPilotDataPanel(),
SubPanel.DEVICE: FrogPilotDevicePanel(),
SubPanel.LATERAL: FrogPilotLateralPanel(),
SubPanel.LONGITUDINAL: FrogPilotLongitudinalPanel(),
SubPanel.MODEL: FrogPilotModelPanel(),
SubPanel.SOUNDS: FrogPilotSoundsPanel(),
SubPanel.THEME: FrogPilotThemePanel(),
SubPanel.UTILITIES: FrogPilotUtilitiesPanel(),
SubPanel.VEHICLES: FrogPilotVehiclesPanel(),
SubPanel.VISUALS: FrogPilotVisualsPanel(),
}
self._load_shown_descriptions()
self._load_toggle_levels()
self._check_force_open_descriptions()
self._tuning_level_item = multiple_button_item(
lambda: "Tuning Level",
lambda: (
"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"
),
buttons=[lambda: "Minimal", lambda: "Standard", lambda: "Advanced", lambda: "Developer"],
button_width=TUNING_BUTTON_WIDTH,
selected_index=self._tuning_level,
callback=self._on_tuning_level_changed,
icon="../../frogpilot/assets/toggle_icons/icon_tuning.png",
)
self._sound_panel_item = button_item(
lambda: "Alerts and Sounds",
lambda: "MANAGE",
lambda: "<b>Adjust alert volumes and enable custom notifications.</b>",
callback=lambda: self._open_subpanel(SubPanel.SOUNDS),
)
self._model_panel_item = button_item(
lambda: "Driving Model",
lambda: "MANAGE",
lambda: "<b>Select and configure driving models.</b>",
callback=lambda: self._open_subpanel(SubPanel.MODEL),
)
self._longitudinal_panel_item = button_item(
lambda: "Gas / Brake",
lambda: "MANAGE",
lambda: "<b>Fine-tune acceleration and braking controls.</b>",
callback=lambda: self._open_subpanel(SubPanel.LONGITUDINAL),
)
self._lateral_panel_item = button_item(
lambda: "Steering",
lambda: "MANAGE",
lambda: "<b>Fine-tune steering controls.</b>",
callback=lambda: self._open_subpanel(SubPanel.LATERAL),
)
self._data_panel_item = button_item(
lambda: "Data",
lambda: "MANAGE",
lambda: "<b>Manage data and backups.</b>",
callback=lambda: self._open_subpanel(SubPanel.DATA),
)
self._device_panel_item = button_item(
lambda: "Device Controls",
lambda: "MANAGE",
lambda: "<b>Configure device settings and screen options.</b>",
callback=lambda: self._open_subpanel(SubPanel.DEVICE),
)
self._utilities_panel_item = button_item(
lambda: "Utilities",
lambda: "MANAGE",
lambda: "<b>Tools to keep FrogPilot running smoothly.</b>",
callback=lambda: self._open_subpanel(SubPanel.UTILITIES),
)
self._visuals_panel_item = button_item(
lambda: "Appearance",
lambda: "MANAGE",
lambda: "<b>Customize the look of the driving screen.</b>",
callback=lambda: self._open_subpanel(SubPanel.VISUALS),
)
self._theme_panel_item = button_item(
lambda: "Theme",
lambda: "MANAGE",
lambda: "<b>Customize themes and colors.</b>",
callback=lambda: self._open_subpanel(SubPanel.THEME),
)
self._vehicles_panel_item = button_item(
lambda: "Vehicle Settings",
lambda: "MANAGE",
lambda: "<b>Configure car-specific options.</b>",
callback=lambda: self._open_subpanel(SubPanel.VEHICLES),
)
items = [
self._tuning_level_item,
self._sound_panel_item,
self._model_panel_item,
self._longitudinal_panel_item,
self._lateral_panel_item,
self._data_panel_item,
self._device_panel_item,
self._utilities_panel_item,
self._visuals_panel_item,
self._theme_panel_item,
self._vehicles_panel_item,
]
self._main_scroller = Scroller(items, line_separator=True, spacing=0)
ui_state.add_offroad_transition_callback(self._update_variables)
def _load_shown_descriptions(self):
try:
data = self._params.get("ShownToggleDescriptions")
if data:
self._shown_descriptions = json.loads(data)
except (json.JSONDecodeError, TypeError):
self._shown_descriptions = {}
def _save_shown_descriptions(self):
self._params.put_nonblocking("ShownToggleDescriptions", json.dumps(self._shown_descriptions))
def _load_toggle_levels(self):
keys = self._params.all_keys()
for key in keys:
key_str = key.decode() if isinstance(key, bytes) else key
self._frogpilot_toggle_levels[key_str] = self._params.get_tuning_level(key)
def _check_force_open_descriptions(self):
class_name = "FrogPilotLayout"
if not self._shown_descriptions.get(class_name, False):
self._force_open_descriptions = True
def _on_tuning_level_changed(self, level: int):
self._tuning_level = level
self._params.put_int("TuningLevel", level)
update_frogpilot_toggles()
self._update_panel_visibility()
if level == TUNING_LEVELS["DEVELOPER"]:
gui_app.set_modal_overlay(alert_dialog(
"WARNING: These settings are risky and can drastically change how openpilot drives. "
"Only change if you fully understand what they do!"
))
def _open_subpanel(self, subpanel: SubPanel):
if self._current_subpanel != SubPanel.NONE:
self._subpanels[self._current_subpanel].hide_event()
self._current_subpanel = subpanel
if subpanel != SubPanel.NONE:
self._subpanels[subpanel].show_event()
def _close_subpanel(self):
if self._current_subpanel != SubPanel.NONE:
self._subpanels[self._current_subpanel].hide_event()
self._current_subpanel = SubPanel.NONE
def _render(self, rect):
if self._current_subpanel != SubPanel.NONE:
self._subpanels[self._current_subpanel].render(rect)
else:
self._main_scroller.render(rect)
def show_event(self):
super().show_event()
self._main_scroller.show_event()
class_name = "FrogPilotLayout"
if not self._shown_descriptions.get(class_name, False):
self._shown_descriptions[class_name] = True
self._save_shown_descriptions()
if self._force_open_descriptions:
self._force_open_descriptions = False
gui_app.set_modal_overlay(alert_dialog(
"All toggle descriptions are currently expanded. You can tap a toggle's name to open or close its description at any time!"
))
if self._current_subpanel != SubPanel.NONE:
self._subpanels[self._current_subpanel].show_event()
self._update_variables()
def hide_event(self):
super().hide_event()
if self._current_subpanel != SubPanel.NONE:
self._subpanels[self._current_subpanel].hide_event()
update_frogpilot_toggles()
def _update_variables(self):
try:
car_params_bytes = self._params.get("CarParamsPersistent")
if car_params_bytes:
CP = messaging.log_from_bytes(car_params_bytes, car.CarParams)
self._car_make = CP.brand
self._car_model = CP.carFingerprint
self._friction = CP.lateralTuning.torque.friction
self._has_alpha_longitudinal = CP.alphaLongitudinalAvailable
self._has_bsm = CP.enableBsm
self._has_dash_speed_limits = self._car_make in ("ford", "hyundai", "toyota")
self._has_nnff_log = nnff_supported(self._car_model)
self._has_openpilot_longitudinal = CP.openpilotLongitudinalControl
self._has_pcm_cruise = CP.pcmCruise
self._has_pedal = CP.enableGasInterceptorDEPRECATED
self._has_radar = not CP.radarUnavailable
self._has_sng = CP.autoResumeSng
self._is_angle_car = CP.steerControlType == car.CarParams.SteerControlType.angle
self._is_bolt = self._car_model in ("CHEVROLET_BOLT_CC", "CHEVROLET_BOLT_EUV")
self._is_gm = self._car_make == "gm"
self._is_hkg = self._car_make == "hyundai"
self._is_subaru = self._car_make == "subaru"
self._is_torque_car = CP.lateralTuning.which() == "torque"
self._is_toyota = self._car_make == "toyota"
self._is_tsk = CP.secOcRequired
self._is_volt = self._car_model == "CHEVROLET_VOLT"
self._lat_accel_factor = CP.lateralTuning.torque.latAccelFactor
self._longitudinal_actuator_delay = CP.longitudinalActuatorDelay
self._start_accel = CP.startAccel
self._steer_actuator_delay = CP.steerActuatorDelay
self._steer_ratio = CP.steerRatio
self._stop_accel = CP.stopAccel
self._stopping_decel_rate = CP.stoppingDecelRate
self._v_ego_starting = CP.vEgoStarting
self._v_ego_stopping = CP.vEgoStopping
self._update_stock_values(CP)
except Exception:
pass
try:
fp_car_params_bytes = self._params.get("FrogPilotCarParamsPersistent")
if fp_car_params_bytes:
FPCP = messaging.log_from_bytes(fp_car_params_bytes, custom.FrogPilotCarParams)
self._can_use_pedal = FPCP.canUsePedal
self._can_use_sdsu = FPCP.canUseSDSU
self._openpilot_longitudinal_control_disabled = FPCP.openpilotLongitudinalControlDisabled
except Exception:
pass
try:
ltp_bytes = self._params.get("LiveTorqueParameters")
if ltp_bytes:
LTP = messaging.log_from_bytes(ltp_bytes, log.LiveTorqueParametersData)
self._has_auto_tune = LTP.useParams
except Exception:
pass
self._update_panel_visibility()
def _update_stock_values(self, CP):
stock_params = [
("SteerDelayStock", "SteerDelay", self._steer_actuator_delay),
("SteerFrictionStock", "SteerFriction", self._friction),
("SteerKPStock", "SteerKP", self._steer_kp),
("SteerLatAccelStock", "SteerLatAccel", self._lat_accel_factor),
("LongitudinalActuatorDelayStock", "LongitudinalActuatorDelay", self._longitudinal_actuator_delay),
("StartAccelStock", "StartAccel", self._start_accel),
("SteerRatioStock", "SteerRatio", self._steer_ratio),
("StopAccelStock", "StopAccel", self._stop_accel),
("StoppingDecelRateStock", "StoppingDecelRate", self._stopping_decel_rate),
("VEgoStartingStock", "VEgoStarting", self._v_ego_starting),
("VEgoStoppingStock", "VEgoStopping", self._v_ego_stopping),
]
for stock_key, user_key, new_value in stock_params:
if new_value == 0:
continue
current_stock = self._params.get_float(stock_key)
if current_stock != new_value:
current_user = self._params.get_float(user_key)
if current_user == current_stock or current_stock == 0:
self._params.put_float_nonblocking(user_key, new_value)
self._params.put_float_nonblocking(stock_key, new_value)
def _update_panel_visibility(self):
self._longitudinal_panel_item.set_visible(self._has_openpilot_longitudinal)
device_mgmt_level = self._frogpilot_toggle_levels.get("DeviceManagement", 0)
screen_mgmt_level = self._frogpilot_toggle_levels.get("ScreenManagement", 0)
self._device_panel_item.set_visible(self._tuning_level >= device_mgmt_level or self._tuning_level >= screen_mgmt_level)
@property
def tuning_level(self) -> int:
return self._tuning_level
@property
def has_openpilot_longitudinal(self) -> bool:
return self._has_openpilot_longitudinal
@property
def car_make(self) -> str:
return self._car_make
@property
def car_model(self) -> str:
return self._car_model
@@ -0,0 +1,702 @@
from enum import IntEnum
from openpilot.common.conversions import Conversions as CV
from openpilot.common.params import Params
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.widgets import Widget
from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog
from openpilot.system.ui.widgets.list_view import ListItem, ToggleAction
from openpilot.system.ui.widgets.scroller_tici import Scroller
from openpilot.frogpilot.common.frogpilot_variables import nnff_supported, update_frogpilot_toggles
from openpilot.frogpilot.system.ui.widgets.frogpilot_controls import (
FrogPilotButtonToggleControl,
FrogPilotConfirmationDialog,
FrogPilotManageControl,
FrogPilotParamValueButtonControl,
FrogPilotParamValueControl,
)
ADVANCED_LATERAL_TUNE_KEYS = {
"ForceAutoTune",
"ForceAutoTuneOff",
"ForceTorqueController",
"SteerDelay",
"SteerFriction",
"SteerKP",
"SteerLatAccel",
"SteerRatio",
}
AOL_KEYS = {
"AlwaysOnLateralLKAS",
"PauseAOLOnBrake",
}
LANE_CHANGE_KEYS = {
"LaneChangeTime",
"LaneDetectionWidth",
"MinimumLaneChangeSpeed",
"NudgelessLaneChange",
"OneLaneChange",
}
LATERAL_TUNE_KEYS = {
"NNFF",
"NNFFLite",
"TurnDesires",
}
QOL_KEYS = {
"PauseLateralSpeed",
}
FOOT_TO_METER = CV.FOOT_TO_METER
METER_TO_FOOT = CV.METER_TO_FOOT
KM_TO_MILE = 1.0 / CV.MPH_TO_KPH
MILE_TO_KM = CV.MPH_TO_KPH
class SubPanel(IntEnum):
MAIN = 0
ADVANCED_LATERAL_TUNE = 1
AOL = 2
LANE_CHANGE = 3
LATERAL_TUNE = 4
QOL = 5
def build_lane_change_time_labels():
labels = {}
for i in range(51):
val = i / 10.0
if val == 0:
labels[val] = "Instant"
elif val == 1.0:
labels[val] = "1.0 second"
else:
labels[val] = f"{val:.1f} seconds"
return labels
def build_imperial_speed_labels():
labels = {}
for i in range(100):
labels[i] = "Off" if i == 0 else f"{i} mph"
return labels
def build_metric_speed_labels():
labels = {}
for i in range(151):
labels[i] = "Off" if i == 0 else f"{i} km/h"
return labels
def build_imperial_distance_labels():
labels = {}
for i in range(151):
val = i / 10.0
if val == 0:
labels[val] = "Off"
elif i == 1:
labels[val] = "1 foot"
else:
labels[val] = f"{val:.1f} feet"
return labels
def build_metric_distance_labels():
labels = {}
for i in range(51):
val = i / 10.0
if val == 0:
labels[val] = "Off"
elif i == 1:
labels[val] = "1 meter"
else:
labels[val] = f"{val:.1f} meters"
return labels
class FrogPilotLateralPanel(Widget):
def __init__(self, parent=None):
super().__init__()
self._current_panel = SubPanel.MAIN
self._is_metric = False
self._params = Params()
self._parent = parent
self._started = False
self._toggles = {}
self._car_model = ""
self._friction = 0.0
self._has_auto_tune = True
self._has_nnff_log = False
self._is_angle_car = False
self._is_torque_car = False
self._lat_accel_factor = 0.0
self._lkas_allowed_for_aol = False
self._steer_actuator_delay = 0.0
self._steer_kp = 1.0
self._steer_ratio = 0.0
self._tuning_level = 0
self._build_main_panel()
self._build_advanced_lateral_tune_panel()
self._build_aol_panel()
self._build_lane_change_panel()
self._build_lateral_tune_panel()
self._build_qol_panel()
ui_state.add_offroad_transition_callback(self._on_offroad_transition)
def _on_offroad_transition(self):
self._is_metric = self._params.get_bool("IsMetric")
self._update_metric()
self._update_car_params()
self._update_toggles()
def _build_main_panel(self):
self._advanced_lateral_tune_control = FrogPilotManageControl(
"AdvancedLateralTune",
"Advanced Lateral Tuning",
"<b>Advanced steering control changes to fine-tune how openpilot drives.</b>",
"../../frogpilot/assets/toggle_icons/icon_advanced_lateral_tune.png",
)
self._advanced_lateral_tune_control.set_manage_callback(self._open_advanced_lateral_tune)
self._aol_control = FrogPilotManageControl(
"AlwaysOnLateral",
"Always On Lateral",
"<b>openpilot's steering remains active even when the accelerator or brake pedals are pressed.</b>",
"../../frogpilot/assets/toggle_icons/icon_always_on_lateral.png",
)
self._aol_control.set_manage_callback(self._open_aol_panel)
self._lane_changes_control = FrogPilotManageControl(
"LaneChanges",
"Lane Changes",
"<b>Allow openpilot to change lanes.</b>",
"../../frogpilot/assets/toggle_icons/icon_lane.png",
)
self._lane_changes_control.set_manage_callback(self._open_lane_change_panel)
self._lateral_tune_control = FrogPilotManageControl(
"LateralTune",
"Lateral Tuning",
"<b>Miscellaneous steering control changes</b> to fine-tune how openpilot drives.",
"../../frogpilot/assets/toggle_icons/icon_lateral_tune.png",
)
self._lateral_tune_control.set_manage_callback(self._open_lateral_tune_panel)
self._qol_lateral_control = FrogPilotManageControl(
"QOLLateral",
"Quality of Life",
"<b>Steering control changes to fine-tune how openpilot drives.</b>",
"../../frogpilot/assets/toggle_icons/icon_quality_of_life.png",
)
self._qol_lateral_control.set_manage_callback(self._open_qol_panel)
main_items = [
self._advanced_lateral_tune_control,
self._aol_control,
self._lane_changes_control,
self._lateral_tune_control,
self._qol_lateral_control,
]
self._toggles["AdvancedLateralTune"] = self._advanced_lateral_tune_control
self._toggles["AlwaysOnLateral"] = self._aol_control
self._toggles["LaneChanges"] = self._lane_changes_control
self._toggles["LateralTune"] = self._lateral_tune_control
self._toggles["QOLLateral"] = self._qol_lateral_control
self._main_scroller = Scroller(main_items, line_separator=True, spacing=0)
def _build_advanced_lateral_tune_panel(self):
self._steer_delay_control = FrogPilotParamValueButtonControl(
"SteerDelay",
"Actuator Delay",
"<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.",
"",
min_value=0.01,
max_value=1.0,
interval=0.01,
button_texts=["Reset"],
)
self._steer_delay_control.set_button_click_callback(lambda _: self._reset_param("SteerDelay", self._steer_actuator_delay))
self._steer_friction_control = FrogPilotParamValueButtonControl(
"SteerFriction",
"Friction",
"<b>Compensates for steering friction.</b> Increase if the wheel sticks near center; decrease if it jitters. Auto-learned by default.",
"",
min_value=0.0,
max_value=1.0,
interval=0.01,
button_texts=["Reset"],
)
self._steer_friction_control.set_button_click_callback(lambda _: self._reset_param("SteerFriction", self._friction))
self._steer_kp_control = FrogPilotParamValueButtonControl(
"SteerKP",
"Kp Factor",
"<b>How strongly openpilot corrects lane position.</b> Higher is tighter but twitchier; lower is smoother but slower. Auto-learned by default.",
"",
min_value=0.5,
max_value=1.5,
interval=0.01,
button_texts=["Reset"],
)
self._steer_kp_control.set_button_click_callback(lambda _: self._reset_param("SteerKP", self._steer_kp))
self._steer_lat_accel_control = FrogPilotParamValueButtonControl(
"SteerLatAccel",
"Lateral Acceleration",
"<b>Maps steering torque to turning response.</b> Increase for sharper turns; decrease for gentler steering. Auto-learned by default.",
"",
min_value=0.5,
max_value=1.5,
interval=0.01,
button_texts=["Reset"],
)
self._steer_lat_accel_control.set_button_click_callback(lambda _: self._reset_param("SteerLatAccel", self._lat_accel_factor))
self._steer_ratio_control = FrogPilotParamValueButtonControl(
"SteerRatio",
"Steer Ratio",
"<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.",
"",
min_value=5.0,
max_value=25.0,
interval=0.01,
button_texts=["Reset"],
)
self._steer_ratio_control.set_button_click_callback(lambda _: self._reset_param("SteerRatio", self._steer_ratio))
self._force_auto_tune_item = ListItem(
title="Force Auto-Tune On",
description="<b>Force-enable openpilot's live auto-tuning for \"Friction\" and \"Lateral Acceleration\".</b>",
action_item=ToggleAction(
initial_state=self._params.get_bool("ForceAutoTune"),
callback=lambda state: self._on_toggle("ForceAutoTune", state),
),
)
self._force_auto_tune_off_item = ListItem(
title="Force Auto-Tune Off",
description="<b>Force-disable openpilot's live auto-tuning for \"Friction\" and \"Lateral Acceleration\" and use the set value instead.</b>",
action_item=ToggleAction(
initial_state=self._params.get_bool("ForceAutoTuneOff"),
callback=lambda state: self._on_toggle("ForceAutoTuneOff", state),
),
)
self._force_torque_controller_item = ListItem(
title="Force Torque Controller",
description="<b>Use torque-based steering control instead of angle-based control for smoother lane keeping, especially in curves.</b>",
action_item=ToggleAction(
initial_state=self._params.get_bool("ForceTorqueController"),
callback=lambda state: self._on_reboot_toggle("ForceTorqueController", state),
),
)
advanced_items = [
self._steer_delay_control,
self._steer_friction_control,
self._steer_kp_control,
self._steer_lat_accel_control,
self._steer_ratio_control,
self._force_auto_tune_item,
self._force_auto_tune_off_item,
self._force_torque_controller_item,
]
self._toggles["SteerDelay"] = self._steer_delay_control
self._toggles["SteerFriction"] = self._steer_friction_control
self._toggles["SteerKP"] = self._steer_kp_control
self._toggles["SteerLatAccel"] = self._steer_lat_accel_control
self._toggles["SteerRatio"] = self._steer_ratio_control
self._toggles["ForceAutoTune"] = self._force_auto_tune_item
self._toggles["ForceAutoTuneOff"] = self._force_auto_tune_off_item
self._toggles["ForceTorqueController"] = self._force_torque_controller_item
self._advanced_lateral_tune_scroller = Scroller(advanced_items, line_separator=True, spacing=0)
def _build_aol_panel(self):
self._aol_lkas_item = ListItem(
title="Enable With LKAS",
description="<b>Enable \"Always On Lateral\" whenever \"LKAS\" is on, even when openpilot is not engaged.</b>",
action_item=ToggleAction(
initial_state=self._params.get_bool("AlwaysOnLateralLKAS"),
callback=lambda state: self._on_toggle("AlwaysOnLateralLKAS", state),
),
)
self._pause_aol_on_brake_control = FrogPilotParamValueControl(
"PauseAOLOnBrake",
"Pause on Brake Press Below",
"<b>Pause \"Always On Lateral\" below the set speed while the brake pedal is pressed.</b>",
"",
min_value=0,
max_value=99,
fast_increase=True,
)
aol_items = [
self._aol_lkas_item,
self._pause_aol_on_brake_control,
]
self._toggles["AlwaysOnLateralLKAS"] = self._aol_lkas_item
self._toggles["PauseAOLOnBrake"] = self._pause_aol_on_brake_control
self._aol_scroller = Scroller(aol_items, line_separator=True, spacing=0)
def _build_lane_change_panel(self):
self._nudgeless_lane_change_item = ListItem(
title="Automatic Lane Changes",
description="<b>When the turn signal is on, openpilot will automatically change lanes.</b> No steering-wheel nudge required!",
action_item=ToggleAction(
initial_state=self._params.get_bool("NudgelessLaneChange"),
callback=lambda state: self._on_toggle("NudgelessLaneChange", state),
),
)
lane_change_time_labels = build_lane_change_time_labels()
self._lane_change_time_control = FrogPilotParamValueControl(
"LaneChangeTime",
"Lane Change Delay",
"<b>Delay between turn signal activation and the start of an automatic lane change.</b>",
"",
min_value=0,
max_value=5,
value_labels=lane_change_time_labels,
interval=0.1,
)
self._minimum_lane_change_speed_control = FrogPilotParamValueControl(
"MinimumLaneChangeSpeed",
"Minimum Lane Change Speed",
"<b>Lowest speed at which openpilot will change lanes.</b>",
"",
min_value=0,
max_value=99,
fast_increase=True,
)
self._lane_detection_width_control = FrogPilotParamValueControl(
"LaneDetectionWidth",
"Minimum Lane Width",
"<b>Prevent automatic lane changes into lanes narrower than the set width.</b>",
"",
min_value=0,
max_value=15,
interval=0.1,
fast_increase=True,
)
self._one_lane_change_item = ListItem(
title="One Lane Change Per Signal",
description="<b>Limit automatic lane changes to one per turn-signal activation.</b>",
action_item=ToggleAction(
initial_state=self._params.get_bool("OneLaneChange"),
callback=lambda state: self._on_toggle("OneLaneChange", state),
),
)
lane_change_items = [
self._nudgeless_lane_change_item,
self._lane_change_time_control,
self._minimum_lane_change_speed_control,
self._lane_detection_width_control,
self._one_lane_change_item,
]
self._toggles["NudgelessLaneChange"] = self._nudgeless_lane_change_item
self._toggles["LaneChangeTime"] = self._lane_change_time_control
self._toggles["MinimumLaneChangeSpeed"] = self._minimum_lane_change_speed_control
self._toggles["LaneDetectionWidth"] = self._lane_detection_width_control
self._toggles["OneLaneChange"] = self._one_lane_change_item
self._lane_change_scroller = Scroller(lane_change_items, line_separator=True, spacing=0)
def _build_lateral_tune_panel(self):
self._turn_desires_item = ListItem(
title="Force Turn Desires Below Lane Change Speed",
description="<b>While driving below the minimum lane change speed with an active turn signal, instruct openpilot to turn left/right.</b>",
action_item=ToggleAction(
initial_state=self._params.get_bool("TurnDesires"),
callback=lambda state: self._on_toggle("TurnDesires", state),
),
)
self._nnff_item = ListItem(
title="Neural Network Feedforward (NNFF)",
description="<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.",
action_item=ToggleAction(
initial_state=self._params.get_bool("NNFF"),
callback=lambda state: self._on_reboot_toggle("NNFF", state),
),
)
self._nnff_lite_item = ListItem(
title="Neural Network Feedforward (NNFF) Lite",
description="<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.",
action_item=ToggleAction(
initial_state=self._params.get_bool("NNFFLite"),
callback=lambda state: self._on_reboot_toggle("NNFFLite", state),
),
)
lateral_tune_items = [
self._turn_desires_item,
self._nnff_item,
self._nnff_lite_item,
]
self._toggles["TurnDesires"] = self._turn_desires_item
self._toggles["NNFF"] = self._nnff_item
self._toggles["NNFFLite"] = self._nnff_lite_item
self._lateral_tune_scroller = Scroller(lateral_tune_items, line_separator=True, spacing=0)
def _build_qol_panel(self):
self._pause_lateral_speed_control = FrogPilotParamValueButtonControl(
"PauseLateralSpeed",
"Pause Steering Below",
"<b>Pause steering below the set speed.</b>",
"",
min_value=0,
max_value=99,
fast_increase=True,
button_params=["PauseLateralOnSignal"],
button_texts=["Turn Signal Only"],
)
qol_items = [
self._pause_lateral_speed_control,
]
self._toggles["PauseLateralSpeed"] = self._pause_lateral_speed_control
self._qol_scroller = Scroller(qol_items, line_separator=True, spacing=0)
def _on_toggle(self, param: str, state: bool):
self._params.put_bool(param, state)
update_frogpilot_toggles()
self._update_toggles()
def _on_reboot_toggle(self, param: str, state: bool):
self._params.put_bool(param, state)
update_frogpilot_toggles()
self._update_toggles()
if self._started:
gui_app.set_modal_overlay(ConfirmDialog(
"Reboot required to take effect.",
"Reboot Now",
"Reboot Later",
))
def _reset_param(self, param: str, default_value: float):
def on_confirm():
self._params.put_float(param, default_value)
if param == "SteerDelay":
self._steer_delay_control.refresh()
elif param == "SteerFriction":
self._steer_friction_control.refresh()
elif param == "SteerKP":
self._steer_kp_control.refresh()
elif param == "SteerLatAccel":
self._steer_lat_accel_control.refresh()
elif param == "SteerRatio":
self._steer_ratio_control.refresh()
gui_app.set_modal_overlay(ConfirmDialog(
f"Reset to its default value ({default_value:.2f})?",
"Reset",
"Cancel",
))
def _open_advanced_lateral_tune(self):
self._current_panel = SubPanel.ADVANCED_LATERAL_TUNE
def _open_aol_panel(self):
self._current_panel = SubPanel.AOL
def _open_lane_change_panel(self):
self._current_panel = SubPanel.LANE_CHANGE
def _open_lateral_tune_panel(self):
self._current_panel = SubPanel.LATERAL_TUNE
def _open_qol_panel(self):
self._current_panel = SubPanel.QOL
def _close_sub_panel(self):
self._current_panel = SubPanel.MAIN
def _update_car_params(self):
try:
from cereal import car, messaging
car_params_bytes = self._params.get("CarParamsPersistent")
if car_params_bytes:
CP = messaging.log_from_bytes(car_params_bytes, car.CarParams)
self._car_model = CP.carFingerprint
self._friction = CP.lateralTuning.torque.friction
self._has_nnff_log = nnff_supported(self._car_model)
self._is_angle_car = CP.steerControlType == car.CarParams.SteerControlType.angle
self._is_torque_car = CP.lateralTuning.which() == "torque"
self._lat_accel_factor = CP.lateralTuning.torque.latAccelFactor
self._steer_actuator_delay = CP.steerActuatorDelay
self._steer_ratio = CP.steerRatio
self._update_steering_control_titles()
self._update_steering_control_ranges()
except Exception:
pass
try:
from cereal import log
ltp_bytes = self._params.get("LiveTorqueParameters")
if ltp_bytes:
from cereal import messaging
LTP = messaging.log_from_bytes(ltp_bytes, log.LiveTorqueParametersData)
self._has_auto_tune = LTP.useParams
except Exception:
pass
def _update_steering_control_titles(self):
if self._steer_actuator_delay != 0:
self._steer_delay_control.set_title(f"Actuator Delay (Default: {self._steer_actuator_delay:.2f})")
if self._friction != 0:
self._steer_friction_control.set_title(f"Friction (Default: {self._friction:.2f})")
if self._steer_kp != 0:
self._steer_kp_control.set_title(f"Kp Factor (Default: {self._steer_kp:.2f})")
if self._lat_accel_factor != 0:
self._steer_lat_accel_control.set_title(f"Lateral Acceleration (Default: {self._lat_accel_factor:.2f})")
if self._steer_ratio != 0:
self._steer_ratio_control.set_title(f"Steer Ratio (Default: {self._steer_ratio:.2f})")
def _update_steering_control_ranges(self):
if self._steer_kp > 0:
self._steer_kp_control.update_control(self._steer_kp * 0.5, self._steer_kp * 1.5)
if self._lat_accel_factor > 0:
self._steer_lat_accel_control.update_control(self._lat_accel_factor * 0.5, self._lat_accel_factor * 1.5)
if self._steer_ratio > 0:
self._steer_ratio_control.update_control(self._steer_ratio * 0.5, self._steer_ratio * 1.5)
def _update_metric(self):
if self._is_metric:
speed_labels = build_metric_speed_labels()
distance_labels = build_metric_distance_labels()
max_speed = 150
max_distance = 5.0
else:
speed_labels = build_imperial_speed_labels()
distance_labels = build_imperial_distance_labels()
max_speed = 99
max_distance = 15.0
self._minimum_lane_change_speed_control.update_control(0, max_speed, speed_labels)
self._pause_aol_on_brake_control.update_control(0, max_speed, speed_labels)
self._pause_lateral_speed_control.update_control(0, max_speed, speed_labels)
self._lane_detection_width_control.update_control(0, max_distance, distance_labels)
def _update_toggles(self):
self._tuning_level = self._params.get_int("TuningLevel") or 0
forcing_auto_tune = not self._has_auto_tune and self._params.get_bool("ForceAutoTune")
forcing_auto_tune_off = self._has_auto_tune and self._params.get_bool("ForceAutoTuneOff")
forcing_torque_controller = not self._is_angle_car and self._params.get_bool("ForceTorqueController")
using_nnff = self._has_nnff_log and self._params.get_bool("LateralTune") and self._params.get_bool("NNFF")
nudgeless_enabled = self._params.get_bool("LaneChanges") and self._params.get_bool("NudgelessLaneChange")
if hasattr(self._aol_lkas_item, 'set_visible'):
self._aol_lkas_item.set_visible(self._lkas_allowed_for_aol)
if hasattr(self._force_auto_tune_item, 'set_visible'):
visible = not self._has_auto_tune and not self._is_angle_car
visible = visible and (self._is_torque_car or forcing_torque_controller or using_nnff)
self._force_auto_tune_item.set_visible(visible)
if hasattr(self._force_auto_tune_off_item, 'set_visible'):
self._force_auto_tune_off_item.set_visible(self._has_auto_tune)
if hasattr(self._force_torque_controller_item, 'set_visible'):
visible = not self._is_angle_car and not self._is_torque_car
self._force_torque_controller_item.set_visible(visible)
if hasattr(self._lane_change_time_control, 'set_visible'):
self._lane_change_time_control.set_visible(nudgeless_enabled)
if hasattr(self._lane_detection_width_control, 'set_visible'):
self._lane_detection_width_control.set_visible(nudgeless_enabled)
if hasattr(self._nnff_item, 'set_visible'):
visible = self._has_nnff_log and not self._is_angle_car
self._nnff_item.set_visible(visible)
if hasattr(self._nnff_lite_item, 'set_visible'):
visible = not using_nnff and not self._is_angle_car
self._nnff_lite_item.set_visible(visible)
if hasattr(self._steer_delay_control, 'set_visible'):
self._steer_delay_control.set_visible(self._steer_actuator_delay != 0)
if hasattr(self._steer_friction_control, 'set_visible'):
visible = self._friction != 0
visible = visible and (self._has_auto_tune if forcing_auto_tune_off else not forcing_auto_tune)
visible = visible and (self._is_torque_car or forcing_torque_controller or using_nnff)
visible = visible and not using_nnff
self._steer_friction_control.set_visible(visible)
if hasattr(self._steer_kp_control, 'set_visible'):
visible = self._steer_kp != 0
visible = visible and (self._is_torque_car or forcing_torque_controller or using_nnff)
visible = visible and not self._is_angle_car
self._steer_kp_control.set_visible(visible)
if hasattr(self._steer_lat_accel_control, 'set_visible'):
visible = self._lat_accel_factor != 0
visible = visible and (self._has_auto_tune if forcing_auto_tune_off else not forcing_auto_tune)
visible = visible and (self._is_torque_car or forcing_torque_controller or using_nnff)
visible = visible and not using_nnff
self._steer_lat_accel_control.set_visible(visible)
if hasattr(self._steer_ratio_control, 'set_visible'):
visible = self._steer_ratio != 0
visible = visible and (self._has_auto_tune if forcing_auto_tune_off else not forcing_auto_tune)
self._steer_ratio_control.set_visible(visible)
def show_event(self):
super().show_event()
self._main_scroller.show_event()
self._is_metric = self._params.get_bool("IsMetric")
self._update_car_params()
self._update_metric()
self._update_toggles()
def hide_event(self):
super().hide_event()
self._current_panel = SubPanel.MAIN
def _render(self, rect):
if self._current_panel == SubPanel.ADVANCED_LATERAL_TUNE:
self._advanced_lateral_tune_scroller.render(rect)
elif self._current_panel == SubPanel.AOL:
self._aol_scroller.render(rect)
elif self._current_panel == SubPanel.LANE_CHANGE:
self._lane_change_scroller.render(rect)
elif self._current_panel == SubPanel.LATERAL_TUNE:
self._lateral_tune_scroller.render(rect)
elif self._current_panel == SubPanel.QOL:
self._qol_scroller.render(rect)
else:
self._main_scroller.render(rect)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,480 @@
import shutil
import threading
import time
from datetime import datetime
from enum import IntEnum
from pathlib import Path
from openpilot.common.params import Params
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
from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog, alert_dialog, DialogResult
from openpilot.system.ui.widgets.list_view import ListItem, ToggleAction, ButtonAction, TextAction, ITEM_TEXT_VALUE_COLOR
from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog
from openpilot.system.ui.widgets.scroller_tici import Scroller
from openpilot.frogpilot.common.frogpilot_variables import update_frogpilot_toggles
from openpilot.frogpilot.system.ui.widgets.frogpilot_controls import (
FrogPilotButtonsControl,
)
MAPS_FOLDER_PATH = Path("/data/media/0/osm/offline")
# US State maps by region
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"
}
# World country maps by continent
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"
}
SCHEDULE_OPTIONS = ["Manually", "Weekly", "Monthly"]
class SubPanel(IntEnum):
MAIN = 0
COUNTRIES = 1
STATES = 2
def calculate_directory_size(directory: Path) -> str:
"""Calculate directory size and return formatted string."""
MB = 1024.0 * 1024.0
GB = 1024.0 * MB
if not directory.exists():
return "0 MB"
total_size = 0
for file in directory.rglob("*"):
if file.is_file():
total_size += file.stat().st_size
if total_size >= GB:
return f"{total_size / GB:.2f} GB"
return f"{total_size / MB:.2f} MB"
def day_suffix(day: int) -> str:
"""Get ordinal suffix for day."""
if day % 10 == 1 and day != 11:
return "st"
if day % 10 == 2 and day != 12:
return "nd"
if day % 10 == 3 and day != 13:
return "rd"
return "th"
def format_current_date() -> str:
"""Format current date as 'Month Day(suffix), Year'."""
now = datetime.now()
return now.strftime(f"%B {now.day}{day_suffix(now.day)}, %Y")
def format_elapsed_time(elapsed_ms: float) -> str:
"""Format elapsed time in milliseconds to readable string."""
total_seconds = int(elapsed_ms / 1000)
hours = total_seconds // 3600
minutes = (total_seconds % 3600) // 60
seconds = total_seconds % 60
parts = []
if hours > 0:
parts.append(f"{hours} {'hour' if hours == 1 else 'hours'}")
if minutes > 0:
parts.append(f"{minutes} {'minute' if minutes == 1 else 'minutes'}")
parts.append(f"{seconds} {'second' if seconds == 1 else 'seconds'}")
return " ".join(parts)
class FrogPilotMapsPanel(Widget):
def __init__(self):
super().__init__()
self._current_panel = SubPanel.MAIN
self._params = Params()
self._params_memory = Params("", True)
self._toggles = {}
# State tracking
self._cancelling_download = False
self._has_maps_selected = False
self._online = False
self._parked = True
self._started = False
# Download tracking
self._download_start_time = None
self._elapsed_time_ms = 0
# Pending dialog action tracking
self._pending_action = None
self._pending_data = {}
self._build_panel()
ui_state.add_offroad_transition_callback(self._update_toggles)
def _build_panel(self):
# Preferred Schedule - ButtonControl for schedule options
self._preferred_schedule_control = FrogPilotButtonsControl(
"Automatically Update Maps",
"<b>How often maps update</b> from \"OpenStreetMap (OSM)\" with the latest speed limit information. Weekly updates run every Sunday; monthly updates run on the 1st.",
"",
button_texts=SCHEDULE_OPTIONS,
)
self._preferred_schedule_control.set_click_callback(self._on_preferred_schedule_click)
self._update_schedule_button()
# Download Maps Button
self._download_maps_control = FrogPilotButtonsControl(
"Download Maps",
"<b>Manually update your selected map sources</b> so \"Speed Limit Controller\" has the latest speed limit information.",
"",
button_texts=["DOWNLOAD"],
)
self._download_maps_control.set_click_callback(self._on_download_maps_click)
# Last Updated Label
last_update = self._params.get("LastMapsUpdate", encoding="utf-8") or "Never"
self._last_updated_item = ListItem(
title="Last Updated",
action_item=TextAction(lambda: self._params.get("LastMapsUpdate", encoding="utf-8") or "Never", color=ITEM_TEXT_VALUE_COLOR),
)
# Select Maps - Countries/States
self._select_maps_control = FrogPilotButtonsControl(
"Map Sources",
"<b>Select the countries or U.S. states to use with \"Speed Limit Controller\".</b>",
"",
button_texts=["COUNTRIES", "STATES"],
)
self._select_maps_control.set_click_callback(self._on_select_maps_click)
# Progress labels
self._download_status_item = ListItem(
title="Progress",
action_item=TextAction(lambda: self._get_download_status(), color=ITEM_TEXT_VALUE_COLOR),
)
self._download_time_elapsed_item = ListItem(
title="Time Elapsed",
action_item=TextAction(lambda: self._get_time_elapsed(), color=ITEM_TEXT_VALUE_COLOR),
)
self._download_eta_item = ListItem(
title="Time Remaining",
action_item=TextAction(lambda: self._get_download_eta(), color=ITEM_TEXT_VALUE_COLOR),
)
# Remove Maps Button
self._remove_maps_control = FrogPilotButtonsControl(
"Remove Maps",
"<b>Delete downloaded map data</b> to free up storage space.",
"",
button_texts=["REMOVE"],
)
self._remove_maps_control.set_click_callback(self._on_remove_maps_click)
# Storage Used Label
self._maps_size_item = ListItem(
title="Storage Used",
action_item=TextAction(lambda: calculate_directory_size(MAPS_FOLDER_PATH), color=ITEM_TEXT_VALUE_COLOR),
)
main_items = [
self._preferred_schedule_control,
self._download_maps_control,
self._last_updated_item,
self._select_maps_control,
self._download_status_item,
self._download_time_elapsed_item,
self._download_eta_item,
self._remove_maps_control,
self._maps_size_item,
]
# Initially hide download progress items
if hasattr(self._download_status_item, "set_visible"):
self._download_status_item.set_visible(False)
self._download_time_elapsed_item.set_visible(False)
self._download_eta_item.set_visible(False)
self._toggles["PreferredSchedule"] = self._preferred_schedule_control
self._toggles["DownloadMaps"] = self._download_maps_control
self._toggles["LastUpdated"] = self._last_updated_item
self._toggles["SelectMaps"] = self._select_maps_control
self._toggles["DownloadStatus"] = self._download_status_item
self._toggles["DownloadTimeElapsed"] = self._download_time_elapsed_item
self._toggles["DownloadETA"] = self._download_eta_item
self._toggles["RemoveMaps"] = self._remove_maps_control
self._toggles["MapsSize"] = self._maps_size_item
self._main_scroller = Scroller(main_items, line_separator=True, spacing=0)
def _get_download_status(self) -> str:
"""Get current download status."""
return "Calculating..."
def _get_time_elapsed(self) -> str:
"""Get formatted elapsed time."""
if self._elapsed_time_ms > 0:
return format_elapsed_time(self._elapsed_time_ms)
return "Calculating..."
def _get_download_eta(self) -> str:
"""Get estimated time remaining."""
return "Calculating..."
def _update_schedule_button(self):
"""Update schedule button to show current selection."""
schedule_index = self._params.get_int("PreferredSchedule") or 0
self._preferred_schedule_control.set_checked_button(schedule_index)
def _on_preferred_schedule_click(self, button_id: int):
self._params.put_int("PreferredSchedule", button_id)
update_frogpilot_toggles()
def _on_download_maps_click(self, button_id: int):
# Check if we're cancelling
if self._params_memory.get_bool("DownloadMaps"):
self._pending_action = "cancel_download"
gui_app.set_modal_overlay(ConfirmDialog(
"Cancel the download?",
"Yes",
"No",
))
else:
self._start_download()
def _start_download(self):
"""Start the map download."""
self._download_start_time = datetime.now()
self._elapsed_time_ms = 0
# Show progress items
if hasattr(self._download_status_item, "set_visible"):
self._download_status_item.set_visible(True)
self._download_time_elapsed_item.set_visible(True)
self._download_eta_item.set_visible(True)
# Hide last updated and remove button
if hasattr(self._last_updated_item, "set_visible"):
self._last_updated_item.set_visible(False)
if hasattr(self._remove_maps_control, "set_visible"):
self._remove_maps_control.set_visible(False)
# Change button text to CANCEL
self._download_maps_control.set_text(0, "CANCEL")
# Trigger download
self._params_memory.put_bool("DownloadMaps", True)
def _cancel_download(self):
"""Cancel the current download."""
self._cancelling_download = True
self._download_maps_control.set_enabled(False)
self._params_memory.put_bool("CancelDownloadMaps", True)
self._params_memory.remove("DownloadMaps")
def reset():
self._cancelling_download = False
self._download_maps_control.set_enabled(True)
self._download_maps_control.set_text(0, "DOWNLOAD")
if hasattr(self._download_status_item, "set_visible"):
self._download_status_item.set_visible(False)
self._download_time_elapsed_item.set_visible(False)
self._download_eta_item.set_visible(False)
if hasattr(self._last_updated_item, "set_visible"):
self._last_updated_item.set_visible(True)
if hasattr(self._remove_maps_control, "set_visible"):
self._remove_maps_control.set_visible(MAPS_FOLDER_PATH.exists())
threading.Timer(2.5, reset).start()
def _on_select_maps_click(self, button_id: int):
if button_id == 0:
self._current_panel = SubPanel.COUNTRIES
else:
self._current_panel = SubPanel.STATES
def _on_remove_maps_click(self, button_id: int):
self._pending_action = "remove_maps"
gui_app.set_modal_overlay(ConfirmDialog(
"Delete all downloaded maps?",
"Delete",
"Cancel",
))
def handle_dialog_result(self, result: DialogResult, selection: str = ""):
"""Handle dialog results for pending actions."""
action = self._pending_action
self._pending_action = None
if action == "cancel_download":
if result == DialogResult.CONFIRM:
self._cancel_download()
elif action == "remove_maps":
if result == DialogResult.CONFIRM:
def remove_thread():
if MAPS_FOLDER_PATH.exists():
shutil.rmtree(MAPS_FOLDER_PATH, ignore_errors=True)
threading.Thread(target=remove_thread, daemon=True).start()
def _update_toggles(self):
self._has_maps_selected = bool(self._params.get("MapsSelected", encoding="utf-8"))
# Remove maps button only visible if maps folder exists
if hasattr(self._remove_maps_control, "set_visible"):
self._remove_maps_control.set_visible(MAPS_FOLDER_PATH.exists())
def _close_sub_panel(self):
self._current_panel = SubPanel.MAIN
self._has_maps_selected = bool(self._params.get("MapsSelected", encoding="utf-8"))
def show_event(self):
super().show_event()
self._main_scroller.show_event()
self._update_toggles()
self._update_schedule_button()
self._started = ui_state.started
def hide_event(self):
super().hide_event()
self._current_panel = SubPanel.MAIN
def _render(self, rect):
self._started = ui_state.started
self._parked = not self._started
# Update download button enabled state
download_active = self._params_memory.get_bool("DownloadMaps")
self._download_maps_control.set_enabled(
not self._cancelling_download and self._has_maps_selected and self._online and self._parked
)
if self._current_panel == SubPanel.COUNTRIES:
# Would render countries selection panel
self._main_scroller.render(rect)
elif self._current_panel == SubPanel.STATES:
# Would render states selection panel
self._main_scroller.render(rect)
else:
self._main_scroller.render(rect)
@@ -0,0 +1,770 @@
import json
import threading
from enum import IntEnum
from pathlib import Path
from openpilot.common.params import Params
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.widgets import Widget, DialogResult
from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog, alert_dialog
from openpilot.system.ui.widgets.list_view import ListItem, ToggleAction, TextAction, ITEM_TEXT_VALUE_COLOR
from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog
from openpilot.system.ui.widgets.scroller_tici import Scroller
from openpilot.frogpilot.common.frogpilot_utilities import clean_model_name
from openpilot.frogpilot.common.frogpilot_variables import update_frogpilot_toggles
from openpilot.frogpilot.system.ui.widgets.frogpilot_controls import (
FrogPilotButtonsControl,
FrogPilotConfirmationDialog,
)
MODEL_DIR = Path("/data/models/")
TINYGRAD_SUFFIXES = [
"_driving_policy_metadata.pkl",
"_driving_policy_tinygrad.pkl",
"_driving_vision_metadata.pkl",
"_driving_vision_tinygrad.pkl",
]
class SubPanel(IntEnum):
MAIN = 0
MODEL_LABELS = 1
def has_all_tinygrad_files(model_key: str) -> bool:
"""Check if a model has all required tinygrad files."""
for suffix in TINYGRAD_SUFFIXES:
if not (MODEL_DIR / f"{model_key}{suffix}").exists():
return False
return True
class FrogPilotModelPanel(Widget):
def __init__(self):
super().__init__()
self._current_panel = SubPanel.MAIN
self._params = Params()
self._params_memory = Params("", True)
self._toggles = {}
self._tuning_level = 0
# State tracking
self._all_models_downloaded = False
self._all_models_downloading = False
self._cancelling_download = False
self._current_model = ""
self._default_model = ""
self._finalizing_download = False
self._model_downloading = False
self._no_models_downloaded = False
self._online = False
self._parked = True
self._started = False
self._tinygrad_update = False
self._updating_tinygrad = False
# Model mappings
self._available_model_names: list[str] = []
self._model_file_to_name: dict[str, str] = {}
self._model_file_to_name_processed: dict[str, str] = {}
# Get default model
default_model_bytes = self._params.get_key_default_value("DrivingModel")
self._default_model = default_model_bytes.decode() if default_model_bytes else ""
self._build_main_panel()
self._build_model_labels_panel()
ui_state.add_offroad_transition_callback(self._update_toggles)
def _build_main_panel(self):
self._auto_download_item = ListItem(
title="Automatically Download New Models",
description="<b>Automatically download new driving models</b> as they become available.",
action_item=ToggleAction(
initial_state=self._params.get_bool("AutomaticallyDownloadModels"),
callback=lambda state: self._simple_toggle("AutomaticallyDownloadModels", state),
),
)
self._delete_model_control = FrogPilotButtonsControl(
"Delete Driving Models",
"<b>Delete downloaded driving models</b> to free up storage space.",
"",
button_texts=["DELETE", "DELETE ALL"],
)
self._delete_model_control.set_click_callback(self._on_delete_model_click)
self._download_model_control = FrogPilotButtonsControl(
"Download Driving Models",
"<b>Manually download driving models</b> to the device.",
"",
button_texts=["DOWNLOAD", "DOWNLOAD ALL"],
)
self._download_model_control.set_click_callback(self._on_download_model_click)
self._model_randomizer_item = ListItem(
title="Model Randomizer",
description="<b>Select a random driving model each drive</b> and use feedback prompts at the end of the drive to help find the model that best suits you!",
action_item=ToggleAction(
initial_state=self._params.get_bool("ModelRandomizer"),
callback=self._on_model_randomizer_toggle,
),
)
self._manage_blacklist_control = FrogPilotButtonsControl(
"Manage Model Blacklist",
"<b>Add or remove driving models from the \"Model Randomizer\" blacklist.</b>",
"",
button_texts=["ADD", "REMOVE", "REMOVE ALL"],
)
self._manage_blacklist_control.set_click_callback(self._on_manage_blacklist_click)
self._manage_scores_control = FrogPilotButtonsControl(
"Manage Model Ratings",
"<b>View or reset saved model ratings</b> used by the \"Model Randomizer\".",
"",
button_texts=["RESET", "VIEW"],
)
self._manage_scores_control.set_click_callback(self._on_manage_scores_click)
self._select_model_item = ListItem(
title="Select Driving Model",
description="<b>Choose which driving model openpilot uses.</b>",
action_item=TextAction(lambda: self._get_current_model_display(), color=ITEM_TEXT_VALUE_COLOR),
callback=self._on_select_model_click,
)
self._update_tinygrad_control = FrogPilotButtonsControl(
"Update Model Manager",
"<b>Update the \"Model Manager\"</b> to support the latest models.",
"",
button_texts=["UPDATE"],
)
self._update_tinygrad_control.set_click_callback(self._on_update_tinygrad_click)
main_items = [
self._auto_download_item,
self._delete_model_control,
self._download_model_control,
self._model_randomizer_item,
self._manage_blacklist_control,
self._manage_scores_control,
self._select_model_item,
self._update_tinygrad_control,
]
self._toggles["AutomaticallyDownloadModels"] = self._auto_download_item
self._toggles["DeleteModel"] = self._delete_model_control
self._toggles["DownloadModel"] = self._download_model_control
self._toggles["ModelRandomizer"] = self._model_randomizer_item
self._toggles["ManageBlacklistedModels"] = self._manage_blacklist_control
self._toggles["ManageScores"] = self._manage_scores_control
self._toggles["SelectModel"] = self._select_model_item
self._toggles["UpdateTinygrad"] = self._update_tinygrad_control
self._main_scroller = Scroller(main_items, line_separator=True, spacing=0)
def _build_model_labels_panel(self):
self._model_labels_items: list[ListItem] = []
self._model_labels_scroller = Scroller(self._model_labels_items, line_separator=True, spacing=0)
def _get_current_model_display(self) -> str:
"""Get the display string for the current model."""
display = self._current_model
model_key = clean_model_name(self._params.get("DrivingModel", encoding="utf-8") or "")
if model_key == self._default_model:
display += " (Default)"
return display
def _simple_toggle(self, param: str, state: bool):
self._params.put_bool(param, state)
update_frogpilot_toggles()
def _on_model_randomizer_toggle(self, state: bool):
self._params.put_bool("ModelRandomizer", state)
update_frogpilot_toggles()
self._update_toggles()
if state and not self._all_models_downloaded:
gui_app.set_modal_overlay(ConfirmDialog(
"The \"Model Randomizer\" works only with downloaded models. Download all models now?",
"Yes",
"No",
))
def _on_delete_model_click(self, button_id: int):
deletable_models = self._get_deletable_models()
if not deletable_models:
gui_app.set_modal_overlay(alert_dialog("No models available to delete."))
return
if button_id == 0:
# Delete single model
gui_app.set_modal_overlay(MultiOptionDialog(
"Select a driving model to delete",
deletable_models,
))
elif button_id == 1:
# Delete all models
gui_app.set_modal_overlay(ConfirmDialog(
"Are you sure you want to delete all of your downloaded driving models?",
"Delete",
"Cancel",
))
def _get_deletable_models(self) -> list[str]:
"""Get list of models that can be deleted (excludes current and default)."""
deletable = []
if not MODEL_DIR.exists():
return deletable
for file in MODEL_DIR.iterdir():
if not file.is_file():
continue
base = file.stem
for model_key in self._model_file_to_name_processed:
if base.startswith(model_key):
model_name = self._model_file_to_name_processed[model_key]
if model_name not in deletable:
deletable.append(model_name)
break
# Remove current model and default model from deletable list
current_clean = clean_model_name(self._current_model)
if current_clean in deletable:
deletable.remove(current_clean)
default_name = self._model_file_to_name_processed.get(clean_model_name(self._default_model), "")
if default_name in deletable:
deletable.remove(default_name)
deletable.sort()
return deletable
def _delete_model(self, model_name: str):
"""Delete a specific model's files."""
model_file = None
for key, name in self._model_file_to_name_processed.items():
if name == model_name:
model_file = key
break
if not model_file or not MODEL_DIR.exists():
return
for file in MODEL_DIR.iterdir():
if file.is_file() and file.stem.startswith(model_file):
file.unlink()
self._all_models_downloaded = False
self._update_deletable_state()
def _delete_all_models(self):
"""Delete all deletable models."""
deletable = self._get_deletable_models()
if not MODEL_DIR.exists():
return
for file in MODEL_DIR.iterdir():
if not file.is_file():
continue
base = file.stem
for model_key in self._model_file_to_name_processed:
model_name = self._model_file_to_name_processed[model_key]
if model_name in deletable and base.startswith(model_key):
file.unlink()
break
self._all_models_downloaded = False
self._no_models_downloaded = True
self._update_deletable_state()
def _update_deletable_state(self):
"""Update the enabled state of delete buttons."""
deletable = self._get_deletable_models()
self._no_models_downloaded = len(deletable) == 0
can_delete = not (self._all_models_downloading or self._model_downloading or self._no_models_downloaded)
self._delete_model_control.set_enabled(can_delete)
def _on_download_model_click(self, button_id: int):
if self._tinygrad_update:
gui_app.set_modal_overlay(ConfirmDialog(
"Tinygrad is out of date and must be updated before you can download new models. Update now?",
"Yes",
"No",
))
return
if button_id == 0:
# Download single model or cancel
if self._model_downloading:
self._params_memory.put_bool("CancelModelDownload", True)
self._cancelling_download = True
else:
downloadable = self._get_downloadable_models()
if not downloadable:
gui_app.set_modal_overlay(alert_dialog("All models are already downloaded."))
return
gui_app.set_modal_overlay(MultiOptionDialog(
"Select a driving model to download",
downloadable,
))
elif button_id == 1:
# Download all or cancel
if self._all_models_downloading:
self._params_memory.put_bool("CancelModelDownload", True)
self._cancelling_download = True
else:
self._params_memory.put_bool("DownloadAllModels", True)
self._params_memory.put("ModelDownloadProgress", "Downloading...")
self._download_model_control.set_text(1, "CANCEL")
self._download_model_control.set_visible_button(0, False)
self._all_models_downloading = True
def _get_downloadable_models(self) -> list[str]:
"""Get list of models that can be downloaded."""
downloadable = list(self._available_model_names)
for model_key in self._model_file_to_name:
model_name = self._model_file_to_name[model_key]
if has_all_tinygrad_files(model_key):
if model_name in downloadable:
downloadable.remove(model_name)
downloadable.sort()
return downloadable
def _start_model_download(self, model_name: str):
"""Start downloading a specific model."""
model_key = None
for key, name in self._model_file_to_name.items():
if name == model_name:
model_key = key
break
if model_key:
self._params_memory.put("ModelToDownload", model_key)
self._params_memory.put("ModelDownloadProgress", "Downloading...")
self._download_model_control.set_text(0, "CANCEL")
self._download_model_control.set_visible_button(1, False)
self._model_downloading = True
def _on_manage_blacklist_click(self, button_id: int):
blacklisted_str = self._params.get("BlacklistedModels", encoding="utf-8") or ""
blacklisted = [m for m in blacklisted_str.split(",") if m]
if button_id == 0:
# Add to blacklist
blacklistable = []
for model_key in self._model_file_to_name_processed:
if model_key not in blacklisted:
blacklistable.append(self._model_file_to_name_processed[model_key])
if len(blacklistable) <= 1:
remaining = blacklistable[0] if blacklistable else "None"
gui_app.set_modal_overlay(alert_dialog(
f"There are no more driving models to blacklist. The only available model is \"{remaining}\"!"
))
return
blacklistable.sort()
gui_app.set_modal_overlay(MultiOptionDialog(
"Select a driving model to add to the blacklist",
blacklistable,
))
elif button_id == 1:
# Remove from blacklist
whitelistable = []
for model_key in blacklisted:
model_name = self._model_file_to_name_processed.get(model_key, "")
if model_name:
whitelistable.append(model_name)
if not whitelistable:
gui_app.set_modal_overlay(alert_dialog("No models are currently blacklisted."))
return
whitelistable.sort()
gui_app.set_modal_overlay(MultiOptionDialog(
"Select a driving model to remove from the blacklist",
whitelistable,
))
elif button_id == 2:
# Remove all from blacklist
if not blacklisted:
gui_app.set_modal_overlay(alert_dialog("No models are currently blacklisted."))
return
gui_app.set_modal_overlay(ConfirmDialog(
"Are you sure you want to remove all of your blacklisted driving models?",
"Yes",
"No",
))
def _add_to_blacklist(self, model_name: str):
"""Add a model to the blacklist."""
model_key = None
for key, name in self._model_file_to_name_processed.items():
if name == model_name:
model_key = key
break
if model_key:
blacklisted_str = self._params.get("BlacklistedModels", encoding="utf-8") or ""
blacklisted = [m for m in blacklisted_str.split(",") if m]
if model_key not in blacklisted:
blacklisted.append(model_key)
self._params.put("BlacklistedModels", ",".join(blacklisted))
def _remove_from_blacklist(self, model_name: str):
"""Remove a model from the blacklist."""
model_key = None
for key, name in self._model_file_to_name_processed.items():
if name == model_name:
model_key = key
break
if model_key:
blacklisted_str = self._params.get("BlacklistedModels", encoding="utf-8") or ""
blacklisted = [m for m in blacklisted_str.split(",") if m]
if model_key in blacklisted:
blacklisted.remove(model_key)
self._params.put("BlacklistedModels", ",".join(blacklisted))
def _clear_blacklist(self):
"""Clear all models from blacklist."""
self._params.remove("BlacklistedModels")
def _on_manage_scores_click(self, button_id: int):
if button_id == 0:
# Reset scores
gui_app.set_modal_overlay(ConfirmDialog(
"Reset all model drives and ratings? This clears your drive history and collected feedback!",
"Yes",
"No",
))
elif button_id == 1:
# View scores
self._update_model_labels()
self._current_panel = SubPanel.MODEL_LABELS
def _reset_model_scores(self):
"""Reset all model drives and scores."""
self._params.remove("ModelDrivesAndScores")
def _update_model_labels(self):
"""Update the model labels panel with current ratings."""
self._model_labels_items.clear()
scores_str = self._params.get("ModelDrivesAndScores", encoding="utf-8") or "{}"
try:
model_drives_and_scores = json.loads(scores_str)
except json.JSONDecodeError:
model_drives_and_scores = {}
for model_name in sorted(self._available_model_names):
clean_name = clean_model_name(model_name)
model_data = model_drives_and_scores.get(clean_name, {})
drives = model_data.get("Drives", 0)
score = model_data.get("Score", 0)
if drives == 1:
drives_display = f"{drives} Drive"
elif drives > 0:
drives_display = f"{drives} Drives"
else:
drives_display = "N/A"
if drives > 0:
score_display = f"Score: {score}%"
else:
score_display = "N/A"
label_text = f"{score_display} ({drives_display})"
item = ListItem(
title=clean_name,
action_item=TextAction(label_text, color=ITEM_TEXT_VALUE_COLOR),
)
self._model_labels_items.append(item)
self._model_labels_scroller = Scroller(self._model_labels_items, line_separator=True, spacing=0)
def _on_select_model_click(self):
selectable = []
for model_key in self._model_file_to_name:
if model_key != clean_model_name(self._default_model) and has_all_tinygrad_files(model_key):
selectable.append(self._model_file_to_name[model_key])
selectable.sort()
# Add default model at the beginning
default_name = self._model_file_to_name.get(clean_model_name(self._default_model), "")
if default_name:
selectable.insert(0, f"{default_name} (Default)")
current_display = self._current_model
model_key = clean_model_name(self._params.get("DrivingModel", encoding="utf-8") or "")
if model_key == self._default_model:
current_display += " (Default)"
gui_app.set_modal_overlay(MultiOptionDialog(
"Select a Model",
selectable,
current_display,
))
def _select_model(self, model_name: str):
"""Select a driving model."""
model_name = model_name.replace(" (Default)", "")
self._current_model = model_name
model_key = None
for key, name in self._model_file_to_name.items():
if name == model_name:
model_key = key
break
if model_key:
self._params.put("DrivingModel", model_key)
update_frogpilot_toggles()
if self._started:
gui_app.set_modal_overlay(ConfirmDialog(
"Reboot required to take effect.",
"Reboot Now",
"Reboot Later",
))
self._update_deletable_state()
def _on_update_tinygrad_click(self, button_id: int):
if self._updating_tinygrad:
self._params_memory.put_bool("CancelModelDownload", True)
self._update_tinygrad_control.set_enabled(False)
self._cancelling_download = True
else:
gui_app.set_modal_overlay(ConfirmDialog(
"Updating Tinygrad will delete existing Tinygrad-based driving models and need to be re-downloaded. Proceed?",
"Yes",
"No",
))
def _start_tinygrad_update(self):
"""Start the tinygrad update process."""
self._params_memory.put_bool("UpdateTinygrad", True)
self._params_memory.put("ModelDownloadProgress", "Downloading...")
self._update_tinygrad_control.set_text(0, "CANCEL")
self._updating_tinygrad = True
def _translate_progress(self, progress: str) -> str:
"""Translate download progress messages."""
translations = {
"Downloading...": "Downloading...",
"Downloaded!": "Downloaded!",
"All models downloaded!": "All models downloaded!",
"Repository unavailable": "Repository unavailable",
}
if progress in translations:
return translations[progress]
progress_lower = progress.lower()
if "cancelled" in progress_lower:
return "Download cancelled..."
if "failed" in progress_lower:
return "Download failed..."
if "offline" in progress_lower:
return "GitHub and GitLab are offline..."
return progress
def _update_download_state(self):
"""Update UI based on download progress."""
if self._finalizing_download:
return
progress = self._params_memory.get("ModelDownloadProgress", encoding="utf-8") or ""
if self._all_models_downloading or self._model_downloading:
import re
download_failed = bool(re.search(r"cancelled|exists|failed|missing|offline", progress, re.IGNORECASE))
translated = self._translate_progress(progress)
if progress in ("All models downloaded!", "Downloaded!") or download_failed:
self._finalizing_download = True
def finalize():
self._all_models_downloading = False
self._cancelling_download = False
self._finalizing_download = False
self._model_downloading = False
self._no_models_downloaded = False
# Update all models downloaded state
downloadable = self._get_downloadable_models()
self._all_models_downloaded = len(downloadable) == 0
self._params_memory.remove("ModelDownloadProgress")
self._download_model_control.set_enabled(True)
self._download_model_control.set_text(0, "DOWNLOAD")
self._download_model_control.set_text(1, "DOWNLOAD ALL")
self._download_model_control.set_visible_button(0, True)
self._download_model_control.set_visible_button(1, True)
threading.Timer(2.5, finalize).start()
if self._updating_tinygrad:
import re
download_failed = bool(re.search(r"cancelled|exists|failed|missing|offline", progress, re.IGNORECASE))
translated = self._translate_progress(progress)
if progress == "Updated!" or download_failed:
self._finalizing_download = True
def finalize_tinygrad():
check_progress = self._params_memory.get("ModelDownloadProgress", encoding="utf-8") or ""
self._model_downloading = bool(check_progress)
if self._model_downloading:
self._download_model_control.set_text(1, "CANCEL")
self._download_model_control.set_visible_button(0, False)
else:
self._cancelling_download = False
self._tinygrad_update = self._params.get_bool("TinygradUpdateAvailable")
self._finalizing_download = False
self._updating_tinygrad = False
self._update_tinygrad_control.set_enabled(self._tinygrad_update)
self._update_tinygrad_control.set_text(0, "UPDATE")
threading.Timer(2.5, finalize_tinygrad).start()
def _update_button_states(self):
"""Update button enabled/visible states."""
can_delete = not (self._all_models_downloading or self._model_downloading or self._no_models_downloaded)
self._delete_model_control.set_enabled(can_delete)
# Download buttons
self._download_model_control.set_text(0, "CANCEL" if self._model_downloading else "DOWNLOAD")
self._download_model_control.set_text(1, "CANCEL" if self._all_models_downloading else "DOWNLOAD ALL")
can_download_single = (not self._all_models_downloaded and not self._all_models_downloading and
not self._cancelling_download and not self._finalizing_download and
not self._updating_tinygrad and self._online and self._parked)
can_download_all = (not self._all_models_downloaded and not self._model_downloading and
not self._cancelling_download and not self._finalizing_download and
not self._updating_tinygrad and self._online and self._parked)
self._download_model_control.set_enabled_buttons(0, can_download_single)
self._download_model_control.set_enabled_buttons(1, can_download_all)
self._download_model_control.set_visible_button(0, not self._all_models_downloading)
self._download_model_control.set_visible_button(1, not self._model_downloading)
# Tinygrad update button
can_update = (not self._model_downloading and not self._cancelling_download and
not self._finalizing_download and self._online and self._parked and self._tinygrad_update)
self._update_tinygrad_control.set_enabled(can_update)
def _update_toggles(self):
self._tuning_level = self._params.get_int("TuningLevel") or 0
model_randomizer = self._params.get_bool("ModelRandomizer")
# ManageBlacklistedModels and ManageScores only visible when ModelRandomizer enabled
if hasattr(self._manage_blacklist_control, "set_visible"):
self._manage_blacklist_control.set_visible(model_randomizer)
if hasattr(self._manage_scores_control, "set_visible"):
self._manage_scores_control.set_visible(model_randomizer)
# SelectModel only visible when ModelRandomizer disabled
if hasattr(self._select_model_item, "set_visible"):
self._select_model_item.set_visible(not model_randomizer)
def _load_model_data(self):
"""Load available models and current state."""
self._all_models_downloading = self._params_memory.get_bool("DownloadAllModels")
progress = self._params_memory.get("ModelDownloadProgress", encoding="utf-8") or ""
self._model_downloading = bool(progress)
self._tinygrad_update = self._params.get_bool("TinygradUpdateAvailable")
self._updating_tinygrad = self._params_memory.get_bool("UpdateTinygrad")
self._model_downloading = self._model_downloading and not self._updating_tinygrad
# Load available models
available_models_str = self._params.get("AvailableModels", encoding="utf-8") or ""
available_models = sorted([m for m in available_models_str.split(",") if m])
available_names_str = self._params.get("AvailableModelNames", encoding="utf-8") or ""
self._available_model_names = sorted([m for m in available_names_str.split(",") if m])
# Build mappings
self._model_file_to_name.clear()
self._model_file_to_name_processed.clear()
for i in range(min(len(available_models), len(self._available_model_names))):
model_key = available_models[i]
model_name = self._available_model_names[i]
self._model_file_to_name[model_key] = model_name
self._model_file_to_name_processed[model_key] = clean_model_name(model_name)
# Check downloadable models
downloadable = self._get_downloadable_models()
self._all_models_downloaded = len(downloadable) == 0
# Check deletable models
self._update_deletable_state()
# Get current model
model_key = clean_model_name(self._params.get("DrivingModel", encoding="utf-8") or "")
if not has_all_tinygrad_files(model_key):
model_key = self._default_model
self._current_model = self._model_file_to_name.get(model_key, "")
def _close_sub_panel(self):
self._current_panel = SubPanel.MAIN
def show_event(self):
super().show_event()
self._main_scroller.show_event()
self._load_model_data()
self._update_toggles()
self._started = ui_state.started
def hide_event(self):
super().hide_event()
self._current_panel = SubPanel.MAIN
def _render(self, rect):
# Update online/parked state
self._started = ui_state.started
self._parked = not self._started # Simplified - in real impl check frogpilot_scene.parked
# Update download state
self._update_download_state()
self._update_button_states()
if self._current_panel == SubPanel.MODEL_LABELS:
self._model_labels_scroller.render(rect)
else:
self._main_scroller.render(rect)
@@ -0,0 +1,371 @@
import json
import threading
import time
from datetime import date, datetime
from enum import IntEnum
from pathlib import Path
from openpilot.common.params import Params
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
from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog, alert_dialog, DialogResult
from openpilot.system.ui.widgets.keyboard import Keyboard
from openpilot.system.ui.widgets.list_view import ListItem, ToggleAction, ButtonAction, TextAction, ITEM_TEXT_VALUE_COLOR
from openpilot.system.ui.widgets.scroller_tici import Scroller
from openpilot.frogpilot.common.frogpilot_variables import update_frogpilot_toggles
from openpilot.frogpilot.system.ui.widgets.frogpilot_controls import (
FrogPilotButtonsControl,
FrogPilotButtonControl,
)
class SubPanel(IntEnum):
MAIN = 0
INSTRUCTIONS = 1
class FrogPilotNavigationPanel(Widget):
def __init__(self):
super().__init__()
self._current_panel = SubPanel.MAIN
self._params = Params()
self._params_memory = Params("", True)
self._toggles = {}
self._tuning_level = 0
# State tracking
self._mapbox_public_key_set = False
self._mapbox_secret_key_set = False
self._online = False
self._parked = True
self._started = False
self._updating_limits = False
# Pending dialog action tracking
self._pending_action = None # "add_public_key", "remove_public_key", "add_secret_key", "remove_secret_key", "cancel_update", "start_update"
self._pending_data = {}
# Keyboard for text input
self._keyboard = Keyboard()
self._build_panel()
ui_state.add_offroad_transition_callback(self._update_toggles)
def _build_panel(self):
# IP Label
self._ip_label_item = ListItem(
title="Manage Your Settings At",
action_item=TextAction(lambda: self._get_ip_address(), color=ITEM_TEXT_VALUE_COLOR),
)
# Public Mapbox Key Control
self._public_mapbox_control = FrogPilotButtonsControl(
"Public Mapbox Key",
"<b>Manage your Public Mapbox Key.</b>",
"",
button_texts=["ADD", "TEST"],
)
self._public_mapbox_control.set_click_callback(self._on_public_mapbox_click)
# Secret Mapbox Key Control
self._secret_mapbox_control = FrogPilotButtonsControl(
"Secret Mapbox Key",
"<b>Manage your Secret Mapbox Key.</b>",
"",
button_texts=["ADD", "TEST"],
)
self._secret_mapbox_control.set_click_callback(self._on_secret_mapbox_click)
# Setup Button
self._setup_button_item = ListItem(
title="Mapbox Setup Instructions",
description="<b>Instructions on how to set up Mapbox</b> for \"Primeless Navigation\".",
action_item=ButtonAction(
text="VIEW",
callback=self._on_setup_click,
),
)
# Speed Limit Filler Control
self._speed_limit_filler_control = FrogPilotButtonControl(
"SpeedLimitFiller",
"Speed Limit Filler",
"<b>Automatically collect missing or incorrect speed limits while you drive</b> using speeds limits sourced from your dashboard (if supported), "
"Mapbox, and \"Navigate on openpilot\".<br><br>"
"When you're parked and connected to Wi-Fi, FrogPilot will automatically processes this data into a file "
"to be used with the tool located at \"SpeedLimitFiller.frogpilot.com\".<br><br>"
"You can download this file from \"The Pond\" in the \"Download Speed Limits\" menu.<br><br>"
"Need a step-by-step guide? Visit <b>#speed-limit-filler</b> in the FrogPilot Discord!",
"",
button_texts=["CANCEL", "Manually Update Speed Limits"],
)
self._speed_limit_filler_control.set_button_click_callback(self._on_speed_limit_filler_click)
self._speed_limit_filler_control.set_visible_button(0, False)
main_items = [
self._ip_label_item,
self._public_mapbox_control,
self._secret_mapbox_control,
self._setup_button_item,
self._speed_limit_filler_control,
]
self._toggles["IPLabel"] = self._ip_label_item
self._toggles["PublicMapboxKey"] = self._public_mapbox_control
self._toggles["SecretMapboxKey"] = self._secret_mapbox_control
self._toggles["SetupButton"] = self._setup_button_item
self._toggles["SpeedLimitFiller"] = self._speed_limit_filler_control
self._main_scroller = Scroller(main_items, line_separator=True, spacing=0)
def _get_ip_address(self) -> str:
"""Get current IP address for settings management."""
# This would need to be wired up to the wifi module
return "Offline..."
def _update_buttons(self):
"""Update Mapbox key button states."""
public_key = self._params.get("MapboxPublicKey", encoding="utf-8") or ""
secret_key = self._params.get("MapboxSecretKey", encoding="utf-8") or ""
self._mapbox_public_key_set = public_key.startswith("pk")
self._mapbox_secret_key_set = secret_key.startswith("sk")
self._public_mapbox_control.set_text(0, "REMOVE" if self._mapbox_public_key_set else "ADD")
self._public_mapbox_control.set_visible_button(1, self._mapbox_public_key_set and self._online)
self._secret_mapbox_control.set_text(0, "REMOVE" if self._mapbox_secret_key_set else "ADD")
self._secret_mapbox_control.set_visible_button(1, self._mapbox_secret_key_set and self._online)
def _on_public_mapbox_click(self, button_id: int):
if button_id == 0:
# ADD or REMOVE
if self._mapbox_public_key_set:
self._pending_action = "remove_public_key"
gui_app.set_modal_overlay(ConfirmDialog(
"Remove your Public Mapbox Key?",
"Remove",
"Cancel",
))
else:
self._pending_action = "add_public_key"
self._keyboard.reset(min_text_size=80)
self._keyboard.set_title("Enter your Public Mapbox Key")
gui_app.set_modal_overlay(self._keyboard, callback=self._on_keyboard_result)
elif button_id == 1:
# TEST
self._test_public_key()
def _on_secret_mapbox_click(self, button_id: int):
if button_id == 0:
# ADD or REMOVE
if self._mapbox_secret_key_set:
self._pending_action = "remove_secret_key"
gui_app.set_modal_overlay(ConfirmDialog(
"Remove your Secret Mapbox Key?",
"Remove",
"Cancel",
))
else:
self._pending_action = "add_secret_key"
self._keyboard.reset(min_text_size=80)
self._keyboard.set_title("Enter your Secret Mapbox Key")
gui_app.set_modal_overlay(self._keyboard, callback=self._on_keyboard_result)
elif button_id == 1:
# TEST
self._test_secret_key()
def _test_public_key(self):
"""Test the public Mapbox key."""
self._public_mapbox_control.set_value("Testing...")
# In a real implementation, this would make an HTTP request
# For now, we'll just show a placeholder response
def test_thread():
time.sleep(1)
self._public_mapbox_control.set_value("")
# Would show result dialog here
threading.Thread(target=test_thread, daemon=True).start()
def _test_secret_key(self):
"""Test the secret Mapbox key."""
self._secret_mapbox_control.set_value("Testing...")
def test_thread():
time.sleep(1)
self._secret_mapbox_control.set_value("")
# Would show result dialog here
threading.Thread(target=test_thread, daemon=True).start()
def _on_setup_click(self):
self._current_panel = SubPanel.INSTRUCTIONS
def _on_speed_limit_filler_click(self, button_id: int):
if button_id == 0:
# CANCEL
self._pending_action = "cancel_update"
gui_app.set_modal_overlay(ConfirmDialog(
"Cancel the speed-limit update?",
"Yes",
"No",
))
elif button_id == 1:
# Manually Update Speed Limits
# Check request limits
overpass_requests_str = self._params.get("OverpassRequests", encoding="utf-8") or "{}"
try:
overpass_requests = json.loads(overpass_requests_str)
except json.JSONDecodeError:
overpass_requests = {}
total_requests = overpass_requests.get("total_requests", 0)
max_requests = overpass_requests.get("max_requests", 10000)
saved_day = overpass_requests.get("day", date.today().day)
current_day = date.today().day
if saved_day != current_day:
total_requests = 0
if total_requests >= max_requests:
now = datetime.now()
seconds_until_midnight = (24 * 3600) - (now.hour * 3600 + now.minute * 60 + now.second)
hours = seconds_until_midnight // 3600
minutes = (seconds_until_midnight % 3600) // 60
gui_app.set_modal_overlay(alert_dialog(
f"You've hit today's request limit.\n\nIt will reset in {hours} hours and {minutes} minutes."
))
self._speed_limit_filler_control.clear_checked_buttons()
return
self._speed_limit_filler_control.set_visible_button(0, True)
self._speed_limit_filler_control.set_visible_button(1, False)
self._pending_action = "start_update"
gui_app.set_modal_overlay(ConfirmDialog(
"This process takes a while. It's recommended to start when you're done driving and connected to stable Wi-Fi. Continue?",
"Continue",
"Cancel",
))
def _on_keyboard_result(self, result: DialogResult):
"""Callback for keyboard modal overlay."""
self.handle_dialog_result(result, self._keyboard.text)
def handle_dialog_result(self, result: DialogResult, selection: str = ""):
"""Handle dialog results for pending actions."""
action = self._pending_action
self._pending_action = None
if action == "add_public_key":
if result == DialogResult.CONFIRM and selection:
key = selection.strip()
if not key.startswith("pk."):
key = "pk." + key
self._params.put("MapboxPublicKey", key)
self._update_buttons()
elif action == "remove_public_key":
if result == DialogResult.CONFIRM:
self._params.remove("MapboxPublicKey")
self._update_buttons()
elif action == "add_secret_key":
if result == DialogResult.CONFIRM and selection:
key = selection.strip()
if not key.startswith("sk."):
key = "sk." + key
self._params.put("MapboxSecretKey", key)
self._update_buttons()
elif action == "remove_secret_key":
if result == DialogResult.CONFIRM:
self._params.remove("MapboxSecretKey")
self._update_buttons()
elif action == "cancel_update":
if result == DialogResult.CONFIRM:
self._updating_limits = False
self._speed_limit_filler_control.set_enabled_button(0, False)
self._speed_limit_filler_control.set_value("Cancelled...")
self._params_memory.remove("UpdateSpeedLimits")
def reset():
self._speed_limit_filler_control.clear_checked_buttons()
self._speed_limit_filler_control.set_enabled_button(0, True)
self._speed_limit_filler_control.set_value("")
self._speed_limit_filler_control.set_visible_button(0, False)
self._speed_limit_filler_control.set_visible_button(1, True)
self._params_memory.remove("UpdateSpeedLimitsStatus")
threading.Timer(2.5, reset).start()
elif action == "start_update":
if result == DialogResult.CONFIRM:
self._updating_limits = True
self._speed_limit_filler_control.set_value("Calculating...")
self._params_memory.put("UpdateSpeedLimitsStatus", "Calculating...")
self._params_memory.put_bool("UpdateSpeedLimits", True)
else:
self._speed_limit_filler_control.set_visible_button(0, False)
self._speed_limit_filler_control.set_visible_button(1, True)
self._speed_limit_filler_control.clear_checked_buttons()
def _update_toggles(self):
self._tuning_level = self._params.get_int("TuningLevel") or 0
self._update_buttons()
def _close_sub_panel(self):
self._current_panel = SubPanel.MAIN
def show_event(self):
super().show_event()
self._main_scroller.show_event()
self._update_toggles()
self._started = ui_state.started
def hide_event(self):
super().hide_event()
self._current_panel = SubPanel.MAIN
def _render(self, rect):
self._started = ui_state.started
self._parked = not self._started
# Update speed limit filler state
if self._updating_limits:
status = self._params_memory.get("UpdateSpeedLimitsStatus", encoding="utf-8") or ""
if status == "Completed!":
self._updating_limits = False
self._speed_limit_filler_control.set_value("Completed!")
def reset():
self._speed_limit_filler_control.clear_checked_buttons()
self._speed_limit_filler_control.set_value("")
self._speed_limit_filler_control.set_visible_button(0, False)
self._speed_limit_filler_control.set_visible_button(1, True)
self._params_memory.remove("UpdateSpeedLimitsStatus")
threading.Timer(2.5, reset).start()
else:
self._speed_limit_filler_control.set_value(status)
else:
self._speed_limit_filler_control.set_enabled_button(1, self._online and self._parked)
if not self._online:
self._speed_limit_filler_control.set_value("Offline...")
elif not self._parked:
self._speed_limit_filler_control.set_value("Not parked")
else:
self._speed_limit_filler_control.set_value("")
if self._current_panel == SubPanel.INSTRUCTIONS:
# Would render setup instructions image
self._main_scroller.render(rect)
else:
self._main_scroller.render(rect)
@@ -0,0 +1,458 @@
import re
import subprocess
import threading
from enum import IntEnum
from pathlib import Path
from openpilot.common.params import Params
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.list_view import ListItem, ToggleAction
from openpilot.system.ui.widgets.scroller_tici import Scroller
from openpilot.frogpilot.common.frogpilot_variables import ACTIVE_THEME_PATH, update_frogpilot_toggles
from openpilot.frogpilot.system.ui.widgets.frogpilot_controls import (
FrogPilotManageControl,
FrogPilotParamValueButtonControl,
)
STOCK_SOUNDS_PATH = Path("/data/openpilot/selfdrive/assets/sounds")
THEME_SOUNDS_PATH = ACTIVE_THEME_PATH / "sounds"
ALERT_VOLUME_CONTROL_KEYS = {
"DisengageVolume",
"EngageVolume",
"PromptDistractedVolume",
"PromptVolume",
"RefuseVolume",
"WarningImmediateVolume",
"WarningSoftVolume",
}
CUSTOM_ALERTS_KEYS = {
"GoatScream",
"GreenLightAlert",
"LeadDepartingAlert",
"LoudBlindspotAlert",
"SpeedLimitChangedAlert",
}
# Minimum volume for warning alerts (25%)
WARNING_MIN_VOLUME = 25
class SubPanel(IntEnum):
MAIN = 0
ALERT_VOLUME_CONTROL = 1
CUSTOM_ALERTS = 2
def build_volume_labels() -> dict[int, str]:
"""Build volume labels from 0-101 where 0=Muted, 101=Auto."""
labels = {}
for i in range(102):
if i == 0:
labels[i] = "Muted"
elif i == 101:
labels[i] = "Auto"
else:
labels[i] = f"{i}%"
return labels
def camel_to_snake(name: str) -> str:
"""Convert CamelCase to snake_case."""
return re.sub(r'([A-Z])', r'_\1', name).lower().lstrip('_')
class FrogPilotSoundsPanel(Widget):
def __init__(self):
super().__init__()
self._current_panel = SubPanel.MAIN
self._params = Params()
self._params_memory = Params("", True)
self._sound_player_process: subprocess.Popen | None = None
self._started = False
self._toggles = {}
self._tuning_level = 0
# Car capabilities (will be loaded from frogpilot_variables)
self._has_bsm = False
self._has_openpilot_longitudinal = False
self._build_main_panel()
self._build_alert_volume_panel()
self._build_custom_alerts_panel()
self._initialize_sound_player()
ui_state.add_offroad_transition_callback(self._update_toggles)
def _build_main_panel(self):
self._alert_volume_control = FrogPilotManageControl(
"AlertVolumeControl",
"Alert Volume Controller",
"<b>Set how loud each type of openpilot alert is</b> to keep routine prompts from becoming distracting.",
"../../frogpilot/assets/toggle_icons/icon_mute.png",
)
self._alert_volume_control.set_manage_callback(self._open_alert_volume_panel)
self._custom_alerts_control = FrogPilotManageControl(
"CustomAlerts",
"FrogPilot Alerts",
"<b>Optional FrogPilot alerts</b> that highlight driving events in a more noticeable way.",
"../../frogpilot/assets/toggle_icons/icon_green_light.png",
)
self._custom_alerts_control.set_manage_callback(self._open_custom_alerts_panel)
main_items = [
self._alert_volume_control,
self._custom_alerts_control,
]
self._toggles["AlertVolumeControl"] = self._alert_volume_control
self._toggles["CustomAlerts"] = self._custom_alerts_control
self._main_scroller = Scroller(main_items, line_separator=True, spacing=0)
def _build_alert_volume_panel(self):
volume_labels = build_volume_labels()
# Disengage Volume (0-101)
self._disengage_volume_control = FrogPilotParamValueButtonControl(
"DisengageVolume",
"Disengage Volume",
"<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_value=0,
max_value=101,
value_labels=volume_labels,
fast_increase=True,
button_texts=["Test"],
checkable=False,
)
self._disengage_volume_control.set_button_click_callback(lambda _: self._test_sound("DisengageVolume"))
# Engage Volume (0-101)
self._engage_volume_control = FrogPilotParamValueButtonControl(
"EngageVolume",
"Engage Volume",
"<b>Set the volume for the chime when openpilot engages</b>, such as after pressing the \"RESUME\" or \"SET\" steering wheel buttons.",
"",
min_value=0,
max_value=101,
value_labels=volume_labels,
fast_increase=True,
button_texts=["Test"],
checkable=False,
)
self._engage_volume_control.set_button_click_callback(lambda _: self._test_sound("EngageVolume"))
# Prompt Volume (0-101)
self._prompt_volume_control = FrogPilotParamValueButtonControl(
"PromptVolume",
"Prompt Volume",
"<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_value=0,
max_value=101,
value_labels=volume_labels,
fast_increase=True,
button_texts=["Test"],
checkable=False,
)
self._prompt_volume_control.set_button_click_callback(lambda _: self._test_sound("PromptVolume"))
# Prompt Distracted Volume (0-101)
self._prompt_distracted_volume_control = FrogPilotParamValueButtonControl(
"PromptDistractedVolume",
"Prompt Distracted Volume",
"<b>Set the volume for prompts when openpilot detects driver distraction or unresponsiveness.</b><br><br>Examples include: \"Pay Attention\", \"Touch Steering Wheel\".",
"",
min_value=0,
max_value=101,
value_labels=volume_labels,
fast_increase=True,
button_texts=["Test"],
checkable=False,
)
self._prompt_distracted_volume_control.set_button_click_callback(lambda _: self._test_sound("PromptDistractedVolume"))
# Refuse Volume (0-101)
self._refuse_volume_control = FrogPilotParamValueButtonControl(
"RefuseVolume",
"Refuse Volume",
"<b>Set the volume for alerts when openpilot refuses to engage.</b><br><br>Examples include: \"Brake Hold Active\", \"Door Open\", \"Seatbelt Unlatched\".",
"",
min_value=0,
max_value=101,
value_labels=volume_labels,
fast_increase=True,
button_texts=["Test"],
checkable=False,
)
self._refuse_volume_control.set_button_click_callback(lambda _: self._test_sound("RefuseVolume"))
# Warning Soft Volume (25-101, minimum 25%)
self._warning_soft_volume_control = FrogPilotParamValueButtonControl(
"WarningSoftVolume",
"Warning Soft Volume",
"<b>Set the volume for softer warnings about potential risks.</b><br><br>Examples include: \"BRAKE! Risk of Collision\", \"Steering Temporarily Unavailable\".",
"",
min_value=WARNING_MIN_VOLUME,
max_value=101,
value_labels=volume_labels,
fast_increase=True,
button_texts=["Test"],
checkable=False,
)
self._warning_soft_volume_control.set_button_click_callback(lambda _: self._test_sound("WarningSoftVolume"))
# Warning Immediate Volume (25-101, minimum 25%)
self._warning_immediate_volume_control = FrogPilotParamValueButtonControl(
"WarningImmediateVolume",
"Warning Immediate Volume",
"<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_value=WARNING_MIN_VOLUME,
max_value=101,
value_labels=volume_labels,
fast_increase=True,
button_texts=["Test"],
checkable=False,
)
self._warning_immediate_volume_control.set_button_click_callback(lambda _: self._test_sound("WarningImmediateVolume"))
alert_volume_items = [
self._disengage_volume_control,
self._engage_volume_control,
self._prompt_volume_control,
self._prompt_distracted_volume_control,
self._refuse_volume_control,
self._warning_soft_volume_control,
self._warning_immediate_volume_control,
]
self._toggles["DisengageVolume"] = self._disengage_volume_control
self._toggles["EngageVolume"] = self._engage_volume_control
self._toggles["PromptVolume"] = self._prompt_volume_control
self._toggles["PromptDistractedVolume"] = self._prompt_distracted_volume_control
self._toggles["RefuseVolume"] = self._refuse_volume_control
self._toggles["WarningSoftVolume"] = self._warning_soft_volume_control
self._toggles["WarningImmediateVolume"] = self._warning_immediate_volume_control
self._alert_volume_scroller = Scroller(alert_volume_items, line_separator=True, spacing=0)
def _build_custom_alerts_panel(self):
self._goat_scream_item = ListItem(
title="Goat Scream",
description="<b>Play the infamous \"Goat Scream\" when the steering controller reaches its limit.</b> Based on the \"Turn Exceeds Steering Limit\" event.",
action_item=ToggleAction(
initial_state=self._params.get_bool("GoatScream"),
callback=lambda state: self._simple_toggle("GoatScream", state),
),
)
self._green_light_alert_item = ListItem(
title="Green Light Alert",
description="<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>",
action_item=ToggleAction(
initial_state=self._params.get_bool("GreenLightAlert"),
callback=lambda state: self._simple_toggle("GreenLightAlert", state),
),
)
self._lead_departing_alert_item = ListItem(
title="Lead Departing Alert",
description="<b>Play an alert when the lead vehicle departs from a stop.</b>",
action_item=ToggleAction(
initial_state=self._params.get_bool("LeadDepartingAlert"),
callback=lambda state: self._simple_toggle("LeadDepartingAlert", state),
),
)
self._loud_blindspot_alert_item = ListItem(
title="Loud \"Car Detected in Blindspot\" Alert",
description="<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.",
action_item=ToggleAction(
initial_state=self._params.get_bool("LoudBlindspotAlert"),
callback=lambda state: self._simple_toggle("LoudBlindspotAlert", state),
),
)
self._speed_limit_changed_alert_item = ListItem(
title="Speed Limit Changed Alert",
description="<b>Play an alert when the posted speed limit changes.</b>",
action_item=ToggleAction(
initial_state=self._params.get_bool("SpeedLimitChangedAlert"),
callback=lambda state: self._simple_toggle("SpeedLimitChangedAlert", state),
),
)
custom_alerts_items = [
self._goat_scream_item,
self._green_light_alert_item,
self._lead_departing_alert_item,
self._loud_blindspot_alert_item,
self._speed_limit_changed_alert_item,
]
self._toggles["GoatScream"] = self._goat_scream_item
self._toggles["GreenLightAlert"] = self._green_light_alert_item
self._toggles["LeadDepartingAlert"] = self._lead_departing_alert_item
self._toggles["LoudBlindspotAlert"] = self._loud_blindspot_alert_item
self._toggles["SpeedLimitChangedAlert"] = self._speed_limit_changed_alert_item
self._custom_alerts_scroller = Scroller(custom_alerts_items, line_separator=True, spacing=0)
def _simple_toggle(self, param: str, state: bool):
self._params.put_bool(param, state)
update_frogpilot_toggles()
def _initialize_sound_player(self):
"""Initialize a Python subprocess for playing test sounds."""
program = '''
import numpy as np
import sounddevice as sd
import sys
import wave
while True:
try:
line = sys.stdin.readline()
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
'''
try:
self._sound_player_process = subprocess.Popen(
["python3", "-u", "-c", program],
stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except Exception:
self._sound_player_process = None
def _test_sound(self, key: str):
"""Test a sound by playing it or triggering via params."""
# Remove "Volume" suffix to get base alert name
base_name = key.replace("Volume", "")
if self._started:
# If driving, trigger via TestAlert param (handled by openpilot)
update_frogpilot_toggles()
# Convert to camelCase for TestAlert param
camel_case_alert = base_name[0].lower() + base_name[1:]
self._params_memory.put("TestAlert", camel_case_alert)
else:
# If parked, play directly via sound player process
snake_case_alert = camel_to_snake(base_name)
# Check for custom theme sound first, then fall back to stock
theme_path = THEME_SOUNDS_PATH / f"{snake_case_alert}.wav"
stock_path = STOCK_SOUNDS_PATH / f"{snake_case_alert}.wav"
sound_path = theme_path if theme_path.exists() else stock_path
if not sound_path.exists():
return
# Get volume from param (0-101, where 101 is auto)
volume_param = self._params.get_float(key)
if volume_param is None:
volume_param = self._params.get_int(key) or 100
# Auto (101) defaults to 50%
volume = volume_param / 100.0 if volume_param <= 100 else 0.5
self._play_sound(str(sound_path), volume)
def _play_sound(self, path: str, volume: float):
"""Play a sound file at the specified volume."""
if self._sound_player_process is None or self._sound_player_process.poll() is not None:
self._initialize_sound_player()
if self._sound_player_process and self._sound_player_process.stdin:
try:
message = f"{path}|{volume}\n"
self._sound_player_process.stdin.write(message.encode())
self._sound_player_process.stdin.flush()
except Exception:
pass
def _open_alert_volume_panel(self):
self._current_panel = SubPanel.ALERT_VOLUME_CONTROL
def _open_custom_alerts_panel(self):
self._current_panel = SubPanel.CUSTOM_ALERTS
def _close_sub_panel(self):
self._current_panel = SubPanel.MAIN
def _update_toggles(self):
self._tuning_level = self._params.get_int("TuningLevel") or 0
# Check visibility conditions for specific toggles
# LoudBlindspotAlert only visible if car has BSM
if hasattr(self._loud_blindspot_alert_item, "set_visible"):
self._loud_blindspot_alert_item.set_visible(self._has_bsm)
# SpeedLimitChangedAlert visible if ShowSpeedLimits OR (hasOpenpilotLongitudinal AND SpeedLimitController)
show_speed_limits = self._params.get_bool("ShowSpeedLimits")
speed_limit_controller = self._params.get_bool("SpeedLimitController")
slc_visible = show_speed_limits or (self._has_openpilot_longitudinal and speed_limit_controller)
if hasattr(self._speed_limit_changed_alert_item, "set_visible"):
self._speed_limit_changed_alert_item.set_visible(slc_visible)
def _load_car_capabilities(self):
"""Load car capabilities from frogpilot variables."""
try:
from openpilot.frogpilot.common.frogpilot_variables import get_frogpilot_toggles
toggles = get_frogpilot_toggles()
self._has_bsm = getattr(toggles, "has_bsm", False)
self._has_openpilot_longitudinal = getattr(toggles, "has_openpilot_longitudinal", False)
except Exception:
self._has_bsm = False
self._has_openpilot_longitudinal = False
def show_event(self):
super().show_event()
self._main_scroller.show_event()
self._load_car_capabilities()
self._update_toggles()
self._started = ui_state.started
def hide_event(self):
super().hide_event()
self._current_panel = SubPanel.MAIN
# Clean up sound player process
if self._sound_player_process:
try:
self._sound_player_process.terminate()
except Exception:
pass
self._sound_player_process = None
def _render(self, rect):
self._started = ui_state.started
if self._current_panel == SubPanel.ALERT_VOLUME_CONTROL:
self._alert_volume_scroller.render(rect)
elif self._current_panel == SubPanel.CUSTOM_ALERTS:
self._custom_alerts_scroller.render(rect)
else:
self._main_scroller.render(rect)
@@ -0,0 +1,976 @@
import re
import shutil
import threading
from enum import IntEnum
from pathlib import Path
from openpilot.common.params import Params
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
from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog, alert_dialog, DialogResult
from openpilot.system.ui.widgets.keyboard import Keyboard
from openpilot.system.ui.widgets.list_view import ListItem, ToggleAction, TextAction, ITEM_TEXT_VALUE_COLOR
from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog
from openpilot.system.ui.widgets.scroller_tici import Scroller
from openpilot.frogpilot.common.frogpilot_variables import update_frogpilot_toggles
from openpilot.frogpilot.system.ui.widgets.frogpilot_controls import (
FrogPilotButtonsControl,
FrogPilotButtonToggleControl,
FrogPilotConfirmationDialog,
FrogPilotManageControl,
)
THEME_PACKS_DIR = Path("/data/themes/theme_packs/")
WHEELS_DIR = Path("/data/themes/steering_wheels/")
CUSTOM_THEME_KEYS = {
"ColorScheme",
"DistanceIconPack",
"DownloadStatusLabel",
"IconPack",
"SignalAnimation",
"SoundPack",
"WheelIcon",
}
HOLIDAY_THEMES = [
"New Year's",
"Valentine's Day",
"St. Patrick's Day",
"World Frog Day",
"April Fools",
"Easter",
"May the Fourth",
"Cinco de Mayo",
"Stitch Day",
"Fourth of July",
"Halloween",
"Thanksgiving",
"Christmas",
]
# Asset type configurations: (sub_folder, param_key, downloadable_param, download_key)
ASSET_CONFIGS = {
"ColorScheme": ("colors", "ColorScheme", "DownloadableColors", "ColorToDownload"),
"DistanceIconPack": ("distance_icons", "DistanceIconPack", "DownloadableDistanceIcons", "DistanceIconToDownload"),
"IconPack": ("icons", "IconPack", "DownloadableIcons", "IconToDownload"),
"SignalAnimation": ("signals", "SignalAnimation", "DownloadableSignals", "SignalToDownload"),
"SoundPack": ("sounds", "SoundPack", "DownloadableSounds", "SoundToDownload"),
"WheelIcon": ("", "WheelIcon", "DownloadableWheels", "WheelToDownload"),
}
class SubPanel(IntEnum):
MAIN = 0
CUSTOM_THEMES = 1
def is_user_created_theme(theme_name: str) -> bool:
"""Check if a theme is user-created."""
return theme_name.endswith("-user_created")
def normalize_theme_name(name: str) -> str:
"""Normalize a theme name for file matching."""
normalized = name.lower()
normalized = re.sub(r'[()]', '-', normalized)
normalized = re.sub(r'\s+', '-', normalized)
normalized = re.sub(r'[^a-z0-9\-]', '', normalized)
normalized = normalized.rstrip('-')
return normalized
def get_theme_display_name(param_key: str, params: Params) -> str:
"""Get the display name for a theme from its stored param value."""
value = params.get(param_key, encoding="utf-8") or ""
if not value:
return "Stock"
base_name = value
# Extract creator if present (after ~)
creator = ""
tilde_idx = base_name.find("~")
if tilde_idx >= 0:
creator = base_name[tilde_idx + 1:]
base_name = base_name[:tilde_idx]
# Split on - or _ and capitalize each part
separator = "-" if "-" in base_name else "_"
parts = [p for p in base_name.split(separator) if p]
parts = [p.capitalize() for p in parts]
# Format display name
if "-" in base_name and len(parts) > 1:
display_name = f"{parts[0]} ({' '.join(parts[1:])})"
else:
display_name = " ".join(parts)
# Add user created indicator
if is_user_created_theme(value):
display_name = display_name.split(" (")[0] + " 🌟"
# Add creator
if creator:
display_name += f" - by: {creator}"
return display_name
def store_theme_name(input_name: str, param_key: str, params: Params) -> str:
"""Store a theme name and return its display name."""
output = input_name.lower()
output = output.replace("(", "").replace(")", "").replace("'", "").replace(".", "")
# Use - for names with parentheses, _ otherwise
if "(" in input_name:
output = output.replace(" ", "-")
else:
output = output.replace(" ", "_")
# Handle user created marker
output = output.replace("_🌟", "-user_created").replace(" 🌟", "-user_created")
output = output.strip()
params.put(param_key, output)
return get_theme_display_name(param_key, params)
def get_theme_list(directory: Path, sub_folder: str, asset_param: str, params: Params, exclude_current: bool = True) -> list[str]:
"""Get list of available themes from a directory."""
use_files = not sub_folder
current_asset = params.get(asset_param, encoding="utf-8") or "" if exclude_current else ""
theme_list = []
if not directory.exists():
return theme_list
for entry in directory.iterdir():
# Skip current asset
if entry.stem == current_asset:
continue
# For files mode, skip directories
if use_files and entry.is_dir():
continue
# For sub-folder mode, check if sub-folder exists
if not use_files:
target_path = entry / sub_folder
if not target_path.exists():
continue
base_name = entry.stem
user_created = is_user_created_theme(base_name)
if user_created:
base_name = base_name.replace("-user_created", "")
# Extract creator
creator = ""
tilde_idx = base_name.find("~")
if tilde_idx >= 0:
creator = base_name[tilde_idx + 1:]
base_name = base_name[:tilde_idx]
# Split and capitalize
separator = "-" if "-" in base_name else "_"
parts = [p for p in base_name.split(separator) if p]
parts = [p.capitalize() for p in parts]
# Format display name
if user_created:
display_name = " ".join(parts)
else:
if len(parts) <= 1 or use_files:
display_name = " ".join(parts)
else:
display_name = f"{parts[0]} ({' '.join(parts[1:])})"
if user_created:
display_name += " 🌟"
if creator:
display_name += f" - by: {creator}"
theme_list.append(display_name)
return sorted(theme_list)
def update_asset_param(asset_param: str, params: Params, value: str, add: bool):
"""Update the downloadable asset list."""
assets_str = params.get(asset_param, encoding="utf-8") or ""
assets = [a for a in assets_str.split(",") if a]
if add:
if value not in assets:
assets.append(value)
else:
if value in assets:
assets.remove(value)
assets.sort()
params.put(asset_param, ",".join(assets))
def download_theme_asset(input_name: str, download_key: str, downloadable_param: str, params: Params, params_memory: Params):
"""Initiate a theme asset download."""
output = input_name
# Handle creator suffix
tilde_idx = output.find("~")
if tilde_idx >= 0:
output = output[:tilde_idx].lower() + "~" + output[tilde_idx + 1:]
else:
output = output.lower()
output = output.replace("(", "").replace(")", "")
output = output.replace(" ", "-" if "(" in input_name else "_")
params_memory.put(download_key, output)
def delete_theme_asset(directory: Path, sub_folder: str, downloadable_param: str, theme_to_delete: str, params: Params):
"""Delete a theme asset."""
use_files = not sub_folder
# Normalize the name for matching
base_name = theme_to_delete.lower()
base_name = re.sub(r'[()]', '-', base_name)
base_name = base_name.replace(" ", "-")
base_name = re.sub(r'[^a-z0-9\-]', '', base_name)
base_name = base_name.rstrip('-')
base_underscore = base_name.replace("-", "_")
candidate_names = [
base_name,
base_name + "-user-created",
base_underscore,
base_underscore + "-user_created",
]
if use_files:
# Delete file
for file in directory.iterdir():
if not file.is_file():
continue
normalized_file = file.stem.lower().replace("_", "-")
normalized_file = re.sub(r'[^a-z0-9\-~]', '', normalized_file)
if normalized_file in candidate_names:
file.unlink()
break
else:
# Delete directory
for candidate in candidate_names:
target_dir = directory / candidate / sub_folder
if target_dir.exists():
shutil.rmtree(target_dir.parent, ignore_errors=True)
break
# Update downloadable list - add back to available downloads
update_asset_param(downloadable_param, params, theme_to_delete, True)
class FrogPilotThemePanel(Widget):
def __init__(self):
super().__init__()
self._current_panel = SubPanel.MAIN
self._params = Params()
self._params_memory = Params("", True)
self._toggles = {}
self._tuning_level = 0
# State tracking
self._cancelling_download = False
self._finalizing_download = False
self._online = False
self._parked = True
self._random_themes = False
self._started = False
self._theme_downloading = False
# Pending dialog action tracking
self._pending_action = None # "delete", "download", "select", "delete_confirm", "custom_top", "custom_bottom", "clear_startup"
self._pending_asset_type = None # "ColorScheme", "DistanceIconPack", etc.
self._pending_selection = None # Selected item from first dialog
# Download state per asset type
self._color_downloading = False
self._distance_icon_downloading = False
self._icon_downloading = False
self._signal_downloading = False
self._sound_downloading = False
self._wheel_downloading = False
# Downloaded state (no more available to download)
self._colors_downloaded = False
self._distance_icons_downloaded = False
self._icons_downloaded = False
self._signals_downloaded = False
self._sounds_downloaded = False
self._wheels_downloaded = False
# Download status
self._download_status = "Idle"
# Keyboard for text input
self._keyboard = Keyboard()
self._build_main_panel()
self._build_custom_themes_panel()
ui_state.add_offroad_transition_callback(self._update_toggles)
def _build_main_panel(self):
self._custom_themes_control = FrogPilotManageControl(
"CustomThemes",
"Custom Themes",
"<b>The overall look and feel of openpilot.</b> Use the \"Theme Maker\" in \"The Pond\" to create and share your own themes!",
"../../frogpilot/assets/toggle_icons/icon_frog.png",
)
self._custom_themes_control.set_manage_callback(self._open_custom_themes_panel)
self._holiday_themes_item = ListItem(
title="Holiday Themes",
description="<b>Themes based on U.S. holidays.</b> Minor holidays last one day; major holidays (Christmas, Easter, Halloween) run for a full week.",
action_item=ToggleAction(
initial_state=self._params.get_bool("HolidayThemes"),
callback=lambda state: self._simple_toggle("HolidayThemes", state),
),
)
self._rainbow_path_item = ListItem(
title="Rainbow Path",
description="<b>Color the driving path like a Mario Kart-style \"Rainbow Road\".</b>",
action_item=ToggleAction(
initial_state=self._params.get_bool("RainbowPath"),
callback=lambda state: self._simple_toggle("RainbowPath", state),
),
)
self._random_events_item = ListItem(
title="Random Events",
description="<b>Occasional on-screen effects triggered by driving conditions.</b> These are purely visual and don't impact how openpilot drives!",
action_item=ToggleAction(
initial_state=self._params.get_bool("RandomEvents"),
callback=lambda state: self._simple_toggle("RandomEvents", state),
),
)
self._random_themes_control = FrogPilotButtonToggleControl(
"RandomThemes",
"Random Themes",
"<b>Pick a random theme between each drive</b> from the themes you have downloaded. Great for variety without changing settings while driving.",
"../../frogpilot/assets/toggle_icons/icon_random_themes.png",
button_params=["RandomThemesHolidays"],
button_texts=["Include Holiday Themes"],
)
self._random_themes_control.set_toggle_callback(self._on_random_themes_toggle)
self._startup_alert_control = FrogPilotButtonsControl(
"Startup Alert",
"<b>Customize the \"Startup Alert\" message</b> shown at the start of each drive.",
"../../frogpilot/assets/toggle_icons/icon_message.png",
button_texts=["STOCK", "FROGPILOT", "CUSTOM", "CLEAR"],
)
self._startup_alert_control.set_click_callback(self._on_startup_alert_click)
self._update_startup_alert_buttons()
main_items = [
self._custom_themes_control,
self._holiday_themes_item,
self._rainbow_path_item,
self._random_events_item,
self._random_themes_control,
self._startup_alert_control,
]
self._toggles["CustomThemes"] = self._custom_themes_control
self._toggles["HolidayThemes"] = self._holiday_themes_item
self._toggles["RainbowPath"] = self._rainbow_path_item
self._toggles["RandomEvents"] = self._random_events_item
self._toggles["RandomThemes"] = self._random_themes_control
self._toggles["StartupAlert"] = self._startup_alert_control
self._main_scroller = Scroller(main_items, line_separator=True, spacing=0)
def _build_custom_themes_panel(self):
# Color Scheme
self._color_scheme_control = FrogPilotButtonsControl(
"Color Scheme",
"<b>The color scheme used throughout openpilot.</b> Use the \"Theme Maker\" in \"The Pond\" to create and share your own themes!",
"",
button_texts=["DELETE", "DOWNLOAD", "SELECT"],
)
self._color_scheme_control.set_click_callback(self._on_color_scheme_click)
self._color_scheme_control.set_value(get_theme_display_name("ColorScheme", self._params))
# Distance Icon Pack
self._distance_icon_control = FrogPilotButtonsControl(
"Distance Button",
"<b>The distance button icons shown on the driving screen.</b> Use the \"Theme Maker\" in \"The Pond\" to create and share your own themes!",
"",
button_texts=["DELETE", "DOWNLOAD", "SELECT"],
)
self._distance_icon_control.set_click_callback(self._on_distance_icon_click)
self._distance_icon_control.set_value(get_theme_display_name("DistanceIconPack", self._params))
# Icon Pack
self._icon_pack_control = FrogPilotButtonsControl(
"Icon Pack",
"<b>The icon style used across openpilot.</b> Use the \"Theme Maker\" in \"The Pond\" to create and share your own themes!",
"",
button_texts=["DELETE", "DOWNLOAD", "SELECT"],
)
self._icon_pack_control.set_click_callback(self._on_icon_pack_click)
self._icon_pack_control.set_value(get_theme_display_name("IconPack", self._params))
# Signal Animation
self._signal_animation_control = FrogPilotButtonsControl(
"Turn Signal",
"<b>Themed turn-signal animations.</b> Use the \"Theme Maker\" in \"The Pond\" to create and share your own themes!",
"",
button_texts=["DELETE", "DOWNLOAD", "SELECT"],
)
self._signal_animation_control.set_click_callback(self._on_signal_animation_click)
self._signal_animation_control.set_value(get_theme_display_name("SignalAnimation", self._params))
# Sound Pack
self._sound_pack_control = FrogPilotButtonsControl(
"Sound Pack",
"<b>The sound pack used by openpilot.</b> Use the \"Theme Maker\" in \"The Pond\" to create and share your own themes!",
"",
button_texts=["DELETE", "DOWNLOAD", "SELECT"],
)
self._sound_pack_control.set_click_callback(self._on_sound_pack_click)
self._sound_pack_control.set_value(get_theme_display_name("SoundPack", self._params))
# Wheel Icon
self._wheel_icon_control = FrogPilotButtonsControl(
"Steering Wheel",
"<b>The steering-wheel icon</b> shown at the top-right of the driving screen. Use the \"Theme Maker\" in \"The Pond\" to create and share your own themes!",
"",
button_texts=["DELETE", "DOWNLOAD", "SELECT"],
)
self._wheel_icon_control.set_click_callback(self._on_wheel_icon_click)
self._wheel_icon_control.set_value(get_theme_display_name("WheelIcon", self._params))
# Download Status Label
self._download_status_item = ListItem(
title="Download Status",
action_item=TextAction(lambda: self._download_status, color=ITEM_TEXT_VALUE_COLOR),
)
custom_theme_items = [
self._color_scheme_control,
self._distance_icon_control,
self._icon_pack_control,
self._signal_animation_control,
self._sound_pack_control,
self._wheel_icon_control,
self._download_status_item,
]
self._toggles["ColorScheme"] = self._color_scheme_control
self._toggles["DistanceIconPack"] = self._distance_icon_control
self._toggles["IconPack"] = self._icon_pack_control
self._toggles["SignalAnimation"] = self._signal_animation_control
self._toggles["SoundPack"] = self._sound_pack_control
self._toggles["WheelIcon"] = self._wheel_icon_control
self._toggles["DownloadStatusLabel"] = self._download_status_item
self._custom_themes_scroller = Scroller(custom_theme_items, line_separator=True, spacing=0)
def _simple_toggle(self, param: str, state: bool):
self._params.put_bool(param, state)
update_frogpilot_toggles()
def _on_random_themes_toggle(self, state: bool):
self._params.put_bool("RandomThemes", state)
update_frogpilot_toggles()
self._random_themes = state
if state:
gui_app.set_modal_overlay(alert_dialog(
"\"Random Themes\" only works with downloaded themes, so make sure you download the themes you want it to use!"
))
# Hide SELECT buttons and clear values
self._color_scheme_control.set_value("")
self._color_scheme_control.set_visible_button(2, False)
self._distance_icon_control.set_value("")
self._distance_icon_control.set_visible_button(2, False)
self._icon_pack_control.set_value("")
self._icon_pack_control.set_visible_button(2, False)
self._signal_animation_control.set_value("")
self._signal_animation_control.set_visible_button(2, False)
self._sound_pack_control.set_value("")
self._sound_pack_control.set_visible_button(2, False)
self._wheel_icon_control.set_value("")
self._wheel_icon_control.set_visible_button(2, False)
else:
# Show SELECT buttons and restore values
self._color_scheme_control.set_value(get_theme_display_name("ColorScheme", self._params))
self._color_scheme_control.set_visible_button(2, True)
self._distance_icon_control.set_value(get_theme_display_name("DistanceIconPack", self._params))
self._distance_icon_control.set_visible_button(2, True)
self._icon_pack_control.set_value(get_theme_display_name("IconPack", self._params))
self._icon_pack_control.set_visible_button(2, True)
self._signal_animation_control.set_value(get_theme_display_name("SignalAnimation", self._params))
self._signal_animation_control.set_visible_button(2, True)
self._sound_pack_control.set_value(get_theme_display_name("SoundPack", self._params))
self._sound_pack_control.set_visible_button(2, True)
self._wheel_icon_control.set_value(get_theme_display_name("WheelIcon", self._params))
self._wheel_icon_control.set_visible_button(2, True)
def _update_startup_alert_buttons(self):
"""Update startup alert button states based on current values."""
current_top = self._params.get("StartupMessageTop", encoding="utf-8") or ""
current_bottom = self._params.get("StartupMessageBottom", encoding="utf-8") or ""
stock_top = "Be ready to take over at any time"
stock_bottom = "Always keep hands on wheel and eyes on road"
frogpilot_top = "Hop in and buckle up!"
frogpilot_bottom = "Human-tested, frog-approved 🐸"
if current_top == stock_top and current_bottom == stock_bottom:
self._startup_alert_control.set_checked_button(0)
elif current_top == frogpilot_top and current_bottom == frogpilot_bottom:
self._startup_alert_control.set_checked_button(1)
elif current_top or current_bottom:
self._startup_alert_control.set_checked_button(2)
def _on_startup_alert_click(self, button_id: int):
stock_top = "Be ready to take over at any time"
stock_bottom = "Always keep hands on wheel and eyes on road"
frogpilot_top = "Hop in and buckle up!"
frogpilot_bottom = "Human-tested, frog-approved 🐸"
if button_id == 0:
# Stock
self._params.put("StartupMessageTop", stock_top)
self._params.put("StartupMessageBottom", stock_bottom)
elif button_id == 1:
# FrogPilot
self._params.put("StartupMessageTop", frogpilot_top)
self._params.put("StartupMessageBottom", frogpilot_bottom)
elif button_id == 2:
# Custom - show input dialog for top message
self._pending_action = "custom_top"
current_top = self._params.get("StartupMessageTop", encoding="utf-8") or ""
self._keyboard.reset()
self._keyboard.set_title("Enter the text for the top half")
self._keyboard.set_text(current_top)
gui_app.set_modal_overlay(self._keyboard, callback=self._on_keyboard_result)
elif button_id == 3:
# Clear - show confirmation
self._pending_action = "clear_startup"
gui_app.set_modal_overlay(ConfirmDialog(
"Are you sure you want to completely reset your startup message?",
"Yes",
"No",
))
def _get_control_for_asset(self, asset_type: str) -> FrogPilotButtonsControl:
"""Get the control widget for an asset type."""
controls = {
"ColorScheme": self._color_scheme_control,
"DistanceIconPack": self._distance_icon_control,
"IconPack": self._icon_pack_control,
"SignalAnimation": self._signal_animation_control,
"SoundPack": self._sound_pack_control,
"WheelIcon": self._wheel_icon_control,
}
return controls.get(asset_type)
def _get_downloading_attr(self, asset_type: str) -> str:
"""Get the downloading attribute name for an asset type."""
attrs = {
"ColorScheme": "_color_downloading",
"DistanceIconPack": "_distance_icon_downloading",
"IconPack": "_icon_downloading",
"SignalAnimation": "_signal_downloading",
"SoundPack": "_sound_downloading",
"WheelIcon": "_wheel_downloading",
}
return attrs.get(asset_type)
def _get_downloaded_attr(self, asset_type: str) -> str:
"""Get the downloaded attribute name for an asset type."""
attrs = {
"ColorScheme": "_colors_downloaded",
"DistanceIconPack": "_distance_icons_downloaded",
"IconPack": "_icons_downloaded",
"SignalAnimation": "_signals_downloaded",
"SoundPack": "_sounds_downloaded",
"WheelIcon": "_wheels_downloaded",
}
return attrs.get(asset_type)
def _handle_asset_click(self, button_id: int, asset_type: str):
"""Generic handler for asset button clicks (DELETE, DOWNLOAD, SELECT)."""
config = ASSET_CONFIGS[asset_type]
sub_folder, param_key, downloadable_param, download_key = config
directory = WHEELS_DIR if asset_type == "WheelIcon" else THEME_PACKS_DIR
downloading_attr = self._get_downloading_attr(asset_type)
if button_id == 0:
# DELETE - show selection dialog
theme_list = get_theme_list(directory, sub_folder, param_key, self._params)
if not theme_list:
gui_app.set_modal_overlay(alert_dialog(f"No {asset_type.lower()} available to delete."))
return
self._pending_action = "delete"
self._pending_asset_type = asset_type
gui_app.set_modal_overlay(MultiOptionDialog(
f"Select a {asset_type.lower()} to delete",
theme_list,
))
elif button_id == 1:
# DOWNLOAD or CANCEL
if getattr(self, downloading_attr):
# Cancel download
self._cancelling_download = True
self._params_memory.put_bool("CancelThemeDownload", True)
def reset_cancel():
self._cancelling_download = False
setattr(self, downloading_attr, False)
self._theme_downloading = False
self._params_memory.put_bool("CancelThemeDownload", False)
threading.Timer(2.5, reset_cancel).start()
else:
# Start download - show selection dialog
downloadable_str = self._params.get(downloadable_param, encoding="utf-8") or ""
downloadable = [d for d in downloadable_str.split(",") if d]
if not downloadable:
gui_app.set_modal_overlay(alert_dialog(f"All {asset_type.lower()}s are already downloaded."))
return
self._pending_action = "download"
self._pending_asset_type = asset_type
gui_app.set_modal_overlay(MultiOptionDialog(
f"Select a {asset_type.lower()} to download",
downloadable,
))
elif button_id == 2:
# SELECT - show selection dialog
theme_list = get_theme_list(directory, sub_folder, param_key, self._params, exclude_current=False)
# Add default options
if asset_type == "SignalAnimation":
theme_list.append("None")
elif asset_type == "WheelIcon":
theme_list.append("None")
theme_list.append("Stock")
else:
theme_list.append("Stock")
theme_list.extend(HOLIDAY_THEMES)
theme_list.sort()
current = get_theme_display_name(param_key, self._params)
self._pending_action = "select"
self._pending_asset_type = asset_type
gui_app.set_modal_overlay(MultiOptionDialog(
f"Select a {asset_type.lower()}",
theme_list,
current,
))
def _on_color_scheme_click(self, button_id: int):
self._handle_asset_click(button_id, "ColorScheme")
def _on_distance_icon_click(self, button_id: int):
self._handle_asset_click(button_id, "DistanceIconPack")
def _on_icon_pack_click(self, button_id: int):
self._handle_asset_click(button_id, "IconPack")
def _on_signal_animation_click(self, button_id: int):
self._handle_asset_click(button_id, "SignalAnimation")
def _on_sound_pack_click(self, button_id: int):
self._handle_asset_click(button_id, "SoundPack")
def _on_wheel_icon_click(self, button_id: int):
self._handle_asset_click(button_id, "WheelIcon")
def _on_keyboard_result(self, result: DialogResult):
"""Callback for keyboard modal overlay."""
self.handle_dialog_result(result, self._keyboard.text)
def handle_dialog_result(self, result: DialogResult, selection: str = ""):
"""Handle dialog results for all pending actions."""
action = self._pending_action
asset_type = self._pending_asset_type
self._pending_action = None
if action == "delete":
# First dialog - theme selection for delete
if result != DialogResult.CONFIRM or not selection:
self._pending_asset_type = None
return
# Show confirmation dialog
self._pending_action = "delete_confirm"
self._pending_selection = selection
gui_app.set_modal_overlay(ConfirmDialog(
f'Delete the "{selection}" {asset_type.lower()}?',
"Delete",
"Cancel",
))
elif action == "delete_confirm":
# Confirmation for delete
if result != DialogResult.CONFIRM:
self._pending_asset_type = None
self._pending_selection = None
return
selection = self._pending_selection
self._pending_selection = None
config = ASSET_CONFIGS[asset_type]
sub_folder, param_key, downloadable_param, download_key = config
directory = WHEELS_DIR if asset_type == "WheelIcon" else THEME_PACKS_DIR
# Mark as not all downloaded anymore
downloaded_attr = self._get_downloaded_attr(asset_type)
setattr(self, downloaded_attr, False)
# Delete the asset
delete_theme_asset(directory, sub_folder, downloadable_param, selection, self._params)
self._pending_asset_type = None
elif action == "download":
# Theme selection for download
if result != DialogResult.CONFIRM or not selection:
self._pending_asset_type = None
return
config = ASSET_CONFIGS[asset_type]
sub_folder, param_key, downloadable_param, download_key = config
# Set downloading flags
downloading_attr = self._get_downloading_attr(asset_type)
setattr(self, downloading_attr, True)
self._theme_downloading = True
self._params_memory.put("ThemeDownloadProgress", "Downloading...")
self._download_status = "Downloading..."
# Initiate download
download_theme_asset(selection, download_key, downloadable_param, self._params, self._params_memory)
self._pending_asset_type = None
elif action == "select":
# Theme selection
if result != DialogResult.CONFIRM or not selection:
self._pending_asset_type = None
return
config = ASSET_CONFIGS[asset_type]
sub_folder, param_key, downloadable_param, download_key = config
# Store the theme and update display
control = self._get_control_for_asset(asset_type)
display_name = store_theme_name(selection, param_key, self._params)
control.set_value(display_name)
self._pending_asset_type = None
elif action == "custom_top":
# Custom startup message - top line
if result != DialogResult.CONFIRM or not selection:
return
self._params.put("StartupMessageTop", selection.strip())
# Now show dialog for bottom line
self._pending_action = "custom_bottom"
current_bottom = self._params.get("StartupMessageBottom", encoding="utf-8") or ""
self._keyboard.reset()
self._keyboard.set_title("Enter the text for the bottom half")
self._keyboard.set_text(current_bottom)
gui_app.set_modal_overlay(self._keyboard, callback=self._on_keyboard_result)
elif action == "custom_bottom":
# Custom startup message - bottom line
if result == DialogResult.CONFIRM and selection:
self._params.put("StartupMessageBottom", selection.strip())
self._update_startup_alert_buttons()
elif action == "clear_startup":
# Clear startup message confirmation
if result == DialogResult.CONFIRM:
self._params.remove("StartupMessageTop")
self._params.remove("StartupMessageBottom")
self._startup_alert_control.clear_checked_buttons()
def _translate_progress(self, progress: str) -> str:
"""Translate download progress messages."""
translations = {
"Download cancelled...": "Download cancelled...",
"Download failed...": "Download failed...",
"Downloaded!": "Downloaded!",
"Downloading...": "Downloading...",
"GitHub and GitLab are offline...": "GitHub and GitLab are offline...",
"Repository unavailable": "Repository unavailable",
"Unpacking theme...": "Unpacking theme...",
"Verifying authenticity...": "Verifying authenticity...",
}
if progress in translations:
return translations[progress]
if progress.endswith("%"):
return progress
return "Idle"
def _update_download_state(self):
"""Update UI based on download progress."""
if self._finalizing_download:
return
if not self._theme_downloading:
return
progress = self._params_memory.get("ThemeDownloadProgress", encoding="utf-8") or ""
download_failed = bool(re.search(r"cancelled|exists|failed|offline", progress, re.IGNORECASE))
if progress and progress != "Downloading...":
self._download_status = self._translate_progress(progress)
if progress == "Downloaded!" or download_failed:
self._finalizing_download = True
def finalize():
self._color_downloading = False
self._distance_icon_downloading = False
self._finalizing_download = False
self._icon_downloading = False
self._signal_downloading = False
self._sound_downloading = False
self._theme_downloading = False
self._wheel_downloading = False
# Update downloaded states
self._colors_downloaded = not self._params.get("DownloadableColors", encoding="utf-8")
self._distance_icons_downloaded = not self._params.get("DownloadableDistanceIcons", encoding="utf-8")
self._icons_downloaded = not self._params.get("DownloadableIcons", encoding="utf-8")
self._signals_downloaded = not self._params.get("DownloadableSignals", encoding="utf-8")
self._sounds_downloaded = not self._params.get("DownloadableSounds", encoding="utf-8")
self._wheels_downloaded = not self._params.get("DownloadableWheels", encoding="utf-8")
self._params_memory.remove("CancelThemeDownload")
self._params_memory.remove("ThemeDownloadProgress")
self._download_status = "Idle"
threading.Timer(2.5, finalize).start()
def _update_button_states(self):
"""Update button enabled/visible states."""
# Helper for updating each asset control
def update_asset_buttons(control, downloading, downloaded):
control.set_text(1, "CANCEL" if downloading else "DOWNLOAD")
control.set_enabled_buttons(0, not self._theme_downloading)
can_download = (self._online and
(not self._theme_downloading or downloading) and
not self._cancelling_download and
not self._finalizing_download and
not downloaded and
self._parked)
control.set_enabled_buttons(1, can_download)
control.set_enabled_buttons(2, not self._theme_downloading)
update_asset_buttons(self._color_scheme_control, self._color_downloading, self._colors_downloaded)
update_asset_buttons(self._distance_icon_control, self._distance_icon_downloading, self._distance_icons_downloaded)
update_asset_buttons(self._icon_pack_control, self._icon_downloading, self._icons_downloaded)
update_asset_buttons(self._signal_animation_control, self._signal_downloading, self._signals_downloaded)
update_asset_buttons(self._sound_pack_control, self._sound_downloading, self._sounds_downloaded)
update_asset_buttons(self._wheel_icon_control, self._wheel_downloading, self._wheels_downloaded)
def _open_custom_themes_panel(self):
self._current_panel = SubPanel.CUSTOM_THEMES
def _close_sub_panel(self):
self._current_panel = SubPanel.MAIN
def _update_toggles(self):
self._tuning_level = self._params.get_int("TuningLevel") or 0
# DistanceIconPack only visible if QOLVisuals AND OnroadDistanceButton
qol_visuals = self._params.get_bool("QOLVisuals")
onroad_distance_button = self._params.get_bool("OnroadDistanceButton")
if hasattr(self._distance_icon_control, "set_visible"):
self._distance_icon_control.set_visible(qol_visuals and onroad_distance_button)
# RandomThemes only visible if CustomThemes enabled
custom_themes = self._params.get_bool("CustomThemes")
if hasattr(self._random_themes_control, "set_visible"):
self._random_themes_control.set_visible(custom_themes)
def _load_downloaded_states(self):
"""Load initial downloaded states."""
self._colors_downloaded = not self._params.get("DownloadableColors", encoding="utf-8")
self._distance_icons_downloaded = not self._params.get("DownloadableDistanceIcons", encoding="utf-8")
self._icons_downloaded = not self._params.get("DownloadableIcons", encoding="utf-8")
self._signals_downloaded = not self._params.get("DownloadableSignals", encoding="utf-8")
self._sounds_downloaded = not self._params.get("DownloadableSounds", encoding="utf-8")
self._wheels_downloaded = not self._params.get("DownloadableWheels", encoding="utf-8")
self._random_themes = self._params.get_bool("RandomThemes")
if self._random_themes:
# Hide SELECT buttons and clear values
self._color_scheme_control.set_value("")
self._color_scheme_control.set_visible_button(2, False)
self._distance_icon_control.set_value("")
self._distance_icon_control.set_visible_button(2, False)
self._icon_pack_control.set_value("")
self._icon_pack_control.set_visible_button(2, False)
self._signal_animation_control.set_value("")
self._signal_animation_control.set_visible_button(2, False)
self._sound_pack_control.set_value("")
self._sound_pack_control.set_visible_button(2, False)
self._wheel_icon_control.set_value("")
self._wheel_icon_control.set_visible_button(2, False)
def show_event(self):
super().show_event()
self._main_scroller.show_event()
self._load_downloaded_states()
self._update_toggles()
self._update_startup_alert_buttons()
self._started = ui_state.started
def hide_event(self):
super().hide_event()
self._current_panel = SubPanel.MAIN
def _render(self, rect):
self._started = ui_state.started
self._parked = not self._started
# Update download state
self._update_download_state()
self._update_button_states()
if self._current_panel == SubPanel.CUSTOM_THEMES:
self._custom_themes_scroller.render(rect)
else:
self._main_scroller.render(rect)
+376
View File
@@ -0,0 +1,376 @@
import json
import threading
import time
from pathlib import Path
from openpilot.common.params import Params
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.widgets import Widget
from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog, alert_dialog, DialogResult
from openpilot.system.ui.widgets.keyboard import Keyboard
from openpilot.system.ui.widgets.list_view import ListItem, ToggleAction, ButtonAction, TextAction, ITEM_TEXT_VALUE_COLOR
from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog
from openpilot.system.ui.widgets.scroller_tici import Scroller
from openpilot.frogpilot.common.frogpilot_variables import update_frogpilot_toggles
from openpilot.frogpilot.system.ui.widgets.frogpilot_controls import (
FrogPilotButtonsControl,
)
ERROR_LOG_PATH = Path("/data/error_logs/error.txt")
# Keys that should NOT be reset
EXCLUDED_KEYS = {
"AvailableModels",
"AvailableModelNames",
"FrogPilotStats",
"GithubSshKeys",
"GithubUsername",
"MapBoxRequests",
"ModelDrivesAndScores",
"OverpassRequests",
"SpeedLimits",
"SpeedLimitsFiltered",
"UpdaterAvailableBranches",
}
REPORT_MESSAGES = [
"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 FrogPilotUtilitiesPanel(Widget):
def __init__(self):
super().__init__()
self._params = Params()
self._params_memory = Params("", True)
self._toggles = {}
# State tracking
self._flash_status = ""
self._reset_status = ""
self._online = False
# Pending dialog action tracking
self._pending_action = None # "flash_panda", "report_select", "report_extra", "report_discord", "reset_default", "reset_stock"
self._pending_data = {}
# Keyboard for text input
self._keyboard = Keyboard()
self._build_panel()
ui_state.add_offroad_transition_callback(self._update_toggles)
def _build_panel(self):
# Debug Mode Toggle
self._debug_mode_item = ListItem(
title="Debug Mode",
description="<b>Use all of FrogPilot's developer metrics on your next drive</b> to diagnose issues and improve bug reports.",
action_item=ToggleAction(
initial_state=self._params.get_bool("DebugMode"),
callback=lambda state: self._simple_toggle("DebugMode", state),
),
)
# Flash Panda Button
self._flash_panda_control = FrogPilotButtonsControl(
"Flash Panda",
"<b>Flash the latest, official firmware onto your Panda device</b> to restore core functionality, fix bugs, or ensure you have the most up-to-date software.",
"",
button_texts=["FLASH"],
)
self._flash_panda_control.set_click_callback(self._on_flash_panda_click)
# Force Drive State Buttons
self._force_drive_state_control = FrogPilotButtonsControl(
"Force Drive State",
"<b>Force openpilot to be offroad or onroad.</b>",
"",
button_texts=["OFFROAD", "ONROAD", "OFF"],
)
self._force_drive_state_control.set_click_callback(self._on_force_drive_state_click)
self._force_drive_state_control.set_checked_button(2)
# Report Issue Button
self._report_issue_control = FrogPilotButtonsControl(
"Report a Bug or an Issue",
"<b>Send a bug report</b> so we can help fix the problem!",
"",
button_texts=["REPORT"],
)
self._report_issue_control.set_click_callback(self._on_report_issue_click)
# Reset Toggles to Default Button
self._reset_default_control = FrogPilotButtonsControl(
"Reset Toggles to Default",
"<b>Reset all toggles to their default values.</b>",
"",
button_texts=["RESET"],
)
self._reset_default_control.set_click_callback(self._on_reset_default_click)
# Reset Toggles to Stock Button
self._reset_stock_control = FrogPilotButtonsControl(
"Reset Toggles to Stock openpilot",
"<b>Reset all toggles to match stock openpilot.</b>",
"",
button_texts=["RESET"],
)
self._reset_stock_control.set_click_callback(self._on_reset_stock_click)
items = [
self._debug_mode_item,
self._flash_panda_control,
self._force_drive_state_control,
self._report_issue_control,
self._reset_default_control,
self._reset_stock_control,
]
self._toggles["DebugMode"] = self._debug_mode_item
self._toggles["FlashPanda"] = self._flash_panda_control
self._toggles["ForceDriveState"] = self._force_drive_state_control
self._toggles["ReportIssue"] = self._report_issue_control
self._toggles["ResetDefault"] = self._reset_default_control
self._toggles["ResetStock"] = self._reset_stock_control
self._scroller = Scroller(items, line_separator=True, spacing=0)
def _simple_toggle(self, param: str, state: bool):
self._params.put_bool(param, state)
update_frogpilot_toggles()
def _on_flash_panda_click(self, button_id: int):
self._pending_action = "flash_panda"
gui_app.set_modal_overlay(ConfirmDialog(
"Are you sure you want to flash the Panda firmware?",
"Flash",
"Cancel",
))
def _do_flash_panda(self):
"""Flash panda firmware in a background thread."""
def flash_thread():
self._flash_panda_control.set_enabled(False)
self._flash_panda_control.set_value("Flashing...")
self._params_memory.put_bool("FlashPanda", True)
# Wait for flash to complete
while self._params_memory.get_bool("FlashPanda"):
time.sleep(0.05) # UI_FREQ equivalent
self._flash_panda_control.set_value("Flashed!")
time.sleep(2.5)
self._flash_panda_control.set_value("Rebooting...")
time.sleep(2.5)
HARDWARE.reboot()
threading.Thread(target=flash_thread, daemon=True).start()
def _on_force_drive_state_click(self, button_id: int):
if button_id == 0:
# OFFROAD
self._params.put_bool("ForceOffroad", True)
self._params.put_bool("ForceOnroad", False)
elif button_id == 1:
# ONROAD - copy persistent car params
car_params = self._params.get("CarParamsPersistent")
if car_params:
self._params.put("CarParams", car_params)
frogpilot_car_params = self._params.get("FrogPilotCarParamsPersistent")
if frogpilot_car_params:
self._params.put("FrogPilotCarParams", frogpilot_car_params)
self._params.put_bool("ForceOffroad", False)
self._params.put_bool("ForceOnroad", True)
elif button_id == 2:
# OFF
self._params.put_bool("ForceOffroad", False)
self._params.put_bool("ForceOnroad", False)
update_frogpilot_toggles()
def _on_report_issue_click(self, button_id: int):
# Check if online (would need to be wired up properly to frogpilot_scene.online)
# For now, we'll proceed with the report flow
# Build report messages list
messages = list(REPORT_MESSAGES)
# Add crash option if error log exists
if ERROR_LOG_PATH.exists():
messages.insert(0, "I saw an alert that said \"openpilot crashed\"")
self._pending_action = "report_select"
self._pending_data = {}
gui_app.set_modal_overlay(MultiOptionDialog(
"What's going on?",
messages,
))
def _on_reset_default_click(self, button_id: int):
self._pending_action = "reset_default"
gui_app.set_modal_overlay(ConfirmDialog(
"Are you sure you want to reset all toggles to their default values?",
"Reset",
"Cancel",
))
def _on_reset_stock_click(self, button_id: int):
self._pending_action = "reset_stock"
gui_app.set_modal_overlay(ConfirmDialog(
"Are you sure you want to reset all toggles to match stock openpilot?",
"Reset",
"Cancel",
))
def _do_reset_toggles(self, use_stock: bool):
"""Reset toggles in a background thread."""
control = self._reset_stock_control if use_stock else self._reset_default_control
def reset_thread():
control.set_enabled(False)
control.set_value("Resetting...")
all_keys = self._params.all_keys()
for key in all_keys:
if key in EXCLUDED_KEYS:
continue
try:
if use_stock:
stock_value = self._params.get_stock_value(key)
if stock_value is not None:
self._params.put(key, stock_value)
else:
default_value = self._params.get_key_default_value(key)
if default_value is not None:
self._params.put(key, default_value)
except Exception:
# Skip keys that don't have default/stock values
pass
update_frogpilot_toggles()
control.set_value("Reset!")
time.sleep(2.5)
control.set_value("")
control.set_enabled(True)
threading.Thread(target=reset_thread, daemon=True).start()
def _on_keyboard_result(self, result: DialogResult):
"""Callback for keyboard modal overlay."""
self.handle_dialog_result(result, self._keyboard.text)
def handle_dialog_result(self, result: DialogResult, selection: str = ""):
"""Handle dialog results for all pending actions."""
action = self._pending_action
self._pending_action = None
if action == "flash_panda":
if result == DialogResult.CONFIRM:
self._do_flash_panda()
elif action == "report_select":
# Report issue - first dialog (issue selection)
if result != DialogResult.CONFIRM or not selection:
self._pending_data = {}
return
self._pending_data["selected_issue"] = selection
# Check if we need extra input
if "crashed" in selection.lower() or "not sure" in selection.lower() or "something else" in selection.lower():
self._pending_action = "report_extra"
self._keyboard.reset()
self._keyboard.set_title("Please describe what's happening")
gui_app.set_modal_overlay(self._keyboard, callback=self._on_keyboard_result)
else:
# Skip to discord username
self._pending_action = "report_discord"
current_discord = self._params.get("DiscordUsername", encoding="utf-8") or ""
self._keyboard.reset()
self._keyboard.set_title("What's your Discord username?")
self._keyboard.set_text(current_discord)
gui_app.set_modal_overlay(self._keyboard, callback=self._on_keyboard_result)
elif action == "report_extra":
# Extra description for the issue
if result != DialogResult.CONFIRM or not selection:
self._pending_data = {}
return
# Append extra description to selected issue
self._pending_data["selected_issue"] += " \u2014 " + selection.strip()
# Now get discord username
self._pending_action = "report_discord"
current_discord = self._params.get("DiscordUsername", encoding="utf-8") or ""
self._keyboard.reset()
self._keyboard.set_title("What's your Discord username?")
self._keyboard.set_text(current_discord)
gui_app.set_modal_overlay(self._keyboard, callback=self._on_keyboard_result)
elif action == "report_discord":
# Discord username input
discord_user = selection.strip() if result == DialogResult.CONFIRM else ""
# Create report data
report_data = {
"DiscordUser": discord_user,
"Issue": self._pending_data.get("selected_issue", ""),
}
# Save discord username and report
if discord_user:
self._params.put_nonblocking("DiscordUsername", discord_user)
self._params_memory.put("IssueReported", json.dumps(report_data))
self._pending_data = {}
# Show confirmation
gui_app.set_modal_overlay(alert_dialog(
"Report Sent! Thanks for letting us know!"
))
elif action == "reset_default":
if result == DialogResult.CONFIRM:
self._do_reset_toggles(use_stock=False)
elif action == "reset_stock":
if result == DialogResult.CONFIRM:
self._do_reset_toggles(use_stock=True)
def _update_toggles(self):
# Report Issue button only visible for FrogAI repo
git_remote = self._params.get("GitRemote", encoding="utf-8") or ""
is_frogai = git_remote.lower() == "https://github.com/frogai/openpilot.git"
if hasattr(self._report_issue_control, "set_visible"):
self._report_issue_control.set_visible(is_frogai)
def show_event(self):
super().show_event()
self._scroller.show_event()
self._update_toggles()
def _render(self, rect):
self._scroller.render(rect)
@@ -0,0 +1,723 @@
import re
from enum import IntEnum
from pathlib import Path
from openpilot.common.params import Params
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.widgets import Widget
from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog, alert_dialog
from openpilot.system.ui.widgets.list_view import ListItem, ToggleAction, ButtonAction, TextAction, ITEM_TEXT_VALUE_COLOR
from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog
from openpilot.system.ui.widgets.scroller_tici import Scroller
from openpilot.frogpilot.common.frogpilot_variables import update_frogpilot_toggles
from openpilot.frogpilot.system.ui.widgets.frogpilot_controls import (
FrogPilotButtonsControl,
FrogPilotButtonToggleControl,
FrogPilotConfirmationDialog,
FrogPilotManageControl,
FrogPilotParamValueControl,
FrogPilotParamValueButtonControl,
)
OPENDBC_PATH = Path("/data/openpilot/opendbc/car")
# Map car makes to their parent brand folder in opendbc
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",
}
CAR_MAKES = [
"Acura", "Audi", "Buick", "Cadillac", "Chevrolet", "Chrysler", "CUPRA",
"Dodge", "Ford", "Genesis", "GMC", "Holden", "Honda", "Hyundai", "Jeep",
"Kia", "Lexus", "Lincoln", "MAN", "Mazda", "Nissan", "Peugeot", "Ram",
"Rivian", "SEAT", "Škoda", "Subaru", "Tesla", "Toyota", "Volkswagen",
]
GM_KEYS = {"VoltSNG"}
HKG_KEYS = {"TacoTuneHacks"}
SUBARU_KEYS = {"SubaruSNG"}
TOYOTA_KEYS = {"ClusterOffset", "FrogsGoMoosTweak", "LockDoorsTimer", "SNGHack", "ToyotaDoors"}
LONGITUDINAL_KEYS = {"FrogsGoMoosTweak", "SNGHack", "VoltSNG"}
VEHICLE_INFO_KEYS = {"BlindSpotSupport", "HardwareDetected", "OpenpilotLongitudinal", "PedalSupport", "RadarSupport", "SDSUSupport", "SNGSupport"}
class SubPanel(IntEnum):
MAIN = 0
GM = 1
HKG = 2
SUBARU = 3
TOYOTA = 4
VEHICLE_INFO = 5
def get_car_names(car_make: str) -> tuple[list[str], dict[str, str]]:
"""
Parse opendbc values.py to get car names for a given make.
Returns (car_names_list, car_name_to_platform_map).
"""
car_names = []
car_models = {}
folder = MAKE_TO_FOLDER.get(car_make.lower(), "")
if not folder:
return car_names, car_models
values_path = OPENDBC_PATH / folder / "values.py"
if not values_path.exists():
return car_names, car_models
try:
content = values_path.read_text()
except Exception:
return car_names, car_models
# Remove comments and footnotes
content = re.sub(r'#[^\n]*', '', content)
content = re.sub(r'footnotes=\[[^\]]*\],\s*', '', content)
# Find platform definitions: PLATFORM_NAME = SomeClass(
platform_pattern = re.compile(r'(\w+)\s*=\s*\w+\s*\(')
platforms = []
for match in platform_pattern.finditer(content):
platforms.append((match.start(), match.group(1)))
platforms.append((len(content), ""))
# Find car names: CarDocs*("Car Name"
car_name_pattern = re.compile(r'CarDocs\w*\s*\(\s*"([^"]+)"')
lower_make = car_make.lower()
for i in range(len(platforms) - 1):
start = platforms[i][0]
end = platforms[i + 1][0]
platform_name = platforms[i][1]
section = content[start:end]
for match in car_name_pattern.finditer(section):
car_name = match.group(1)
if car_name.lower().startswith(lower_make):
car_models[car_name] = platform_name
car_names.append(car_name)
car_names.sort(key=str.lower)
return car_names, car_models
def build_lock_timer_labels() -> dict[int, str]:
"""Build labels for lock doors timer (0-300 seconds)."""
labels = {}
for i in range(0, 301):
if i == 0:
labels[i] = "Never"
elif i == 1:
labels[i] = "1 second"
else:
labels[i] = f"{i} seconds"
return labels
class FrogPilotVehiclesPanel(Widget):
def __init__(self):
super().__init__()
self._current_panel = SubPanel.MAIN
self._params = Params()
self._toggles = {}
self._tuning_level = 0
# Car model mapping (car_name -> platform)
self._car_models: dict[str, str] = {}
# State tracking
self._started = False
# Car capabilities (loaded from frogpilot_variables)
self._has_bsm = False
self._has_openpilot_longitudinal = False
self._has_pedal = False
self._has_radar = False
self._has_sdsu = False
self._has_sng = False
self._has_zss = False
self._can_use_pedal = False
self._can_use_sdsu = False
self._has_alpha_longitudinal = False
self._openpilot_longitudinal_disabled = False
# Car brand flags
self._is_gm = False
self._is_hkg = False
self._is_hkg_canfd = False
self._is_subaru = False
self._is_toyota = False
self._is_volt = False
self._build_main_panel()
self._build_gm_panel()
self._build_hkg_panel()
self._build_subaru_panel()
self._build_toyota_panel()
self._build_vehicle_info_panel()
ui_state.add_offroad_transition_callback(self._update_toggles)
def _build_main_panel(self):
# Car Make Selection
self._car_make_item = ListItem(
title="Car Make",
action_item=ButtonAction(
text="SELECT",
callback=self._on_car_make_click,
),
)
# Car Model Selection
self._car_model_item = ListItem(
title="Car Model",
action_item=ButtonAction(
text="SELECT",
callback=self._on_car_model_click,
),
)
# Force Fingerprint Toggle
self._force_fingerprint_item = ListItem(
title="Disable Automatic Fingerprint Detection",
description="<b>Force the selected fingerprint</b> and prevent it from ever changing.",
action_item=ToggleAction(
initial_state=self._params.get_bool("ForceFingerprint"),
callback=lambda state: self._simple_toggle("ForceFingerprint", state),
),
)
# Disable openpilot Longitudinal Toggle
self._disable_op_long_item = ListItem(
title="Disable openpilot Longitudinal Control",
description="<b>Disable openpilot longitudinal</b> and use the car's stock ACC instead.",
action_item=ToggleAction(
initial_state=self._params.get_bool("DisableOpenpilotLongitudinal"),
callback=self._on_disable_op_long_toggle,
),
)
# GM Settings
self._gm_control = FrogPilotManageControl(
"GMToggles",
"General Motors Settings",
"<b>FrogPilot features for General Motors vehicles.</b>",
"",
)
self._gm_control.set_manage_callback(self._open_gm_panel)
# HKG Settings
self._hkg_control = FrogPilotManageControl(
"HKGToggles",
"Hyundai/Kia/Genesis Settings",
"<b>FrogPilot features for Genesis, Hyundai, and Kia vehicles.</b>",
"",
)
self._hkg_control.set_manage_callback(self._open_hkg_panel)
# Subaru Settings
self._subaru_control = FrogPilotManageControl(
"SubaruToggles",
"Subaru Settings",
"<b>FrogPilot features for Subaru vehicles.</b>",
"",
)
self._subaru_control.set_manage_callback(self._open_subaru_panel)
# Toyota Settings
self._toyota_control = FrogPilotManageControl(
"ToyotaToggles",
"Toyota/Lexus Settings",
"<b>FrogPilot features for Lexus and Toyota vehicles.</b>",
"",
)
self._toyota_control.set_manage_callback(self._open_toyota_panel)
# Vehicle Info
self._vehicle_info_control = FrogPilotManageControl(
"VehicleInfo",
"Vehicle Info",
"<b>Information about your vehicle in regards to openpilot support and functionality.</b>",
"",
)
self._vehicle_info_control.set_manage_callback(self._open_vehicle_info_panel)
main_items = [
self._car_make_item,
self._car_model_item,
self._force_fingerprint_item,
self._disable_op_long_item,
self._gm_control,
self._hkg_control,
self._subaru_control,
self._toyota_control,
self._vehicle_info_control,
]
self._toggles["CarMake"] = self._car_make_item
self._toggles["CarModel"] = self._car_model_item
self._toggles["ForceFingerprint"] = self._force_fingerprint_item
self._toggles["DisableOpenpilotLongitudinal"] = self._disable_op_long_item
self._toggles["GMToggles"] = self._gm_control
self._toggles["HKGToggles"] = self._hkg_control
self._toggles["SubaruToggles"] = self._subaru_control
self._toggles["ToyotaToggles"] = self._toyota_control
self._toggles["VehicleInfo"] = self._vehicle_info_control
self._main_scroller = Scroller(main_items, line_separator=True, spacing=0)
def _build_gm_panel(self):
self._volt_sng_item = ListItem(
title="Stop-and-Go Hack",
description="<b>Force stop-and-go</b> on the 2017 Chevy Volt.",
action_item=ToggleAction(
initial_state=self._params.get_bool("VoltSNG"),
callback=lambda state: self._simple_toggle("VoltSNG", state),
),
)
gm_items = [
self._volt_sng_item,
]
self._toggles["VoltSNG"] = self._volt_sng_item
self._gm_scroller = Scroller(gm_items, line_separator=True, spacing=0)
def _build_hkg_panel(self):
self._taco_tune_item = ListItem(
title="\"Taco Bell Run\" Torque Hack",
description="<b>The steering torque hack from comma's 2022 \"Taco Bell Run\".</b> Designed to increase steering torque at low speeds for left and right turns.",
action_item=ToggleAction(
initial_state=self._params.get_bool("TacoTuneHacks"),
callback=self._on_taco_tune_toggle,
),
)
hkg_items = [
self._taco_tune_item,
]
self._toggles["TacoTuneHacks"] = self._taco_tune_item
self._hkg_scroller = Scroller(hkg_items, line_separator=True, spacing=0)
def _build_subaru_panel(self):
self._subaru_sng_item = ListItem(
title="Stop and Go",
description="<b>Stop and go for supported Subaru vehicles.</b>",
action_item=ToggleAction(
initial_state=self._params.get_bool("SubaruSNG"),
callback=lambda state: self._simple_toggle("SubaruSNG", state),
),
)
subaru_items = [
self._subaru_sng_item,
]
self._toggles["SubaruSNG"] = self._subaru_sng_item
self._subaru_scroller = Scroller(subaru_items, line_separator=True, spacing=0)
def _build_toyota_panel(self):
# Toyota Doors with Lock/Unlock buttons
self._toyota_doors_control = FrogPilotButtonToggleControl(
"ToyotaDoors",
"Automatically Lock/Unlock Doors",
"<b>Automatically lock/unlock doors</b> when shifting in and out of drive.",
"",
button_params=["LockDoors", "UnlockDoors"],
button_texts=["Lock", "Unlock"],
)
# Cluster Offset with Reset button
self._cluster_offset_control = FrogPilotParamValueButtonControl(
"ClusterOffset",
"Dashboard Speed Offset",
"<b>The speed offset openpilot uses to match the speed on the dashboard display.</b>",
"",
min_value=1.000,
max_value=1.050,
label="x",
interval=0.001,
button_texts=["Reset"],
)
self._cluster_offset_control.set_button_click_callback(self._on_cluster_offset_reset)
# FrogsGoMoo's Tweaks
self._frogs_go_moos_item = ListItem(
title="FrogsGoMoo's Personal Tweaks",
description="<b>Personal tweaks by FrogsGoMoo for quicker acceleration and smoother braking.</b>",
action_item=ToggleAction(
initial_state=self._params.get_bool("FrogsGoMoosTweak"),
callback=lambda state: self._simple_toggle("FrogsGoMoosTweak", state),
),
)
# Lock Doors Timer
lock_timer_labels = build_lock_timer_labels()
self._lock_doors_timer_control = FrogPilotParamValueControl(
"LockDoorsTimer",
"Lock Doors On Ignition Off After",
"<b>Automatically lock the doors on ignition off</b> when no one is detected in the front seats.<br><br><b>Warning:</b> openpilot can't detect if keys are still inside the car, so ensure you have a spare key to prevent accidental lockouts!",
"",
min_value=0,
max_value=300,
value_labels=lock_timer_labels,
interval=1,
)
# SNG Hack
self._sng_hack_item = ListItem(
title="Stop-and-Go Hack",
description="<b>Force stop-and-go</b> on Lexus/Toyota vehicles without stock stop-and-go functionality.",
action_item=ToggleAction(
initial_state=self._params.get_bool("SNGHack"),
callback=lambda state: self._simple_toggle("SNGHack", state),
),
)
toyota_items = [
self._toyota_doors_control,
self._cluster_offset_control,
self._frogs_go_moos_item,
self._lock_doors_timer_control,
self._sng_hack_item,
]
self._toggles["ToyotaDoors"] = self._toyota_doors_control
self._toggles["ClusterOffset"] = self._cluster_offset_control
self._toggles["FrogsGoMoosTweak"] = self._frogs_go_moos_item
self._toggles["LockDoorsTimer"] = self._lock_doors_timer_control
self._toggles["SNGHack"] = self._sng_hack_item
self._toyota_scroller = Scroller(toyota_items, line_separator=True, spacing=0)
def _build_vehicle_info_panel(self):
# All are read-only labels
self._hardware_detected_item = ListItem(
title="3rd Party Hardware Detected",
description="<b>Detected 3rd party hardware.</b>",
action_item=TextAction(lambda: self._get_hardware_detected(), color=ITEM_TEXT_VALUE_COLOR),
)
self._bsm_support_item = ListItem(
title="Blind Spot Support",
description="<b>Does openpilot use the vehicle's blind spot data?</b>",
action_item=TextAction(lambda: "Yes" if self._has_bsm else "No", color=ITEM_TEXT_VALUE_COLOR),
)
self._pedal_support_item = ListItem(
title="comma Pedal Support",
description="<b>Does your vehicle support the \"comma pedal\"?</b>",
action_item=TextAction(lambda: "Yes" if self._can_use_pedal else "No", color=ITEM_TEXT_VALUE_COLOR),
)
self._op_long_support_item = ListItem(
title="openpilot Longitudinal Support",
description="<b>Can openpilot control the vehicle's acceleration and braking?</b>",
action_item=TextAction(lambda: "Yes" if self._has_openpilot_longitudinal else "No", color=ITEM_TEXT_VALUE_COLOR),
)
self._radar_support_item = ListItem(
title="Radar Support",
description="<b>Does openpilot use the vehicle's radar data</b> alongside the device's camera for tracking lead vehicles?",
action_item=TextAction(lambda: "Yes" if self._has_radar else "No", color=ITEM_TEXT_VALUE_COLOR),
)
self._sdsu_support_item = ListItem(
title="SDSU Support",
description="<b>Does your vehicle support \"SDSUs\"?</b>",
action_item=TextAction(lambda: "Yes" if self._can_use_sdsu else "No", color=ITEM_TEXT_VALUE_COLOR),
)
self._sng_support_item = ListItem(
title="Stop-and-Go Support",
description="<b>Does your vehicle support stop-and-go driving?</b>",
action_item=TextAction(lambda: "Yes" if self._has_sng else "No", color=ITEM_TEXT_VALUE_COLOR),
)
vehicle_info_items = [
self._hardware_detected_item,
self._bsm_support_item,
self._pedal_support_item,
self._op_long_support_item,
self._radar_support_item,
self._sdsu_support_item,
self._sng_support_item,
]
self._toggles["HardwareDetected"] = self._hardware_detected_item
self._toggles["BlindSpotSupport"] = self._bsm_support_item
self._toggles["PedalSupport"] = self._pedal_support_item
self._toggles["OpenpilotLongitudinal"] = self._op_long_support_item
self._toggles["RadarSupport"] = self._radar_support_item
self._toggles["SDSUSupport"] = self._sdsu_support_item
self._toggles["SNGSupport"] = self._sng_support_item
self._vehicle_info_scroller = Scroller(vehicle_info_items, line_separator=True, spacing=0)
def _get_hardware_detected(self) -> str:
"""Get comma-separated list of detected hardware."""
detected = []
if self._has_pedal:
detected.append("comma Pedal")
if self._has_sdsu:
detected.append("SDSU")
if self._has_zss:
detected.append("ZSS")
return ", ".join(detected) if detected else "None"
def _simple_toggle(self, param: str, state: bool):
self._params.put_bool(param, state)
update_frogpilot_toggles()
def _on_car_make_click(self):
gui_app.set_modal_overlay(MultiOptionDialog(
"Choose your car make",
CAR_MAKES,
))
# Note: Dialog result handling would set CarMake param
def _on_car_model_click(self):
car_make = self._params.get("CarMake", encoding="utf-8") or ""
if not car_make:
gui_app.set_modal_overlay(alert_dialog("Please select a car make first."))
return
car_names, self._car_models = get_car_names(car_make)
if not car_names:
gui_app.set_modal_overlay(alert_dialog(f"No models found for {car_make}."))
return
gui_app.set_modal_overlay(MultiOptionDialog(
"Choose your car model",
car_names,
))
# Note: Dialog result handling would set CarModel and CarModelName params
def _on_disable_op_long_toggle(self, state: bool):
if state:
def on_confirm():
self._params.put_bool("DisableOpenpilotLongitudinal", True)
update_frogpilot_toggles()
if self._started:
gui_app.set_modal_overlay(ConfirmDialog(
"Reboot required to take effect.",
"Reboot Now",
"Reboot Later",
))
gui_app.set_modal_overlay(ConfirmDialog(
"Are you sure you want to completely disable openpilot longitudinal control?",
"Yes",
"No",
))
else:
self._params.put_bool("DisableOpenpilotLongitudinal", False)
update_frogpilot_toggles()
self._update_toggles()
def _on_taco_tune_toggle(self, state: bool):
self._params.put_bool("TacoTuneHacks", state)
update_frogpilot_toggles()
if state and self._started:
gui_app.set_modal_overlay(ConfirmDialog(
"Reboot required to take effect.",
"Reboot Now",
"Reboot Later",
))
def _on_cluster_offset_reset(self, button_id: int):
default_value = self._params.get_key_default_value("ClusterOffset")
if default_value:
try:
self._params.put_float("ClusterOffset", float(default_value))
except (ValueError, TypeError):
self._params.put_float("ClusterOffset", 1.015)
def _open_gm_panel(self):
self._current_panel = SubPanel.GM
def _open_hkg_panel(self):
self._current_panel = SubPanel.HKG
def _open_subaru_panel(self):
self._current_panel = SubPanel.SUBARU
def _open_toyota_panel(self):
self._current_panel = SubPanel.TOYOTA
def _open_vehicle_info_panel(self):
self._current_panel = SubPanel.VEHICLE_INFO
def _close_sub_panel(self):
self._current_panel = SubPanel.MAIN
def _load_car_capabilities(self):
"""Load car capabilities from frogpilot variables."""
try:
from openpilot.frogpilot.common.frogpilot_variables import get_frogpilot_toggles
toggles = get_frogpilot_toggles()
self._has_bsm = getattr(toggles, "has_bsm", False)
self._has_openpilot_longitudinal = getattr(toggles, "has_openpilot_longitudinal", False)
self._has_pedal = getattr(toggles, "has_pedal", False)
self._has_radar = getattr(toggles, "has_radar", False)
self._has_sdsu = getattr(toggles, "has_sdsu", False)
self._has_sng = getattr(toggles, "has_sng", False)
self._has_zss = getattr(toggles, "has_zss", False)
self._can_use_pedal = getattr(toggles, "can_use_pedal", False)
self._can_use_sdsu = getattr(toggles, "can_use_sdsu", False)
self._has_alpha_longitudinal = getattr(toggles, "has_alpha_longitudinal", False)
self._openpilot_longitudinal_disabled = getattr(toggles, "openpilot_longitudinal_disabled", False)
self._is_gm = getattr(toggles, "is_gm", False)
self._is_hkg = getattr(toggles, "is_hkg", False)
self._is_hkg_canfd = getattr(toggles, "is_hkg_canfd", False)
self._is_subaru = getattr(toggles, "is_subaru", False)
self._is_toyota = getattr(toggles, "is_toyota", False)
self._is_volt = getattr(toggles, "is_volt", False)
except Exception:
pass
def _update_toggles(self):
self._tuning_level = self._params.get_int("TuningLevel") or 0
# GM panel visibility
volt_sng_visible = self._is_gm and self._has_openpilot_longitudinal and self._is_volt and not self._has_sng
if hasattr(self._volt_sng_item, "set_visible"):
self._volt_sng_item.set_visible(volt_sng_visible)
# GM parent visible if any child is visible
gm_visible = volt_sng_visible
if hasattr(self._gm_control, "set_visible"):
self._gm_control.set_visible(gm_visible)
# HKG panel visibility
taco_tune_visible = self._is_hkg and self._is_hkg_canfd
if hasattr(self._taco_tune_item, "set_visible"):
self._taco_tune_item.set_visible(taco_tune_visible)
# HKG parent visible if any child is visible
hkg_visible = taco_tune_visible
if hasattr(self._hkg_control, "set_visible"):
self._hkg_control.set_visible(hkg_visible)
# Subaru panel visibility
subaru_sng_visible = self._is_subaru and self._has_sng
if hasattr(self._subaru_sng_item, "set_visible"):
self._subaru_sng_item.set_visible(subaru_sng_visible)
# Subaru parent visible if any child is visible
subaru_visible = subaru_sng_visible
if hasattr(self._subaru_control, "set_visible"):
self._subaru_control.set_visible(subaru_visible)
# Toyota panel visibility
toyota_doors_visible = self._is_toyota
cluster_offset_visible = self._is_toyota
frogs_go_moos_visible = self._is_toyota and self._has_openpilot_longitudinal
lock_doors_timer_visible = self._is_toyota
sng_hack_visible = self._is_toyota and self._has_openpilot_longitudinal and not self._has_sng
if hasattr(self._toyota_doors_control, "set_visible"):
self._toyota_doors_control.set_visible(toyota_doors_visible)
if hasattr(self._cluster_offset_control, "set_visible"):
self._cluster_offset_control.set_visible(cluster_offset_visible)
if hasattr(self._frogs_go_moos_item, "set_visible"):
self._frogs_go_moos_item.set_visible(frogs_go_moos_visible)
if hasattr(self._lock_doors_timer_control, "set_visible"):
self._lock_doors_timer_control.set_visible(lock_doors_timer_visible)
if hasattr(self._sng_hack_item, "set_visible"):
self._sng_hack_item.set_visible(sng_hack_visible)
# Toyota parent visible if any child is visible
toyota_visible = toyota_doors_visible or cluster_offset_visible or frogs_go_moos_visible or lock_doors_timer_visible or sng_hack_visible
if hasattr(self._toyota_control, "set_visible"):
self._toyota_control.set_visible(toyota_visible)
# Disable openpilot longitudinal visibility
disable_op_long_visible = ((self._has_openpilot_longitudinal or self._openpilot_longitudinal_disabled) and
not self._has_alpha_longitudinal)
if hasattr(self._disable_op_long_item, "set_visible"):
self._disable_op_long_item.set_visible(disable_op_long_visible)
def _update_car_display(self):
"""Update car make/model display values."""
car_make = self._params.get("CarMake", encoding="utf-8") or ""
car_model_name = self._params.get("CarModelName", encoding="utf-8") or ""
if not car_model_name:
car_model_name = self._params.get("CarModel", encoding="utf-8") or ""
# Update display values if controls support it
# Note: This would need the ListItem to support set_value
def show_event(self):
super().show_event()
self._main_scroller.show_event()
self._load_car_capabilities()
self._update_toggles()
self._update_car_display()
self._started = ui_state.started
def hide_event(self):
super().hide_event()
self._current_panel = SubPanel.MAIN
def _render(self, rect):
self._started = ui_state.started
if self._current_panel == SubPanel.GM:
self._gm_scroller.render(rect)
elif self._current_panel == SubPanel.HKG:
self._hkg_scroller.render(rect)
elif self._current_panel == SubPanel.SUBARU:
self._subaru_scroller.render(rect)
elif self._current_panel == SubPanel.TOYOTA:
self._toyota_scroller.render(rect)
elif self._current_panel == SubPanel.VEHICLE_INFO:
self._vehicle_info_scroller.render(rect)
else:
self._main_scroller.render(rect)
@@ -0,0 +1,855 @@
from enum import IntEnum
from openpilot.common.conversions import Conversions as CV
from openpilot.common.params import Params
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.list_view import ListItem, ToggleAction
from openpilot.system.ui.widgets.scroller_tici import Scroller
from openpilot.frogpilot.common.frogpilot_variables import update_frogpilot_toggles
from openpilot.frogpilot.system.ui.widgets.frogpilot_controls import (
FrogPilotButtonsControl,
FrogPilotButtonToggleControl,
FrogPilotManageControl,
FrogPilotParamValueControl,
)
ADVANCED_CUSTOM_ONROAD_UI_KEYS = {
"HideAlerts",
"HideLeadMarker",
"HideMaxSpeed",
"HideSpeed",
"HideSpeedLimit",
"WheelSpeed",
}
CUSTOM_ONROAD_UI_KEYS = {
"AccelerationPath",
"AdjacentPath",
"BlindSpotPath",
"Compass",
"OnroadDistanceButton",
"PedalsOnUI",
"RotatingWheel",
}
MODEL_UI_KEYS = {
"DynamicPathWidth",
"LaneLinesWidth",
"PathEdgeWidth",
"PathWidth",
"RoadEdgesWidth",
}
NAVIGATION_UI_KEYS = {
"RoadNameUI",
"ShowSpeedLimits",
"SLCMapboxFiller",
"UseVienna",
}
QUALITY_OF_LIFE_KEYS = {
"CameraView",
"DriverCamera",
"StoppedTimer",
}
INCH_TO_CM = 2.54
CM_TO_INCH = 1.0 / INCH_TO_CM
FOOT_TO_METER = CV.FOOT_TO_METER
METER_TO_FOOT = CV.METER_TO_FOOT
class SubPanel(IntEnum):
MAIN = 0
ADVANCED_CUSTOM_UI = 1
CUSTOM_UI = 2
MODEL_UI = 3
NAVIGATION_UI = 4
QUALITY_OF_LIFE = 5
def build_imperial_small_distance_labels():
"""Build labels for inches (0-24)."""
labels = {}
for i in range(25):
if i == 0:
labels[i] = "Off"
elif i == 1:
labels[i] = "1 inch"
else:
labels[i] = f"{i} inches"
return labels
def build_metric_small_distance_labels():
"""Build labels for centimeters (0-60)."""
labels = {}
for i in range(61):
if i == 0:
labels[i] = "Off"
elif i == 1:
labels[i] = "1 centimeter"
else:
labels[i] = f"{i} centimeters"
return labels
def build_imperial_distance_labels():
"""Build labels for feet (0-10)."""
labels = {}
for i in range(11):
if i == 0:
labels[i] = "Off"
elif i == 1:
labels[i] = "1 foot"
else:
labels[i] = f"{i} feet"
return labels
def build_metric_distance_labels():
"""Build labels for meters (0.0-3.0 in 0.1 steps)."""
labels = {}
for i in range(31):
val = i / 10.0
if val == 0.0:
labels[val] = "Off"
elif val == 1.0:
labels[val] = "1 meter"
else:
labels[val] = f"{val:.1f} meters"
return labels
def build_path_edge_labels():
"""Build labels for path edge width (0-100%)."""
labels = {}
for i in range(101):
if i == 0:
labels[i] = "Off"
else:
labels[i] = f"{i}%"
return labels
class FrogPilotVisualsPanel(Widget):
def __init__(self):
super().__init__()
self._current_panel = SubPanel.MAIN
self._is_metric = False
self._params = Params()
self._toggles = {}
self._tuning_level = 0
# Car capabilities
self._has_bsm = False
self._has_openpilot_longitudinal = False
# Build all panels
self._build_main_panel()
self._build_advanced_custom_ui_panel()
self._build_custom_ui_panel()
self._build_model_ui_panel()
self._build_navigation_ui_panel()
self._build_quality_of_life_panel()
ui_state.add_offroad_transition_callback(self._on_offroad_transition)
def _on_offroad_transition(self):
previous_metric = self._is_metric
self._is_metric = self._params.get_bool("IsMetric")
if self._is_metric != previous_metric:
self._convert_metric_values(previous_metric)
self._update_metric()
self._load_car_capabilities()
self._update_toggles()
def _simple_toggle(self, param: str, state: bool):
self._params.put_bool(param, state)
update_frogpilot_toggles()
# ==================== MAIN PANEL ====================
def _build_main_panel(self):
self._advanced_custom_ui_control = FrogPilotManageControl(
"AdvancedCustomUI",
"Advanced UI Controls",
"<b>Advanced visual changes</b> to fine-tune how the driving screen looks.",
"../../frogpilot/assets/toggle_icons/icon_advanced_device.png",
)
self._advanced_custom_ui_control.set_manage_callback(self._open_advanced_custom_ui)
self._custom_ui_control = FrogPilotManageControl(
"CustomUI",
"Driving Screen Widgets",
"<b>Custom FrogPilot widgets</b> for the driving screen.",
"../assets/icons/calibration.png",
)
self._custom_ui_control.set_manage_callback(self._open_custom_ui)
self._model_ui_control = FrogPilotManageControl(
"ModelUI",
"Model UI",
"<b>Model visualizations</b> for the driving path, lane lines, path edges, and road edges.",
"../../frogpilot/assets/toggle_icons/icon_road.png",
)
self._model_ui_control.set_manage_callback(self._open_model_ui)
self._navigation_ui_control = FrogPilotManageControl(
"NavigationUI",
"Navigation Widgets",
"<b>Speed limits, and other navigation widgets.</b>",
"../../frogpilot/assets/toggle_icons/icon_map.png",
)
self._navigation_ui_control.set_manage_callback(self._open_navigation_ui)
self._qol_visuals_control = FrogPilotManageControl(
"QOLVisuals",
"Quality of Life",
"<b>Miscellaneous visual changes</b> to fine-tune how the driving screen looks.",
"../../frogpilot/assets/toggle_icons/icon_quality_of_life.png",
)
self._qol_visuals_control.set_manage_callback(self._open_quality_of_life)
main_items = [
self._advanced_custom_ui_control,
self._custom_ui_control,
self._model_ui_control,
self._navigation_ui_control,
self._qol_visuals_control,
]
self._toggles["AdvancedCustomUI"] = self._advanced_custom_ui_control
self._toggles["CustomUI"] = self._custom_ui_control
self._toggles["ModelUI"] = self._model_ui_control
self._toggles["NavigationUI"] = self._navigation_ui_control
self._toggles["QOLVisuals"] = self._qol_visuals_control
self._main_scroller = Scroller(main_items, line_separator=True, spacing=0)
# ==================== ADVANCED CUSTOM UI PANEL ====================
def _build_advanced_custom_ui_panel(self):
self._hide_speed_item = ListItem(
title="Hide Current Speed",
description="<b>Hide the current speed</b> from the driving screen.",
action_item=ToggleAction(
initial_state=self._params.get_bool("HideSpeed"),
callback=lambda state: self._simple_toggle("HideSpeed", state),
),
)
self._hide_lead_marker_item = ListItem(
title="Hide Lead Marker",
description="<b>Hide the lead-vehicle marker</b> from the driving screen.",
action_item=ToggleAction(
initial_state=self._params.get_bool("HideLeadMarker"),
callback=lambda state: self._simple_toggle("HideLeadMarker", state),
),
)
self._hide_max_speed_item = ListItem(
title="Hide Max Speed",
description="<b>Hide the max speed</b> from the driving screen.",
action_item=ToggleAction(
initial_state=self._params.get_bool("HideMaxSpeed"),
callback=lambda state: self._simple_toggle("HideMaxSpeed", state),
),
)
self._hide_alerts_item = ListItem(
title="Hide Non-Critical Alerts",
description="<b>Hide non-critical alerts</b> from the driving screen.",
action_item=ToggleAction(
initial_state=self._params.get_bool("HideAlerts"),
callback=lambda state: self._simple_toggle("HideAlerts", state),
),
)
self._hide_speed_limit_item = ListItem(
title="Hide Speed Limits",
description="<b>Hide posted speed limits</b> from the driving screen.",
action_item=ToggleAction(
initial_state=self._params.get_bool("HideSpeedLimit"),
callback=lambda state: self._simple_toggle("HideSpeedLimit", state),
),
)
self._wheel_speed_item = ListItem(
title="Use Wheel Speed",
description="<b>Use the vehicle's wheel speed</b> instead of the cluster speed. This is purely a visual change and doesn't impact how openpilot drives!",
action_item=ToggleAction(
initial_state=self._params.get_bool("WheelSpeed"),
callback=lambda state: self._simple_toggle("WheelSpeed", state),
),
)
advanced_items = [
self._hide_speed_item,
self._hide_lead_marker_item,
self._hide_max_speed_item,
self._hide_alerts_item,
self._hide_speed_limit_item,
self._wheel_speed_item,
]
self._toggles["HideSpeed"] = self._hide_speed_item
self._toggles["HideLeadMarker"] = self._hide_lead_marker_item
self._toggles["HideMaxSpeed"] = self._hide_max_speed_item
self._toggles["HideAlerts"] = self._hide_alerts_item
self._toggles["HideSpeedLimit"] = self._hide_speed_limit_item
self._toggles["WheelSpeed"] = self._wheel_speed_item
self._advanced_custom_ui_scroller = Scroller(advanced_items, line_separator=True, spacing=0)
# ==================== CUSTOM UI PANEL ====================
def _build_custom_ui_panel(self):
self._acceleration_path_item = ListItem(
title="Acceleration Path",
description="<b>Color the driving path by planned acceleration and braking.</b>",
action_item=ToggleAction(
initial_state=self._params.get_bool("AccelerationPath"),
callback=lambda state: self._simple_toggle("AccelerationPath", state),
),
)
self._adjacent_path_item = ListItem(
title="Adjacent Lanes",
description="<b>Show the driving paths for the left and right lanes.</b>",
action_item=ToggleAction(
initial_state=self._params.get_bool("AdjacentPath"),
callback=lambda state: self._simple_toggle("AdjacentPath", state),
),
)
self._blind_spot_path_item = ListItem(
title="Blind Spot Path",
description="<b>Show a red path when a vehicle is in that lane's blind spot.</b>",
action_item=ToggleAction(
initial_state=self._params.get_bool("BlindSpotPath"),
callback=lambda state: self._simple_toggle("BlindSpotPath", state),
),
)
self._compass_item = ListItem(
title="Compass",
description="<b>Show the current driving direction</b> with a simple on-screen compass.",
action_item=ToggleAction(
initial_state=self._params.get_bool("Compass"),
callback=lambda state: self._simple_toggle("Compass", state),
),
)
self._onroad_distance_button_item = ListItem(
title="Driving Personality Button",
description="<b>Control and view the current driving personality</b> via a driving screen widget.",
action_item=ToggleAction(
initial_state=self._params.get_bool("OnroadDistanceButton"),
callback=lambda state: self._simple_toggle("OnroadDistanceButton", state),
),
)
# PedalsOnUI with Dynamic/Static mutually exclusive options
self._pedals_on_ui_control = FrogPilotButtonToggleControl(
"PedalsOnUI",
"Gas / Brake Pedal Indicators",
"<b>On-screen gas and brake indicators.</b><br><br><b>Dynamic</b>: Opacity changes according to how much openpilot is accelerating or braking<br><b>Static</b>: Full when active, dim when not",
"",
button_params=["DynamicPedalsOnUI", "StaticPedalsOnUI"],
button_texts=["Dynamic", "Static"],
)
self._pedals_on_ui_control.set_button_callback(self._on_pedals_button_click)
self._rotating_wheel_item = ListItem(
title="Rotating Steering Wheel",
description="<b>Rotate the driving screen wheel</b> with the physical steering wheel.",
action_item=ToggleAction(
initial_state=self._params.get_bool("RotatingWheel"),
callback=lambda state: self._simple_toggle("RotatingWheel", state),
),
)
custom_items = [
self._acceleration_path_item,
self._adjacent_path_item,
self._blind_spot_path_item,
self._compass_item,
self._onroad_distance_button_item,
self._pedals_on_ui_control,
self._rotating_wheel_item,
]
self._toggles["AccelerationPath"] = self._acceleration_path_item
self._toggles["AdjacentPath"] = self._adjacent_path_item
self._toggles["BlindSpotPath"] = self._blind_spot_path_item
self._toggles["Compass"] = self._compass_item
self._toggles["OnroadDistanceButton"] = self._onroad_distance_button_item
self._toggles["PedalsOnUI"] = self._pedals_on_ui_control
self._toggles["RotatingWheel"] = self._rotating_wheel_item
self._custom_ui_scroller = Scroller(custom_items, line_separator=True, spacing=0)
def _on_pedals_button_click(self, button_id: int):
"""Handle mutually exclusive Dynamic/Static pedals options."""
if button_id == 0:
# Dynamic clicked - disable Static
self._params.put_bool("StaticPedalsOnUI", False)
elif button_id == 1:
# Static clicked - disable Dynamic
self._params.put_bool("DynamicPedalsOnUI", False)
update_frogpilot_toggles()
# ==================== MODEL UI PANEL ====================
def _build_model_ui_panel(self):
self._dynamic_path_width_item = ListItem(
title="Dynamic Path Width",
description="<b>Change the path width based on engagement.</b><br><br><b>Fully Engaged</b>: 100%<br><b>Always On Lateral</b>: 75%<br><b>Disengaged</b>: 50%",
action_item=ToggleAction(
initial_state=self._params.get_bool("DynamicPathWidth"),
callback=lambda state: self._simple_toggle("DynamicPathWidth", state),
),
)
# Lane Lines Width - 0-24 inches or 0-60 cm
self._lane_lines_width_control = FrogPilotParamValueControl(
"LaneLinesWidth",
"Lane Lines Width",
"<b>Set the lane-line thickness.</b><br><br>Default matches the MUTCD lane-line width standard of 4 inches.",
"",
min_value=0,
max_value=24,
unit=" inches",
labels=build_imperial_small_distance_labels(),
)
# Path Edge Width - 0-100%
self._path_edge_width_control = FrogPilotParamValueControl(
"PathEdgeWidth",
"Path Edges Width",
"<b>Set the driving-path edge width</b> that represents different driving modes and statuses.<br><br>Default is 20% of the total path width.<br><br>Color Guide:<br><br>- <b>Light Blue</b>: Always On Lateral<br>- <b>Green</b>: Default<br>- <b>Orange</b>: Experimental Mode<br>- <b>Red</b>: Traffic Mode<br>- <b>Yellow</b>: Conditional Experimental Mode overridden",
"",
min_value=0,
max_value=100,
unit="",
labels=build_path_edge_labels(),
)
# Path Width - 0-10 feet or 0-3 meters (0.1 step)
self._path_width_control = FrogPilotParamValueControl(
"PathWidth",
"Path Width",
"<b>Set the driving-path width.</b><br><br>Default (6.1 feet) matches the width of a 2019 Lexus ES 350.",
"",
min_value=0,
max_value=10,
unit=" feet",
labels=build_imperial_distance_labels(),
step=0.1,
)
# Road Edges Width - 0-24 inches or 0-60 cm
self._road_edges_width_control = FrogPilotParamValueControl(
"RoadEdgesWidth",
"Road Edges Width",
"<b>Set the road-edge thickness.</b><br><br>Default matches half of the MUTCD lane-line width standard of 4 inches.",
"",
min_value=0,
max_value=24,
unit=" inches",
labels=build_imperial_small_distance_labels(),
)
model_items = [
self._dynamic_path_width_item,
self._lane_lines_width_control,
self._path_edge_width_control,
self._path_width_control,
self._road_edges_width_control,
]
self._toggles["DynamicPathWidth"] = self._dynamic_path_width_item
self._toggles["LaneLinesWidth"] = self._lane_lines_width_control
self._toggles["PathEdgeWidth"] = self._path_edge_width_control
self._toggles["PathWidth"] = self._path_width_control
self._toggles["RoadEdgesWidth"] = self._road_edges_width_control
self._model_ui_scroller = Scroller(model_items, line_separator=True, spacing=0)
# ==================== NAVIGATION UI PANEL ====================
def _build_navigation_ui_panel(self):
self._road_name_ui_item = ListItem(
title="Road Name",
description="<b>Display the road name at the bottom of the driving screen</b> using data from \"OpenStreetMap (OSM)\".",
action_item=ToggleAction(
initial_state=self._params.get_bool("RoadNameUI"),
callback=lambda state: self._simple_toggle("RoadNameUI", state),
),
)
self._show_speed_limits_item = ListItem(
title="Show Speed Limits",
description="<b>Show speed limits</b> in the top-left corner of the driving screen. Uses data from the car's dashboard (if supported) and \"OpenStreetMap (OSM)\".",
action_item=ToggleAction(
initial_state=self._params.get_bool("ShowSpeedLimits"),
callback=lambda state: self._on_show_speed_limits_toggle(state),
),
)
self._slc_mapbox_filler_item = ListItem(
title="Show Speed Limits from Mapbox",
description="<b>Use Mapbox speed-limit data when no other source is available.</b>",
action_item=ToggleAction(
initial_state=self._params.get_bool("SLCMapboxFiller"),
callback=lambda state: self._simple_toggle("SLCMapboxFiller", state),
),
)
self._use_vienna_item = ListItem(
title="Use Vienna-Style Speed Signs",
description="<b>Show Vienna-style (EU) speed-limit signs</b> instead of MUTCD (US).",
action_item=ToggleAction(
initial_state=self._params.get_bool("UseVienna"),
callback=lambda state: self._simple_toggle("UseVienna", state),
),
)
navigation_items = [
self._road_name_ui_item,
self._show_speed_limits_item,
self._slc_mapbox_filler_item,
self._use_vienna_item,
]
self._toggles["RoadNameUI"] = self._road_name_ui_item
self._toggles["ShowSpeedLimits"] = self._show_speed_limits_item
self._toggles["SLCMapboxFiller"] = self._slc_mapbox_filler_item
self._toggles["UseVienna"] = self._use_vienna_item
self._navigation_ui_scroller = Scroller(navigation_items, line_separator=True, spacing=0)
def _on_show_speed_limits_toggle(self, state: bool):
"""Handle ShowSpeedLimits toggle and update dependent visibility."""
self._params.put_bool("ShowSpeedLimits", state)
update_frogpilot_toggles()
self._update_toggles()
# ==================== QUALITY OF LIFE PANEL ====================
def _build_quality_of_life_panel(self):
# Camera View - 4 options: Auto, Driver, Standard, Wide
self._camera_view_control = FrogPilotButtonsControl(
"CameraView",
"Camera View",
"<b>Select the active camera view.</b> This is purely a visual change and doesn't impact how openpilot drives!",
"",
button_texts=["AUTO", "DRIVER", "STANDARD", "WIDE"],
checkable=True,
exclusive=True,
)
self._camera_view_control.set_click_callback(self._on_camera_view_click)
self._update_camera_view_selection()
self._driver_camera_item = ListItem(
title="Show Driver Camera When In Reverse",
description="<b>Show the driver camera feed</b> when the vehicle is in reverse.",
action_item=ToggleAction(
initial_state=self._params.get_bool("DriverCamera"),
callback=lambda state: self._simple_toggle("DriverCamera", state),
),
)
self._stopped_timer_item = ListItem(
title="Stopped Timer",
description="<b>Show a timer when stopped</b> in place of the current speed to indicate how long the vehicle has been stopped.",
action_item=ToggleAction(
initial_state=self._params.get_bool("StoppedTimer"),
callback=lambda state: self._simple_toggle("StoppedTimer", state),
),
)
qol_items = [
self._camera_view_control,
self._driver_camera_item,
self._stopped_timer_item,
]
self._toggles["CameraView"] = self._camera_view_control
self._toggles["DriverCamera"] = self._driver_camera_item
self._toggles["StoppedTimer"] = self._stopped_timer_item
self._qol_scroller = Scroller(qol_items, line_separator=True, spacing=0)
def _on_camera_view_click(self, button_id: int):
"""Handle camera view selection (0=Auto, 1=Driver, 2=Standard, 3=Wide)."""
self._params.put_int("CameraView", button_id)
self._update_camera_view_selection()
update_frogpilot_toggles()
def _update_camera_view_selection(self):
"""Update the camera view button selection state."""
current = self._params.get_int("CameraView") or 0
if hasattr(self._camera_view_control, "set_checked_button"):
self._camera_view_control.set_checked_button(current)
# ==================== PANEL NAVIGATION ====================
def _open_advanced_custom_ui(self):
self._current_panel = SubPanel.ADVANCED_CUSTOM_UI
def _open_custom_ui(self):
self._current_panel = SubPanel.CUSTOM_UI
def _open_model_ui(self):
self._current_panel = SubPanel.MODEL_UI
def _open_navigation_ui(self):
self._current_panel = SubPanel.NAVIGATION_UI
def _open_quality_of_life(self):
self._current_panel = SubPanel.QUALITY_OF_LIFE
def _close_sub_panel(self):
self._current_panel = SubPanel.MAIN
# ==================== METRIC CONVERSION ====================
def _convert_metric_values(self, was_metric: bool):
"""Convert stored values when metric setting changes."""
if was_metric:
# Converting from metric to imperial
small_conversion = CM_TO_INCH
distance_conversion = METER_TO_FOOT
else:
# Converting from imperial to metric
small_conversion = INCH_TO_CM
distance_conversion = FOOT_TO_METER
# Convert lane lines width (inches <-> cm)
lane_lines_width = self._params.get_int("LaneLinesWidth") or 0
self._params.put_int("LaneLinesWidth", int(lane_lines_width * small_conversion))
# Convert road edges width (inches <-> cm)
road_edges_width = self._params.get_int("RoadEdgesWidth") or 0
self._params.put_int("RoadEdgesWidth", int(road_edges_width * small_conversion))
# Convert path width (feet <-> meters)
path_width = self._params.get_float("PathWidth") or 0.0
self._params.put_float("PathWidth", path_width * distance_conversion)
def _update_metric(self):
"""Update control labels and ranges based on metric setting."""
if self._is_metric:
# Metric: cm for small distances, meters for path width
self._lane_lines_width_control.set_description(
"<b>Set the lane-line thickness.</b><br><br>Default matches the MUTCD lane-line width standard of 10 centimeters."
)
self._path_width_control.set_description(
"<b>Set the driving-path width.</b><br><br>Default (1.9 meters) matches the width of a 2019 Lexus ES 350."
)
self._road_edges_width_control.set_description(
"<b>Set the road-edge thickness.</b><br><br>Default matches half of the MUTCD lane-line width standard of 10 centimeters."
)
if hasattr(self._lane_lines_width_control, "update_control"):
self._lane_lines_width_control.update_control(0, 60, build_metric_small_distance_labels())
if hasattr(self._road_edges_width_control, "update_control"):
self._road_edges_width_control.update_control(0, 60, build_metric_small_distance_labels())
if hasattr(self._path_width_control, "update_control"):
self._path_width_control.update_control(0, 3, build_metric_distance_labels())
else:
# Imperial: inches for small distances, feet for path width
self._lane_lines_width_control.set_description(
"<b>Set the lane-line thickness.</b><br><br>Default matches the MUTCD lane-line width standard of 4 inches."
)
self._path_width_control.set_description(
"<b>Set the driving-path width.</b><br><br>Default (6.1 feet) matches the width of a 2019 Lexus ES 350."
)
self._road_edges_width_control.set_description(
"<b>Set the road-edge thickness.</b><br><br>Default matches half of the MUTCD lane-line width standard of 4 inches."
)
if hasattr(self._lane_lines_width_control, "update_control"):
self._lane_lines_width_control.update_control(0, 24, build_imperial_small_distance_labels())
if hasattr(self._road_edges_width_control, "update_control"):
self._road_edges_width_control.update_control(0, 24, build_imperial_small_distance_labels())
if hasattr(self._path_width_control, "update_control"):
self._path_width_control.update_control(0, 10, build_imperial_distance_labels())
# ==================== VISIBILITY UPDATES ====================
def _load_car_capabilities(self):
"""Load car capabilities from frogpilot variables."""
try:
from openpilot.frogpilot.common.frogpilot_variables import get_frogpilot_toggles
toggles = get_frogpilot_toggles()
self._has_bsm = getattr(toggles, "has_bsm", False)
self._has_openpilot_longitudinal = getattr(toggles, "has_openpilot_longitudinal", False)
except Exception:
self._has_bsm = False
self._has_openpilot_longitudinal = False
def _update_toggles(self):
"""Update toggle visibility based on tuning level and car capabilities."""
self._tuning_level = self._params.get_int("TuningLevel") or 0
# Load toggle levels
try:
import json
toggle_levels_str = self._params.get("FrogPilotTogglesLevels", encoding="utf-8") or "{}"
toggle_levels = json.loads(toggle_levels_str)
except Exception:
toggle_levels = {}
# First, hide all parent toggles
for key in ["AdvancedCustomUI", "CustomUI", "ModelUI", "NavigationUI", "QOLVisuals"]:
if key in self._toggles and hasattr(self._toggles[key], "set_visible"):
self._toggles[key].set_visible(False)
# Check which child toggles are visible and show their parents accordingly
slc_enabled = self._params.get_bool("SpeedLimitController")
show_speed_limits = self._params.get_bool("ShowSpeedLimits")
mapbox_key = self._params.get("MapboxSecretKey", encoding="utf-8") or ""
# Advanced Custom UI children
advanced_visible = False
for key in ADVANCED_CUSTOM_ONROAD_UI_KEYS:
if key not in self._toggles:
continue
toggle_level = toggle_levels.get(key, 0)
visible = self._tuning_level >= toggle_level
# Special visibility conditions
if key == "HideLeadMarker":
visible = visible and self._has_openpilot_longitudinal
elif key == "HideSpeedLimit":
visible = visible and self._has_openpilot_longitudinal and slc_enabled
if hasattr(self._toggles[key], "set_visible"):
self._toggles[key].set_visible(visible)
if visible:
advanced_visible = True
if advanced_visible and hasattr(self._toggles["AdvancedCustomUI"], "set_visible"):
self._toggles["AdvancedCustomUI"].set_visible(True)
# Custom UI children
custom_visible = False
for key in CUSTOM_ONROAD_UI_KEYS:
if key not in self._toggles:
continue
toggle_level = toggle_levels.get(key, 0)
visible = self._tuning_level >= toggle_level
# Special visibility conditions
if key == "AccelerationPath":
visible = visible and self._has_openpilot_longitudinal
elif key == "BlindSpotPath":
visible = visible and self._has_bsm
elif key == "OnroadDistanceButton":
visible = visible and self._has_openpilot_longitudinal
elif key == "PedalsOnUI":
visible = visible and self._has_openpilot_longitudinal
if hasattr(self._toggles[key], "set_visible"):
self._toggles[key].set_visible(visible)
if visible:
custom_visible = True
if custom_visible and hasattr(self._toggles["CustomUI"], "set_visible"):
self._toggles["CustomUI"].set_visible(True)
# Model UI children
model_visible = False
for key in MODEL_UI_KEYS:
if key not in self._toggles:
continue
toggle_level = toggle_levels.get(key, 0)
visible = self._tuning_level >= toggle_level
if hasattr(self._toggles[key], "set_visible"):
self._toggles[key].set_visible(visible)
if visible:
model_visible = True
if model_visible and hasattr(self._toggles["ModelUI"], "set_visible"):
self._toggles["ModelUI"].set_visible(True)
# Navigation UI children
nav_visible = False
for key in NAVIGATION_UI_KEYS:
if key not in self._toggles:
continue
toggle_level = toggle_levels.get(key, 0)
visible = self._tuning_level >= toggle_level
# Special visibility conditions
if key == "ShowSpeedLimits":
# ShowSpeedLimits visible when SpeedLimitController is OFF or no longitudinal
visible = visible and (not slc_enabled or not self._has_openpilot_longitudinal)
elif key == "SLCMapboxFiller":
# Visible if ShowSpeedLimits enabled, SLC off (or no longitudinal), and Mapbox key present
visible = visible and show_speed_limits
visible = visible and (not slc_enabled or not self._has_openpilot_longitudinal)
visible = visible and bool(mapbox_key)
elif key == "UseVienna":
# Visible if either ShowSpeedLimits or SpeedLimitController is enabled
visible = visible and (show_speed_limits or slc_enabled)
if hasattr(self._toggles[key], "set_visible"):
self._toggles[key].set_visible(visible)
if visible:
nav_visible = True
if nav_visible and hasattr(self._toggles["NavigationUI"], "set_visible"):
self._toggles["NavigationUI"].set_visible(True)
# Quality of Life children
qol_visible = False
for key in QUALITY_OF_LIFE_KEYS:
if key not in self._toggles:
continue
toggle_level = toggle_levels.get(key, 0)
visible = self._tuning_level >= toggle_level
if hasattr(self._toggles[key], "set_visible"):
self._toggles[key].set_visible(visible)
if visible:
qol_visible = True
if qol_visible and hasattr(self._toggles["QOLVisuals"], "set_visible"):
self._toggles["QOLVisuals"].set_visible(True)
# ==================== LIFECYCLE ====================
def show_event(self):
super().show_event()
self._main_scroller.show_event()
self._is_metric = self._params.get_bool("IsMetric")
self._update_metric()
self._load_car_capabilities()
self._update_toggles()
self._update_camera_view_selection()
def hide_event(self):
super().hide_event()
self._current_panel = SubPanel.MAIN
def _render(self, rect):
if self._current_panel == SubPanel.ADVANCED_CUSTOM_UI:
self._advanced_custom_ui_scroller.render(rect)
elif self._current_panel == SubPanel.CUSTOM_UI:
self._custom_ui_scroller.render(rect)
elif self._current_panel == SubPanel.MODEL_UI:
self._model_ui_scroller.render(rect)
elif self._current_panel == SubPanel.NAVIGATION_UI:
self._navigation_ui_scroller.render(rect)
elif self._current_panel == SubPanel.QUALITY_OF_LIFE:
self._qol_scroller.render(rect)
else:
self._main_scroller.render(rect)
@@ -0,0 +1,166 @@
from openpilot.common.params import Params
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
from openpilot.system.ui.widgets.confirm_dialog import DialogResult
from openpilot.system.ui.widgets.list_view import ListItem, ButtonAction, ITEM_TEXT_VALUE_COLOR, TextAction
from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog
from openpilot.system.ui.widgets.scroller_tici import Scroller
from openpilot.frogpilot.common.frogpilot_variables import update_frogpilot_toggles
# Button function mappings
BUTTON_FUNCTIONS = {
0: "No Action",
3: "Pause Steering",
}
LONGITUDINAL_FUNCTIONS = {
1: "Change \"Personality Profile\"",
2: "Force openpilot to Coast",
4: "Pause Acceleration/Braking",
5: "Toggle \"Experimental Mode\" On/Off",
6: "Toggle \"Traffic Mode\" On/Off",
}
# Button parameter configurations
WHEEL_TOGGLES = [
("DistanceButtonControl", "Distance Button", "<b>Action performed when the \"Distance\" button is pressed.</b>"),
("LongDistanceButtonControl", "Distance Button (Long Press)", "<b>Action performed when the \"Distance\" button is pressed for more than 0.5 seconds.</b>"),
("VeryLongDistanceButtonControl", "Distance Button (Very Long Press)", "<b>Action performed when the \"Distance\" button is pressed for more than 2.5 seconds.</b>"),
("LKASButtonControl", "LKAS Button", "<b>Action performed when the \"LKAS\" button is pressed.</b>"),
]
class FrogPilotWheelPanel(Widget):
def __init__(self):
super().__init__()
self._params = Params()
self._toggles = {}
self._tuning_level = 0
# Car capabilities
self._has_openpilot_longitudinal = False
self._is_subaru = False
self._lkas_allowed_for_aol = False
# Pending dialog action tracking
self._pending_action = None # "select_function"
self._pending_param = None
self._build_panel()
ui_state.add_offroad_transition_callback(self._update_toggles)
def _build_panel(self):
items = []
for param, title, desc in WHEEL_TOGGLES:
control = ListItem(
title=title,
description=desc,
action_item=ButtonAction(
text="SELECT",
callback=lambda p=param: self._on_button_click(p),
),
)
# Store reference for updating value display
self._toggles[param] = control
items.append(control)
self._scroller = Scroller(items, line_separator=True, spacing=0)
def _get_function_name(self, param: str) -> str:
"""Get the display name for the currently selected function."""
value = self._params.get_int(param)
# Check both base and longitudinal functions
all_functions = {**BUTTON_FUNCTIONS}
if self._has_openpilot_longitudinal:
all_functions.update(LONGITUDINAL_FUNCTIONS)
return all_functions.get(value, "No Action")
def _on_button_click(self, param: str):
"""Handle button click to open function selection dialog."""
# Build available functions list
functions = dict(BUTTON_FUNCTIONS)
if self._has_openpilot_longitudinal:
functions.update(LONGITUDINAL_FUNCTIONS)
# Get current selection
current_value = self._params.get_int(param)
current_name = functions.get(current_value, "No Action")
# Show selection dialog
self._pending_action = "select_function"
self._pending_param = param
gui_app.set_modal_overlay(MultiOptionDialog(
"Select a function to assign to this button",
list(functions.values()),
current_name,
))
def handle_dialog_result(self, result: DialogResult, selection: str = ""):
"""Handle dialog results for pending actions."""
action = self._pending_action
param = self._pending_param
self._pending_action = None
self._pending_param = None
if action == "select_function":
if result != DialogResult.CONFIRM or not selection:
return
# Find the function ID for the selected name
all_functions = {**BUTTON_FUNCTIONS, **LONGITUDINAL_FUNCTIONS}
function_id = None
for fid, name in all_functions.items():
if name == selection:
function_id = fid
break
if function_id is not None and param:
self._params.put_int(param, function_id)
update_frogpilot_toggles()
def _load_car_capabilities(self):
"""Load car capabilities from frogpilot variables."""
try:
from openpilot.frogpilot.common.frogpilot_variables import get_frogpilot_toggles
toggles = get_frogpilot_toggles()
self._has_openpilot_longitudinal = getattr(toggles, "has_openpilot_longitudinal", False)
self._is_subaru = getattr(toggles, "is_subaru", False)
self._lkas_allowed_for_aol = getattr(toggles, "lkas_allowed_for_aol", False)
except Exception:
pass
def _update_toggles(self):
self._tuning_level = self._params.get_int("TuningLevel") or 0
self._load_car_capabilities()
# LKAS button visibility
lkas_visible = True
if self._is_subaru:
lkas_visible = False
elif self._lkas_allowed_for_aol:
aol_enabled = self._params.get_bool("AlwaysOnLateral")
aol_lkas = self._params.get_bool("AlwaysOnLateralLKAS")
if aol_enabled and aol_lkas:
lkas_visible = False
lkas_control = self._toggles.get("LKASButtonControl")
if lkas_control and hasattr(lkas_control, "set_visible"):
lkas_control.set_visible(lkas_visible)
def show_event(self):
super().show_event()
self._scroller.show_event()
self._load_car_capabilities()
self._update_toggles()
def _render(self, rect):
self._scroller.render(rect)
@@ -0,0 +1,3 @@
from openpilot.frogpilot.ui.layouts.settings.frogpilot import FrogPilotLayout
__all__ = ["FrogPilotLayout"]
@@ -5,6 +5,7 @@ from collections.abc import Callable
from openpilot.selfdrive.ui.layouts.settings.developer import DeveloperLayout
from openpilot.selfdrive.ui.layouts.settings.device import DeviceLayout
from openpilot.selfdrive.ui.layouts.settings.firehose import FirehoseLayout
from openpilot.selfdrive.ui.layouts.settings.frogpilot import FrogPilotLayout
from openpilot.selfdrive.ui.layouts.settings.software import SoftwareLayout
from openpilot.selfdrive.ui.layouts.settings.toggles import TogglesLayout
from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
@@ -37,6 +38,7 @@ class PanelType(IntEnum):
SOFTWARE = 3
FIREHOSE = 4
DEVELOPER = 5
FROGPILOT = 6
@dataclass
@@ -62,6 +64,7 @@ class SettingsLayout(Widget):
PanelType.SOFTWARE: PanelInfo(tr_noop("Software"), SoftwareLayout()),
PanelType.FIREHOSE: PanelInfo(tr_noop("Firehose"), FirehoseLayout()),
PanelType.DEVELOPER: PanelInfo(tr_noop("Developer"), DeveloperLayout()),
PanelType.FROGPILOT: PanelInfo(tr_noop("FrogPilot"), FrogPilotLayout()),
}
self._font_medium = gui_app.font(FontWeight.MEDIUM)