From 483f967636a7e45b9141a51db8614471f529c877 Mon Sep 17 00:00:00 2001
From: James <91348155+FrogAi@users.noreply.github.com>
Date: Mon, 19 Jan 2026 21:58:52 -0700
Subject: [PATCH] We do a little vibe coding
---
.../system/ui/widgets/frogpilot_controls.py | 1101 +++++++++
.../ui/layouts/settings/data_settings.py | 1082 ++++++++
.../ui/layouts/settings/device_settings.py | 425 ++++
frogpilot/ui/layouts/settings/frogpilot.py | 407 +++
.../ui/layouts/settings/lateral_settings.py | 702 ++++++
.../layouts/settings/longitudinal_settings.py | 2182 +++++++++++++++++
.../ui/layouts/settings/maps_settings.py | 480 ++++
.../ui/layouts/settings/model_settings.py | 770 ++++++
.../layouts/settings/navigation_settings.py | 371 +++
.../ui/layouts/settings/sounds_settings.py | 458 ++++
.../ui/layouts/settings/theme_settings.py | 976 ++++++++
frogpilot/ui/layouts/settings/utilities.py | 376 +++
.../ui/layouts/settings/vehicle_settings.py | 723 ++++++
.../ui/layouts/settings/visual_settings.py | 855 +++++++
.../ui/layouts/settings/wheel_settings.py | 166 ++
selfdrive/ui/layouts/settings/frogpilot.py | 3 +
selfdrive/ui/layouts/settings/settings.py | 3 +
17 files changed, 11080 insertions(+)
create mode 100644 frogpilot/system/ui/widgets/frogpilot_controls.py
create mode 100644 frogpilot/ui/layouts/settings/data_settings.py
create mode 100644 frogpilot/ui/layouts/settings/device_settings.py
create mode 100644 frogpilot/ui/layouts/settings/frogpilot.py
create mode 100644 frogpilot/ui/layouts/settings/lateral_settings.py
create mode 100644 frogpilot/ui/layouts/settings/longitudinal_settings.py
create mode 100644 frogpilot/ui/layouts/settings/maps_settings.py
create mode 100644 frogpilot/ui/layouts/settings/model_settings.py
create mode 100644 frogpilot/ui/layouts/settings/navigation_settings.py
create mode 100644 frogpilot/ui/layouts/settings/sounds_settings.py
create mode 100644 frogpilot/ui/layouts/settings/theme_settings.py
create mode 100644 frogpilot/ui/layouts/settings/utilities.py
create mode 100644 frogpilot/ui/layouts/settings/vehicle_settings.py
create mode 100644 frogpilot/ui/layouts/settings/visual_settings.py
create mode 100644 frogpilot/ui/layouts/settings/wheel_settings.py
create mode 100644 selfdrive/ui/layouts/settings/frogpilot.py
diff --git a/frogpilot/system/ui/widgets/frogpilot_controls.py b/frogpilot/system/ui/widgets/frogpilot_controls.py
new file mode 100644
index 0000000000..14b0e46b79
--- /dev/null
+++ b/frogpilot/system/ui/widgets/frogpilot_controls.py
@@ -0,0 +1,1101 @@
+import math
+import os
+import pyray as rl
+
+from collections.abc import Callable
+
+from openpilot.common.params import Params
+from openpilot.system.ui.lib.application import FontWeight, gui_app
+from openpilot.system.ui.widgets import DialogResult, Widget
+from openpilot.system.ui.widgets.button import Button, ButtonStyle
+from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog
+from openpilot.system.ui.widgets.label import Label
+from openpilot.system.ui.widgets.list_view import ITEM_DESC_FONT_SIZE, ITEM_DESC_TEXT_COLOR
+from openpilot.system.ui.widgets.toggle import Toggle
+
+__all__ = [
+ "CONTROL_HEIGHT",
+ "DEFAULT_BUTTON_HEIGHT",
+ "DEFAULT_BUTTON_WIDTH",
+ "FROGPILOT_VALUE_COLOR",
+ "ITEM_SPACING",
+ "SEPARATOR_MARGIN",
+ "FrogPilotButtonControl",
+ "FrogPilotButtonsControl",
+ "FrogPilotButtonToggleControl",
+ "FrogPilotConfirmationDialog",
+ "FrogPilotDualParamValueControl",
+ "FrogPilotListWidget",
+ "FrogPilotManageControl",
+ "FrogPilotParamValueButtonControl",
+ "FrogPilotParamValueControl",
+ "GifAnimation",
+ "clear_gif",
+ "load_gif",
+ "load_image",
+ "load_texture_cached",
+ "open_descriptions",
+]
+
+CONTROL_HEIGHT = 120
+DEFAULT_BUTTON_HEIGHT = 100
+DEFAULT_BUTTON_WIDTH = 225
+FROGPILOT_VALUE_COLOR = rl.Color(224, 232, 121, 255)
+ITEM_SPACING = 25
+SEPARATOR_MARGIN = 40
+
+_gif_cache: dict[str, "GifAnimation"] = {}
+_texture_cache: dict[str, rl.Texture] = {}
+
+
+class GifAnimation:
+ def __init__(self, gif_path: str, size: tuple[int, int] | None = None):
+ self._current_frame = 0
+ self._frame_count = 0
+ self._frame_delay = 0.1
+ self._frames: list[rl.Texture] = []
+ self._last_update = 0.0
+ self._loaded = False
+ self._path = gif_path
+ self._running = False
+ self._size = size
+
+ self._load_gif()
+
+ def _load_gif(self) -> None:
+ if not os.path.exists(self._path):
+ return
+
+ try:
+ frame_count = rl.ffi.new("int *")
+ image = rl.load_image_anim(self._path.encode(), frame_count)
+ self._frame_count = frame_count[0]
+
+ if self._frame_count <= 0:
+ rl.unload_image(image)
+ return
+
+ frame_height = image.height // self._frame_count
+ for i in range(self._frame_count):
+ frame_rect = rl.Rectangle(0, i * frame_height, image.width, frame_height)
+ frame_image = rl.image_from_image(image, frame_rect)
+
+ if self._size:
+ rl.image_resize(rl.ffi.addressof(frame_image), self._size[0], self._size[1])
+
+ texture = rl.load_texture_from_image(frame_image)
+ self._frames.append(texture)
+ rl.unload_image(frame_image)
+
+ rl.unload_image(image)
+ self._loaded = True
+ except Exception:
+ self._loaded = False
+
+ @property
+ def file_name(self) -> str:
+ return self._path
+
+ @property
+ def is_loaded(self) -> bool:
+ return self._loaded and len(self._frames) > 0
+
+ @property
+ def is_running(self) -> bool:
+ return self._running
+
+ @property
+ def scaled_size(self) -> tuple[int, int] | None:
+ return self._size
+
+ def get_current_texture(self) -> rl.Texture | None:
+ if not self.is_loaded:
+ return None
+ return self._frames[self._current_frame]
+
+ def render(self, x: int, y: int, tint: rl.Color = rl.WHITE) -> None:
+ self.update()
+ texture = self.get_current_texture()
+ if texture:
+ rl.draw_texture(texture, x, y, tint)
+
+ def set_scaled_size(self, size: tuple[int, int]) -> None:
+ if self._size == size:
+ return
+ self._size = size
+
+ def start(self) -> None:
+ self._running = True
+ self._last_update = rl.get_time()
+
+ def stop(self) -> None:
+ self._running = False
+
+ def unload(self) -> None:
+ for texture in self._frames:
+ rl.unload_texture(texture)
+ self._frames.clear()
+ self._loaded = False
+
+ def update(self) -> None:
+ if not self._running or not self.is_loaded:
+ return
+
+ current_time = rl.get_time()
+ if current_time - self._last_update >= self._frame_delay:
+ self._current_frame = (self._current_frame + 1) % self._frame_count
+ self._last_update = current_time
+
+
+def clear_gif(gif: GifAnimation | None) -> None:
+ if gif is None:
+ return
+
+ gif.stop()
+ gif.unload()
+
+
+def load_gif(gif_path: str, size: tuple[int, int], use_cache: bool = True) -> GifAnimation | None:
+ if not gif_path or not os.path.exists(gif_path):
+ return None
+
+ cache_key = f"{gif_path}_{size[0]}x{size[1]}"
+
+ if use_cache and cache_key in _gif_cache:
+ cached = _gif_cache[cache_key]
+ if cached.is_loaded:
+ if not cached.is_running:
+ cached.start()
+ return cached
+
+ gif = GifAnimation(gif_path, size)
+ if not gif.is_loaded:
+ return None
+
+ gif.start()
+
+ if use_cache:
+ _gif_cache[cache_key] = gif
+
+ return gif
+
+
+def load_image(base_path: str, size: tuple[int, int]) -> tuple[rl.Texture | None, GifAnimation | None]:
+ gif_path = base_path + ".gif"
+ if os.path.exists(gif_path):
+ gif = load_gif(gif_path, size)
+ return (None, gif)
+
+ png_path = base_path + ".png"
+ texture = load_texture_cached(png_path, size[0], size[1])
+ return (texture, None)
+
+
+def load_texture_cached(path: str, width: int = 0, height: int = 0) -> rl.Texture | None:
+ if not path:
+ return None
+
+ cache_key = f"{path}_{width}_{height}"
+ if cache_key in _texture_cache:
+ return _texture_cache[cache_key]
+
+ actual_path = path
+ if not os.path.exists(path):
+ for ext in [".png", ".gif", ".jpg", ".jpeg"]:
+ test_path = path + ext
+ if os.path.exists(test_path):
+ actual_path = test_path
+ break
+ else:
+ return None
+
+ texture = gui_app.texture(actual_path, width, height) if width > 0 and height > 0 else rl.load_texture(actual_path)
+ _texture_cache[cache_key] = texture
+ return texture
+
+
+def open_descriptions(force_open: bool, toggles: dict) -> None:
+ if force_open:
+ for key, toggle in toggles.items():
+ if key != "CESpeed" and hasattr(toggle, "show_description"):
+ toggle.show_description()
+
+
+class FrogPilotButtonControl(Widget):
+ def __init__(self,
+ param: str,
+ title: str,
+ description: str,
+ icon: str = "",
+ button_texts: list[str] = None,
+ checkable: bool = False,
+ exclusive: bool = False,
+ minimum_button_width: int = DEFAULT_BUTTON_WIDTH):
+ super().__init__()
+
+ self._button_checked: list[bool] = []
+ self._button_enabled: list[bool] = []
+ self._buttons: list[Button] = []
+ self._checkable = checkable
+ self._click_callback: Callable[[int], None] | None = None
+ self._description = description
+ self._disabled_click_callback: Callable[[int], None] | None = None
+ self._exclusive = exclusive
+ self._minimum_button_width = minimum_button_width
+ self._param_key = param
+ self._params = Params()
+
+ self._desc_label = Label(description, font_size=ITEM_DESC_FONT_SIZE,
+ text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
+ text_color=ITEM_DESC_TEXT_COLOR)
+ self._title_label = Label(title, font_size=50, font_weight=FontWeight.MEDIUM,
+ text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT)
+ self._toggle = Toggle(initial_state=self._params.get_bool(param),
+ callback=self._on_toggle_changed)
+
+ button_texts = button_texts or []
+ for i, text in enumerate(button_texts):
+ btn = Button(text, click_callback=lambda idx=i: self._on_button_click(idx),
+ button_style=ButtonStyle.LIST_ACTION)
+ self._buttons.append(btn)
+ self._button_checked.append(False)
+ self._button_enabled.append(True)
+
+ def _on_button_click(self, button_id: int) -> None:
+ if not self._button_enabled[button_id]:
+ if self._disabled_click_callback:
+ self._disabled_click_callback(button_id)
+ return
+
+ if self._checkable:
+ if self._exclusive:
+ for i in range(len(self._button_checked)):
+ self._button_checked[i] = (i == button_id)
+ else:
+ self._button_checked[button_id] = not self._button_checked[button_id]
+
+ if self._click_callback:
+ self._click_callback(button_id)
+
+ def _on_toggle_changed(self, state: bool) -> None:
+ self._params.put_bool(self._param_key, state)
+ self.refresh()
+
+ def _render(self, rect: rl.Rectangle) -> None:
+ desc_height = ITEM_DESC_FONT_SIZE if self._description else 0
+ title_height = 50
+ toggle_width = 160
+
+ visible_buttons = [(i, btn) for i, btn in enumerate(self._buttons) if btn.is_visible]
+ total_button_width = sum(self._minimum_button_width for _ in visible_buttons) + 10 * max(0, len(visible_buttons) - 1)
+
+ text_width = rect.width - total_button_width - toggle_width - 60
+
+ title_rect = rl.Rectangle(rect.x + 20, rect.y + 10, text_width, title_height)
+ self._title_label.render(title_rect)
+
+ if self._description:
+ desc_rect = rl.Rectangle(rect.x + 20, rect.y + 10 + title_height + 5, text_width, desc_height)
+ self._desc_label.render(desc_rect)
+
+ if visible_buttons:
+ button_x = rect.x + text_width + 30
+ button_y = rect.y + (rect.height - DEFAULT_BUTTON_HEIGHT) // 2
+
+ toggle_state = self._toggle.get_state()
+ for i, btn in visible_buttons:
+ btn.set_enabled(self._button_enabled[i] and toggle_state)
+
+ if self._checkable and self._button_checked[i]:
+ btn.set_button_style(ButtonStyle.PRIMARY)
+ else:
+ btn.set_button_style(ButtonStyle.LIST_ACTION)
+
+ btn_rect = rl.Rectangle(button_x, button_y, self._minimum_button_width, DEFAULT_BUTTON_HEIGHT)
+ btn.render(btn_rect)
+ button_x += self._minimum_button_width + 10
+
+ toggle_x = rect.x + rect.width - toggle_width - 20
+ toggle_y = rect.y + (rect.height - 80) // 2
+ self._toggle.render(rl.Rectangle(toggle_x, toggle_y, toggle_width, 80))
+
+ def clear_checked_buttons(self) -> None:
+ for i in range(len(self._button_checked)):
+ self._button_checked[i] = False
+
+ def refresh(self) -> None:
+ state = self._params.get_bool(self._param_key)
+ self._toggle.set_state(state)
+
+ for i in range(len(self._button_enabled)):
+ self._button_enabled[i] = state
+
+ def set_checked_button(self, button_id: int) -> None:
+ if 0 <= button_id < len(self._button_checked):
+ self._button_checked[button_id] = True
+
+ def set_click_callback(self, callback: Callable[[int], None]) -> None:
+ self._click_callback = callback
+
+ def set_disabled_click_callback(self, callback: Callable[[int], None]) -> None:
+ self._disabled_click_callback = callback
+
+ def set_enabled(self, enable: bool) -> None:
+ for i in range(len(self._button_enabled)):
+ self._button_enabled[i] = enable
+
+ def set_enabled_buttons(self, button_id: int, enable: bool) -> None:
+ if 0 <= button_id < len(self._button_enabled):
+ self._button_enabled[button_id] = enable
+
+ def set_text(self, button_id: int, text: str) -> None:
+ if 0 <= button_id < len(self._buttons):
+ self._buttons[button_id].set_text(text)
+
+ def set_visible_button(self, button_id: int, visible: bool) -> None:
+ if 0 <= button_id < len(self._buttons):
+ self._buttons[button_id].set_visible(visible)
+
+ def show_event(self) -> None:
+ self.refresh()
+
+
+class FrogPilotButtonsControl(Widget):
+ def __init__(self,
+ title: str,
+ description: str,
+ icon: str = "",
+ button_texts: list[str] = None,
+ checkable: bool = False,
+ exclusive: bool = True,
+ minimum_button_width: int = DEFAULT_BUTTON_WIDTH):
+ super().__init__()
+
+ self._button_checked: list[bool] = []
+ self._button_enabled: list[bool] = []
+ self._buttons: list[Button] = []
+ self._checkable = checkable
+ self._click_callback: Callable[[int], None] | None = None
+ self._description = description
+ self._disabled_click_callback: Callable[[int], None] | None = None
+ self._exclusive = exclusive
+ self._minimum_button_width = minimum_button_width
+
+ self._desc_label = Label(description, font_size=ITEM_DESC_FONT_SIZE,
+ text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
+ text_color=ITEM_DESC_TEXT_COLOR)
+ self._title_label = Label(title, font_size=50, font_weight=FontWeight.MEDIUM,
+ text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT)
+
+ button_texts = button_texts or []
+ for i, text in enumerate(button_texts):
+ btn = Button(text, click_callback=lambda idx=i: self._on_button_click(idx),
+ button_style=ButtonStyle.LIST_ACTION)
+ self._buttons.append(btn)
+ self._button_checked.append(False)
+ self._button_enabled.append(True)
+
+ def _on_button_click(self, button_id: int) -> None:
+ if not self._button_enabled[button_id]:
+ if self._disabled_click_callback:
+ self._disabled_click_callback(button_id)
+ return
+
+ if self._checkable:
+ if self._exclusive:
+ for i in range(len(self._button_checked)):
+ self._button_checked[i] = (i == button_id)
+ else:
+ self._button_checked[button_id] = not self._button_checked[button_id]
+
+ if self._click_callback:
+ self._click_callback(button_id)
+
+ def _render(self, rect: rl.Rectangle) -> None:
+ desc_height = ITEM_DESC_FONT_SIZE if self._description else 0
+ text_width = rect.width * 0.5
+ title_height = 50
+
+ title_rect = rl.Rectangle(rect.x + 20, rect.y + 10, text_width, title_height)
+ self._title_label.render(title_rect)
+
+ if self._description:
+ desc_rect = rl.Rectangle(rect.x + 20, rect.y + 10 + title_height + 5, text_width, desc_height)
+ self._desc_label.render(desc_rect)
+
+ visible_buttons = [(i, btn) for i, btn in enumerate(self._buttons) if btn.is_visible]
+ if visible_buttons:
+ total_button_width = sum(self._minimum_button_width for _ in visible_buttons) + 10 * (len(visible_buttons) - 1)
+ button_x = rect.x + rect.width - total_button_width - 20
+ button_y = rect.y + (rect.height - DEFAULT_BUTTON_HEIGHT) // 2
+
+ for i, btn in visible_buttons:
+ btn.set_enabled(self._button_enabled[i])
+
+ if self._checkable and self._button_checked[i]:
+ btn.set_button_style(ButtonStyle.PRIMARY)
+ else:
+ btn.set_button_style(ButtonStyle.LIST_ACTION)
+
+ btn_rect = rl.Rectangle(button_x, button_y, self._minimum_button_width, DEFAULT_BUTTON_HEIGHT)
+ btn.render(btn_rect)
+ button_x += self._minimum_button_width + 10
+
+ def clear_checked_buttons(self) -> None:
+ for i in range(len(self._button_checked)):
+ self._button_checked[i] = False
+
+ def set_checked_button(self, button_id: int) -> None:
+ if 0 <= button_id < len(self._button_checked):
+ self._button_checked[button_id] = True
+
+ def set_click_callback(self, callback: Callable[[int], None]) -> None:
+ self._click_callback = callback
+
+ def set_disabled_click_callback(self, callback: Callable[[int], None]) -> None:
+ self._disabled_click_callback = callback
+
+ def set_enabled(self, enable: bool) -> None:
+ for i in range(len(self._button_enabled)):
+ self._button_enabled[i] = enable
+
+ def set_enabled_buttons(self, button_id: int, enable: bool) -> None:
+ if 0 <= button_id < len(self._button_enabled):
+ self._button_enabled[button_id] = enable
+
+ def set_text(self, button_id: int, text: str) -> None:
+ if 0 <= button_id < len(self._buttons):
+ self._buttons[button_id].set_text(text)
+
+ def set_visible_button(self, button_id: int, visible: bool) -> None:
+ if 0 <= button_id < len(self._buttons):
+ self._buttons[button_id].set_visible(visible)
+
+
+class FrogPilotButtonToggleControl(FrogPilotButtonControl):
+ def __init__(self,
+ param: str,
+ title: str,
+ description: str,
+ icon: str = "",
+ button_params: list[str] = None,
+ button_texts: list[str] = None,
+ exclusive: bool = False,
+ minimum_button_width: int = DEFAULT_BUTTON_WIDTH):
+ super().__init__(param, title, description, icon, button_texts, True, exclusive, minimum_button_width)
+
+ self._button_params = button_params or []
+
+ for i, bp in enumerate(self._button_params):
+ if i < len(self._button_checked):
+ self._button_checked[i] = self._params.get_bool(bp)
+
+ def _on_button_click(self, button_id: int) -> None:
+ if not self._button_enabled[button_id]:
+ if self._disabled_click_callback:
+ self._disabled_click_callback(button_id)
+ return
+
+ if button_id < len(self._button_params):
+ new_state = not self._button_checked[button_id]
+ self._button_checked[button_id] = new_state
+ self._params.put_bool(self._button_params[button_id], new_state)
+
+ if self._click_callback:
+ self._click_callback(button_id)
+
+ def refresh(self) -> None:
+ super().refresh()
+
+ for i, bp in enumerate(self._button_params):
+ if i < len(self._button_checked):
+ self._button_checked[i] = self._params.get_bool(bp)
+
+
+class FrogPilotConfirmationDialog(ConfirmDialog):
+ def __init__(self, prompt_text: str, confirm_text: str, cancel_text: str, rich: bool = False):
+ super().__init__(prompt_text, confirm_text, cancel_text, rich)
+
+ @staticmethod
+ def create_toggle_reboot() -> "FrogPilotConfirmationDialog":
+ return FrogPilotConfirmationDialog(
+ "Reboot required to take effect.",
+ "Reboot Now",
+ "Reboot Later"
+ )
+
+ @staticmethod
+ def create_yesorno(prompt_text: str) -> "FrogPilotConfirmationDialog":
+ return FrogPilotConfirmationDialog(prompt_text, "Yes", "No")
+
+ @staticmethod
+ def toggle_reboot(parent=None) -> bool:
+ return False
+
+ @staticmethod
+ def yesorno(prompt_text: str, parent=None) -> bool:
+ return False
+
+
+class FrogPilotDualParamValueControl(Widget):
+ def __init__(self, control1: "FrogPilotParamValueControl", control2: "FrogPilotParamValueControl"):
+ super().__init__()
+ self._control1 = control1
+ self._control2 = control2
+
+ def _render(self, rect: rl.Rectangle) -> None:
+ half_width = rect.width // 2 - 5
+
+ control1_rect = rl.Rectangle(rect.x, rect.y, half_width, rect.height)
+ control2_rect = rl.Rectangle(rect.x + half_width + 10, rect.y, half_width, rect.height)
+
+ self._control1.render(control1_rect)
+ self._control2.render(control2_rect)
+
+ def refresh(self) -> None:
+ self._control1.refresh()
+ self._control2.refresh()
+
+ def update_control(self, min_value: float, max_value: float, value_labels: dict[float, str] = None) -> None:
+ self._control1.update_control(min_value, max_value, value_labels)
+ self._control2.update_control(min_value, max_value, value_labels)
+
+
+class FrogPilotListWidget(Widget):
+ def __init__(self, spacing: int = ITEM_SPACING):
+ super().__init__()
+ self._items: list[Widget] = []
+ self._spacing = spacing
+
+ def _render(self, rect: rl.Rectangle) -> None:
+ if not self._items:
+ return
+
+ current_y = rect.y
+ visible_items = [item for item in self._items if item.is_visible]
+
+ for i, item in enumerate(visible_items):
+ item_height = item.rect.height if item.rect.height > 0 else CONTROL_HEIGHT
+ item_rect = rl.Rectangle(rect.x, current_y, rect.width, item_height)
+
+ item.render(item_rect)
+
+ if i < len(visible_items) - 1:
+ separator_y = current_y + item_height + self._spacing // 2
+ rl.draw_line(
+ int(rect.x + SEPARATOR_MARGIN),
+ int(separator_y),
+ int(rect.x + rect.width - SEPARATOR_MARGIN),
+ int(separator_y),
+ ITEM_DESC_TEXT_COLOR
+ )
+
+ current_y += item_height + self._spacing
+
+ def add_item(self, widget: Widget, expanding: bool = False) -> None:
+ self._items.append(widget)
+
+ def clear(self) -> None:
+ self._items.clear()
+
+ def insert_item(self, index: int, widget: Widget, expanding: bool = False) -> None:
+ self._items.insert(index, widget)
+
+ def set_spacing(self, spacing: int) -> None:
+ self._spacing = spacing
+
+
+class FrogPilotManageControl(Widget):
+ def __init__(self, param: str, title: str, description: str, icon: str = ""):
+ super().__init__()
+
+ self._description = description
+ self._manage_callback: Callable[[], None] | None = None
+ self._manage_visible = True
+ self._param_key = param
+ self._params = Params()
+
+ self._desc_label = Label(description, font_size=ITEM_DESC_FONT_SIZE,
+ text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
+ text_color=ITEM_DESC_TEXT_COLOR)
+ self._manage_button = Button("MANAGE", click_callback=self._on_manage_clicked,
+ button_style=ButtonStyle.LIST_ACTION)
+ self._title_label = Label(title, font_size=50, font_weight=FontWeight.MEDIUM,
+ text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT)
+ self._toggle = Toggle(initial_state=self._params.get_bool(param),
+ callback=self._on_toggle_changed)
+
+ def _on_manage_clicked(self) -> None:
+ if self._manage_callback:
+ self._manage_callback()
+
+ def _on_toggle_changed(self, state: bool) -> None:
+ self._params.put_bool(self._param_key, state)
+ self.refresh()
+
+ def _render(self, rect: rl.Rectangle) -> None:
+ desc_height = ITEM_DESC_FONT_SIZE if self._description else 0
+ manage_width = 150 if self._manage_visible else 0
+ title_height = 50
+ toggle_width = 160
+
+ text_width = rect.width - toggle_width - manage_width - 60
+
+ title_rect = rl.Rectangle(rect.x + 20, rect.y + 10, text_width, title_height)
+ self._title_label.render(title_rect)
+
+ if self._description:
+ desc_rect = rl.Rectangle(rect.x + 20, rect.y + 10 + title_height + 5, text_width, desc_height)
+ self._desc_label.render(desc_rect)
+
+ if self._manage_visible:
+ manage_x = rect.x + text_width + 30
+ manage_y = rect.y + (rect.height - DEFAULT_BUTTON_HEIGHT) // 2
+ self._manage_button.render(rl.Rectangle(manage_x, manage_y, manage_width, DEFAULT_BUTTON_HEIGHT))
+
+ toggle_x = rect.x + rect.width - toggle_width - 20
+ toggle_y = rect.y + (rect.height - 80) // 2
+ self._toggle.render(rl.Rectangle(toggle_x, toggle_y, toggle_width, 80))
+
+ def refresh(self) -> None:
+ state = self._params.get_bool(self._param_key)
+ self._toggle.set_state(state)
+ self._manage_button.set_enabled(state)
+
+ def set_manage_callback(self, callback: Callable[[], None]) -> None:
+ self._manage_callback = callback
+
+ def set_manage_visibility(self, visible: bool) -> None:
+ self._manage_visible = visible
+
+ def show_event(self) -> None:
+ self.refresh()
+
+
+class FrogPilotParamValueButtonControl(Widget):
+ def __init__(self,
+ param: str,
+ title: str,
+ description: str,
+ icon: str = "",
+ min_value: float = 0,
+ max_value: float = 100,
+ label: str = "",
+ value_labels: dict[float, str] = None,
+ interval: float = 1.0,
+ fast_increase: bool = False,
+ button_params: list[str] = None,
+ button_texts: list[str] = None,
+ left_button: bool = False,
+ checkable: bool = True,
+ minimum_button_width: int = DEFAULT_BUTTON_WIDTH):
+ super().__init__()
+
+ self._button_checked: list[bool] = []
+ self._button_click_callback: Callable[[int], None] | None = None
+ self._button_enabled: list[bool] = []
+ self._button_params = button_params or []
+ self._buttons: list[Button] = []
+ self._checkable = checkable
+ self._decrement_repeating = False
+ self._description = description
+ self._display_warning = False
+ self._factor = 10 ** math.ceil(-math.log10(interval)) if interval > 0 else 1
+ self._fast_increase = fast_increase
+ self._increment_repeating = False
+ self._interval = interval
+ self._label_suffix = label
+ self._label_width = 200
+ self._last_action_time = 0.0
+ self._left_button = left_button
+ self._max_value = max_value
+ self._min_value = min_value
+ self._minimum_button_width = minimum_button_width
+ self._param_key = param
+ self._params = Params()
+ self._value_changed_callback: Callable[[float], None] | None = None
+ self._value_labels = value_labels or {}
+ self._warning_shown = False
+
+ self._value = self._read_param_value()
+ self._previous_value = self._value
+
+ self._decrement_button = Button("-", click_callback=self._on_decrement,
+ font_size=50, button_style=ButtonStyle.LIST_ACTION)
+ self._desc_label = Label(description, font_size=ITEM_DESC_FONT_SIZE,
+ text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
+ text_color=ITEM_DESC_TEXT_COLOR)
+ self._increment_button = Button("+", click_callback=self._on_increment,
+ font_size=50, button_style=ButtonStyle.LIST_ACTION)
+ self._title_label = Label(title, font_size=50, font_weight=FontWeight.MEDIUM,
+ text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT)
+ self._value_label = Label(self._format_value(), font_size=50,
+ text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT,
+ text_color=FROGPILOT_VALUE_COLOR)
+
+ button_texts = button_texts or []
+ for i, text in enumerate(button_texts):
+ checked = checkable and i < len(self._button_params) and self._params.get_bool(self._button_params[i])
+ btn = Button(text, click_callback=lambda idx=i: self._on_button_click(idx),
+ button_style=ButtonStyle.PRIMARY if checked else ButtonStyle.LIST_ACTION)
+ self._buttons.append(btn)
+ self._button_checked.append(checked)
+ self._button_enabled.append(True)
+
+ def _format_value(self) -> str:
+ for val, label in self._value_labels.items():
+ if round(val * self._factor) == round(self._value * self._factor):
+ return label
+
+ if self._interval >= 1:
+ return f"{int(self._value)}{self._label_suffix}"
+ else:
+ decimals = max(0, int(math.ceil(-math.log10(self._interval))))
+ return f"{self._value:.{decimals}f}{self._label_suffix}"
+
+ def _on_button_click(self, button_id: int) -> None:
+ if self._checkable and button_id < len(self._button_params):
+ new_state = not self._button_checked[button_id]
+ self._button_checked[button_id] = new_state
+ self._params.put_bool(self._button_params[button_id], new_state)
+
+ self._buttons[button_id].set_button_style(
+ ButtonStyle.PRIMARY if new_state else ButtonStyle.LIST_ACTION
+ )
+
+ if self._button_click_callback:
+ self._button_click_callback(button_id)
+
+ def _on_decrement(self) -> None:
+ if self._display_warning and not self._warning_shown:
+ self._show_warning()
+
+ current_time = rl.get_time()
+ if current_time - self._last_action_time > 0.65:
+ self._decrement_repeating = False
+
+ delta = self._interval * 5 if self._decrement_repeating and self._fast_increase else self._interval
+ self._value = max(self._value - delta, self._min_value)
+ self._update_value()
+
+ if round(self._value / self._interval) % 5 == 0:
+ self._decrement_repeating = True
+
+ self._last_action_time = current_time
+
+ def _on_increment(self) -> None:
+ if self._display_warning and not self._warning_shown:
+ self._show_warning()
+
+ current_time = rl.get_time()
+ if current_time - self._last_action_time > 0.65:
+ self._increment_repeating = False
+
+ delta = self._interval * 5 if self._increment_repeating and self._fast_increase else self._interval
+ self._value = min(self._value + delta, self._max_value)
+ self._update_value()
+
+ if round(self._value / self._interval) % 5 == 0:
+ self._increment_repeating = True
+
+ self._last_action_time = current_time
+
+ def _read_param_value(self) -> float:
+ try:
+ return float(self._params.get_int(self._param_key))
+ except (ValueError, TypeError):
+ try:
+ return self._params.get_float(self._param_key)
+ except (ValueError, TypeError):
+ return self._min_value
+
+ def _render(self, rect: rl.Rectangle) -> None:
+ button_size = 100
+ total_btn_width = sum(self._minimum_button_width for _ in self._buttons) + 10 * max(0, len(self._buttons) - 1)
+
+ if self._left_button:
+ btn_start_x = rect.x + 20
+ value_start_x = btn_start_x + total_btn_width + 20
+ else:
+ value_start_x = rect.x + rect.width - button_size * 2 - self._label_width - 40
+ btn_start_x = rect.x + rect.width - total_btn_width - button_size * 2 - 60
+
+ text_width = value_start_x - rect.x - 40 if not self._left_button else rect.width - total_btn_width - self._label_width - button_size * 2 - 100
+
+ desc_height = ITEM_DESC_FONT_SIZE if self._description else 0
+ title_height = 50
+
+ title_rect = rl.Rectangle(rect.x + 20, rect.y + 10, text_width, title_height)
+ self._title_label.render(title_rect)
+
+ if self._description:
+ desc_rect = rl.Rectangle(rect.x + 20, rect.y + 10 + title_height + 5, text_width, desc_height)
+ self._desc_label.render(desc_rect)
+
+ button_y = rect.y + (rect.height - DEFAULT_BUTTON_HEIGHT) // 2
+ current_btn_x = btn_start_x
+
+ for i, btn in enumerate(self._buttons):
+ btn.set_enabled(self._button_enabled[i])
+ btn.render(rl.Rectangle(current_btn_x, button_y, self._minimum_button_width, DEFAULT_BUTTON_HEIGHT))
+ current_btn_x += self._minimum_button_width + 10
+
+ value_x = rect.x + rect.width - button_size * 2 - self._label_width - 40
+ value_y = rect.y + (rect.height - 50) // 2
+ self._value_label.render(rl.Rectangle(value_x, value_y, self._label_width, 50))
+
+ button_y = rect.y + (rect.height - button_size) // 2
+
+ dec_x = rect.x + rect.width - button_size * 2 - 30
+ self._decrement_button.render(rl.Rectangle(dec_x, button_y, button_size, button_size))
+
+ inc_x = rect.x + rect.width - button_size - 20
+ self._increment_button.render(rl.Rectangle(inc_x, button_y, button_size, button_size))
+
+ def _show_warning(self) -> None:
+ self._warning_shown = True
+
+ def _update_value(self) -> None:
+ self._value = round(self._value * self._factor) / self._factor
+ self._value_label.set_text(self._format_value())
+
+ self._decrement_button.set_enabled(self._value > self._min_value)
+ self._increment_button.set_enabled(self._value < self._max_value)
+
+ if self._value_changed_callback:
+ self._value_changed_callback(self._value)
+
+ def _write_param_value(self) -> None:
+ if self._value == self._previous_value:
+ return
+
+ if self._interval >= 1 and self._interval == int(self._interval):
+ self._params.put_int(self._param_key, int(self._value))
+ else:
+ self._params.put_float(self._param_key, self._value)
+
+ self._previous_value = self._value
+
+ def hide_event(self) -> None:
+ self._warning_shown = False
+ self._write_param_value()
+
+ def refresh(self) -> None:
+ self._value = self._read_param_value()
+ self._value = max(self._min_value, min(self._max_value, self._value))
+ self._previous_value = self._value
+ self._update_value()
+ self._write_param_value()
+
+ if self._checkable:
+ for i, bp in enumerate(self._button_params):
+ if i < len(self._button_checked):
+ checked = self._params.get_bool(bp)
+ self._button_checked[i] = checked
+ self._buttons[i].set_button_style(
+ ButtonStyle.PRIMARY if checked else ButtonStyle.LIST_ACTION
+ )
+
+ def set_button_click_callback(self, callback: Callable[[int], None]) -> None:
+ self._button_click_callback = callback
+
+ def set_enabled_buttons(self, button_id: int, enable: bool) -> None:
+ if 0 <= button_id < len(self._button_enabled):
+ self._button_enabled[button_id] = enable
+
+ def set_value_changed_callback(self, callback: Callable[[float], None]) -> None:
+ self._value_changed_callback = callback
+
+ def set_warning(self, warning: str) -> None:
+ self._display_warning = True
+
+ def show_event(self) -> None:
+ self.refresh()
+
+ def update_control(self, min_value: float, max_value: float, value_labels: dict[float, str] = None) -> None:
+ self._min_value = min_value
+ self._max_value = max_value
+ if value_labels is not None:
+ self._value_labels = value_labels
+ self.refresh()
+
+
+class FrogPilotParamValueControl(Widget):
+ def __init__(self,
+ param: str,
+ title: str,
+ description: str,
+ icon: str = "",
+ min_value: float = 0,
+ max_value: float = 100,
+ label: str = "",
+ value_labels: dict[float, str] = None,
+ interval: float = 1.0,
+ fast_increase: bool = False,
+ label_width: int = 350):
+ super().__init__()
+
+ self._decrement_repeating = False
+ self._description = description
+ self._display_warning = False
+ self._factor = 10 ** math.ceil(-math.log10(interval)) if interval > 0 else 1
+ self._fast_increase = fast_increase
+ self._increment_repeating = False
+ self._interval = interval
+ self._label_suffix = label
+ self._label_width = label_width
+ self._last_action_time = 0.0
+ self._max_value = max_value
+ self._min_value = min_value
+ self._param_key = param
+ self._params = Params()
+ self._value_changed_callback: Callable[[float], None] | None = None
+ self._value_labels = value_labels or {}
+ self._warning_shown = False
+
+ self._value = self._read_param_value()
+ self._previous_value = self._value
+
+ self._decrement_button = Button("-", click_callback=self._on_decrement,
+ font_size=50, button_style=ButtonStyle.LIST_ACTION)
+ self._desc_label = Label(description, font_size=ITEM_DESC_FONT_SIZE,
+ text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
+ text_color=ITEM_DESC_TEXT_COLOR)
+ self._increment_button = Button("+", click_callback=self._on_increment,
+ font_size=50, button_style=ButtonStyle.LIST_ACTION)
+ self._title_label = Label(title, font_size=50, font_weight=FontWeight.MEDIUM,
+ text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT)
+ self._value_label = Label(self._format_value(), font_size=50,
+ text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT,
+ text_color=FROGPILOT_VALUE_COLOR)
+
+ def _format_value(self) -> str:
+ for val, label in self._value_labels.items():
+ if round(val * self._factor) == round(self._value * self._factor):
+ return label
+
+ if self._interval >= 1:
+ return f"{int(self._value)}{self._label_suffix}"
+ else:
+ decimals = max(0, int(math.ceil(-math.log10(self._interval))))
+ return f"{self._value:.{decimals}f}{self._label_suffix}"
+
+ def _on_decrement(self) -> None:
+ if self._display_warning and not self._warning_shown:
+ self._show_warning()
+
+ current_time = rl.get_time()
+ if current_time - self._last_action_time > 0.65:
+ self._decrement_repeating = False
+
+ delta = self._interval * 5 if self._decrement_repeating and self._fast_increase else self._interval
+ self._value = max(self._value - delta, self._min_value)
+ self._update_value()
+
+ if round(self._value / self._interval) % 5 == 0:
+ self._decrement_repeating = True
+
+ self._last_action_time = current_time
+
+ def _on_increment(self) -> None:
+ if self._display_warning and not self._warning_shown:
+ self._show_warning()
+
+ current_time = rl.get_time()
+ if current_time - self._last_action_time > 0.65:
+ self._increment_repeating = False
+
+ delta = self._interval * 5 if self._increment_repeating and self._fast_increase else self._interval
+ self._value = min(self._value + delta, self._max_value)
+ self._update_value()
+
+ if round(self._value / self._interval) % 5 == 0:
+ self._increment_repeating = True
+
+ self._last_action_time = current_time
+
+ def _read_param_value(self) -> float:
+ try:
+ return float(self._params.get_int(self._param_key))
+ except (ValueError, TypeError):
+ try:
+ return self._params.get_float(self._param_key)
+ except (ValueError, TypeError):
+ return self._min_value
+
+ def _render(self, rect: rl.Rectangle) -> None:
+ button_size = 100
+ desc_height = ITEM_DESC_FONT_SIZE if self._description else 0
+ text_width = rect.width - self._label_width - button_size * 2 - 60
+ title_height = 50
+ value_width = self._label_width
+
+ title_rect = rl.Rectangle(rect.x + 20, rect.y + 10, text_width, title_height)
+ self._title_label.render(title_rect)
+
+ if self._description:
+ desc_rect = rl.Rectangle(rect.x + 20, rect.y + 10 + title_height + 5, text_width, desc_height)
+ self._desc_label.render(desc_rect)
+
+ value_x = rect.x + text_width + 20
+ value_y = rect.y + (rect.height - 50) // 2
+ self._value_label.render(rl.Rectangle(value_x, value_y, value_width, 50))
+
+ button_y = rect.y + (rect.height - button_size) // 2
+
+ dec_x = rect.x + rect.width - button_size * 2 - 30
+ self._decrement_button.render(rl.Rectangle(dec_x, button_y, button_size, button_size))
+
+ inc_x = rect.x + rect.width - button_size - 20
+ self._increment_button.render(rl.Rectangle(inc_x, button_y, button_size, button_size))
+
+ def _show_warning(self) -> None:
+ self._warning_shown = True
+
+ def _update_value(self) -> None:
+ self._value = round(self._value * self._factor) / self._factor
+ self._value_label.set_text(self._format_value())
+
+ self._decrement_button.set_enabled(self._value > self._min_value)
+ self._increment_button.set_enabled(self._value < self._max_value)
+
+ if self._value_changed_callback:
+ self._value_changed_callback(self._value)
+
+ def _write_param_value(self) -> None:
+ if self._value == self._previous_value:
+ return
+
+ if self._interval >= 1 and self._interval == int(self._interval):
+ self._params.put_int(self._param_key, int(self._value))
+ else:
+ self._params.put_float(self._param_key, self._value)
+
+ self._previous_value = self._value
+
+ def hide_event(self) -> None:
+ self._warning_shown = False
+ self._write_param_value()
+
+ def refresh(self) -> None:
+ self._value = self._read_param_value()
+ self._value = max(self._min_value, min(self._max_value, self._value))
+ self._previous_value = self._value
+ self._update_value()
+ self._write_param_value()
+
+ def set_value_changed_callback(self, callback: Callable[[float], None]) -> None:
+ self._value_changed_callback = callback
+
+ def set_warning(self, warning: str) -> None:
+ self._display_warning = True
+
+ def show_event(self) -> None:
+ self.refresh()
+
+ def update_control(self, min_value: float, max_value: float, value_labels: dict[float, str] = None) -> None:
+ self._min_value = min_value
+ self._max_value = max_value
+ if value_labels is not None:
+ self._value_labels = value_labels
+ self.refresh()
diff --git a/frogpilot/ui/layouts/settings/data_settings.py b/frogpilot/ui/layouts/settings/data_settings.py
new file mode 100644
index 0000000000..9d0aa6c508
--- /dev/null
+++ b/frogpilot/ui/layouts/settings/data_settings.py
@@ -0,0 +1,1082 @@
+import json
+import os
+import shutil
+import subprocess
+import threading
+import time
+
+from datetime import datetime
+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.keyboard import Keyboard
+from openpilot.system.ui.widgets.list_view import ListItem, 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 (
+ BACKUP_PATH,
+ ERROR_LOGS_PATH,
+ FROGPILOT_BACKUPS,
+ SCREEN_RECORDINGS_PATH,
+ TOGGLE_BACKUPS,
+ update_frogpilot_toggles,
+)
+from openpilot.frogpilot.system.ui.widgets.frogpilot_controls import (
+ FrogPilotButtonsControl,
+ FrogPilotConfirmationDialog,
+)
+
+DRIVING_DATA_PATHS = [
+ Path("/data/media/0/realdata"),
+ Path("/data/media/0/realdata_HD"),
+ Path("/data/media/0/realdata_konik"),
+]
+
+METER_TO_MILE = 0.000621371
+MS_TO_KPH = 3.6
+MS_TO_MPH = 2.23694
+
+KEY_MAP = {
+ "AEBEvents": ("Total Emergency Brake Alerts", "count"),
+ "AOLTime": ("Time Using \"Always On Lateral\"", "timePercent"),
+ "CruiseSpeedTimes": ("Favorite Set Speed", "speed"),
+ "CurrentMonthsMeters": ("Distance Driven This Month", "distance"),
+ "DayTime": ("Time Driving (Daytime)", "timePercent"),
+ "Disengages": ("Total Disengagements", "count"),
+ "Engages": ("Total Engagements", "count"),
+ "ExperimentalModeTime": ("Time Using \"Experimental Mode\"", "timePercent"),
+ "FrogChirps": ("Total Frog Chirps", "count"),
+ "FrogHops": ("Total Frog Hops", "count"),
+ "FrogPilotDrives": ("Total Drives", "count"),
+ "FrogPilotMeters": ("Total Distance Driven", "distance"),
+ "FrogPilotSeconds": ("Total Driving Time", "time"),
+ "FrogSqueaks": ("Total Frog Squeaks", "count"),
+ "GoatScreams": ("Total Goat Screams", "count"),
+ "HighestAcceleration": ("Highest Acceleration Rate", "accel"),
+ "LateralTime": ("Time Using Lateral Control", "timePercent"),
+ "LongestDistanceWithoutOverride": ("Longest Distance Without an Override", "distance"),
+ "LongitudinalTime": ("Time Using Longitudinal Control", "timePercent"),
+ "ModelTimes": ("Driving Models:", "parent"),
+ "Month": ("Month", "other"),
+ "NightTime": ("Time Driving (Nighttime)", "timePercent"),
+ "Overrides": ("Total Overrides", "count"),
+ "OverrideTime": ("Time Overriding openpilot", "timePercent"),
+ "PersonalityTimes": ("Driving Personalities:", "parent"),
+ "RandomEvents": ("Random Events:", "parent"),
+ "StandstillTime": ("Time Stopped", "timePercent"),
+ "StopLightTime": ("Time Spent at Stoplights", "timePercent"),
+ "TrackedTime": ("Total Time Tracked", "time"),
+ "WeatherTimes": ("Time Driven (Weather):", "parent"),
+}
+
+RANDOM_EVENTS_MAP = {
+ "accel30": "UwUs",
+ "accel35": "Loch Ness Encounters",
+ "accel40": "Visits to 1955",
+ "dejaVuCurve": "Deja Vu Moments",
+ "firefoxSteerSaturated": "Internet Explorer Weeeeeeees",
+ "hal9000": "HAL 9000 Denials",
+ "openpilotCrashedRandomEvent": "openpilot Crashes",
+ "thisIsFineSteerSaturated": "This Is Fine Moments",
+ "toBeContinued": "To Be Continued Moments",
+ "vCruise69": "Noices",
+ "yourFrogTriedToKillMe": "Attempted Frog Murders",
+ "youveGotMail": "Total Mail Received",
+}
+
+IGNORED_KEYS = {"Month"}
+
+
+def format_ordinal(day):
+ if 11 <= day <= 13:
+ suffix = "th"
+ elif day % 10 == 1:
+ suffix = "st"
+ elif day % 10 == 2:
+ suffix = "nd"
+ elif day % 10 == 3:
+ suffix = "rd"
+ else:
+ suffix = "th"
+ return f"{day}{suffix}"
+
+
+def format_friendly_date(dt):
+ day = dt.day
+ return f"{dt.strftime('%B')} {format_ordinal(day)}, {dt.year} ({dt.strftime('%I:%M %p').lstrip('0')})"
+
+
+def parse_recording_name(filename):
+ if not filename.endswith(".mp4"):
+ return None, None
+
+ clean_name = filename[:-4]
+ separator = "--" if "--" in clean_name else "_"
+ parts = clean_name.split(separator)
+
+ if len(parts) >= 2:
+ try:
+ date = datetime.strptime(parts[0], "%Y-%m-%d")
+ time_part = datetime.strptime(parts[1], "%H-%M-%S")
+ dt = datetime.combine(date.date(), time_part.time())
+ return format_friendly_date(dt), filename
+ except ValueError:
+ pass
+
+ friendly = clean_name.replace("_", " ")
+ return friendly, filename
+
+
+def parse_backup_name(filename, mod_time=None):
+ friendly = filename
+
+ if filename.endswith("_auto.tar.zst") and mod_time:
+ parts = filename.replace(".tar.zst", "").split("_")
+ if len(parts) >= 3:
+ friendly = format_friendly_date(mod_time).rsplit(" (", 1)[0] + f" ({parts[1]})"
+
+ if friendly == filename:
+ friendly = filename.replace(".tar.zst", "").replace("_", " ")
+
+ return friendly, filename
+
+
+def parse_toggle_backup_name(dirname):
+ friendly = dirname
+
+ if dirname.endswith("_auto"):
+ parts = dirname.replace("_auto", "").split("_")
+ if len(parts) >= 2:
+ try:
+ date = datetime.strptime(parts[0], "%Y-%m-%d")
+ time_part = datetime.strptime(parts[1], "%H-%M-%S")
+ dt = datetime.combine(date.date(), time_part.time())
+ friendly = format_friendly_date(dt)
+ except ValueError:
+ pass
+
+ if friendly == dirname:
+ friendly = dirname.replace("_", " ")
+
+ return friendly, dirname
+
+
+class FrogPilotDataPanel(Widget):
+ def __init__(self):
+ super().__init__()
+
+ self._is_metric = False
+ self._params = Params()
+ self._show_stats = False
+
+ # State for dialogs and operations
+ self._pending_dialog = None
+ self._pending_action = None
+ self._pending_data = {}
+
+ # Screen recordings data
+ self._recordings_map = {}
+ self._recordings_list = []
+
+ # FrogPilot backups data
+ self._fp_backups_map = {}
+ self._fp_backups_list = []
+
+ # Toggle backups data
+ self._toggle_backups_map = {}
+ self._toggle_backups_list = []
+
+ # Keyboard for text input
+ self._keyboard = Keyboard()
+
+ # Delete Driving Data Button
+ self._delete_driving_data_control = FrogPilotButtonsControl(
+ "DeleteDrivingData",
+ "Delete Driving Data",
+ "Delete all stored driving footage and data to free up storage space or to simply just erase driving data.",
+ "",
+ button_texts=["DELETE"],
+ )
+ self._delete_driving_data_control.set_click_callback(self._on_delete_driving_data_click)
+
+ # Delete Error Logs Button
+ self._delete_error_logs_control = FrogPilotButtonsControl(
+ "DeleteErrorLogs",
+ "Delete Error Logs",
+ "Delete collected error logs to free up space and clear old crash records.",
+ "",
+ button_texts=["DELETE"],
+ )
+ self._delete_error_logs_control.set_click_callback(self._on_delete_error_logs_click)
+
+ # Screen Recordings Buttons
+ self._screen_recordings_control = FrogPilotButtonsControl(
+ "ScreenRecordings",
+ "Screen Recordings",
+ "Delete or rename screen recordings.",
+ "",
+ button_texts=["DELETE", "DELETE ALL", "RENAME"],
+ )
+ self._screen_recordings_control.set_click_callback(self._on_screen_recordings_click)
+
+ # FrogPilot Backups Buttons
+ self._frogpilot_backups_control = FrogPilotButtonsControl(
+ "FrogPilotBackups",
+ "FrogPilot Backups",
+ "Create, delete, or restore FrogPilot backups.",
+ "",
+ button_texts=["BACKUP", "DELETE", "DELETE ALL", "RESTORE"],
+ )
+ self._frogpilot_backups_control.set_click_callback(self._on_frogpilot_backups_click)
+
+ # Toggle Backups Buttons
+ self._toggle_backups_control = FrogPilotButtonsControl(
+ "ToggleBackups",
+ "Toggle Backups",
+ "Create, delete, or restore toggle backups.",
+ "",
+ button_texts=["BACKUP", "DELETE", "DELETE ALL", "RESTORE"],
+ )
+ self._toggle_backups_control.set_click_callback(self._on_toggle_backups_click)
+
+ # FrogPilot Stats Buttons
+ self._stats_control = FrogPilotButtonsControl(
+ "FrogPilotStats",
+ "FrogPilot Stats",
+ "View your collected FrogPilot stats.",
+ "",
+ button_texts=["RESET", "VIEW"],
+ )
+ self._stats_control.set_click_callback(self._on_stats_click)
+
+ main_items = [
+ self._delete_driving_data_control,
+ self._delete_error_logs_control,
+ self._screen_recordings_control,
+ self._frogpilot_backups_control,
+ self._toggle_backups_control,
+ self._stats_control,
+ ]
+
+ self._main_scroller = Scroller(main_items, line_separator=True, spacing=0)
+ self._stats_scroller = None
+ self._stats_items = []
+
+ ui_state.add_offroad_transition_callback(self._on_offroad_transition)
+
+ def _on_offroad_transition(self):
+ self._is_metric = self._params.get_bool("IsMetric")
+ if self._show_stats:
+ self._update_stats_labels()
+
+ # ==================== DELETE DRIVING DATA ====================
+ def _on_delete_driving_data_click(self, button_id: int):
+ self._pending_action = "delete_driving_data"
+ gui_app.set_modal_overlay(ConfirmDialog(
+ "Delete all driving data and footage?",
+ "Delete",
+ "Cancel",
+ ))
+
+ def _do_delete_driving_data(self):
+ def delete_thread():
+ self._delete_driving_data_control.set_enabled(False)
+ self._delete_driving_data_control.set_value("Deleting...")
+
+ for path in DRIVING_DATA_PATHS:
+ if not path.exists():
+ continue
+
+ for entry in path.iterdir():
+ if entry.is_dir():
+ try:
+ preserve = os.getxattr(str(entry), b"user.preserve") == b"1"
+ except OSError:
+ preserve = False
+
+ if not preserve:
+ shutil.rmtree(entry, ignore_errors=True)
+
+ self._delete_driving_data_control.set_value("Deleted!")
+ time.sleep(2.5)
+
+ self._delete_driving_data_control.set_value("")
+ self._delete_driving_data_control.set_enabled(True)
+
+ threading.Thread(target=delete_thread, daemon=True).start()
+
+ # ==================== DELETE ERROR LOGS ====================
+ def _on_delete_error_logs_click(self, button_id: int):
+ self._pending_action = "delete_error_logs"
+ gui_app.set_modal_overlay(ConfirmDialog(
+ "Delete all error logs?",
+ "Delete",
+ "Cancel",
+ ))
+
+ def _do_delete_error_logs(self):
+ def delete_thread():
+ self._delete_error_logs_control.set_enabled(False)
+ self._delete_error_logs_control.set_value("Deleting...")
+
+ if ERROR_LOGS_PATH.exists():
+ shutil.rmtree(ERROR_LOGS_PATH, ignore_errors=True)
+ ERROR_LOGS_PATH.mkdir(parents=True, exist_ok=True)
+
+ self._delete_error_logs_control.set_value("Deleted!")
+ time.sleep(2.5)
+
+ self._delete_error_logs_control.set_value("")
+ self._delete_error_logs_control.set_enabled(True)
+
+ threading.Thread(target=delete_thread, daemon=True).start()
+
+ # ==================== SCREEN RECORDINGS ====================
+ def _on_screen_recordings_click(self, button_id: int):
+ SCREEN_RECORDINGS_PATH.mkdir(parents=True, exist_ok=True)
+
+ recordings = []
+ self._recordings_map = {}
+
+ if SCREEN_RECORDINGS_PATH.exists():
+ for f in SCREEN_RECORDINGS_PATH.iterdir():
+ if f.is_file() and f.suffix.lower() == ".mp4":
+ friendly, original = parse_recording_name(f.name)
+ if friendly:
+ recordings.append((friendly, original))
+ self._recordings_map[friendly] = original
+
+ recordings.sort(key=lambda x: x[1], reverse=True)
+ self._recordings_list = [r[0] for r in recordings]
+
+ if not self._recordings_list:
+ gui_app.set_modal_overlay(alert_dialog("No screen recordings found."))
+ return
+
+ if button_id == 0:
+ # DELETE single recording
+ self._pending_action = "recording_delete_select"
+ gui_app.set_modal_overlay(MultiOptionDialog(
+ "Choose a screen recording to delete",
+ self._recordings_list,
+ ))
+
+ elif button_id == 1:
+ # DELETE ALL recordings
+ self._pending_action = "recording_delete_all"
+ gui_app.set_modal_overlay(ConfirmDialog(
+ "Delete all screen recordings?",
+ "Delete All",
+ "Cancel",
+ ))
+
+ elif button_id == 2:
+ # RENAME recording
+ self._pending_action = "recording_rename_select"
+ gui_app.set_modal_overlay(MultiOptionDialog(
+ "Choose a screen recording to rename",
+ self._recordings_list,
+ ))
+
+ def _do_delete_recording(self, selection: str):
+ def delete_thread():
+ self._screen_recordings_control.set_enabled(False)
+ self._screen_recordings_control.set_value("Deleting...")
+ self._screen_recordings_control.set_visible_button(1, False)
+ self._screen_recordings_control.set_visible_button(2, False)
+
+ filename = self._recordings_map.get(selection, "")
+ if filename:
+ filepath = SCREEN_RECORDINGS_PATH / filename
+ if filepath.exists():
+ filepath.unlink()
+
+ self._screen_recordings_control.set_value("Deleted!")
+ time.sleep(2.5)
+
+ self._screen_recordings_control.set_value("")
+ self._screen_recordings_control.set_enabled(True)
+ self._screen_recordings_control.set_visible_button(1, True)
+ self._screen_recordings_control.set_visible_button(2, True)
+
+ threading.Thread(target=delete_thread, daemon=True).start()
+
+ def _do_delete_all_recordings(self):
+ def delete_thread():
+ self._screen_recordings_control.set_enabled(False)
+ self._screen_recordings_control.set_value("Deleting...")
+ self._screen_recordings_control.set_visible_button(0, False)
+ self._screen_recordings_control.set_visible_button(2, False)
+
+ if SCREEN_RECORDINGS_PATH.exists():
+ shutil.rmtree(SCREEN_RECORDINGS_PATH, ignore_errors=True)
+ SCREEN_RECORDINGS_PATH.mkdir(parents=True, exist_ok=True)
+
+ self._screen_recordings_control.set_value("Deleted!")
+ time.sleep(2.5)
+
+ self._screen_recordings_control.set_value("")
+ self._screen_recordings_control.set_enabled(True)
+ self._screen_recordings_control.set_visible_button(0, True)
+ self._screen_recordings_control.set_visible_button(2, True)
+
+ threading.Thread(target=delete_thread, daemon=True).start()
+
+ def _do_rename_recording(self, selection: str, new_name: str):
+ def rename_thread():
+ self._screen_recordings_control.set_enabled(False)
+ self._screen_recordings_control.set_value("Renaming...")
+ self._screen_recordings_control.set_visible_button(0, False)
+ self._screen_recordings_control.set_visible_button(1, False)
+
+ old_filename = self._recordings_map.get(selection, "")
+ new_filename = new_name.replace(" ", "_") + ".mp4"
+
+ if old_filename:
+ old_path = SCREEN_RECORDINGS_PATH / old_filename
+ new_path = SCREEN_RECORDINGS_PATH / new_filename
+ if old_path.exists() and not new_path.exists():
+ old_path.rename(new_path)
+
+ self._screen_recordings_control.set_value("Renamed!")
+ time.sleep(2.5)
+
+ self._screen_recordings_control.set_value("")
+ self._screen_recordings_control.set_enabled(True)
+ self._screen_recordings_control.set_visible_button(0, True)
+ self._screen_recordings_control.set_visible_button(1, True)
+
+ threading.Thread(target=rename_thread, daemon=True).start()
+
+ # ==================== FROGPILOT BACKUPS ====================
+ def _on_frogpilot_backups_click(self, button_id: int):
+ FROGPILOT_BACKUPS.mkdir(parents=True, exist_ok=True)
+
+ backups = []
+ self._fp_backups_map = {}
+
+ for f in FROGPILOT_BACKUPS.iterdir():
+ if f.is_file() and f.name.endswith(".tar.zst") and "in_progress" not in f.name:
+ mod_time = datetime.fromtimestamp(f.stat().st_mtime)
+ friendly, original = parse_backup_name(f.name, mod_time)
+ backups.append((friendly, original, mod_time))
+ self._fp_backups_map[friendly] = original
+
+ backups.sort(key=lambda x: x[2], reverse=True)
+ self._fp_backups_list = [b[0] for b in backups]
+
+ if button_id == 0:
+ # CREATE BACKUP
+ self._pending_action = "fp_backup_create"
+ self._keyboard.reset()
+ self._keyboard.set_title("Name your backup", "Backup Name")
+ gui_app.set_modal_overlay(self._keyboard, callback=self._on_keyboard_result)
+
+ elif button_id == 1:
+ # DELETE backup
+ if not self._fp_backups_list:
+ gui_app.set_modal_overlay(alert_dialog("No backups found."))
+ return
+ self._pending_action = "fp_backup_delete_select"
+ gui_app.set_modal_overlay(MultiOptionDialog(
+ "Choose a backup to delete",
+ self._fp_backups_list,
+ ))
+
+ elif button_id == 2:
+ # DELETE ALL backups
+ self._pending_action = "fp_backup_delete_all"
+ gui_app.set_modal_overlay(ConfirmDialog(
+ "Delete all FrogPilot backups?",
+ "Delete All",
+ "Cancel",
+ ))
+
+ elif button_id == 3:
+ # RESTORE backup
+ if not self._fp_backups_list:
+ gui_app.set_modal_overlay(alert_dialog("No backups found."))
+ return
+ self._pending_action = "fp_backup_restore_select"
+ gui_app.set_modal_overlay(MultiOptionDialog(
+ "Choose a backup to restore",
+ self._fp_backups_list,
+ ))
+
+ def _do_create_fp_backup(self, name: str):
+ def backup_thread():
+ self._frogpilot_backups_control.set_enabled(False)
+ self._frogpilot_backups_control.set_value("Backing up...")
+ self._frogpilot_backups_control.set_visible_button(1, False)
+ self._frogpilot_backups_control.set_visible_button(2, False)
+ self._frogpilot_backups_control.set_visible_button(3, False)
+
+ backup_name = name.replace(" ", "_") + ".tar.zst"
+ backup_path = FROGPILOT_BACKUPS / backup_name
+
+ subprocess.run(
+ f"tar --use-compress-program=zstd -cf {backup_path} /data/openpilot",
+ shell=True,
+ capture_output=True
+ )
+
+ self._frogpilot_backups_control.set_value("Backup created!")
+ time.sleep(2.5)
+
+ self._frogpilot_backups_control.set_value("")
+ self._frogpilot_backups_control.set_enabled(True)
+ self._frogpilot_backups_control.set_visible_button(1, True)
+ self._frogpilot_backups_control.set_visible_button(2, True)
+ self._frogpilot_backups_control.set_visible_button(3, True)
+
+ threading.Thread(target=backup_thread, daemon=True).start()
+
+ def _do_delete_fp_backup(self, selection: str):
+ def delete_thread():
+ self._frogpilot_backups_control.set_enabled(False)
+ self._frogpilot_backups_control.set_value("Deleting...")
+ self._frogpilot_backups_control.set_visible_button(0, False)
+ self._frogpilot_backups_control.set_visible_button(2, False)
+ self._frogpilot_backups_control.set_visible_button(3, False)
+
+ filename = self._fp_backups_map.get(selection, "")
+ if filename:
+ filepath = FROGPILOT_BACKUPS / filename
+ if filepath.exists():
+ filepath.unlink()
+
+ self._frogpilot_backups_control.set_value("Deleted!")
+ time.sleep(2.5)
+
+ self._frogpilot_backups_control.set_value("")
+ self._frogpilot_backups_control.set_enabled(True)
+ self._frogpilot_backups_control.set_visible_button(0, True)
+ self._frogpilot_backups_control.set_visible_button(2, True)
+ self._frogpilot_backups_control.set_visible_button(3, True)
+
+ threading.Thread(target=delete_thread, daemon=True).start()
+
+ def _do_delete_all_fp_backups(self):
+ def delete_thread():
+ self._frogpilot_backups_control.set_enabled(False)
+ self._frogpilot_backups_control.set_value("Deleting...")
+ self._frogpilot_backups_control.set_visible_button(0, False)
+ self._frogpilot_backups_control.set_visible_button(1, False)
+ self._frogpilot_backups_control.set_visible_button(3, False)
+
+ if FROGPILOT_BACKUPS.exists():
+ shutil.rmtree(FROGPILOT_BACKUPS, ignore_errors=True)
+ FROGPILOT_BACKUPS.mkdir(parents=True, exist_ok=True)
+
+ self._frogpilot_backups_control.set_value("Deleted!")
+ time.sleep(2.5)
+
+ self._frogpilot_backups_control.set_value("")
+ self._frogpilot_backups_control.set_enabled(True)
+ self._frogpilot_backups_control.set_visible_button(0, True)
+ self._frogpilot_backups_control.set_visible_button(1, True)
+ self._frogpilot_backups_control.set_visible_button(3, True)
+
+ threading.Thread(target=delete_thread, daemon=True).start()
+
+ def _do_restore_fp_backup(self, selection: str):
+ def restore_thread():
+ self._frogpilot_backups_control.set_enabled(False)
+ self._frogpilot_backups_control.set_value("Restoring...")
+ self._frogpilot_backups_control.set_visible_button(0, False)
+ self._frogpilot_backups_control.set_visible_button(1, False)
+ self._frogpilot_backups_control.set_visible_button(2, False)
+
+ filename = self._fp_backups_map.get(selection, "")
+ if filename:
+ backup_path = FROGPILOT_BACKUPS / filename
+ subprocess.run(
+ f"rm -rf /data/openpilot/* && tar --use-compress-program=zstd -xf {backup_path} -C /",
+ shell=True,
+ capture_output=True
+ )
+ # Create marker file for backup restore
+ Path("/cache/on_backup").touch()
+
+ self._frogpilot_backups_control.set_value("Restored!")
+ time.sleep(2.5)
+
+ self._frogpilot_backups_control.set_value("Rebooting...")
+ time.sleep(2.5)
+
+ HARDWARE.reboot()
+
+ threading.Thread(target=restore_thread, daemon=True).start()
+
+ # ==================== TOGGLE BACKUPS ====================
+ def _on_toggle_backups_click(self, button_id: int):
+ TOGGLE_BACKUPS.mkdir(parents=True, exist_ok=True)
+
+ backups = []
+ self._toggle_backups_map = {}
+
+ for d in TOGGLE_BACKUPS.iterdir():
+ if d.is_dir() and "in_progress" not in d.name:
+ friendly, original = parse_toggle_backup_name(d.name)
+ backups.append((friendly, original))
+ self._toggle_backups_map[friendly] = original
+
+ backups.sort(key=lambda x: x[1], reverse=True)
+ self._toggle_backups_list = [b[0] for b in backups]
+
+ if button_id == 0:
+ # CREATE BACKUP
+ self._pending_action = "toggle_backup_create"
+ self._keyboard.reset()
+ self._keyboard.set_title("Name your backup", "Backup Name")
+ gui_app.set_modal_overlay(self._keyboard, callback=self._on_keyboard_result)
+
+ elif button_id == 1:
+ # DELETE backup
+ if not self._toggle_backups_list:
+ gui_app.set_modal_overlay(alert_dialog("No backups found."))
+ return
+ self._pending_action = "toggle_backup_delete_select"
+ gui_app.set_modal_overlay(MultiOptionDialog(
+ "Choose a backup to delete",
+ self._toggle_backups_list,
+ ))
+
+ elif button_id == 2:
+ # DELETE ALL backups
+ self._pending_action = "toggle_backup_delete_all"
+ gui_app.set_modal_overlay(ConfirmDialog(
+ "Delete all toggle backups?",
+ "Delete All",
+ "Cancel",
+ ))
+
+ elif button_id == 3:
+ # RESTORE backup
+ if not self._toggle_backups_list:
+ gui_app.set_modal_overlay(alert_dialog("No backups found."))
+ return
+ self._pending_action = "toggle_backup_restore_select"
+ gui_app.set_modal_overlay(MultiOptionDialog(
+ "Choose a backup to restore",
+ self._toggle_backups_list,
+ ))
+
+ def _do_create_toggle_backup(self, name: str):
+ def backup_thread():
+ self._toggle_backups_control.set_enabled(False)
+ self._toggle_backups_control.set_value("Backing up...")
+ self._toggle_backups_control.set_visible_button(1, False)
+ self._toggle_backups_control.set_visible_button(2, False)
+ self._toggle_backups_control.set_visible_button(3, False)
+
+ backup_name = name.replace(" ", "_")
+ backup_path = TOGGLE_BACKUPS / backup_name
+
+ subprocess.run(
+ f"cp -r /data/params/d/ {backup_path}",
+ shell=True,
+ capture_output=True
+ )
+
+ self._toggle_backups_control.set_value("Backup created!")
+ time.sleep(2.5)
+
+ self._toggle_backups_control.set_value("")
+ self._toggle_backups_control.set_enabled(True)
+ self._toggle_backups_control.set_visible_button(1, True)
+ self._toggle_backups_control.set_visible_button(2, True)
+ self._toggle_backups_control.set_visible_button(3, True)
+
+ threading.Thread(target=backup_thread, daemon=True).start()
+
+ def _do_delete_toggle_backup(self, selection: str):
+ def delete_thread():
+ self._toggle_backups_control.set_enabled(False)
+ self._toggle_backups_control.set_value("Deleting...")
+ self._toggle_backups_control.set_visible_button(0, False)
+ self._toggle_backups_control.set_visible_button(2, False)
+ self._toggle_backups_control.set_visible_button(3, False)
+
+ dirname = self._toggle_backups_map.get(selection, "")
+ if dirname:
+ dirpath = TOGGLE_BACKUPS / dirname
+ if dirpath.exists():
+ shutil.rmtree(dirpath, ignore_errors=True)
+
+ self._toggle_backups_control.set_value("Deleted!")
+ time.sleep(2.5)
+
+ self._toggle_backups_control.set_value("")
+ self._toggle_backups_control.set_enabled(True)
+ self._toggle_backups_control.set_visible_button(0, True)
+ self._toggle_backups_control.set_visible_button(2, True)
+ self._toggle_backups_control.set_visible_button(3, True)
+
+ threading.Thread(target=delete_thread, daemon=True).start()
+
+ def _do_delete_all_toggle_backups(self):
+ def delete_thread():
+ self._toggle_backups_control.set_enabled(False)
+ self._toggle_backups_control.set_value("Deleting...")
+ self._toggle_backups_control.set_visible_button(0, False)
+ self._toggle_backups_control.set_visible_button(1, False)
+ self._toggle_backups_control.set_visible_button(3, False)
+
+ if TOGGLE_BACKUPS.exists():
+ shutil.rmtree(TOGGLE_BACKUPS, ignore_errors=True)
+ TOGGLE_BACKUPS.mkdir(parents=True, exist_ok=True)
+
+ self._toggle_backups_control.set_value("Deleted!")
+ time.sleep(2.5)
+
+ self._toggle_backups_control.set_value("")
+ self._toggle_backups_control.set_enabled(True)
+ self._toggle_backups_control.set_visible_button(0, True)
+ self._toggle_backups_control.set_visible_button(1, True)
+ self._toggle_backups_control.set_visible_button(3, True)
+
+ threading.Thread(target=delete_thread, daemon=True).start()
+
+ def _do_restore_toggle_backup(self, selection: str):
+ def restore_thread():
+ self._toggle_backups_control.set_enabled(False)
+ self._toggle_backups_control.set_value("Restoring...")
+ self._toggle_backups_control.set_visible_button(0, False)
+ self._toggle_backups_control.set_visible_button(1, False)
+ self._toggle_backups_control.set_visible_button(2, False)
+
+ dirname = self._toggle_backups_map.get(selection, "")
+ if dirname:
+ backup_path = TOGGLE_BACKUPS / dirname
+ subprocess.run(
+ f"cp -r {backup_path}/* /data/params/d/",
+ shell=True,
+ capture_output=True
+ )
+ update_frogpilot_toggles()
+
+ self._toggle_backups_control.set_value("Restored!")
+ time.sleep(2.5)
+
+ self._toggle_backups_control.set_value("")
+ self._toggle_backups_control.set_enabled(True)
+ self._toggle_backups_control.set_visible_button(0, True)
+ self._toggle_backups_control.set_visible_button(1, True)
+ self._toggle_backups_control.set_visible_button(2, True)
+
+ threading.Thread(target=restore_thread, daemon=True).start()
+
+ # ==================== STATS ====================
+ def _on_stats_click(self, button_id: int):
+ if button_id == 0:
+ # RESET stats
+ self._pending_action = "stats_reset"
+ gui_app.set_modal_overlay(ConfirmDialog(
+ "Are you sure you want to reset all of your FrogPilot stats?",
+ "Reset",
+ "Cancel",
+ ))
+ elif button_id == 1:
+ # VIEW stats
+ self._show_stats = True
+ self._update_stats_labels()
+
+ def _do_reset_stats(self):
+ self._params.remove("FrogPilotStats")
+ if self._show_stats:
+ self._update_stats_labels()
+
+ def _close_stats(self):
+ self._show_stats = False
+
+ # ==================== DIALOG RESULT HANDLING ====================
+ 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 from modal overlays."""
+ action = self._pending_action
+ self._pending_action = None
+
+ if result != DialogResult.CONFIRM:
+ return
+
+ # Delete driving data
+ if action == "delete_driving_data":
+ self._do_delete_driving_data()
+
+ # Delete error logs
+ elif action == "delete_error_logs":
+ self._do_delete_error_logs()
+
+ # Screen recordings - delete single
+ elif action == "recording_delete_select" and selection:
+ self._pending_data["recording_selection"] = selection
+ self._pending_action = "recording_delete_confirm"
+ gui_app.set_modal_overlay(ConfirmDialog(
+ "Delete this screen recording?",
+ "Delete",
+ "Cancel",
+ ))
+ elif action == "recording_delete_confirm":
+ selection = self._pending_data.pop("recording_selection", "")
+ if selection:
+ self._do_delete_recording(selection)
+
+ # Screen recordings - delete all
+ elif action == "recording_delete_all":
+ self._do_delete_all_recordings()
+
+ # Screen recordings - rename
+ elif action == "recording_rename_select" and selection:
+ self._pending_data["recording_selection"] = selection
+ self._pending_action = "recording_rename_input"
+ self._keyboard.reset()
+ self._keyboard.set_title("Enter a new name", "Rename Screen Recording")
+ gui_app.set_modal_overlay(self._keyboard, callback=self._on_keyboard_result)
+ elif action == "recording_rename_input" and selection:
+ old_selection = self._pending_data.pop("recording_selection", "")
+ new_name = selection.strip()
+ if old_selection and new_name:
+ # Check for duplicate name
+ new_filename = new_name.replace(" ", "_") + ".mp4"
+ existing_files = [f.name for f in SCREEN_RECORDINGS_PATH.iterdir() if f.is_file()]
+ if new_filename in existing_files:
+ gui_app.set_modal_overlay(alert_dialog("Name already in use. Please choose a different name!"))
+ else:
+ self._do_rename_recording(old_selection, new_name)
+
+ # FrogPilot backups - create
+ elif action == "fp_backup_create" and selection:
+ backup_name = selection.strip().replace(" ", "_")
+ if backup_name:
+ existing_files = [f.name for f in FROGPILOT_BACKUPS.iterdir() if f.is_file()]
+ if backup_name + ".tar.zst" in existing_files:
+ gui_app.set_modal_overlay(alert_dialog("Name already in use. Please choose a different name!"))
+ else:
+ self._do_create_fp_backup(backup_name)
+
+ # FrogPilot backups - delete single
+ elif action == "fp_backup_delete_select" and selection:
+ self._pending_data["fp_backup_selection"] = selection
+ self._pending_action = "fp_backup_delete_confirm"
+ gui_app.set_modal_overlay(ConfirmDialog(
+ "Delete this backup?",
+ "Delete",
+ "Cancel",
+ ))
+ elif action == "fp_backup_delete_confirm":
+ selection = self._pending_data.pop("fp_backup_selection", "")
+ if selection:
+ self._do_delete_fp_backup(selection)
+
+ # FrogPilot backups - delete all
+ elif action == "fp_backup_delete_all":
+ self._do_delete_all_fp_backups()
+
+ # FrogPilot backups - restore
+ elif action == "fp_backup_restore_select" and selection:
+ self._pending_data["fp_backup_selection"] = selection
+ self._pending_action = "fp_backup_restore_confirm"
+ gui_app.set_modal_overlay(ConfirmDialog(
+ "Restore this backup? This will overwrite your current installation and reboot the device.",
+ "Restore",
+ "Cancel",
+ ))
+ elif action == "fp_backup_restore_confirm":
+ selection = self._pending_data.pop("fp_backup_selection", "")
+ if selection:
+ self._do_restore_fp_backup(selection)
+
+ # Toggle backups - create
+ elif action == "toggle_backup_create" and selection:
+ backup_name = selection.strip().replace(" ", "_")
+ if backup_name:
+ existing_dirs = [d.name for d in TOGGLE_BACKUPS.iterdir() if d.is_dir()]
+ if backup_name in existing_dirs:
+ gui_app.set_modal_overlay(alert_dialog("Name already in use. Please choose a different name!"))
+ else:
+ self._do_create_toggle_backup(backup_name)
+
+ # Toggle backups - delete single
+ elif action == "toggle_backup_delete_select" and selection:
+ self._pending_data["toggle_backup_selection"] = selection
+ self._pending_action = "toggle_backup_delete_confirm"
+ gui_app.set_modal_overlay(ConfirmDialog(
+ "Delete this backup?",
+ "Delete",
+ "Cancel",
+ ))
+ elif action == "toggle_backup_delete_confirm":
+ selection = self._pending_data.pop("toggle_backup_selection", "")
+ if selection:
+ self._do_delete_toggle_backup(selection)
+
+ # Toggle backups - delete all
+ elif action == "toggle_backup_delete_all":
+ self._do_delete_all_toggle_backups()
+
+ # Toggle backups - restore
+ elif action == "toggle_backup_restore_select" and selection:
+ self._pending_data["toggle_backup_selection"] = selection
+ self._pending_action = "toggle_backup_restore_confirm"
+ gui_app.set_modal_overlay(ConfirmDialog(
+ "Restore this backup? This will overwrite your current settings!",
+ "Restore",
+ "Cancel",
+ ))
+ elif action == "toggle_backup_restore_confirm":
+ selection = self._pending_data.pop("toggle_backup_selection", "")
+ if selection:
+ self._do_restore_toggle_backup(selection)
+
+ # Stats reset
+ elif action == "stats_reset":
+ self._do_reset_stats()
+
+ # ==================== STATS DISPLAY ====================
+ def _format_number(self, number):
+ return f"{number:,.0f}" if isinstance(number, float) else f"{number:,}"
+
+ def _format_distance(self, meters):
+ if self._is_metric:
+ value = meters / 1000.0
+ unit = "kilometer" if value == 1.0 else "kilometers"
+ else:
+ value = meters * METER_TO_MILE
+ unit = "mile" if value == 1.0 else "miles"
+ return f"{self._format_number(round(value))} {unit}"
+
+ def _format_time(self, seconds):
+ seconds = int(seconds)
+ days = seconds // 86400
+ hours = (seconds % 86400) // 3600
+ minutes = (seconds % 3600) // 60
+
+ parts = []
+ if days > 0:
+ parts.append(f"{self._format_number(days)} {'day' if days == 1 else 'days'}")
+ if hours > 0 or days > 0:
+ parts.append(f"{self._format_number(hours)} {'hour' if hours == 1 else 'hours'}")
+ parts.append(f"{self._format_number(minutes)} {'minute' if minutes == 1 else 'minutes'}")
+
+ return " ".join(parts)
+
+ def _update_stats_labels(self):
+ self._stats_items = []
+
+ try:
+ stats_data = self._params.get("FrogPilotStats")
+ stats = json.loads(stats_data) if stats_data else {}
+ except (json.JSONDecodeError, TypeError):
+ stats = {}
+
+ tracked_time = stats.get("TrackedTime", 0.0)
+
+ sorted_keys = sorted(KEY_MAP.keys(), key=lambda k: KEY_MAP[k][0].lower())
+
+ for key in sorted_keys:
+ if key in IGNORED_KEYS:
+ continue
+
+ label_text, stat_type = KEY_MAP[key]
+ value = stats.get(key, 0)
+
+ if key == "AEBEvents":
+ total_events = stats.get("TotalEvents", {})
+ count = total_events.get("stockAeb", 0) + total_events.get("fcw", 0)
+ trimmed = label_text.replace("Total ", "", 1) if label_text.startswith("Total ") else label_text
+ display = f"{self._format_number(count)} {trimmed}"
+ self._stats_items.append(ListItem(title=label_text, action_item=TextAction(display, color=ITEM_TEXT_VALUE_COLOR)))
+
+ elif key == "CruiseSpeedTimes" and isinstance(value, dict):
+ max_time = -1
+ best_speed = ""
+ for speed_key, time_val in value.items():
+ if time_val > max_time:
+ best_speed = speed_key
+ max_time = time_val
+
+ if best_speed:
+ speed_val = float(best_speed)
+ if self._is_metric:
+ display_speed = f"{round(speed_val * MS_TO_KPH)} km/h"
+ else:
+ display_speed = f"{round(speed_val * MS_TO_MPH)} mph"
+ display = f"{display_speed} ({self._format_time(max_time)})"
+ self._stats_items.append(ListItem(title=label_text, action_item=TextAction(display, color=ITEM_TEXT_VALUE_COLOR)))
+
+ elif stat_type == "parent" and isinstance(value, dict):
+ self._stats_items.append(ListItem(title=label_text))
+
+ if key == "RandomEvents":
+ sub_keys = sorted(RANDOM_EVENTS_MAP.keys(), key=lambda k: RANDOM_EVENTS_MAP.get(k, k).lower())
+ else:
+ sub_keys = sorted(value.keys(), key=lambda k: k.lower())
+
+ for subkey in sub_keys:
+ if subkey == "Unknown":
+ continue
+
+ if key == "ModelTimes":
+ display_subkey = clean_model_name(subkey)
+ elif key == "RandomEvents":
+ display_subkey = RANDOM_EVENTS_MAP.get(subkey, subkey)
+ elif key == "WeatherTimes":
+ display_subkey = subkey.capitalize()
+ else:
+ display_subkey = subkey
+
+ if key.endswith("Times"):
+ subvalue = self._format_time(value.get(subkey, 0))
+ else:
+ subvalue = self._format_number(value.get(subkey, 0))
+
+ self._stats_items.append(ListItem(title=f" {display_subkey}", action_item=TextAction(subvalue, color=ITEM_TEXT_VALUE_COLOR)))
+
+ else:
+ if stat_type == "accel":
+ display = f"{value:.2f} m/s²"
+ elif stat_type == "count":
+ trimmed = label_text.replace("Total ", "", 1) if label_text.startswith("Total ") else label_text
+ display = f"{self._format_number(int(value))} {trimmed}"
+ elif stat_type == "distance":
+ display = self._format_distance(float(value))
+ elif stat_type in ("time", "timePercent"):
+ display = self._format_time(float(value))
+ else:
+ display = str(value) if value else "0"
+
+ self._stats_items.append(ListItem(title=label_text, action_item=TextAction(display, color=ITEM_TEXT_VALUE_COLOR)))
+
+ if stat_type == "timePercent" and tracked_time > 0:
+ percent = int((float(value) * 100.0) / tracked_time)
+ self._stats_items.append(ListItem(title=f"% of {label_text}", action_item=TextAction(f"{self._format_number(percent)}%", color=ITEM_TEXT_VALUE_COLOR)))
+
+ self._stats_scroller = Scroller(self._stats_items, line_separator=True, spacing=0)
+
+ # ==================== LIFECYCLE ====================
+ def show_event(self):
+ super().show_event()
+ self._main_scroller.show_event()
+ self._is_metric = self._params.get_bool("IsMetric")
+ self._show_stats = False
+
+ def hide_event(self):
+ super().hide_event()
+ self._show_stats = False
+
+ def _render(self, rect):
+ if self._show_stats and self._stats_scroller:
+ self._stats_scroller.render(rect)
+ else:
+ self._main_scroller.render(rect)
diff --git a/frogpilot/ui/layouts/settings/device_settings.py b/frogpilot/ui/layouts/settings/device_settings.py
new file mode 100644
index 0000000000..f28ac96b99
--- /dev/null
+++ b/frogpilot/ui/layouts/settings/device_settings.py
@@ -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",
+ "Settings that control how the device runs, powers off, and manages driving data.",
+ "../../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",
+ "Settings that control screen brightness, screen recording, and timeout duration.",
+ "../../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",
+ "Keep the device on for the set amount of time after a drive before it shuts down automatically.",
+ "",
+ min_value=0,
+ max_value=33,
+ value_labels=shutdown_labels,
+ )
+
+ self._no_logging_item = ListItem(
+ title="Disable Logging",
+ description="WARNING: This will prevent your drives from being recorded and all data will be unobtainable!
Prevent the device from saving driving data.",
+ 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",
+ "WARNING: This will prevent your drives from being uploaded to comma connect which will impact debugging and official support from comma!
Prevent the device from uploading driving data.",
+ "",
+ 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="Save drive footage in higher video quality.",
+ 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",
+ "While parked, if the battery voltage falls below the set level, the device shuts down 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="WARNING: Running at higher temperatures may damage your device!
Allow the device to run at higher temperatures 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="Upload driving data to \"stable.konik.ai\" instead of \"connect.comma.ai\".",
+ 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)",
+ "The screen brightness while not driving.",
+ "",
+ 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)",
+ "The screen brightness while driving.",
+ "",
+ 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",
+ "Add a button to the driving screen to record the display.",
+ "",
+ 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)",
+ "How long the screen stays on after being tapped while not driving.",
+ "",
+ min_value=5,
+ max_value=60,
+ label=" seconds",
+ interval=5,
+ )
+
+ self._screen_timeout_onroad_control = FrogPilotParamValueControl(
+ "ScreenTimeoutOnroad",
+ "Screen Timeout (Onroad)",
+ "How long the screen stays on after being tapped while driving.",
+ "",
+ min_value=5,
+ max_value=60,
+ label=" seconds",
+ interval=5,
+ )
+
+ self._standby_mode_item = ListItem(
+ title="Standby Mode",
+ description="Turn the screen off while driving and automatically wake it up for alerts or engagement state changes.",
+ 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)
diff --git a/frogpilot/ui/layouts/settings/frogpilot.py b/frogpilot/ui/layouts/settings/frogpilot.py
new file mode 100644
index 0000000000..51c1d05c82
--- /dev/null
+++ b/frogpilot/ui/layouts/settings/frogpilot.py
@@ -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: "Adjust alert volumes and enable custom notifications.",
+ callback=lambda: self._open_subpanel(SubPanel.SOUNDS),
+ )
+
+ self._model_panel_item = button_item(
+ lambda: "Driving Model",
+ lambda: "MANAGE",
+ lambda: "Select and configure driving models.",
+ callback=lambda: self._open_subpanel(SubPanel.MODEL),
+ )
+
+ self._longitudinal_panel_item = button_item(
+ lambda: "Gas / Brake",
+ lambda: "MANAGE",
+ lambda: "Fine-tune acceleration and braking controls.",
+ callback=lambda: self._open_subpanel(SubPanel.LONGITUDINAL),
+ )
+
+ self._lateral_panel_item = button_item(
+ lambda: "Steering",
+ lambda: "MANAGE",
+ lambda: "Fine-tune steering controls.",
+ callback=lambda: self._open_subpanel(SubPanel.LATERAL),
+ )
+
+ self._data_panel_item = button_item(
+ lambda: "Data",
+ lambda: "MANAGE",
+ lambda: "Manage data and backups.",
+ callback=lambda: self._open_subpanel(SubPanel.DATA),
+ )
+
+ self._device_panel_item = button_item(
+ lambda: "Device Controls",
+ lambda: "MANAGE",
+ lambda: "Configure device settings and screen options.",
+ callback=lambda: self._open_subpanel(SubPanel.DEVICE),
+ )
+
+ self._utilities_panel_item = button_item(
+ lambda: "Utilities",
+ lambda: "MANAGE",
+ lambda: "Tools to keep FrogPilot running smoothly.",
+ callback=lambda: self._open_subpanel(SubPanel.UTILITIES),
+ )
+
+ self._visuals_panel_item = button_item(
+ lambda: "Appearance",
+ lambda: "MANAGE",
+ lambda: "Customize the look of the driving screen.",
+ callback=lambda: self._open_subpanel(SubPanel.VISUALS),
+ )
+
+ self._theme_panel_item = button_item(
+ lambda: "Theme",
+ lambda: "MANAGE",
+ lambda: "Customize themes and colors.",
+ callback=lambda: self._open_subpanel(SubPanel.THEME),
+ )
+
+ self._vehicles_panel_item = button_item(
+ lambda: "Vehicle Settings",
+ lambda: "MANAGE",
+ lambda: "Configure car-specific options.",
+ 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
diff --git a/frogpilot/ui/layouts/settings/lateral_settings.py b/frogpilot/ui/layouts/settings/lateral_settings.py
new file mode 100644
index 0000000000..6d7515d10c
--- /dev/null
+++ b/frogpilot/ui/layouts/settings/lateral_settings.py
@@ -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",
+ "Advanced steering control changes to fine-tune how openpilot drives.",
+ "../../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",
+ "openpilot's steering remains active even when the accelerator or brake pedals are pressed.",
+ "../../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",
+ "Allow openpilot to change lanes.",
+ "../../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",
+ "Miscellaneous steering control changes 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",
+ "Steering control changes to fine-tune how openpilot drives.",
+ "../../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",
+ "The time between openpilot's steering command and the vehicle's response. Increase if the vehicle reacts late; decrease if it feels jumpy. Auto-learned by default.",
+ "",
+ 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",
+ "Compensates for steering friction. 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",
+ "How strongly openpilot corrects lane position. 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",
+ "Maps steering torque to turning response. 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",
+ "The relationship between steering wheel rotation and road wheel angle. Increase if steering feels too quick or twitchy; decrease if it feels too slow or weak. Auto-learned by default.",
+ "",
+ 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="Force-enable openpilot's live auto-tuning for \"Friction\" and \"Lateral Acceleration\".",
+ 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="Force-disable openpilot's live auto-tuning for \"Friction\" and \"Lateral Acceleration\" and use the set value instead.",
+ 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="Use torque-based steering control instead of angle-based control for smoother lane keeping, especially in curves.",
+ 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="Enable \"Always On Lateral\" whenever \"LKAS\" is on, even when openpilot is not engaged.",
+ 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",
+ "Pause \"Always On Lateral\" below the set speed while the brake pedal is pressed.",
+ "",
+ 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="When the turn signal is on, openpilot will automatically change lanes. 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",
+ "Delay between turn signal activation and the start of an automatic lane change.",
+ "",
+ 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",
+ "Lowest speed at which openpilot will change lanes.",
+ "",
+ min_value=0,
+ max_value=99,
+ fast_increase=True,
+ )
+
+ self._lane_detection_width_control = FrogPilotParamValueControl(
+ "LaneDetectionWidth",
+ "Minimum Lane Width",
+ "Prevent automatic lane changes into lanes narrower than the set width.",
+ "",
+ 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="Limit automatic lane changes to one per turn-signal activation.",
+ 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="While driving below the minimum lane change speed with an active turn signal, instruct openpilot to turn left/right.",
+ 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="Twilsonco's \"Neural Network FeedForward\" controller. Uses a trained neural network model to predict steering torque based on vehicle speed, roll, and past/future planned path data for smoother, model-based steering.",
+ 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="A lightweight version of Twilsonco's \"Neural Network FeedForward\" controller. Uses the \"look-ahead\" planned lateral jerk logic from the full model to help smoothen steering adjustments in curves, but does not use the full neural network for torque calculation.",
+ 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",
+ "Pause steering below the set speed.",
+ "",
+ 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)
diff --git a/frogpilot/ui/layouts/settings/longitudinal_settings.py b/frogpilot/ui/layouts/settings/longitudinal_settings.py
new file mode 100644
index 0000000000..b46a946e43
--- /dev/null
+++ b/frogpilot/ui/layouts/settings/longitudinal_settings.py
@@ -0,0 +1,2182 @@
+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.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, ButtonAction
+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,
+ FrogPilotDualParamValueControl,
+ FrogPilotManageControl,
+ FrogPilotParamValueButtonControl,
+ FrogPilotParamValueControl,
+)
+
+ADVANCED_LONGITUDINAL_TUNE_KEYS = {
+ "LongitudinalActuatorDelay",
+ "MaxDesiredAcceleration",
+ "StartAccel",
+ "StopAccel",
+ "StoppingDecelRate",
+ "VEgoStarting",
+ "VEgoStopping",
+}
+
+AGGRESSIVE_PERSONALITY_KEYS = {
+ "AggressiveFollow",
+ "AggressiveJerkAcceleration",
+ "AggressiveJerkDeceleration",
+ "AggressiveJerkDanger",
+ "AggressiveJerkSpeed",
+ "AggressiveJerkSpeedDecrease",
+ "ResetAggressivePersonality",
+}
+
+CONDITIONAL_EXPERIMENTAL_KEYS = {
+ "CESpeed",
+ "CESpeedLead",
+ "CECurves",
+ "CELead",
+ "CEModelStopTime",
+ "CESignalSpeed",
+ "CEStopLights",
+ "ShowCEMStatus",
+}
+
+CURVE_SPEED_KEYS = {
+ "CalibratedLateralAcceleration",
+ "CalibrationProgress",
+ "ResetCurveData",
+ "ShowCSCStatus",
+}
+
+CUSTOM_DRIVING_PERSONALITY_KEYS = {
+ "AggressivePersonalityProfile",
+ "RelaxedPersonalityProfile",
+ "StandardPersonalityProfile",
+ "TrafficPersonalityProfile",
+}
+
+LONGITUDINAL_TUNE_KEYS = {
+ "AccelerationProfile",
+ "DecelerationProfile",
+ "HumanAcceleration",
+ "HumanFollowing",
+ "HumanLaneChanges",
+ "LeadDetectionThreshold",
+ "TacoTune",
+}
+
+QOL_KEYS = {
+ "CustomCruise",
+ "CustomCruiseLong",
+ "ForceStops",
+ "IncreasedStoppedDistance",
+ "MapGears",
+ "ReverseCruise",
+ "SetSpeedOffset",
+ "WeatherPresets",
+}
+
+RELAXED_PERSONALITY_KEYS = {
+ "RelaxedFollow",
+ "RelaxedJerkAcceleration",
+ "RelaxedJerkDeceleration",
+ "RelaxedJerkDanger",
+ "RelaxedJerkSpeed",
+ "RelaxedJerkSpeedDecrease",
+ "ResetRelaxedPersonality",
+}
+
+SPEED_LIMIT_CONTROLLER_KEYS = {
+ "SLCOffsets",
+ "SLCFallback",
+ "SLCOverride",
+ "SLCPriority",
+ "SLCQOL",
+ "SLCVisuals",
+}
+
+SPEED_LIMIT_CONTROLLER_OFFSETS_KEYS = {
+ "Offset1",
+ "Offset2",
+ "Offset3",
+ "Offset4",
+ "Offset5",
+ "Offset6",
+ "Offset7",
+}
+
+SPEED_LIMIT_CONTROLLER_QOL_KEYS = {
+ "SetSpeedLimit",
+ "SLCConfirmation",
+ "SLCLookaheadHigher",
+ "SLCLookaheadLower",
+ "SLCMapboxFiller",
+}
+
+SPEED_LIMIT_CONTROLLER_VISUAL_KEYS = {
+ "ShowSLCOffset",
+ "SpeedLimitSources",
+}
+
+STANDARD_PERSONALITY_KEYS = {
+ "StandardFollow",
+ "StandardJerkAcceleration",
+ "StandardJerkDeceleration",
+ "StandardJerkDanger",
+ "StandardJerkSpeed",
+ "StandardJerkSpeedDecrease",
+ "ResetStandardPersonality",
+}
+
+TRAFFIC_PERSONALITY_KEYS = {
+ "TrafficFollow",
+ "TrafficJerkAcceleration",
+ "TrafficJerkDeceleration",
+ "TrafficJerkDanger",
+ "TrafficJerkSpeed",
+ "TrafficJerkSpeedDecrease",
+ "ResetTrafficPersonality",
+}
+
+WEATHER_KEYS = {
+ "LowVisibilityOffsets",
+ "RainOffsets",
+ "RainStormOffsets",
+ "SetWeatherKey",
+ "SnowOffsets",
+}
+
+WEATHER_LOW_VISIBILITY_KEYS = {
+ "IncreaseFollowingLowVisibility",
+ "IncreasedStoppedDistanceLowVisibility",
+ "ReduceAccelerationLowVisibility",
+ "ReduceLateralAccelerationLowVisibility",
+}
+
+WEATHER_RAIN_KEYS = {
+ "IncreaseFollowingRain",
+ "IncreasedStoppedDistanceRain",
+ "ReduceAccelerationRain",
+ "ReduceLateralAccelerationRain",
+}
+
+WEATHER_RAIN_STORM_KEYS = {
+ "IncreaseFollowingRainStorm",
+ "IncreasedStoppedDistanceRainStorm",
+ "ReduceAccelerationRainStorm",
+ "ReduceLateralAccelerationRainStorm",
+}
+
+WEATHER_SNOW_KEYS = {
+ "IncreaseFollowingSnow",
+ "IncreasedStoppedDistanceSnow",
+ "ReduceAccelerationSnow",
+ "ReduceLateralAccelerationSnow",
+}
+
+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_LONGITUDINAL_TUNE = 1
+ AGGRESSIVE_PERSONALITY = 2
+ CONDITIONAL_EXPERIMENTAL = 3
+ CURVE_SPEED = 4
+ CUSTOM_DRIVING_PERSONALITY = 5
+ LONGITUDINAL_TUNE = 6
+ QOL = 7
+ RELAXED_PERSONALITY = 8
+ SPEED_LIMIT_CONTROLLER = 9
+ SPEED_LIMIT_CONTROLLER_OFFSETS = 10
+ SPEED_LIMIT_CONTROLLER_QOL = 11
+ SPEED_LIMIT_CONTROLLER_VISUALS = 12
+ STANDARD_PERSONALITY = 13
+ TRAFFIC_PERSONALITY = 14
+ WEATHER = 15
+ WEATHER_LOW_VISIBILITY = 16
+ WEATHER_RAIN = 17
+ WEATHER_RAIN_STORM = 18
+ WEATHER_SNOW = 19
+
+
+def build_stop_time_labels():
+ labels = {}
+ for i in range(10):
+ if i == 0:
+ labels[i] = "Off"
+ elif i == 1:
+ labels[i] = "1 second"
+ else:
+ labels[i] = f"{i} seconds"
+ return labels
+
+
+def build_follow_time_labels():
+ labels = {}
+ for i in range(301):
+ val = i / 100.0
+ if round(val * 100) == 100:
+ labels[val] = f"{val:.2f} second"
+ else:
+ labels[val] = f"{val:.2f} 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(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():
+ labels = {}
+ for i in range(4):
+ if i == 0:
+ labels[i] = "Off"
+ elif i == 1:
+ labels[i] = "1 meter"
+ else:
+ labels[i] = f"{i} meters"
+ return labels
+
+
+class FrogPilotLongitudinalPanel(Widget):
+ def __init__(self, parent=None):
+ super().__init__()
+
+ self._current_panel = SubPanel.MAIN
+ self._custom_personality_open = False
+ self._is_metric = False
+ self._params = Params()
+ self._parent = parent
+ self._qol_open = False
+ self._slc_open = False
+ self._started = False
+ self._toggles = {}
+ self._tuning_level = 0
+ self._weather_open = False
+
+ self._has_dash_speed_limits = False
+ self._has_pcm_cruise = False
+ self._has_radar = False
+ self._is_gm = False
+ self._is_toyota = False
+ self._is_tsk = False
+
+ self._longitudinal_actuator_delay = 0.0
+ self._start_accel = 0.0
+ self._stop_accel = 0.0
+ self._stopping_decel_rate = 0.0
+ self._v_ego_starting = 0.0
+ self._v_ego_stopping = 0.0
+
+ self._build_main_panel()
+ self._build_advanced_longitudinal_tune_panel()
+ self._build_conditional_experimental_panel()
+ self._build_curve_speed_panel()
+ self._build_custom_driving_personality_panel()
+ self._build_traffic_personality_panel()
+ self._build_aggressive_personality_panel()
+ self._build_standard_personality_panel()
+ self._build_relaxed_personality_panel()
+ self._build_longitudinal_tune_panel()
+ self._build_qol_panel()
+ self._build_weather_panel()
+ self._build_weather_low_visibility_panel()
+ self._build_weather_rain_panel()
+ self._build_weather_rain_storm_panel()
+ self._build_weather_snow_panel()
+ self._build_speed_limit_controller_panel()
+ self._build_slc_offsets_panel()
+ self._build_slc_qol_panel()
+ self._build_slc_visuals_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_longitudinal_tune_control = FrogPilotManageControl(
+ "AdvancedLongitudinalTune",
+ "Advanced Longitudinal Tuning",
+ "Advanced acceleration and braking control changes to fine-tune how openpilot drives.",
+ "../../frogpilot/assets/toggle_icons/icon_advanced_longitudinal_tune.png",
+ )
+ self._advanced_longitudinal_tune_control.set_manage_callback(self._open_advanced_longitudinal_tune)
+
+ self._conditional_experimental_control = FrogPilotManageControl(
+ "ConditionalExperimental",
+ "Conditional Experimental Mode",
+ "Automatically switch to \"Experimental Mode\" when set conditions are met. Allows the model to handle challenging situations with smarter decision making.",
+ "../../frogpilot/assets/toggle_icons/icon_conditional.png",
+ )
+ self._conditional_experimental_control.set_manage_callback(self._open_conditional_experimental)
+
+ self._curve_speed_controller_control = FrogPilotManageControl(
+ "CurveSpeedController",
+ "Curve Speed Controller",
+ "Automatically slow down for upcoming curves using data learned from your driving style, adapting to curves as you would.",
+ "../../frogpilot/assets/toggle_icons/icon_speed_map.png",
+ )
+ self._curve_speed_controller_control.set_manage_callback(self._open_curve_speed)
+
+ self._custom_personalities_control = FrogPilotManageControl(
+ "CustomPersonalities",
+ "Driving Personalities",
+ "Customize the \"Driving Personalities\" to better match your driving style.",
+ "../../frogpilot/assets/toggle_icons/icon_personality.png",
+ )
+ self._custom_personalities_control.set_manage_callback(self._open_custom_driving_personality)
+
+ self._longitudinal_tune_control = FrogPilotManageControl(
+ "LongitudinalTune",
+ "Longitudinal Tuning",
+ "Acceleration and braking control changes to fine-tune how openpilot drives.",
+ "../../frogpilot/assets/toggle_icons/icon_longitudinal_tune.png",
+ )
+ self._longitudinal_tune_control.set_manage_callback(self._open_longitudinal_tune)
+
+ self._qol_longitudinal_control = FrogPilotManageControl(
+ "QOLLongitudinal",
+ "Quality of Life",
+ "Miscellaneous acceleration and braking control changes to fine-tune how openpilot drives.",
+ "../../frogpilot/assets/toggle_icons/icon_quality_of_life.png",
+ )
+ self._qol_longitudinal_control.set_manage_callback(self._open_qol)
+
+ self._speed_limit_controller_control = FrogPilotManageControl(
+ "SpeedLimitController",
+ "Speed Limit Controller",
+ "Limit openpilot's maximum driving speed to the current speed limit obtained from downloaded maps, Mapbox, or the dashboard for supported vehicles (Ford, Genesis, Hyundai, Kia, Lexus, Toyota).",
+ "../../frogpilot/assets/toggle_icons/icon_speed_limit.png",
+ )
+ self._speed_limit_controller_control.set_manage_callback(self._open_speed_limit_controller)
+
+ main_items = [
+ self._advanced_longitudinal_tune_control,
+ self._conditional_experimental_control,
+ self._curve_speed_controller_control,
+ self._custom_personalities_control,
+ self._longitudinal_tune_control,
+ self._qol_longitudinal_control,
+ self._speed_limit_controller_control,
+ ]
+
+ self._toggles["AdvancedLongitudinalTune"] = self._advanced_longitudinal_tune_control
+ self._toggles["ConditionalExperimental"] = self._conditional_experimental_control
+ self._toggles["CurveSpeedController"] = self._curve_speed_controller_control
+ self._toggles["CustomPersonalities"] = self._custom_personalities_control
+ self._toggles["LongitudinalTune"] = self._longitudinal_tune_control
+ self._toggles["QOLLongitudinal"] = self._qol_longitudinal_control
+ self._toggles["SpeedLimitController"] = self._speed_limit_controller_control
+
+ self._main_scroller = Scroller(main_items, line_separator=True, spacing=0)
+
+ def _build_advanced_longitudinal_tune_panel(self):
+ self._longitudinal_actuator_delay_control = FrogPilotParamValueControl(
+ "LongitudinalActuatorDelay",
+ "Actuator Delay",
+ "The time between openpilot's throttle or brake command and the vehicle's response. Increase if the vehicle feels slow to react; decrease if it feels too eager or overshoots.",
+ "",
+ min_value=0,
+ max_value=1,
+ label=" seconds",
+ interval=0.01,
+ )
+
+ self._max_desired_acceleration_control = FrogPilotParamValueControl(
+ "MaxDesiredAcceleration",
+ "Maximum Acceleration",
+ "Limit the strongest acceleration openpilot can command.",
+ "",
+ min_value=0.1,
+ max_value=4.0,
+ label=" m/s²",
+ interval=0.1,
+ )
+
+ self._start_accel_control = FrogPilotParamValueControl(
+ "StartAccel",
+ "Start Acceleration",
+ "Extra acceleration applied when starting from a stop. Increase for quicker takeoffs; decrease for smoother, gentler starts.",
+ "",
+ min_value=0,
+ max_value=4,
+ label=" m/s²",
+ interval=0.01,
+ fast_increase=True,
+ )
+
+ self._v_ego_starting_control = FrogPilotParamValueControl(
+ "VEgoStarting",
+ "Start Speed",
+ "The speed at which openpilot exits the stopped state. Increase to reduce creeping; decrease to move sooner after stopping.",
+ "",
+ min_value=0.01,
+ max_value=1,
+ label=" m/s²",
+ interval=0.01,
+ )
+
+ self._stop_accel_control = FrogPilotParamValueControl(
+ "StopAccel",
+ "Stop Acceleration",
+ "Brake force applied to hold the vehicle at a standstill. Increase to prevent rolling on hills; decrease for smoother, softer stops.",
+ "",
+ min_value=-4,
+ max_value=0,
+ label=" m/s²",
+ interval=0.01,
+ fast_increase=True,
+ )
+
+ self._stopping_decel_rate_control = FrogPilotParamValueControl(
+ "StoppingDecelRate",
+ "Stopping Rate",
+ "How quickly braking ramps up when stopping. Increase for shorter, firmer stops; decrease for smoother, longer stops.",
+ "",
+ min_value=0.001,
+ max_value=1,
+ label=" m/s²",
+ interval=0.001,
+ fast_increase=True,
+ )
+
+ self._v_ego_stopping_control = FrogPilotParamValueControl(
+ "VEgoStopping",
+ "Stop Speed",
+ "The speed at which openpilot considers the vehicle stopped. Increase to brake earlier and stop smoothly; decrease to wait longer but risk overshooting.",
+ "",
+ min_value=0.01,
+ max_value=1,
+ label=" m/s²",
+ interval=0.01,
+ )
+
+ advanced_items = [
+ self._longitudinal_actuator_delay_control,
+ self._max_desired_acceleration_control,
+ self._start_accel_control,
+ self._v_ego_starting_control,
+ self._stop_accel_control,
+ self._stopping_decel_rate_control,
+ self._v_ego_stopping_control,
+ ]
+
+ self._toggles["LongitudinalActuatorDelay"] = self._longitudinal_actuator_delay_control
+ self._toggles["MaxDesiredAcceleration"] = self._max_desired_acceleration_control
+ self._toggles["StartAccel"] = self._start_accel_control
+ self._toggles["VEgoStarting"] = self._v_ego_starting_control
+ self._toggles["StopAccel"] = self._stop_accel_control
+ self._toggles["StoppingDecelRate"] = self._stopping_decel_rate_control
+ self._toggles["VEgoStopping"] = self._v_ego_stopping_control
+
+ self._advanced_longitudinal_tune_scroller = Scroller(advanced_items, line_separator=True, spacing=0)
+
+ def _build_conditional_experimental_panel(self):
+ self._ce_speed_control = FrogPilotParamValueControl(
+ "CESpeed",
+ "Below",
+ "Switch to \"Experimental Mode\" when driving below this speed without a lead to help openpilot handle low-speed situations more smoothly.",
+ "",
+ min_value=0,
+ max_value=99,
+ label=" mph",
+ fast_increase=True,
+ )
+
+ self._ce_speed_lead_control = FrogPilotParamValueControl(
+ "CESpeedLead",
+ "With Lead",
+ "Switch to \"Experimental Mode\" when driving below this speed with a lead to help openpilot handle low-speed situations more smoothly.",
+ "",
+ min_value=0,
+ max_value=99,
+ label=" mph",
+ fast_increase=True,
+ )
+
+ self._ce_speed_dual_control = FrogPilotDualParamValueControl(self._ce_speed_control, self._ce_speed_lead_control)
+
+ self._ce_curves_control = FrogPilotButtonToggleControl(
+ "CECurves",
+ "Curve Detected Ahead",
+ "Switch to \"Experimental Mode\" when a curve is detected to allow the model to set an appropriate speed for the curve.",
+ "",
+ button_params=["CECurvesLead"],
+ button_texts=["With Lead"],
+ )
+
+ self._ce_stop_lights_item = ListItem(
+ title="\"Detected\" Stop Lights/Signs",
+ description="Switch to \"Experimental Mode\" whenever the driving model \"detects\" a red light or stop sign.
Disclaimer: openpilot does not explicitly detect traffic lights or stop signs. In \"Experimental Mode\", openpilot makes end-to-end driving decisions from camera input, which means it may stop even when there's no clear reason!",
+ action_item=ToggleAction(
+ initial_state=self._params.get_bool("CEStopLights"),
+ callback=lambda state: self._on_toggle("CEStopLights", state),
+ ),
+ )
+
+ self._ce_lead_control = FrogPilotButtonToggleControl(
+ "CELead",
+ "Lead Detected Ahead",
+ "Switch to \"Experimental Mode\" when a slower or stopped vehicle is detected. Can make braking smoother and more reliable on some vehicles.",
+ "",
+ button_params=["CESlowerLead", "CEStoppedLead"],
+ button_texts=["Slower Lead", "Stopped Lead"],
+ )
+
+ stop_time_labels = build_stop_time_labels()
+ self._ce_model_stop_time_control = FrogPilotParamValueControl(
+ "CEModelStopTime",
+ "Predicted Stop In",
+ "Switch to \"Experimental Mode\" when openpilot predicts a stop within the set time. This is usually triggered when the model \"sees\" a red light or stop sign ahead.
Disclaimer: openpilot does not explicitly detect traffic lights or stop signs. In \"Experimental Mode\", openpilot makes end-to-end driving decisions from camera input, which means it may stop even when there's no clear reason!",
+ "",
+ min_value=0,
+ max_value=9,
+ value_labels=stop_time_labels,
+ )
+
+ self._ce_signal_speed_control = FrogPilotParamValueButtonControl(
+ "CESignalSpeed",
+ "Turn Signal Below",
+ "Switch to \"Experimental Mode\" when using a turn signal below the set speed to allow the model to choose an appropriate speed for smoother left and right turns.",
+ "",
+ min_value=0,
+ max_value=99,
+ label=" mph",
+ fast_increase=True,
+ button_params=["CESignalLaneDetection"],
+ button_texts=["Not For Detected Lanes"],
+ left_button=True,
+ )
+
+ self._show_cem_status_item = ListItem(
+ title="Status Widget",
+ description="Show which condition triggered \"Experimental Mode\" on the driving screen.",
+ action_item=ToggleAction(
+ initial_state=self._params.get_bool("ShowCEMStatus"),
+ callback=lambda state: self._on_toggle("ShowCEMStatus", state),
+ ),
+ )
+
+ conditional_items = [
+ self._ce_speed_dual_control,
+ self._ce_curves_control,
+ self._ce_stop_lights_item,
+ self._ce_lead_control,
+ self._ce_model_stop_time_control,
+ self._ce_signal_speed_control,
+ self._show_cem_status_item,
+ ]
+
+ self._toggles["CESpeed"] = self._ce_speed_dual_control
+ self._toggles["CECurves"] = self._ce_curves_control
+ self._toggles["CEStopLights"] = self._ce_stop_lights_item
+ self._toggles["CELead"] = self._ce_lead_control
+ self._toggles["CEModelStopTime"] = self._ce_model_stop_time_control
+ self._toggles["CESignalSpeed"] = self._ce_signal_speed_control
+ self._toggles["ShowCEMStatus"] = self._show_cem_status_item
+
+ self._conditional_experimental_scroller = Scroller(conditional_items, line_separator=True, spacing=0)
+
+ def _build_curve_speed_panel(self):
+ self._calibrated_lateral_acceleration_item = ListItem(
+ title="Calibrated Lateral Acceleration",
+ description="The learned lateral acceleration from collected driving data. This sets how fast openpilot will take curves. Higher values allow faster cornering; lower values slow the vehicle for gentler turns.",
+ action_item=ButtonAction(
+ initial_text=f"{self._params.get_float('CalibratedLateralAcceleration'):.2f} m/s²",
+ ),
+ )
+
+ self._calibration_progress_item = ListItem(
+ title="Calibration Progress",
+ description="How much curve data has been collected. This is a progress meter; it is normal for the value to stay low and rarely reach 100%.",
+ action_item=ButtonAction(
+ initial_text=f"{self._params.get_float('CalibrationProgress'):.2f}%",
+ ),
+ )
+
+ self._reset_curve_data_item = ListItem(
+ title="Reset Curve Data",
+ description="Reset collected user data for \"Curve Speed Controller\".",
+ action_item=ButtonAction(
+ initial_text="RESET",
+ callback=self._on_reset_curve_data,
+ ),
+ )
+
+ self._show_csc_status_item = ListItem(
+ title="Status Widget",
+ description="Show the \"Curve Speed Controller\" target speed on the driving screen.",
+ action_item=ToggleAction(
+ initial_state=self._params.get_bool("ShowCSCStatus"),
+ callback=lambda state: self._on_toggle("ShowCSCStatus", state),
+ ),
+ )
+
+ curve_speed_items = [
+ self._calibrated_lateral_acceleration_item,
+ self._calibration_progress_item,
+ self._reset_curve_data_item,
+ self._show_csc_status_item,
+ ]
+
+ self._toggles["CalibratedLateralAcceleration"] = self._calibrated_lateral_acceleration_item
+ self._toggles["CalibrationProgress"] = self._calibration_progress_item
+ self._toggles["ResetCurveData"] = self._reset_curve_data_item
+ self._toggles["ShowCSCStatus"] = self._show_csc_status_item
+
+ self._curve_speed_scroller = Scroller(curve_speed_items, line_separator=True, spacing=0)
+
+ def _build_custom_driving_personality_panel(self):
+ self._traffic_personality_control = FrogPilotButtonsControl(
+ "Traffic Mode",
+ "Customize the \"Traffic Mode\" personality profile. Designed for stop-and-go driving.",
+ "../../frogpilot/assets/stock_theme/distance_icons/traffic.png",
+ button_texts=["MANAGE"],
+ )
+ self._traffic_personality_control.set_click_callback(lambda _: self._open_traffic_personality())
+
+ self._aggressive_personality_control = FrogPilotButtonsControl(
+ "Aggressive",
+ "Customize the \"Aggressive\" personality profile. Designed for assertive driving with tighter gaps.",
+ "../../frogpilot/assets/stock_theme/distance_icons/aggressive.png",
+ button_texts=["MANAGE"],
+ )
+ self._aggressive_personality_control.set_click_callback(lambda _: self._open_aggressive_personality())
+
+ self._standard_personality_control = FrogPilotButtonsControl(
+ "Standard",
+ "Customize the \"Standard\" personality profile. Designed for balanced driving with moderate gaps.",
+ "../../frogpilot/assets/stock_theme/distance_icons/standard.png",
+ button_texts=["MANAGE"],
+ )
+ self._standard_personality_control.set_click_callback(lambda _: self._open_standard_personality())
+
+ self._relaxed_personality_control = FrogPilotButtonsControl(
+ "Relaxed",
+ "Customize the \"Relaxed\" personality profile. Designed for smoother, more comfortable driving with larger gaps.",
+ "../../frogpilot/assets/stock_theme/distance_icons/relaxed.png",
+ button_texts=["MANAGE"],
+ )
+ self._relaxed_personality_control.set_click_callback(lambda _: self._open_relaxed_personality())
+
+ custom_personality_items = [
+ self._traffic_personality_control,
+ self._aggressive_personality_control,
+ self._standard_personality_control,
+ self._relaxed_personality_control,
+ ]
+
+ self._toggles["TrafficPersonalityProfile"] = self._traffic_personality_control
+ self._toggles["AggressivePersonalityProfile"] = self._aggressive_personality_control
+ self._toggles["StandardPersonalityProfile"] = self._standard_personality_control
+ self._toggles["RelaxedPersonalityProfile"] = self._relaxed_personality_control
+
+ self._custom_driving_personality_scroller = Scroller(custom_personality_items, line_separator=True, spacing=0)
+
+ def _build_traffic_personality_panel(self):
+ follow_time_labels = build_follow_time_labels()
+
+ self._traffic_follow_control = FrogPilotParamValueControl(
+ "TrafficFollow",
+ "Following Distance",
+ "The minimum following distance to the lead vehicle in \"Traffic Mode\". openpilot blends between this value and the \"Aggressive\" profile as speed increases. Increase for more space; decrease for tighter gaps.",
+ "",
+ min_value=0.5,
+ max_value=3,
+ value_labels=follow_time_labels,
+ interval=0.01,
+ fast_increase=True,
+ )
+
+ self._traffic_jerk_acceleration_control = FrogPilotParamValueControl(
+ "TrafficJerkAcceleration",
+ "Acceleration Smoothness",
+ "How smoothly openpilot accelerates in \"Traffic Mode\". Increase for gentler starts; decrease for faster but more abrupt takeoffs.",
+ "",
+ min_value=25,
+ max_value=200,
+ label="%",
+ )
+
+ self._traffic_jerk_deceleration_control = FrogPilotParamValueControl(
+ "TrafficJerkDeceleration",
+ "Braking Smoothness",
+ "How smoothly openpilot brakes in \"Traffic Mode\". Increase for gentler stops; decrease for quicker but sharper braking.",
+ "",
+ min_value=25,
+ max_value=200,
+ label="%",
+ )
+
+ self._traffic_jerk_danger_control = FrogPilotParamValueControl(
+ "TrafficJerkDanger",
+ "Safety Gap Bias",
+ "How much extra space openpilot keeps from the vehicle ahead in \"Traffic Mode\". Increase for larger gaps and more cautious following; decrease for tighter gaps and closer following.",
+ "",
+ min_value=25,
+ max_value=200,
+ label="%",
+ )
+
+ self._traffic_jerk_speed_decrease_control = FrogPilotParamValueControl(
+ "TrafficJerkSpeedDecrease",
+ "Slowdown Response",
+ "How smoothly openpilot slows down in \"Traffic Mode\". Increase for more gradual deceleration; decrease for faster but sharper slowdowns.",
+ "",
+ min_value=25,
+ max_value=200,
+ label="%",
+ )
+
+ self._traffic_jerk_speed_control = FrogPilotParamValueControl(
+ "TrafficJerkSpeed",
+ "Speed-Up Response",
+ "How smoothly openpilot speeds up in \"Traffic Mode\". Increase for more gradual acceleration; decrease for quicker but more jolting acceleration.",
+ "",
+ min_value=25,
+ max_value=200,
+ label="%",
+ )
+
+ self._reset_traffic_personality_item = ListItem(
+ title="Reset to Defaults",
+ description="Reset \"Traffic Mode\" settings to defaults.",
+ action_item=ButtonAction(
+ initial_text="RESET",
+ callback=self._on_reset_traffic_personality,
+ ),
+ )
+
+ traffic_items = [
+ self._traffic_follow_control,
+ self._traffic_jerk_acceleration_control,
+ self._traffic_jerk_deceleration_control,
+ self._traffic_jerk_danger_control,
+ self._traffic_jerk_speed_decrease_control,
+ self._traffic_jerk_speed_control,
+ self._reset_traffic_personality_item,
+ ]
+
+ self._toggles["TrafficFollow"] = self._traffic_follow_control
+ self._toggles["TrafficJerkAcceleration"] = self._traffic_jerk_acceleration_control
+ self._toggles["TrafficJerkDeceleration"] = self._traffic_jerk_deceleration_control
+ self._toggles["TrafficJerkDanger"] = self._traffic_jerk_danger_control
+ self._toggles["TrafficJerkSpeedDecrease"] = self._traffic_jerk_speed_decrease_control
+ self._toggles["TrafficJerkSpeed"] = self._traffic_jerk_speed_control
+ self._toggles["ResetTrafficPersonality"] = self._reset_traffic_personality_item
+
+ self._traffic_personality_scroller = Scroller(traffic_items, line_separator=True, spacing=0)
+
+ def _build_aggressive_personality_panel(self):
+ follow_time_labels = build_follow_time_labels()
+
+ self._aggressive_follow_control = FrogPilotParamValueControl(
+ "AggressiveFollow",
+ "Following Distance",
+ "How many seconds openpilot follows behind lead vehicles when using the \"Aggressive\" profile. Increase for more space; decrease for tighter gaps.
Default: 1.25 seconds.",
+ "",
+ min_value=1,
+ max_value=3,
+ value_labels=follow_time_labels,
+ interval=0.01,
+ fast_increase=True,
+ )
+
+ self._aggressive_jerk_acceleration_control = FrogPilotParamValueControl(
+ "AggressiveJerkAcceleration",
+ "Acceleration Smoothness",
+ "How smoothly openpilot accelerates with the \"Aggressive\" profile. Increase for gentler starts; decrease for faster but more abrupt takeoffs.",
+ "",
+ min_value=25,
+ max_value=200,
+ label="%",
+ )
+
+ self._aggressive_jerk_deceleration_control = FrogPilotParamValueControl(
+ "AggressiveJerkDeceleration",
+ "Braking Smoothness",
+ "How smoothly openpilot brakes with the \"Aggressive\" profile. Increase for gentler stops; decrease for quicker but sharper braking.",
+ "",
+ min_value=25,
+ max_value=200,
+ label="%",
+ )
+
+ self._aggressive_jerk_danger_control = FrogPilotParamValueControl(
+ "AggressiveJerkDanger",
+ "Safety Gap Bias",
+ "How much extra space openpilot keeps from the vehicle ahead with the \"Aggressive\" profile. Increase for larger gaps and more cautious following; decrease for tighter gaps and closer following.",
+ "",
+ min_value=25,
+ max_value=200,
+ label="%",
+ )
+
+ self._aggressive_jerk_speed_decrease_control = FrogPilotParamValueControl(
+ "AggressiveJerkSpeedDecrease",
+ "Slowdown Response",
+ "How smoothly openpilot slows down with the \"Aggressive\" profile. Increase for more gradual deceleration; decrease for faster but sharper slowdowns.",
+ "",
+ min_value=25,
+ max_value=200,
+ label="%",
+ )
+
+ self._aggressive_jerk_speed_control = FrogPilotParamValueControl(
+ "AggressiveJerkSpeed",
+ "Speed-Up Response",
+ "How smoothly openpilot speeds up with the \"Aggressive\" profile. Increase for more gradual acceleration; decrease for quicker but more jolting acceleration.",
+ "",
+ min_value=25,
+ max_value=200,
+ label="%",
+ )
+
+ self._reset_aggressive_personality_item = ListItem(
+ title="Reset to Defaults",
+ description="Reset the \"Aggressive\" profile to defaults.",
+ action_item=ButtonAction(
+ initial_text="RESET",
+ callback=self._on_reset_aggressive_personality,
+ ),
+ )
+
+ aggressive_items = [
+ self._aggressive_follow_control,
+ self._aggressive_jerk_acceleration_control,
+ self._aggressive_jerk_deceleration_control,
+ self._aggressive_jerk_danger_control,
+ self._aggressive_jerk_speed_decrease_control,
+ self._aggressive_jerk_speed_control,
+ self._reset_aggressive_personality_item,
+ ]
+
+ self._toggles["AggressiveFollow"] = self._aggressive_follow_control
+ self._toggles["AggressiveJerkAcceleration"] = self._aggressive_jerk_acceleration_control
+ self._toggles["AggressiveJerkDeceleration"] = self._aggressive_jerk_deceleration_control
+ self._toggles["AggressiveJerkDanger"] = self._aggressive_jerk_danger_control
+ self._toggles["AggressiveJerkSpeedDecrease"] = self._aggressive_jerk_speed_decrease_control
+ self._toggles["AggressiveJerkSpeed"] = self._aggressive_jerk_speed_control
+ self._toggles["ResetAggressivePersonality"] = self._reset_aggressive_personality_item
+
+ self._aggressive_personality_scroller = Scroller(aggressive_items, line_separator=True, spacing=0)
+
+ def _build_standard_personality_panel(self):
+ follow_time_labels = build_follow_time_labels()
+
+ self._standard_follow_control = FrogPilotParamValueControl(
+ "StandardFollow",
+ "Following Distance",
+ "How many seconds openpilot follows behind lead vehicles when using the \"Standard\" profile. Increase for more space; decrease for tighter gaps.
Default: 1.45 seconds.",
+ "",
+ min_value=1,
+ max_value=3,
+ value_labels=follow_time_labels,
+ interval=0.01,
+ fast_increase=True,
+ )
+
+ self._standard_jerk_acceleration_control = FrogPilotParamValueControl(
+ "StandardJerkAcceleration",
+ "Acceleration Smoothness",
+ "How smoothly openpilot accelerates with the \"Standard\" profile. Increase for gentler starts; decrease for faster but more abrupt takeoffs.",
+ "",
+ min_value=25,
+ max_value=200,
+ label="%",
+ )
+
+ self._standard_jerk_deceleration_control = FrogPilotParamValueControl(
+ "StandardJerkDeceleration",
+ "Braking Smoothness",
+ "How smoothly openpilot brakes with the \"Standard\" profile. Increase for gentler stops; decrease for quicker but sharper braking.",
+ "",
+ min_value=25,
+ max_value=200,
+ label="%",
+ )
+
+ self._standard_jerk_danger_control = FrogPilotParamValueControl(
+ "StandardJerkDanger",
+ "Safety Gap Bias",
+ "How much extra space openpilot keeps from the vehicle ahead with the \"Standard\" profile. Increase for larger gaps and more cautious following; decrease for tighter gaps and closer following.",
+ "",
+ min_value=25,
+ max_value=200,
+ label="%",
+ )
+
+ self._standard_jerk_speed_decrease_control = FrogPilotParamValueControl(
+ "StandardJerkSpeedDecrease",
+ "Slowdown Response",
+ "How smoothly openpilot slows down with the \"Standard\" profile. Increase for more gradual deceleration; decrease for faster but sharper slowdowns.",
+ "",
+ min_value=25,
+ max_value=200,
+ label="%",
+ )
+
+ self._standard_jerk_speed_control = FrogPilotParamValueControl(
+ "StandardJerkSpeed",
+ "Speed-Up Response",
+ "How smoothly openpilot speeds up with the \"Standard\" profile. Increase for more gradual acceleration; decrease for quicker but more jolting acceleration.",
+ "",
+ min_value=25,
+ max_value=200,
+ label="%",
+ )
+
+ self._reset_standard_personality_item = ListItem(
+ title="Reset to Defaults",
+ description="Reset the \"Standard\" profile to defaults.",
+ action_item=ButtonAction(
+ initial_text="RESET",
+ callback=self._on_reset_standard_personality,
+ ),
+ )
+
+ standard_items = [
+ self._standard_follow_control,
+ self._standard_jerk_acceleration_control,
+ self._standard_jerk_deceleration_control,
+ self._standard_jerk_danger_control,
+ self._standard_jerk_speed_decrease_control,
+ self._standard_jerk_speed_control,
+ self._reset_standard_personality_item,
+ ]
+
+ self._toggles["StandardFollow"] = self._standard_follow_control
+ self._toggles["StandardJerkAcceleration"] = self._standard_jerk_acceleration_control
+ self._toggles["StandardJerkDeceleration"] = self._standard_jerk_deceleration_control
+ self._toggles["StandardJerkDanger"] = self._standard_jerk_danger_control
+ self._toggles["StandardJerkSpeedDecrease"] = self._standard_jerk_speed_decrease_control
+ self._toggles["StandardJerkSpeed"] = self._standard_jerk_speed_control
+ self._toggles["ResetStandardPersonality"] = self._reset_standard_personality_item
+
+ self._standard_personality_scroller = Scroller(standard_items, line_separator=True, spacing=0)
+
+ def _build_relaxed_personality_panel(self):
+ follow_time_labels = build_follow_time_labels()
+
+ self._relaxed_follow_control = FrogPilotParamValueControl(
+ "RelaxedFollow",
+ "Following Distance",
+ "How many seconds openpilot follows behind lead vehicles when using the \"Relaxed\" profile. Increase for more space; decrease for tighter gaps.
Default: 1.75 seconds.",
+ "",
+ min_value=1,
+ max_value=3,
+ value_labels=follow_time_labels,
+ interval=0.01,
+ fast_increase=True,
+ )
+
+ self._relaxed_jerk_acceleration_control = FrogPilotParamValueControl(
+ "RelaxedJerkAcceleration",
+ "Acceleration Smoothness",
+ "How smoothly openpilot accelerates with the \"Relaxed\" profile. Increase for gentler starts; decrease for faster but more abrupt takeoffs.",
+ "",
+ min_value=25,
+ max_value=200,
+ label="%",
+ )
+
+ self._relaxed_jerk_deceleration_control = FrogPilotParamValueControl(
+ "RelaxedJerkDeceleration",
+ "Braking Smoothness",
+ "How smoothly openpilot brakes with the \"Relaxed\" profile. Increase for gentler stops; decrease for quicker but sharper braking.",
+ "",
+ min_value=25,
+ max_value=200,
+ label="%",
+ )
+
+ self._relaxed_jerk_danger_control = FrogPilotParamValueControl(
+ "RelaxedJerkDanger",
+ "Safety Gap Bias",
+ "How much extra space openpilot keeps from the vehicle ahead with the \"Relaxed\" profile. Increase for larger gaps and more cautious following; decrease for tighter gaps and closer following.",
+ "",
+ min_value=25,
+ max_value=200,
+ label="%",
+ )
+
+ self._relaxed_jerk_speed_decrease_control = FrogPilotParamValueControl(
+ "RelaxedJerkSpeedDecrease",
+ "Slowdown Response",
+ "How smoothly openpilot slows down with the \"Relaxed\" profile. Increase for more gradual deceleration; decrease for faster but sharper slowdowns.",
+ "",
+ min_value=25,
+ max_value=200,
+ label="%",
+ )
+
+ self._relaxed_jerk_speed_control = FrogPilotParamValueControl(
+ "RelaxedJerkSpeed",
+ "Speed-Up Response",
+ "How smoothly openpilot speeds up with the \"Relaxed\" profile. Increase for more gradual acceleration; decrease for quicker but more jolting acceleration.",
+ "",
+ min_value=25,
+ max_value=200,
+ label="%",
+ )
+
+ self._reset_relaxed_personality_item = ListItem(
+ title="Reset to Defaults",
+ description="Reset the \"Relaxed\" profile to defaults.",
+ action_item=ButtonAction(
+ initial_text="RESET",
+ callback=self._on_reset_relaxed_personality,
+ ),
+ )
+
+ relaxed_items = [
+ self._relaxed_follow_control,
+ self._relaxed_jerk_acceleration_control,
+ self._relaxed_jerk_deceleration_control,
+ self._relaxed_jerk_danger_control,
+ self._relaxed_jerk_speed_decrease_control,
+ self._relaxed_jerk_speed_control,
+ self._reset_relaxed_personality_item,
+ ]
+
+ self._toggles["RelaxedFollow"] = self._relaxed_follow_control
+ self._toggles["RelaxedJerkAcceleration"] = self._relaxed_jerk_acceleration_control
+ self._toggles["RelaxedJerkDeceleration"] = self._relaxed_jerk_deceleration_control
+ self._toggles["RelaxedJerkDanger"] = self._relaxed_jerk_danger_control
+ self._toggles["RelaxedJerkSpeedDecrease"] = self._relaxed_jerk_speed_decrease_control
+ self._toggles["RelaxedJerkSpeed"] = self._relaxed_jerk_speed_control
+ self._toggles["ResetRelaxedPersonality"] = self._reset_relaxed_personality_item
+
+ self._relaxed_personality_scroller = Scroller(relaxed_items, line_separator=True, spacing=0)
+
+ def _build_longitudinal_tune_panel(self):
+ self._acceleration_profile_control = FrogPilotButtonsControl(
+ "Acceleration Profile",
+ "How quickly openpilot speeds up. \"Eco\" is gentle and efficient, \"Sport\" is firmer and more responsive, and \"Sport+\" accelerates at the maximum rate allowed.",
+ "",
+ button_texts=["Standard", "Eco", "Sport", "Sport+"],
+ checkable=True,
+ exclusive=True,
+ )
+ self._acceleration_profile_control.set_click_callback(self._on_acceleration_profile_click)
+ self._acceleration_profile_control.set_checked_button(self._params.get_int("AccelerationProfile"))
+
+ self._deceleration_profile_control = FrogPilotButtonsControl(
+ "Deceleration Profile",
+ "How firmly openpilot slows down. \"Eco\" favors coasting, \"Sport\" applies stronger braking.",
+ "",
+ button_texts=["Standard", "Eco", "Sport"],
+ checkable=True,
+ exclusive=True,
+ )
+ self._deceleration_profile_control.set_click_callback(self._on_deceleration_profile_click)
+ self._deceleration_profile_control.set_checked_button(self._params.get_int("DecelerationProfile"))
+
+ self._human_acceleration_item = ListItem(
+ title="Human-Like Acceleration",
+ description="Acceleration that mimics human behavior by easing the throttle at low speeds and adding extra power when taking off from a stop.",
+ action_item=ToggleAction(
+ initial_state=self._params.get_bool("HumanAcceleration"),
+ callback=lambda state: self._on_toggle("HumanAcceleration", state),
+ ),
+ )
+
+ self._human_following_item = ListItem(
+ title="Human-Like Following",
+ description="Following behavior that mimics human drivers by closing gaps behind faster vehicles for quicker takeoffs and dynamically adjusting the desired following distance for gentler, more efficient braking.",
+ action_item=ToggleAction(
+ initial_state=self._params.get_bool("HumanFollowing"),
+ callback=lambda state: self._on_toggle("HumanFollowing", state),
+ ),
+ )
+
+ self._human_lane_changes_item = ListItem(
+ title="Human-Like Lane Changes",
+ description="Lane-change behavior that mimics human drivers by anticipating and tracking adjacent vehicles during lane changes.",
+ action_item=ToggleAction(
+ initial_state=self._params.get_bool("HumanLaneChanges"),
+ callback=lambda state: self._on_toggle("HumanLaneChanges", state),
+ ),
+ )
+
+ self._lead_detection_threshold_control = FrogPilotParamValueControl(
+ "LeadDetectionThreshold",
+ "Lead Detection Sensitivity",
+ "How sensitive openpilot is to detecting vehicles. Higher sensitivity allows quicker detection at longer distances but may react to non-vehicle objects; lower sensitivity is more conservative and reduces false detections.",
+ "",
+ min_value=25,
+ max_value=50,
+ label="%",
+ )
+
+ self._taco_tune_item = ListItem(
+ title="\"Taco Bell Run\" Turn Speed Hack",
+ description="The turn-speed hack from comma's 2022 \"Taco Bell Run\". Designed to slow down for left and right turns.",
+ action_item=ToggleAction(
+ initial_state=self._params.get_bool("TacoTune"),
+ callback=lambda state: self._on_toggle("TacoTune", state),
+ ),
+ )
+
+ longitudinal_tune_items = [
+ self._acceleration_profile_control,
+ self._deceleration_profile_control,
+ self._human_acceleration_item,
+ self._human_following_item,
+ self._human_lane_changes_item,
+ self._lead_detection_threshold_control,
+ self._taco_tune_item,
+ ]
+
+ self._toggles["AccelerationProfile"] = self._acceleration_profile_control
+ self._toggles["DecelerationProfile"] = self._deceleration_profile_control
+ self._toggles["HumanAcceleration"] = self._human_acceleration_item
+ self._toggles["HumanFollowing"] = self._human_following_item
+ self._toggles["HumanLaneChanges"] = self._human_lane_changes_item
+ self._toggles["LeadDetectionThreshold"] = self._lead_detection_threshold_control
+ self._toggles["TacoTune"] = self._taco_tune_item
+
+ self._longitudinal_tune_scroller = Scroller(longitudinal_tune_items, line_separator=True, spacing=0)
+
+ def _build_qol_panel(self):
+ self._custom_cruise_control = FrogPilotParamValueControl(
+ "CustomCruise",
+ "Cruise Interval",
+ "How much the set speed increases or decreases for each + or – cruise control button press.",
+ "",
+ min_value=1,
+ max_value=99,
+ label=" mph",
+ )
+
+ self._custom_cruise_long_control = FrogPilotParamValueControl(
+ "CustomCruiseLong",
+ "Cruise Interval (Hold)",
+ "How much the set speed increases or decreases while holding the + or – cruise control buttons.",
+ "",
+ min_value=1,
+ max_value=99,
+ label=" mph",
+ )
+
+ self._force_stops_item = ListItem(
+ title="Force Stop at \"Detected\" Stop Lights/Signs",
+ description="Force openpilot to stop whenever the driving model \"detects\" a red light or stop sign.
Disclaimer: openpilot does not explicitly detect traffic lights or stop signs. In \"Experimental Mode\", openpilot makes end-to-end driving decisions from camera input, which means it may stop even when there's no clear reason!",
+ action_item=ToggleAction(
+ initial_state=self._params.get_bool("ForceStops"),
+ callback=lambda state: self._on_toggle("ForceStops", state),
+ ),
+ )
+
+ self._increased_stopped_distance_control = FrogPilotParamValueControl(
+ "IncreasedStoppedDistance",
+ "Increase Stopped Distance by:",
+ "Add extra space when stopped behind vehicles. Increase for more room; decrease for shorter gaps.",
+ "",
+ min_value=0,
+ max_value=10,
+ label=" feet",
+ )
+
+ self._map_gears_control = FrogPilotButtonToggleControl(
+ "MapGears",
+ "Map Accel/Decel to Gears",
+ "Map the Acceleration or Deceleration profiles to the vehicle's \"Eco\" and \"Sport\" gear modes.",
+ "",
+ button_params=["MapAcceleration", "MapDeceleration"],
+ button_texts=["Acceleration", "Deceleration"],
+ )
+
+ self._set_speed_offset_control = FrogPilotParamValueControl(
+ "SetSpeedOffset",
+ "Offset Set Speed by:",
+ "Increase the set speed by the chosen offset. For example, set +5 if you usually drive 5 over the limit.",
+ "",
+ min_value=0,
+ max_value=99,
+ label=" mph",
+ )
+
+ self._reverse_cruise_item = ListItem(
+ title="Reverse Cruise Increase",
+ description="Reverse the cruise control button behavior so a short press increases the set speed by 5 instead of 1.",
+ action_item=ToggleAction(
+ initial_state=self._params.get_bool("ReverseCruise"),
+ callback=lambda state: self._on_toggle("ReverseCruise", state),
+ ),
+ )
+
+ self._weather_presets_control = FrogPilotButtonsControl(
+ "Weather Condition Offsets",
+ "Automatically adjust driving behavior based on real-time weather. Helps maintain comfort and safety in low visibility, rain, or snow.",
+ "",
+ button_texts=["MANAGE"],
+ )
+ self._weather_presets_control.set_click_callback(lambda _: self._open_weather())
+
+ qol_items = [
+ self._custom_cruise_control,
+ self._custom_cruise_long_control,
+ self._force_stops_item,
+ self._increased_stopped_distance_control,
+ self._map_gears_control,
+ self._set_speed_offset_control,
+ self._reverse_cruise_item,
+ self._weather_presets_control,
+ ]
+
+ self._toggles["CustomCruise"] = self._custom_cruise_control
+ self._toggles["CustomCruiseLong"] = self._custom_cruise_long_control
+ self._toggles["ForceStops"] = self._force_stops_item
+ self._toggles["IncreasedStoppedDistance"] = self._increased_stopped_distance_control
+ self._toggles["MapGears"] = self._map_gears_control
+ self._toggles["SetSpeedOffset"] = self._set_speed_offset_control
+ self._toggles["ReverseCruise"] = self._reverse_cruise_item
+ self._toggles["WeatherPresets"] = self._weather_presets_control
+
+ self._qol_scroller = Scroller(qol_items, line_separator=True, spacing=0)
+
+ def _build_weather_panel(self):
+ self._low_visibility_offsets_control = FrogPilotButtonsControl(
+ "Low Visibility",
+ "Driving adjustments for fog, haze, or other low-visibility conditions.",
+ "",
+ button_texts=["MANAGE"],
+ )
+ self._low_visibility_offsets_control.set_click_callback(lambda _: self._open_weather_low_visibility())
+
+ self._rain_offsets_control = FrogPilotButtonsControl(
+ "Rain",
+ "Driving adjustments for rainy conditions.",
+ "",
+ button_texts=["MANAGE"],
+ )
+ self._rain_offsets_control.set_click_callback(lambda _: self._open_weather_rain())
+
+ self._rain_storm_offsets_control = FrogPilotButtonsControl(
+ "Rainstorms",
+ "Driving adjustments for rainstorms.",
+ "",
+ button_texts=["MANAGE"],
+ )
+ self._rain_storm_offsets_control.set_click_callback(lambda _: self._open_weather_rain_storm())
+
+ self._snow_offsets_control = FrogPilotButtonsControl(
+ "Snow",
+ "Driving adjustments for snowy conditions.",
+ "",
+ button_texts=["MANAGE"],
+ )
+ self._snow_offsets_control.set_click_callback(lambda _: self._open_weather_snow())
+
+ self._set_weather_key_control = FrogPilotButtonsControl(
+ "Set Your Own Key",
+ "Set your own \"OpenWeatherMap\" key to increase the weather update rate.
Personal keys grant 1,000 free calls per day, allowing for updates every minute. The default key is shared and only updates every 15 minutes.",
+ "",
+ button_texts=["ADD", "TEST"],
+ )
+ self._set_weather_key_control.set_click_callback(self._on_weather_key_click)
+ self._update_weather_key_button()
+
+ weather_items = [
+ self._low_visibility_offsets_control,
+ self._rain_offsets_control,
+ self._rain_storm_offsets_control,
+ self._snow_offsets_control,
+ self._set_weather_key_control,
+ ]
+
+ self._toggles["LowVisibilityOffsets"] = self._low_visibility_offsets_control
+ self._toggles["RainOffsets"] = self._rain_offsets_control
+ self._toggles["RainStormOffsets"] = self._rain_storm_offsets_control
+ self._toggles["SnowOffsets"] = self._snow_offsets_control
+ self._toggles["SetWeatherKey"] = self._set_weather_key_control
+
+ self._weather_scroller = Scroller(weather_items, line_separator=True, spacing=0)
+
+ def _build_weather_low_visibility_panel(self):
+ follow_time_labels = build_follow_time_labels()
+
+ self._increase_following_low_visibility_control = FrogPilotParamValueControl(
+ "IncreaseFollowingLowVisibility",
+ "Increase Following Distance by:",
+ "Add extra space behind lead vehicles in low visibility. Increase for more space; decrease for tighter gaps.",
+ "",
+ min_value=0,
+ max_value=3,
+ value_labels=follow_time_labels,
+ interval=0.01,
+ fast_increase=True,
+ )
+
+ self._increased_stopped_distance_low_visibility_control = FrogPilotParamValueControl(
+ "IncreasedStoppedDistanceLowVisibility",
+ "Increase Stopped Distance by:",
+ "Add extra buffer when stopped behind vehicles in low visibility. Increase for more room; decrease for shorter gaps.",
+ "",
+ min_value=0,
+ max_value=10,
+ label=" feet",
+ )
+
+ self._reduce_acceleration_low_visibility_control = FrogPilotParamValueControl(
+ "ReduceAccelerationLowVisibility",
+ "Reduce Acceleration by:",
+ "Lower the maximum acceleration in low visibility. Increase for softer takeoffs; decrease for quicker but less stable takeoffs.",
+ "",
+ min_value=0,
+ max_value=99,
+ label="%",
+ )
+
+ self._reduce_lateral_acceleration_low_visibility_control = FrogPilotParamValueControl(
+ "ReduceLateralAccelerationLowVisibility",
+ "Reduce Speed in Curves by:",
+ "Lower the desired speed while driving through curves in low visibility. Increase for safer, gentler turns; decrease for more aggressive driving in curves.",
+ "",
+ min_value=0,
+ max_value=99,
+ label="%",
+ )
+
+ low_visibility_items = [
+ self._increase_following_low_visibility_control,
+ self._increased_stopped_distance_low_visibility_control,
+ self._reduce_acceleration_low_visibility_control,
+ self._reduce_lateral_acceleration_low_visibility_control,
+ ]
+
+ self._toggles["IncreaseFollowingLowVisibility"] = self._increase_following_low_visibility_control
+ self._toggles["IncreasedStoppedDistanceLowVisibility"] = self._increased_stopped_distance_low_visibility_control
+ self._toggles["ReduceAccelerationLowVisibility"] = self._reduce_acceleration_low_visibility_control
+ self._toggles["ReduceLateralAccelerationLowVisibility"] = self._reduce_lateral_acceleration_low_visibility_control
+
+ self._weather_low_visibility_scroller = Scroller(low_visibility_items, line_separator=True, spacing=0)
+
+ def _build_weather_rain_panel(self):
+ follow_time_labels = build_follow_time_labels()
+
+ self._increase_following_rain_control = FrogPilotParamValueControl(
+ "IncreaseFollowingRain",
+ "Increase Following Distance by:",
+ "Add extra space behind lead vehicles in rain. Increase for more space; decrease for tighter gaps.",
+ "",
+ min_value=0,
+ max_value=3,
+ value_labels=follow_time_labels,
+ interval=0.01,
+ fast_increase=True,
+ )
+
+ self._increased_stopped_distance_rain_control = FrogPilotParamValueControl(
+ "IncreasedStoppedDistanceRain",
+ "Increase Stopped Distance by:",
+ "Add extra buffer when stopped behind vehicles in rain. Increase for more room; decrease for shorter gaps.",
+ "",
+ min_value=0,
+ max_value=10,
+ label=" feet",
+ )
+
+ self._reduce_acceleration_rain_control = FrogPilotParamValueControl(
+ "ReduceAccelerationRain",
+ "Reduce Acceleration by:",
+ "Lower the maximum acceleration in rain. Increase for softer takeoffs; decrease for quicker but less stable takeoffs.",
+ "",
+ min_value=0,
+ max_value=99,
+ label="%",
+ )
+
+ self._reduce_lateral_acceleration_rain_control = FrogPilotParamValueControl(
+ "ReduceLateralAccelerationRain",
+ "Reduce Speed in Curves by:",
+ "Lower the desired speed while driving through curves in rain. Increase for safer, gentler turns; decrease for more aggressive driving in curves.",
+ "",
+ min_value=0,
+ max_value=99,
+ label="%",
+ )
+
+ rain_items = [
+ self._increase_following_rain_control,
+ self._increased_stopped_distance_rain_control,
+ self._reduce_acceleration_rain_control,
+ self._reduce_lateral_acceleration_rain_control,
+ ]
+
+ self._toggles["IncreaseFollowingRain"] = self._increase_following_rain_control
+ self._toggles["IncreasedStoppedDistanceRain"] = self._increased_stopped_distance_rain_control
+ self._toggles["ReduceAccelerationRain"] = self._reduce_acceleration_rain_control
+ self._toggles["ReduceLateralAccelerationRain"] = self._reduce_lateral_acceleration_rain_control
+
+ self._weather_rain_scroller = Scroller(rain_items, line_separator=True, spacing=0)
+
+ def _build_weather_rain_storm_panel(self):
+ follow_time_labels = build_follow_time_labels()
+
+ self._increase_following_rain_storm_control = FrogPilotParamValueControl(
+ "IncreaseFollowingRainStorm",
+ "Increase Following Distance by:",
+ "Add extra space behind lead vehicles in a rainstorm. Increase for more space; decrease for tighter gaps.",
+ "",
+ min_value=0,
+ max_value=3,
+ value_labels=follow_time_labels,
+ interval=0.01,
+ fast_increase=True,
+ )
+
+ self._increased_stopped_distance_rain_storm_control = FrogPilotParamValueControl(
+ "IncreasedStoppedDistanceRainStorm",
+ "Increase Stopped Distance by:",
+ "Add extra buffer when stopped behind vehicles in a rainstorm. Increase for more room; decrease for shorter gaps.",
+ "",
+ min_value=0,
+ max_value=10,
+ label=" feet",
+ )
+
+ self._reduce_acceleration_rain_storm_control = FrogPilotParamValueControl(
+ "ReduceAccelerationRainStorm",
+ "Reduce Acceleration by:",
+ "Lower the maximum acceleration in a rainstorm. Increase for softer takeoffs; decrease for quicker but less stable takeoffs.",
+ "",
+ min_value=0,
+ max_value=99,
+ label="%",
+ )
+
+ self._reduce_lateral_acceleration_rain_storm_control = FrogPilotParamValueControl(
+ "ReduceLateralAccelerationRainStorm",
+ "Reduce Speed in Curves by:",
+ "Lower the desired speed while driving through curves in a rainstorm. Increase for safer, gentler turns; decrease for more aggressive driving in curves.",
+ "",
+ min_value=0,
+ max_value=99,
+ label="%",
+ )
+
+ rain_storm_items = [
+ self._increase_following_rain_storm_control,
+ self._increased_stopped_distance_rain_storm_control,
+ self._reduce_acceleration_rain_storm_control,
+ self._reduce_lateral_acceleration_rain_storm_control,
+ ]
+
+ self._toggles["IncreaseFollowingRainStorm"] = self._increase_following_rain_storm_control
+ self._toggles["IncreasedStoppedDistanceRainStorm"] = self._increased_stopped_distance_rain_storm_control
+ self._toggles["ReduceAccelerationRainStorm"] = self._reduce_acceleration_rain_storm_control
+ self._toggles["ReduceLateralAccelerationRainStorm"] = self._reduce_lateral_acceleration_rain_storm_control
+
+ self._weather_rain_storm_scroller = Scroller(rain_storm_items, line_separator=True, spacing=0)
+
+ def _build_weather_snow_panel(self):
+ follow_time_labels = build_follow_time_labels()
+
+ self._increase_following_snow_control = FrogPilotParamValueControl(
+ "IncreaseFollowingSnow",
+ "Increase Following Distance by:",
+ "Add extra space behind lead vehicles in snow. Increase for more space; decrease for tighter gaps.",
+ "",
+ min_value=0,
+ max_value=3,
+ value_labels=follow_time_labels,
+ interval=0.01,
+ fast_increase=True,
+ )
+
+ self._increased_stopped_distance_snow_control = FrogPilotParamValueControl(
+ "IncreasedStoppedDistanceSnow",
+ "Increase Stopped Distance by:",
+ "Add extra buffer when stopped behind vehicles in snow. Increase for more room; decrease for shorter gaps.",
+ "",
+ min_value=0,
+ max_value=10,
+ label=" feet",
+ )
+
+ self._reduce_acceleration_snow_control = FrogPilotParamValueControl(
+ "ReduceAccelerationSnow",
+ "Reduce Acceleration by:",
+ "Lower the maximum acceleration in snow. Increase for softer takeoffs; decrease for quicker but less stable takeoffs.",
+ "",
+ min_value=0,
+ max_value=99,
+ label="%",
+ )
+
+ self._reduce_lateral_acceleration_snow_control = FrogPilotParamValueControl(
+ "ReduceLateralAccelerationSnow",
+ "Reduce Speed in Curves by:",
+ "Lower the desired speed while driving through curves in snow. Increase for safer, gentler turns; decrease for more aggressive driving in curves.",
+ "",
+ min_value=0,
+ max_value=99,
+ label="%",
+ )
+
+ snow_items = [
+ self._increase_following_snow_control,
+ self._increased_stopped_distance_snow_control,
+ self._reduce_acceleration_snow_control,
+ self._reduce_lateral_acceleration_snow_control,
+ ]
+
+ self._toggles["IncreaseFollowingSnow"] = self._increase_following_snow_control
+ self._toggles["IncreasedStoppedDistanceSnow"] = self._increased_stopped_distance_snow_control
+ self._toggles["ReduceAccelerationSnow"] = self._reduce_acceleration_snow_control
+ self._toggles["ReduceLateralAccelerationSnow"] = self._reduce_lateral_acceleration_snow_control
+
+ self._weather_snow_scroller = Scroller(snow_items, line_separator=True, spacing=0)
+
+ def _build_speed_limit_controller_panel(self):
+ self._slc_fallback_control = FrogPilotButtonsControl(
+ "Fallback Speed",
+ "The speed used by \"Speed Limit Controller\" when no speed limit is found.
- Set Speed: Use the cruise set speed
- Experimental Mode: Estimate the limit using the driving model
- Previous Limit: Keep using the last confirmed limit",
+ "",
+ button_texts=["Set Speed", "Experimental Mode", "Previous Limit"],
+ checkable=True,
+ exclusive=True,
+ )
+ self._slc_fallback_control.set_click_callback(self._on_slc_fallback_click)
+ self._slc_fallback_control.set_checked_button(self._params.get_int("SLCFallback"))
+
+ self._slc_override_control = FrogPilotButtonsControl(
+ "Override Speed",
+ "The speed used by \"Speed Limit Controller\" after you manually drive faster than the posted limit.
- Set with Gas Pedal: Use the highest speed reached while pressing the gas
- Max Set Speed: Use the cruise set speed
Overrides clear when openpilot disengages.",
+ "",
+ button_texts=["None", "Set With Gas Pedal", "Max Set Speed"],
+ checkable=True,
+ exclusive=True,
+ )
+ self._slc_override_control.set_click_callback(self._on_slc_override_click)
+ self._slc_override_control.set_checked_button(self._params.get_int("SLCOverride"))
+
+ self._slc_priority_item = ListItem(
+ title="Speed Limit Source Priority",
+ description="The source order for speed limits when more than one is available.",
+ action_item=ButtonAction(
+ initial_text="SELECT",
+ callback=self._on_slc_priority_click,
+ ),
+ )
+
+ self._slc_offsets_control = FrogPilotButtonsControl(
+ "Speed Limit Offsets",
+ "Add an offset to the posted speed limit to better match your driving style.",
+ "",
+ button_texts=["MANAGE"],
+ )
+ self._slc_offsets_control.set_click_callback(lambda _: self._open_slc_offsets())
+
+ self._slc_qol_control = FrogPilotButtonsControl(
+ "Quality of Life",
+ "Miscellaneous \"Speed Limit Controller\" changes to fine-tune how openpilot drives.",
+ "",
+ button_texts=["MANAGE"],
+ )
+ self._slc_qol_control.set_click_callback(lambda _: self._open_slc_qol())
+
+ self._slc_visuals_control = FrogPilotButtonsControl(
+ "Visual Settings",
+ "Visual \"Speed Limit Controller\" changes to fine-tune how the driving screen looks.",
+ "",
+ button_texts=["MANAGE"],
+ )
+ self._slc_visuals_control.set_click_callback(lambda _: self._open_slc_visuals())
+
+ slc_items = [
+ self._slc_fallback_control,
+ self._slc_override_control,
+ self._slc_priority_item,
+ self._slc_offsets_control,
+ self._slc_qol_control,
+ self._slc_visuals_control,
+ ]
+
+ self._toggles["SLCFallback"] = self._slc_fallback_control
+ self._toggles["SLCOverride"] = self._slc_override_control
+ self._toggles["SLCPriority"] = self._slc_priority_item
+ self._toggles["SLCOffsets"] = self._slc_offsets_control
+ self._toggles["SLCQOL"] = self._slc_qol_control
+ self._toggles["SLCVisuals"] = self._slc_visuals_control
+
+ self._speed_limit_controller_scroller = Scroller(slc_items, line_separator=True, spacing=0)
+
+ def _build_slc_offsets_panel(self):
+ self._offset1_control = FrogPilotParamValueControl(
+ "Offset1",
+ "Speed Offset (0–24 mph)",
+ "How much to offset posted speed-limits between 0 and 24 mph.",
+ "",
+ min_value=-99,
+ max_value=99,
+ label=" mph",
+ )
+
+ self._offset2_control = FrogPilotParamValueControl(
+ "Offset2",
+ "Speed Offset (25–34 mph)",
+ "How much to offset posted speed-limits between 25 and 34 mph.",
+ "",
+ min_value=-99,
+ max_value=99,
+ label=" mph",
+ )
+
+ self._offset3_control = FrogPilotParamValueControl(
+ "Offset3",
+ "Speed Offset (35–44 mph)",
+ "How much to offset posted speed-limits between 35 and 44 mph.",
+ "",
+ min_value=-99,
+ max_value=99,
+ label=" mph",
+ )
+
+ self._offset4_control = FrogPilotParamValueControl(
+ "Offset4",
+ "Speed Offset (45–54 mph)",
+ "How much to offset posted speed-limits between 45 and 54 mph.",
+ "",
+ min_value=-99,
+ max_value=99,
+ label=" mph",
+ )
+
+ self._offset5_control = FrogPilotParamValueControl(
+ "Offset5",
+ "Speed Offset (55–64 mph)",
+ "How much to offset posted speed-limits between 55 and 64 mph.",
+ "",
+ min_value=-99,
+ max_value=99,
+ label=" mph",
+ )
+
+ self._offset6_control = FrogPilotParamValueControl(
+ "Offset6",
+ "Speed Offset (65–74 mph)",
+ "How much to offset posted speed-limits between 65 and 74 mph.",
+ "",
+ min_value=-99,
+ max_value=99,
+ label=" mph",
+ )
+
+ self._offset7_control = FrogPilotParamValueControl(
+ "Offset7",
+ "Speed Offset (75–99 mph)",
+ "How much to offset posted speed-limits between 75 and 99 mph.",
+ "",
+ min_value=-99,
+ max_value=99,
+ label=" mph",
+ )
+
+ offset_items = [
+ self._offset1_control,
+ self._offset2_control,
+ self._offset3_control,
+ self._offset4_control,
+ self._offset5_control,
+ self._offset6_control,
+ self._offset7_control,
+ ]
+
+ self._toggles["Offset1"] = self._offset1_control
+ self._toggles["Offset2"] = self._offset2_control
+ self._toggles["Offset3"] = self._offset3_control
+ self._toggles["Offset4"] = self._offset4_control
+ self._toggles["Offset5"] = self._offset5_control
+ self._toggles["Offset6"] = self._offset6_control
+ self._toggles["Offset7"] = self._offset7_control
+
+ self._slc_offsets_scroller = Scroller(offset_items, line_separator=True, spacing=0)
+
+ def _build_slc_qol_panel(self):
+ self._slc_confirmation_control = FrogPilotButtonToggleControl(
+ "SLCConfirmation",
+ "Confirm New Speed Limits",
+ "Ask before changing to a new speed limit. To accept, tap the flashing on-screen widget or press the Cruise Increase button. To deny, press the Cruise Decrease button or ignore the prompt for 30 seconds.",
+ "",
+ button_params=["SLCConfirmationLower", "SLCConfirmationHigher"],
+ button_texts=["Lower Limits", "Higher Limits"],
+ )
+
+ self._slc_lookahead_higher_control = FrogPilotParamValueControl(
+ "SLCLookaheadHigher",
+ "Higher Limit Lookahead Time",
+ "How far ahead openpilot anticipates upcoming higher speed limits from downloaded map data.",
+ "",
+ min_value=0,
+ max_value=30,
+ label=" seconds",
+ )
+
+ self._slc_lookahead_lower_control = FrogPilotParamValueControl(
+ "SLCLookaheadLower",
+ "Lower Limit Lookahead Time",
+ "How far ahead openpilot anticipates upcoming lower speed limits from downloaded map data.",
+ "",
+ min_value=0,
+ max_value=30,
+ label=" seconds",
+ )
+
+ self._set_speed_limit_item = ListItem(
+ title="Match Speed Limit on Engage",
+ description="When openpilot is first enabled, automatically set the max speed to the current posted limit.",
+ action_item=ToggleAction(
+ initial_state=self._params.get_bool("SetSpeedLimit"),
+ callback=lambda state: self._on_toggle("SetSpeedLimit", state),
+ ),
+ )
+
+ self._slc_mapbox_filler_item = ListItem(
+ title="Use Mapbox as Fallback",
+ description="Use Mapbox speed-limit data when no other source is available.",
+ action_item=ToggleAction(
+ initial_state=self._params.get_bool("SLCMapboxFiller"),
+ callback=lambda state: self._on_toggle("SLCMapboxFiller", state),
+ ),
+ )
+
+ slc_qol_items = [
+ self._slc_confirmation_control,
+ self._slc_lookahead_higher_control,
+ self._slc_lookahead_lower_control,
+ self._set_speed_limit_item,
+ self._slc_mapbox_filler_item,
+ ]
+
+ self._toggles["SLCConfirmation"] = self._slc_confirmation_control
+ self._toggles["SLCLookaheadHigher"] = self._slc_lookahead_higher_control
+ self._toggles["SLCLookaheadLower"] = self._slc_lookahead_lower_control
+ self._toggles["SetSpeedLimit"] = self._set_speed_limit_item
+ self._toggles["SLCMapboxFiller"] = self._slc_mapbox_filler_item
+
+ self._slc_qol_scroller = Scroller(slc_qol_items, line_separator=True, spacing=0)
+
+ def _build_slc_visuals_panel(self):
+ self._show_slc_offset_item = ListItem(
+ title="Show Speed Limit Offset",
+ description="Show the current offset from the posted limit on the driving screen.",
+ action_item=ToggleAction(
+ initial_state=self._params.get_bool("ShowSLCOffset"),
+ callback=lambda state: self._on_toggle("ShowSLCOffset", state),
+ ),
+ )
+
+ self._speed_limit_sources_item = ListItem(
+ title="Show Speed Limit Sources",
+ description="Display the speed-limit sources and their current values on the driving screen.",
+ action_item=ToggleAction(
+ initial_state=self._params.get_bool("SpeedLimitSources"),
+ callback=lambda state: self._on_toggle("SpeedLimitSources", state),
+ ),
+ )
+
+ slc_visuals_items = [
+ self._show_slc_offset_item,
+ self._speed_limit_sources_item,
+ ]
+
+ self._toggles["ShowSLCOffset"] = self._show_slc_offset_item
+ self._toggles["SpeedLimitSources"] = self._speed_limit_sources_item
+
+ self._slc_visuals_scroller = Scroller(slc_visuals_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_acceleration_profile_click(self, button_id: int):
+ self._params.put_int("AccelerationProfile", button_id)
+ update_frogpilot_toggles()
+
+ def _on_deceleration_profile_click(self, button_id: int):
+ self._params.put_int("DecelerationProfile", button_id)
+ update_frogpilot_toggles()
+
+ def _on_slc_fallback_click(self, button_id: int):
+ self._params.put_int("SLCFallback", button_id)
+ update_frogpilot_toggles()
+
+ def _on_slc_override_click(self, button_id: int):
+ self._params.put_int("SLCOverride", button_id)
+ update_frogpilot_toggles()
+
+ def _on_slc_priority_click(self):
+ pass
+
+ def _on_reset_curve_data(self):
+ gui_app.set_modal_overlay(ConfirmDialog(
+ "Are you sure you want to completely reset your curvature data?",
+ "Reset",
+ "Cancel",
+ ))
+
+ def _on_reset_traffic_personality(self):
+ gui_app.set_modal_overlay(ConfirmDialog(
+ "Are you sure you want to completely reset your settings for Traffic Mode?",
+ "Reset",
+ "Cancel",
+ ))
+
+ def _on_reset_aggressive_personality(self):
+ gui_app.set_modal_overlay(ConfirmDialog(
+ "Are you sure you want to completely reset your settings for the Aggressive personality?",
+ "Reset",
+ "Cancel",
+ ))
+
+ def _on_reset_standard_personality(self):
+ gui_app.set_modal_overlay(ConfirmDialog(
+ "Are you sure you want to completely reset your settings for the Standard personality?",
+ "Reset",
+ "Cancel",
+ ))
+
+ def _on_reset_relaxed_personality(self):
+ gui_app.set_modal_overlay(ConfirmDialog(
+ "Are you sure you want to completely reset your settings for the Relaxed personality?",
+ "Reset",
+ "Cancel",
+ ))
+
+ def _on_weather_key_click(self, button_id: int):
+ if button_id == 0:
+ key_exists = bool(self._params.get("WeatherToken"))
+ if key_exists:
+ gui_app.set_modal_overlay(ConfirmDialog(
+ "Are you sure you want to remove your key?",
+ "Remove",
+ "Cancel",
+ ))
+
+ def _update_weather_key_button(self):
+ key_exists = bool(self._params.get("WeatherToken"))
+ self._set_weather_key_control.set_text(0, "REMOVE" if key_exists else "ADD")
+ self._set_weather_key_control.set_visible_button(1, key_exists)
+
+ def _update_curve_speed_labels(self):
+ cal_lat_accel = self._params.get_float("CalibratedLateralAcceleration")
+ cal_progress = self._params.get_float("CalibrationProgress")
+ if hasattr(self._calibrated_lateral_acceleration_item, 'action_item'):
+ self._calibrated_lateral_acceleration_item.action_item.set_text(f"{cal_lat_accel:.2f} m/s²")
+ if hasattr(self._calibration_progress_item, 'action_item'):
+ self._calibration_progress_item.action_item.set_text(f"{cal_progress:.2f}%")
+
+ def _open_advanced_longitudinal_tune(self):
+ self._current_panel = SubPanel.ADVANCED_LONGITUDINAL_TUNE
+
+ def _open_conditional_experimental(self):
+ self._current_panel = SubPanel.CONDITIONAL_EXPERIMENTAL
+
+ def _open_curve_speed(self):
+ self._current_panel = SubPanel.CURVE_SPEED
+
+ def _open_custom_driving_personality(self):
+ self._current_panel = SubPanel.CUSTOM_DRIVING_PERSONALITY
+
+ def _open_traffic_personality(self):
+ self._current_panel = SubPanel.TRAFFIC_PERSONALITY
+ self._custom_personality_open = True
+
+ def _open_aggressive_personality(self):
+ self._current_panel = SubPanel.AGGRESSIVE_PERSONALITY
+ self._custom_personality_open = True
+
+ def _open_standard_personality(self):
+ self._current_panel = SubPanel.STANDARD_PERSONALITY
+ self._custom_personality_open = True
+
+ def _open_relaxed_personality(self):
+ self._current_panel = SubPanel.RELAXED_PERSONALITY
+ self._custom_personality_open = True
+
+ def _open_longitudinal_tune(self):
+ self._current_panel = SubPanel.LONGITUDINAL_TUNE
+
+ def _open_qol(self):
+ self._current_panel = SubPanel.QOL
+
+ def _open_weather(self):
+ self._current_panel = SubPanel.WEATHER
+ self._qol_open = True
+
+ def _open_weather_low_visibility(self):
+ self._current_panel = SubPanel.WEATHER_LOW_VISIBILITY
+ self._weather_open = True
+
+ def _open_weather_rain(self):
+ self._current_panel = SubPanel.WEATHER_RAIN
+ self._weather_open = True
+
+ def _open_weather_rain_storm(self):
+ self._current_panel = SubPanel.WEATHER_RAIN_STORM
+ self._weather_open = True
+
+ def _open_weather_snow(self):
+ self._current_panel = SubPanel.WEATHER_SNOW
+ self._weather_open = True
+
+ def _open_speed_limit_controller(self):
+ self._current_panel = SubPanel.SPEED_LIMIT_CONTROLLER
+
+ def _open_slc_offsets(self):
+ self._current_panel = SubPanel.SPEED_LIMIT_CONTROLLER_OFFSETS
+ self._slc_open = True
+
+ def _open_slc_qol(self):
+ self._current_panel = SubPanel.SPEED_LIMIT_CONTROLLER_QOL
+ self._slc_open = True
+
+ def _open_slc_visuals(self):
+ self._current_panel = SubPanel.SPEED_LIMIT_CONTROLLER_VISUALS
+ self._slc_open = True
+
+ def _close_sub_panel(self):
+ self._current_panel = SubPanel.MAIN
+ self._custom_personality_open = False
+ self._qol_open = False
+ self._slc_open = False
+ self._weather_open = False
+
+ 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._has_pcm_cruise = CP.pcmCruise
+ self._has_radar = CP.radarUnavailable is False
+ self._is_gm = CP.carName == "gm"
+ self._is_toyota = CP.carName == "toyota"
+
+ self._longitudinal_actuator_delay = CP.longitudinalActuatorDelay
+ self._start_accel = getattr(CP, 'startAccel', 0.0)
+ self._stop_accel = getattr(CP, 'stopAccel', 0.0)
+ self._stopping_decel_rate = getattr(CP, 'stoppingDecelRate', 0.0)
+ self._v_ego_starting = getattr(CP, 'vEgoStarting', 0.0)
+ self._v_ego_stopping = getattr(CP, 'vEgoStopping', 0.0)
+
+ self._update_advanced_tune_titles()
+ except Exception:
+ pass
+
+ def _update_advanced_tune_titles(self):
+ if self._longitudinal_actuator_delay != 0:
+ self._longitudinal_actuator_delay_control._title_label.set_text(f"Actuator Delay (Default: {self._longitudinal_actuator_delay:.2f})")
+ if self._start_accel != 0:
+ self._start_accel_control._title_label.set_text(f"Start Acceleration (Default: {self._start_accel:.2f})")
+ if self._stop_accel != 0:
+ self._stop_accel_control._title_label.set_text(f"Stop Acceleration (Default: {self._stop_accel:.2f})")
+ if self._stopping_decel_rate != 0:
+ self._stopping_decel_rate_control._title_label.set_text(f"Stopping Rate (Default: {self._stopping_decel_rate:.2f})")
+ if self._v_ego_starting != 0:
+ self._v_ego_starting_control._title_label.set_text(f"Start Speed (Default: {self._v_ego_starting:.2f})")
+ if self._v_ego_stopping != 0:
+ self._v_ego_stopping_control._title_label.set_text(f"Stop Speed (Default: {self._v_ego_stopping:.2f})")
+
+ 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 = 3
+
+ self._offset1_control._title_label.set_text("Speed Offset (0–29 km/h)")
+ self._offset2_control._title_label.set_text("Speed Offset (30–49 km/h)")
+ self._offset3_control._title_label.set_text("Speed Offset (50–59 km/h)")
+ self._offset4_control._title_label.set_text("Speed Offset (60–79 km/h)")
+ self._offset5_control._title_label.set_text("Speed Offset (80–99 km/h)")
+ self._offset6_control._title_label.set_text("Speed Offset (100–119 km/h)")
+ self._offset7_control._title_label.set_text("Speed Offset (120–140 km/h)")
+ else:
+ speed_labels = build_imperial_speed_labels()
+ distance_labels = build_imperial_distance_labels()
+ max_speed = 99
+ max_distance = 10
+
+ self._offset1_control._title_label.set_text("Speed Offset (0–24 mph)")
+ self._offset2_control._title_label.set_text("Speed Offset (25–34 mph)")
+ self._offset3_control._title_label.set_text("Speed Offset (35–44 mph)")
+ self._offset4_control._title_label.set_text("Speed Offset (45–54 mph)")
+ self._offset5_control._title_label.set_text("Speed Offset (55–64 mph)")
+ self._offset6_control._title_label.set_text("Speed Offset (65–74 mph)")
+ self._offset7_control._title_label.set_text("Speed Offset (75–99 mph)")
+
+ self._ce_speed_control.update_control(0, max_speed, speed_labels)
+ self._ce_speed_lead_control.update_control(0, max_speed, speed_labels)
+ self._ce_signal_speed_control.update_control(0, max_speed, speed_labels)
+ self._custom_cruise_control.update_control(1, max_speed, speed_labels)
+ self._custom_cruise_long_control.update_control(1, max_speed, speed_labels)
+ self._set_speed_offset_control.update_control(0, max_speed, speed_labels)
+
+ self._increased_stopped_distance_control.update_control(0, max_distance, distance_labels)
+ self._increased_stopped_distance_low_visibility_control.update_control(0, max_distance, distance_labels)
+ self._increased_stopped_distance_rain_control.update_control(0, max_distance, distance_labels)
+ self._increased_stopped_distance_rain_storm_control.update_control(0, max_distance, distance_labels)
+ self._increased_stopped_distance_snow_control.update_control(0, max_distance, distance_labels)
+
+ offset_max = 150 if self._is_metric else 99
+ self._offset1_control.update_control(-offset_max, offset_max)
+ self._offset2_control.update_control(-offset_max, offset_max)
+ self._offset3_control.update_control(-offset_max, offset_max)
+ self._offset4_control.update_control(-offset_max, offset_max)
+ self._offset5_control.update_control(-offset_max, offset_max)
+ self._offset6_control.update_control(-offset_max, offset_max)
+ self._offset7_control.update_control(-offset_max, offset_max)
+
+ def _update_toggles(self):
+ self._tuning_level = self._params.get_int("TuningLevel") or 0
+
+ human_accel_enabled = self._params.get_bool("LongitudinalTune") and self._params.get_bool("HumanAcceleration")
+ experimental_gm_tune = self._params.get_bool("ExperimentalGMTune")
+ frogs_go_moos_tweak = self._params.get_bool("FrogsGoMoosTweak")
+
+ if hasattr(self._custom_cruise_control, 'set_visible'):
+ self._custom_cruise_control.set_visible(not self._has_pcm_cruise)
+ if hasattr(self._custom_cruise_long_control, 'set_visible'):
+ self._custom_cruise_long_control.set_visible(not self._has_pcm_cruise)
+ if hasattr(self._set_speed_offset_control, 'set_visible'):
+ self._set_speed_offset_control.set_visible(not self._has_pcm_cruise)
+ if hasattr(self._set_speed_limit_item, 'set_visible'):
+ self._set_speed_limit_item.set_visible(not self._has_pcm_cruise)
+
+ if hasattr(self._human_lane_changes_item, 'set_visible'):
+ self._human_lane_changes_item.set_visible(self._has_radar)
+
+ if hasattr(self._map_gears_control, 'set_visible'):
+ self._map_gears_control.set_visible(self._is_toyota and not self._is_tsk)
+
+ if hasattr(self._reverse_cruise_item, 'set_visible'):
+ self._reverse_cruise_item.set_visible(self._is_toyota)
+
+ if hasattr(self._slc_mapbox_filler_item, 'set_visible'):
+ self._slc_mapbox_filler_item.set_visible(bool(self._params.get("MapboxSecretKey")))
+
+ if hasattr(self._start_accel_control, 'set_visible'):
+ self._start_accel_control.set_visible(not human_accel_enabled)
+
+ stopping_controls_visible = True
+ if self._is_gm and experimental_gm_tune:
+ stopping_controls_visible = False
+ if self._is_toyota and frogs_go_moos_tweak:
+ stopping_controls_visible = False
+
+ if hasattr(self._stopping_decel_rate_control, 'set_visible'):
+ self._stopping_decel_rate_control.set_visible(stopping_controls_visible)
+ if hasattr(self._v_ego_starting_control, 'set_visible'):
+ self._v_ego_starting_control.set_visible(stopping_controls_visible)
+ if hasattr(self._v_ego_stopping_control, 'set_visible'):
+ self._v_ego_stopping_control.set_visible(stopping_controls_visible)
+
+ ce_model_stop_visible = self._tuning_level >= 2
+ if hasattr(self._ce_stop_lights_item, 'set_visible'):
+ self._ce_stop_lights_item.set_visible(not ce_model_stop_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_curve_speed_labels()
+ self._update_weather_key_button()
+ self._update_toggles()
+
+ def hide_event(self):
+ super().hide_event()
+ self._current_panel = SubPanel.MAIN
+ self._custom_personality_open = False
+ self._qol_open = False
+ self._slc_open = False
+ self._weather_open = False
+
+ def _render(self, rect):
+ if self._current_panel == SubPanel.ADVANCED_LONGITUDINAL_TUNE:
+ self._advanced_longitudinal_tune_scroller.render(rect)
+ elif self._current_panel == SubPanel.AGGRESSIVE_PERSONALITY:
+ self._aggressive_personality_scroller.render(rect)
+ elif self._current_panel == SubPanel.CONDITIONAL_EXPERIMENTAL:
+ self._conditional_experimental_scroller.render(rect)
+ elif self._current_panel == SubPanel.CURVE_SPEED:
+ self._curve_speed_scroller.render(rect)
+ elif self._current_panel == SubPanel.CUSTOM_DRIVING_PERSONALITY:
+ self._custom_driving_personality_scroller.render(rect)
+ elif self._current_panel == SubPanel.LONGITUDINAL_TUNE:
+ self._longitudinal_tune_scroller.render(rect)
+ elif self._current_panel == SubPanel.QOL:
+ self._qol_scroller.render(rect)
+ elif self._current_panel == SubPanel.RELAXED_PERSONALITY:
+ self._relaxed_personality_scroller.render(rect)
+ elif self._current_panel == SubPanel.SPEED_LIMIT_CONTROLLER:
+ self._speed_limit_controller_scroller.render(rect)
+ elif self._current_panel == SubPanel.SPEED_LIMIT_CONTROLLER_OFFSETS:
+ self._slc_offsets_scroller.render(rect)
+ elif self._current_panel == SubPanel.SPEED_LIMIT_CONTROLLER_QOL:
+ self._slc_qol_scroller.render(rect)
+ elif self._current_panel == SubPanel.SPEED_LIMIT_CONTROLLER_VISUALS:
+ self._slc_visuals_scroller.render(rect)
+ elif self._current_panel == SubPanel.STANDARD_PERSONALITY:
+ self._standard_personality_scroller.render(rect)
+ elif self._current_panel == SubPanel.TRAFFIC_PERSONALITY:
+ self._traffic_personality_scroller.render(rect)
+ elif self._current_panel == SubPanel.WEATHER:
+ self._weather_scroller.render(rect)
+ elif self._current_panel == SubPanel.WEATHER_LOW_VISIBILITY:
+ self._weather_low_visibility_scroller.render(rect)
+ elif self._current_panel == SubPanel.WEATHER_RAIN:
+ self._weather_rain_scroller.render(rect)
+ elif self._current_panel == SubPanel.WEATHER_RAIN_STORM:
+ self._weather_rain_storm_scroller.render(rect)
+ elif self._current_panel == SubPanel.WEATHER_SNOW:
+ self._weather_snow_scroller.render(rect)
+ else:
+ self._main_scroller.render(rect)
diff --git a/frogpilot/ui/layouts/settings/maps_settings.py b/frogpilot/ui/layouts/settings/maps_settings.py
new file mode 100644
index 0000000000..31b9b5f115
--- /dev/null
+++ b/frogpilot/ui/layouts/settings/maps_settings.py
@@ -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",
+ "How often maps update 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",
+ "Manually update your selected map sources 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",
+ "Select the countries or U.S. states to use with \"Speed Limit Controller\".",
+ "",
+ 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",
+ "Delete downloaded map data 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)
diff --git a/frogpilot/ui/layouts/settings/model_settings.py b/frogpilot/ui/layouts/settings/model_settings.py
new file mode 100644
index 0000000000..99cd485267
--- /dev/null
+++ b/frogpilot/ui/layouts/settings/model_settings.py
@@ -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="Automatically download new driving models 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",
+ "Delete downloaded driving models 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",
+ "Manually download driving models 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="Select a random driving model each drive 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",
+ "Add or remove driving models from the \"Model Randomizer\" blacklist.",
+ "",
+ 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",
+ "View or reset saved model ratings 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="Choose which driving model openpilot uses.",
+ 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",
+ "Update the \"Model Manager\" 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)
diff --git a/frogpilot/ui/layouts/settings/navigation_settings.py b/frogpilot/ui/layouts/settings/navigation_settings.py
new file mode 100644
index 0000000000..2f2aea0c4e
--- /dev/null
+++ b/frogpilot/ui/layouts/settings/navigation_settings.py
@@ -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",
+ "Manage your Public Mapbox Key.",
+ "",
+ 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",
+ "Manage your Secret Mapbox Key.",
+ "",
+ 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="Instructions on how to set up Mapbox 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",
+ "Automatically collect missing or incorrect speed limits while you drive using speeds limits sourced from your dashboard (if supported), "
+ "Mapbox, and \"Navigate on openpilot\".
"
+ "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\".
"
+ "You can download this file from \"The Pond\" in the \"Download Speed Limits\" menu.
"
+ "Need a step-by-step guide? Visit #speed-limit-filler 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)
diff --git a/frogpilot/ui/layouts/settings/sounds_settings.py b/frogpilot/ui/layouts/settings/sounds_settings.py
new file mode 100644
index 0000000000..f0de63cf00
--- /dev/null
+++ b/frogpilot/ui/layouts/settings/sounds_settings.py
@@ -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",
+ "Set how loud each type of openpilot alert is 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",
+ "Optional FrogPilot alerts 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",
+ "Set the volume for alerts when openpilot disengages.
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",
+ "Set the volume for the chime when openpilot engages, 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",
+ "Set the volume for prompts that need attention.
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",
+ "Set the volume for prompts when openpilot detects driver distraction or unresponsiveness.
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",
+ "Set the volume for alerts when openpilot refuses to engage.
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",
+ "Set the volume for softer warnings about potential risks.
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",
+ "Set the volume for the loudest warnings that require urgent attention.
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="Play the infamous \"Goat Scream\" when the steering controller reaches its limit. 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="Play an alert when the model predicts a red light has turned green.
Disclaimer: openpilot does not explicitly detect traffic lights. This alert is based on end-to-end model predictions from camera input and may trigger even when the light has not changed.",
+ 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="Play an alert when the lead vehicle departs from a stop.",
+ 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="Play a louder alert if a vehicle is in the blind spot when attempting to change lanes. Based on the \"Car Detected in Blindspot\" event.",
+ 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="Play an alert when the posted speed limit changes.",
+ 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)
diff --git a/frogpilot/ui/layouts/settings/theme_settings.py b/frogpilot/ui/layouts/settings/theme_settings.py
new file mode 100644
index 0000000000..372a184ac0
--- /dev/null
+++ b/frogpilot/ui/layouts/settings/theme_settings.py
@@ -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",
+ "The overall look and feel of openpilot. 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="Themes based on U.S. holidays. 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="Color the driving path like a Mario Kart-style \"Rainbow Road\".",
+ 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="Occasional on-screen effects triggered by driving conditions. 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",
+ "Pick a random theme between each drive 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",
+ "Customize the \"Startup Alert\" message 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",
+ "The color scheme used throughout openpilot. 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",
+ "The distance button icons shown on the driving screen. 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",
+ "The icon style used across openpilot. 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",
+ "Themed turn-signal animations. 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",
+ "The sound pack used by openpilot. 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",
+ "The steering-wheel icon 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)
diff --git a/frogpilot/ui/layouts/settings/utilities.py b/frogpilot/ui/layouts/settings/utilities.py
new file mode 100644
index 0000000000..b58f683329
--- /dev/null
+++ b/frogpilot/ui/layouts/settings/utilities.py
@@ -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="Use all of FrogPilot's developer metrics on your next drive 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",
+ "Flash the latest, official firmware onto your Panda device 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",
+ "Force openpilot to be offroad or onroad.",
+ "",
+ 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",
+ "Send a bug report 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",
+ "Reset all toggles to their default values.",
+ "",
+ 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",
+ "Reset all toggles to match stock openpilot.",
+ "",
+ 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)
diff --git a/frogpilot/ui/layouts/settings/vehicle_settings.py b/frogpilot/ui/layouts/settings/vehicle_settings.py
new file mode 100644
index 0000000000..d79558f487
--- /dev/null
+++ b/frogpilot/ui/layouts/settings/vehicle_settings.py
@@ -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="Force the selected fingerprint 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="Disable openpilot longitudinal 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",
+ "FrogPilot features for General Motors vehicles.",
+ "",
+ )
+ self._gm_control.set_manage_callback(self._open_gm_panel)
+
+ # HKG Settings
+ self._hkg_control = FrogPilotManageControl(
+ "HKGToggles",
+ "Hyundai/Kia/Genesis Settings",
+ "FrogPilot features for Genesis, Hyundai, and Kia vehicles.",
+ "",
+ )
+ self._hkg_control.set_manage_callback(self._open_hkg_panel)
+
+ # Subaru Settings
+ self._subaru_control = FrogPilotManageControl(
+ "SubaruToggles",
+ "Subaru Settings",
+ "FrogPilot features for Subaru vehicles.",
+ "",
+ )
+ self._subaru_control.set_manage_callback(self._open_subaru_panel)
+
+ # Toyota Settings
+ self._toyota_control = FrogPilotManageControl(
+ "ToyotaToggles",
+ "Toyota/Lexus Settings",
+ "FrogPilot features for Lexus and Toyota vehicles.",
+ "",
+ )
+ self._toyota_control.set_manage_callback(self._open_toyota_panel)
+
+ # Vehicle Info
+ self._vehicle_info_control = FrogPilotManageControl(
+ "VehicleInfo",
+ "Vehicle Info",
+ "Information about your vehicle in regards to openpilot support and functionality.",
+ "",
+ )
+ 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="Force stop-and-go 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="The steering torque hack from comma's 2022 \"Taco Bell Run\". 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="Stop and go for supported Subaru vehicles.",
+ 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",
+ "Automatically lock/unlock doors 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",
+ "The speed offset openpilot uses to match the speed on the dashboard display.",
+ "",
+ 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="Personal tweaks by FrogsGoMoo for quicker acceleration and smoother braking.",
+ 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",
+ "Automatically lock the doors on ignition off when no one is detected in the front seats.
Warning: 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="Force stop-and-go 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="Detected 3rd party hardware.",
+ action_item=TextAction(lambda: self._get_hardware_detected(), color=ITEM_TEXT_VALUE_COLOR),
+ )
+
+ self._bsm_support_item = ListItem(
+ title="Blind Spot Support",
+ description="Does openpilot use the vehicle's blind spot data?",
+ 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="Does your vehicle support the \"comma pedal\"?",
+ 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="Can openpilot control the vehicle's acceleration and braking?",
+ 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="Does openpilot use the vehicle's radar data 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="Does your vehicle support \"SDSUs\"?",
+ 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="Does your vehicle support stop-and-go driving?",
+ 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)
diff --git a/frogpilot/ui/layouts/settings/visual_settings.py b/frogpilot/ui/layouts/settings/visual_settings.py
new file mode 100644
index 0000000000..c7d45f86fb
--- /dev/null
+++ b/frogpilot/ui/layouts/settings/visual_settings.py
@@ -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",
+ "Advanced visual changes 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",
+ "Custom FrogPilot widgets 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",
+ "Model visualizations 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",
+ "Speed limits, and other navigation widgets.",
+ "../../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",
+ "Miscellaneous visual changes 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="Hide the current speed 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="Hide the lead-vehicle marker 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="Hide the max speed 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="Hide non-critical alerts 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="Hide posted speed limits 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="Use the vehicle's wheel speed 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="Color the driving path by planned acceleration and braking.",
+ 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="Show the driving paths for the left and right lanes.",
+ 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="Show a red path when a vehicle is in that lane's blind spot.",
+ 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="Show the current driving direction 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="Control and view the current driving personality 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",
+ "On-screen gas and brake indicators.
Dynamic: Opacity changes according to how much openpilot is accelerating or braking
Static: 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="Rotate the driving screen wheel 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="Change the path width based on engagement.
Fully Engaged: 100%
Always On Lateral: 75%
Disengaged: 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",
+ "Set the lane-line thickness.
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",
+ "Set the driving-path edge width that represents different driving modes and statuses.
Default is 20% of the total path width.
Color Guide:
- Light Blue: Always On Lateral
- Green: Default
- Orange: Experimental Mode
- Red: Traffic Mode
- Yellow: 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",
+ "Set the driving-path width.
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",
+ "Set the road-edge thickness.
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="Display the road name at the bottom of the driving screen 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="Show speed limits 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="Use Mapbox speed-limit data when no other source is available.",
+ 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="Show Vienna-style (EU) speed-limit signs 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",
+ "Select the active camera view. 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="Show the driver camera feed 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="Show a timer when stopped 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(
+ "Set the lane-line thickness.
Default matches the MUTCD lane-line width standard of 10 centimeters."
+ )
+ self._path_width_control.set_description(
+ "Set the driving-path width.
Default (1.9 meters) matches the width of a 2019 Lexus ES 350."
+ )
+ self._road_edges_width_control.set_description(
+ "Set the road-edge thickness.
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(
+ "Set the lane-line thickness.
Default matches the MUTCD lane-line width standard of 4 inches."
+ )
+ self._path_width_control.set_description(
+ "Set the driving-path width.
Default (6.1 feet) matches the width of a 2019 Lexus ES 350."
+ )
+ self._road_edges_width_control.set_description(
+ "Set the road-edge thickness.
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)
diff --git a/frogpilot/ui/layouts/settings/wheel_settings.py b/frogpilot/ui/layouts/settings/wheel_settings.py
new file mode 100644
index 0000000000..defe540dfe
--- /dev/null
+++ b/frogpilot/ui/layouts/settings/wheel_settings.py
@@ -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", "Action performed when the \"Distance\" button is pressed."),
+ ("LongDistanceButtonControl", "Distance Button (Long Press)", "Action performed when the \"Distance\" button is pressed for more than 0.5 seconds."),
+ ("VeryLongDistanceButtonControl", "Distance Button (Very Long Press)", "Action performed when the \"Distance\" button is pressed for more than 2.5 seconds."),
+ ("LKASButtonControl", "LKAS Button", "Action performed when the \"LKAS\" button is pressed."),
+]
+
+
+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)
diff --git a/selfdrive/ui/layouts/settings/frogpilot.py b/selfdrive/ui/layouts/settings/frogpilot.py
new file mode 100644
index 0000000000..6b520e80a3
--- /dev/null
+++ b/selfdrive/ui/layouts/settings/frogpilot.py
@@ -0,0 +1,3 @@
+from openpilot.frogpilot.ui.layouts.settings.frogpilot import FrogPilotLayout
+
+__all__ = ["FrogPilotLayout"]
diff --git a/selfdrive/ui/layouts/settings/settings.py b/selfdrive/ui/layouts/settings/settings.py
index 68f45df77d..58f8a0aa35 100644
--- a/selfdrive/ui/layouts/settings/settings.py
+++ b/selfdrive/ui/layouts/settings/settings.py
@@ -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)