From 3ecd8be1f11cc15530f8980e05bcd60c02c4bf2b Mon Sep 17 00:00:00 2001 From: firestar5683 <168790843+firestar5683@users.noreply.github.com> Date: Sun, 14 Jun 2026 23:50:41 -0500 Subject: [PATCH] Raindrops on Roses & Whiskers on Kittens --- common/params_keys.h | 1 + .../ui/layouts/settings/starpilot/vehicle.py | 7 +- .../ui/mici/onroad/augmented_road_view.py | 116 ++++++- selfdrive/ui/qt/onroad/annotated_camera.cc | 51 +++- selfdrive/ui/qt/onroad/annotated_camera.h | 2 + selfdrive/ui/qt/onroad/driver_monitoring.cc | 7 +- selfdrive/ui/qt/onroad/driver_monitoring.h | 2 +- starpilot/common/favorite_slots.py | 132 ++++++++ starpilot/common/starpilot_variables.py | 21 ++ starpilot/common/tests/test_favorite_slots.py | 80 +++++ .../common/tests/test_starpilot_variables.py | 10 + starpilot/controls/starpilot_card.py | 6 + .../controls/tests/test_starpilot_card.py | 32 ++ .../the_pond/assets/components/router.js | 2 +- .../components/tools/device_settings.css | 82 +++++ .../components/tools/device_settings.js | 288 +++++++++++++++++- .../tools/device_settings_layout.json | 215 +++++++++++-- .../system/the_pond/templates/index.html | 2 +- starpilot/system/the_pond/the_pond.py | 132 ++++++++ starpilot/ui/qt/offroad/wheel_settings.cc | 6 + starpilot/ui/qt/onroad/starpilot_buttons.cc | 140 +++++++++ starpilot/ui/qt/onroad/starpilot_buttons.h | 31 ++ 22 files changed, 1332 insertions(+), 33 deletions(-) create mode 100644 starpilot/common/favorite_slots.py create mode 100644 starpilot/common/tests/test_favorite_slots.py diff --git a/common/params_keys.h b/common/params_keys.h index b5479dd86..6e2c97871 100644 --- a/common/params_keys.h +++ b/common/params_keys.h @@ -316,6 +316,7 @@ inline static std::unordered_map keys = { {"StarPilotCarParams", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BYTES, "", ""}}, {"StarPilotCarParamsPersistent", {PERSISTENT, BYTES, "", ""}}, {"StarPilotDongleId", {PERSISTENT | DONT_LOG, STRING, "", "", 0}}, + {"StarPilotFavoriteSlots", {PERSISTENT, JSON, "[]", "[]", 1}}, {"StarPilotStats", {PERSISTENT | DONT_LOG, JSON, "{}", "{}"}}, {"StarPilotTogglesUpdated", {CLEAR_ON_MANAGER_START, BOOL, "0", "0"}}, {"FrogsGoMoosTweak", {PERSISTENT, BOOL, "1", "0", 2}}, diff --git a/selfdrive/ui/layouts/settings/starpilot/vehicle.py b/selfdrive/ui/layouts/settings/starpilot/vehicle.py index 58c9b2144..ea835c575 100644 --- a/selfdrive/ui/layouts/settings/starpilot/vehicle.py +++ b/selfdrive/ui/layouts/settings/starpilot/vehicle.py @@ -52,6 +52,9 @@ ACTION_OPTIONS = [ {"id": 8, "name": tr_noop("Create Bookmark")}, {"id": 9, "name": tr_noop("Toggle Always On Lateral")}, {"id": 10, "name": tr_noop("Adopt Current Speed Limit")}, + {"id": 11, "name": tr_noop("Favorite #1")}, + {"id": 12, "name": tr_noop("Favorite #2")}, + {"id": 13, "name": tr_noop("Favorite #3")}, ] ACTION_NAMES = [o["name"] for o in ACTION_OPTIONS] ACTION_IDS = {o["name"]: o["id"] for o in ACTION_OPTIONS} @@ -529,9 +532,9 @@ class StarPilotVehicleSettingsLayout(_SettingsPage): def _get_available_actions(self, key: str | None = None) -> list[str]: cs = starpilot_state.car_state if key == "MainCruiseButtonControl": - allowed_ids = {0, 9, 10} + allowed_ids = {0, 9, 10, 11, 12, 13} return [tr(o["name"]) for o in ACTION_OPTIONS if o["id"] in allowed_ids] - allowed_ids = set(range(9)) + allowed_ids = set(range(9)) | {11, 12, 13} if key == "LKASButtonControl": allowed_ids.add(9) return [tr(o["name"]) for o in ACTION_OPTIONS diff --git a/selfdrive/ui/mici/onroad/augmented_road_view.py b/selfdrive/ui/mici/onroad/augmented_road_view.py index e7959b0da..8bfaa8276 100644 --- a/selfdrive/ui/mici/onroad/augmented_road_view.py +++ b/selfdrive/ui/mici/onroad/augmented_road_view.py @@ -20,7 +20,10 @@ from openpilot.selfdrive.ui.mici.onroad.starpilot_status import ( ) from openpilot.selfdrive.ui.mici.onroad.cameraview import CameraView from openpilot.selfdrive.ui.lib.starpilot_visuals import get_border_width +from openpilot.starpilot.common.favorite_slots import load_favorite_slots, toggle_favorite_slot from openpilot.system.ui.lib.application import FontWeight, gui_app, MousePos, MouseEvent +from openpilot.system.ui.lib.text_measure import measure_text_cached +from openpilot.system.ui.lib.wrap_text import wrap_text from openpilot.system.ui.widgets.label import UnifiedLabel from openpilot.system.ui.widgets import Widget from openpilot.common.filter_simple import BounceFilter @@ -147,6 +150,114 @@ class BookmarkIcon(Widget): rl.draw_texture(self._icon, int(icon_x), int(icon_y), rl.WHITE) +class FavoriteSlotsOverlay(Widget): + EDGE_MARGIN = 8 + BUTTON_GAP = 8 + MAX_BUTTON_SIZE = 208 + INDICATOR_SIZE = 18 + + def __init__(self): + super().__init__() + self._font = gui_app.font(FontWeight.SEMI_BOLD) + self._button_rects: list[tuple[int, rl.Rectangle]] = [] + self._pressed_slot: int | None = None + self._interacting = False + + def interacting(self): + interacting, self._interacting = self._interacting, False + return interacting + + def _visible_slots(self) -> list[tuple[int, dict]]: + visible = [] + for index, slot in enumerate(load_favorite_slots(ui_state.params)): + if slot.get("enabled") and slot.get("show_onroad") and slot.get("key"): + visible.append((index, slot)) + return visible + + def _slot_rects(self, rect: rl.Rectangle, slots: list[tuple[int, dict]]) -> list[tuple[int, rl.Rectangle]]: + if not slots: + return [] + + slot_count = len(slots) + available_width = rect.width - (2 * self.EDGE_MARGIN) - ((slot_count - 1) * self.BUTTON_GAP) + available_height = rect.height - (2 * self.EDGE_MARGIN) + button_size = min(self.MAX_BUTTON_SIZE, available_height, available_width / slot_count) + row_width = slot_count * button_size + (slot_count - 1) * self.BUTTON_GAP + x = rect.x + (rect.width - row_width) / 2 + y = rect.y + (rect.height - button_size) / 2 + + return [ + (slot_index, rl.Rectangle(x + draw_index * (button_size + self.BUTTON_GAP), y, button_size, button_size)) + for draw_index, (slot_index, _slot) in enumerate(slots) + ] + + def _fit_label(self, label: str, max_width: float, max_height: float) -> tuple[list[str], int]: + label = label or "Favorite" + lines = [label] + for font_size in range(30, 17, -1): + if any(measure_text_cached(self._font, word, font_size).x > max_width for word in label.split()): + continue + lines = wrap_text(self._font, label, font_size, int(max_width)) or [label] + line_height = font_size * 1.12 + if len(lines) <= 3 and len(lines) * line_height <= max_height: + return lines, font_size + return lines[:3], 18 + + def _render(self, rect: rl.Rectangle): + visible_slots = self._visible_slots() + self._button_rects = self._slot_rects(rect, visible_slots) + slot_by_index = dict(visible_slots) + + for slot_index, button_rect in self._button_rects: + slot = slot_by_index[slot_index] + pressed = self._pressed_slot == slot_index + bg = rl.Color(0, 0, 0, 190 if pressed else 166) + border = rl.Color(255, 255, 255, 120 if pressed else 78) + rl.draw_rectangle_rounded(button_rect, 0.18, 12, bg) + rl.draw_rectangle_rounded_lines_ex(button_rect, 0.18, 12, 2, border) + + key = slot.get("key") + active = ui_state.params.get_bool(key) if key else False + indicator = rl.Color(48, 255, 156, 255) if active else rl.Color(135, 135, 135, 255) + indicator_x = button_rect.x + button_rect.width - self.INDICATOR_SIZE - 12 + indicator_y = button_rect.y + 12 + rl.draw_circle(int(indicator_x + self.INDICATOR_SIZE / 2), int(indicator_y + self.INDICATOR_SIZE / 2), self.INDICATOR_SIZE / 2, indicator) + + slot_text = f"#{slot_index + 1}" + rl.draw_text_ex(self._font, slot_text, rl.Vector2(button_rect.x + 12, button_rect.y + 9), 22, 0, rl.Color(255, 255, 255, 180)) + + lines, font_size = self._fit_label( + slot.get("label") or key or "Favorite", + button_rect.width - 24, + button_rect.height - 54, + ) + line_height = font_size * 1.12 + text_y = button_rect.y + 38 + (button_rect.height - 50 - len(lines) * line_height) / 2 + for line in lines: + text_size = rl.measure_text_ex(self._font, line, font_size, 0) + text_x = button_rect.x + (button_rect.width - text_size.x) / 2 + rl.draw_text_ex(self._font, line, rl.Vector2(text_x, text_y), font_size, 0, rl.Color(255, 255, 255, 242)) + text_y += line_height + + def _slot_at(self, pos: MousePos) -> int | None: + for slot_index, rect in self._button_rects: + if rl.check_collision_point_rec(pos, rect): + return slot_index + return None + + def _handle_mouse_press(self, mouse_pos: MousePos): + self._pressed_slot = self._slot_at(mouse_pos) + if self._pressed_slot is not None: + self._interacting = True + + def _handle_mouse_release(self, mouse_pos: MousePos): + released_slot = self._slot_at(mouse_pos) + if self._pressed_slot is not None and released_slot == self._pressed_slot: + toggle_favorite_slot(self._pressed_slot, ui_state.params, ui_state.params_memory) + self._interacting = True + self._pressed_slot = None + + class MinSteerSpeedBanner(Widget): """One-shot-per-drive banner shown for the full first below-min-steer interval.""" @@ -374,6 +485,7 @@ class AugmentedRoadView(CameraView): self._confidence_ball = ConfidenceBall() self._min_steer_speed_banner = MinSteerSpeedBanner() self._standstill_timer = StandstillTimerOverlay() + self._favorite_slots = self._child(FavoriteSlotsOverlay()) self._offroad_label = UnifiedLabel("start the car to\nuse openpilot", 54, FontWeight.DISPLAY, text_color=rl.Color(255, 255, 255, int(255 * 0.9)), alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, @@ -416,7 +528,7 @@ class AugmentedRoadView(CameraView): def _handle_mouse_release(self, mouse_pos: MousePos): # Don't trigger click callback if bookmark or HUD widgets consumed the tap. - if not self._bookmark_icon.interacting() and not self._hud_renderer.user_interacting(): + if not self._bookmark_icon.interacting() and not self._hud_renderer.user_interacting() and not self._favorite_slots.interacting(): super()._handle_mouse_release(mouse_pos) def _render(self, _): @@ -512,6 +624,8 @@ class AugmentedRoadView(CameraView): # Use self._content_rect for positioning within camera bounds if draw_road_overlays: self._confidence_ball.render(self.rect) + if draw_hud_controls and (camera_view_none or is_driver_stream or not in_reverse): + self._favorite_slots.render(self._content_rect) if camera_view_none or is_driver_stream or not in_reverse: self._draw_border() diff --git a/selfdrive/ui/qt/onroad/annotated_camera.cc b/selfdrive/ui/qt/onroad/annotated_camera.cc index fe21332f8..ad5ce5b6d 100644 --- a/selfdrive/ui/qt/onroad/annotated_camera.cc +++ b/selfdrive/ui/qt/onroad/annotated_camera.cc @@ -6,6 +6,7 @@ #include #include #include +#include #include "common/params.h" #include "common/swaglog.h" @@ -28,6 +29,11 @@ AnnotatedCameraWidget::AnnotatedCameraWidget(VisionStreamType type, QWidget *par personality_btn = new DrivingPersonalityButton(this); personality_btn->setVisible(false); + for (int i = 0; i < static_cast(favorite_btns.size()); ++i) { + favorite_btns[i] = new FavoriteButton(i, this); + favorite_btns[i]->setVisible(false); + } + screen_recorder = new ScreenRecorder(this); screen_recorder->setVisible(false); } @@ -58,14 +64,47 @@ void AnnotatedCameraWidget::updateState(const UIState &s, const StarPilotUIState : QPoint(experimental_btn->x(), experimental_btn->y()); starpilot_nvg->experimentalButtonPosition = experimental_button_position; - bool onroad_distance_btn_enabled = starpilot_nvg->dmIconPosition != QPoint(0, 0) && !starpilot_nvg->hideBottomIcons && starpilot_toggles.value("onroad_distance_button").toBool(); - personality_btn->setVisible(onroad_distance_btn_enabled); - if (onroad_distance_btn_enabled) { - personality_btn->move(starpilot_nvg->rightHandDM ? width() - UI_BORDER_SIZE - personality_btn->width() - (UI_BORDER_SIZE / 2) : UI_BORDER_SIZE, starpilot_nvg->dmIconPosition.y() - personality_btn->height() / 2); - personality_btn->updateState(s, fs); + std::vector visible_favorite_btns; + const bool favorites_anchor_ready = starpilot_nvg->dmIconPosition != QPoint(0, 0) && !starpilot_nvg->hideBottomIcons; + for (FavoriteButton *favorite_btn : favorite_btns) { + favorite_btn->updateState(); + if (favorites_anchor_ready && favorite_btn->shouldShow()) { + visible_favorite_btns.push_back(favorite_btn); + } else { + favorite_btn->setVisible(false); + } } - dmon.onroad_distance_btn_enabled = onroad_distance_btn_enabled; + const bool onroad_distance_btn_enabled = favorites_anchor_ready && starpilot_toggles.value("onroad_distance_button").toBool(); + const int gap = UI_BORDER_SIZE / 2; + int controls_width = 0; + if (!visible_favorite_btns.empty()) { + controls_width = visible_favorite_btns.size() * btn_size + (visible_favorite_btns.size() - 1) * gap; + } + if (onroad_distance_btn_enabled) { + controls_width += (controls_width > 0 ? gap : 0) + personality_btn->width(); + } + dmon.onroad_controls_width = controls_width; + + const int controls_y = favorites_anchor_ready + ? std::clamp(starpilot_nvg->dmIconPosition.y() - (btn_size / 2), UI_BORDER_SIZE, height() - UI_BORDER_SIZE - btn_size) + : 0; + int cursor_x = starpilot_nvg->rightHandDM ? width() - UI_BORDER_SIZE : UI_BORDER_SIZE; + + personality_btn->setVisible(onroad_distance_btn_enabled); + if (onroad_distance_btn_enabled) { + const int personality_x = starpilot_nvg->rightHandDM ? cursor_x - personality_btn->width() : cursor_x; + personality_btn->move(personality_x, controls_y); + personality_btn->updateState(s, fs); + cursor_x += starpilot_nvg->rightHandDM ? -(personality_btn->width() + gap) : personality_btn->width() + gap; + } + + for (FavoriteButton *favorite_btn : visible_favorite_btns) { + const int favorite_x = starpilot_nvg->rightHandDM ? cursor_x - favorite_btn->width() : cursor_x; + favorite_btn->move(favorite_x, controls_y); + favorite_btn->setVisible(true); + cursor_x += starpilot_nvg->rightHandDM ? -(favorite_btn->width() + gap) : favorite_btn->width() + gap; + } const QPoint screen_recorder_position = hide_steering_wheel ? experimental_button_position diff --git a/selfdrive/ui/qt/onroad/annotated_camera.h b/selfdrive/ui/qt/onroad/annotated_camera.h index 98acb9b1b..9709ab82d 100644 --- a/selfdrive/ui/qt/onroad/annotated_camera.h +++ b/selfdrive/ui/qt/onroad/annotated_camera.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include "selfdrive/ui/qt/onroad/hud.h" #include "selfdrive/ui/qt/onroad/buttons.h" @@ -42,6 +43,7 @@ private: void paintEvent(QPaintEvent *event) override; DrivingPersonalityButton *personality_btn; + std::array favorite_btns; ScreenRecorder *screen_recorder; protected: diff --git a/selfdrive/ui/qt/onroad/driver_monitoring.cc b/selfdrive/ui/qt/onroad/driver_monitoring.cc index 0543f394f..7931e3301 100644 --- a/selfdrive/ui/qt/onroad/driver_monitoring.cc +++ b/selfdrive/ui/qt/onroad/driver_monitoring.cc @@ -70,11 +70,12 @@ void DriverMonitorRenderer::draw(QPainter &painter, const QRect &surface_rect) { float x = is_rhd ? surface_rect.width() - offset : offset; float y = surface_rect.height() - offset; - if (onroad_distance_btn_enabled) { + if (onroad_controls_width > 0) { + const int controls_offset = onroad_controls_width + (2 * UI_BORDER_SIZE); if (is_rhd) { - x -= UI_BORDER_SIZE + (btn_size + UI_BORDER_SIZE) + UI_BORDER_SIZE; + x -= controls_offset; } else { - x += UI_BORDER_SIZE + (btn_size + UI_BORDER_SIZE) + UI_BORDER_SIZE; + x += controls_offset; } } diff --git a/selfdrive/ui/qt/onroad/driver_monitoring.h b/selfdrive/ui/qt/onroad/driver_monitoring.h index 773e0617d..5c0e230cf 100644 --- a/selfdrive/ui/qt/onroad/driver_monitoring.h +++ b/selfdrive/ui/qt/onroad/driver_monitoring.h @@ -14,7 +14,7 @@ public: StarPilotAnnotatedCameraWidget *starpilot_nvg; - bool onroad_distance_btn_enabled; + int onroad_controls_width = 0; QJsonObject starpilot_toggles; diff --git a/starpilot/common/favorite_slots.py b/starpilot/common/favorite_slots.py new file mode 100644 index 000000000..f09349588 --- /dev/null +++ b/starpilot/common/favorite_slots.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +from collections.abc import Iterable +from typing import Any + +from openpilot.common.params import ParamKeyType, Params + + +FAVORITE_SLOTS_PARAM = "StarPilotFavoriteSlots" +FAVORITE_SLOT_COUNT = 3 + + +def default_favorite_slots() -> list[dict[str, Any]]: + return [ + {"enabled": False, "show_onroad": False, "key": None, "label": ""} + for _ in range(FAVORITE_SLOT_COUNT) + ] + + +def _load_raw_slots(raw_slots: Any) -> list[Any]: + if raw_slots in (None, "", b""): + return default_favorite_slots() + + if isinstance(raw_slots, bytes): + raw_slots = raw_slots.decode("utf-8", errors="replace") + + if isinstance(raw_slots, str): + try: + raw_slots = json.loads(raw_slots) + except json.JSONDecodeError: + return default_favorite_slots() + + if isinstance(raw_slots, dict): + raw_slots = raw_slots.get("slots", []) + + return raw_slots if isinstance(raw_slots, list) else default_favorite_slots() + + +def _get_key_type(params: Params, key: str): + for getter_name in ("get_type", "get_key_type"): + getter = getattr(params, getter_name, None) + if getter is None: + continue + try: + return getter(key) + except Exception: + return None + return None + + +def is_bool_param(params: Params, key: str | None, eligible_keys: Iterable[str] | None = None) -> bool: + if not key: + return False + + if eligible_keys is not None and key not in set(eligible_keys): + return False + + return _get_key_type(params, key) == ParamKeyType.BOOL + + +def normalize_favorite_slots(raw_slots: Any, params: Params | None = None, + eligible_keys: Iterable[str] | None = None) -> list[dict[str, Any]]: + slots = default_favorite_slots() + eligible = set(eligible_keys) if eligible_keys is not None else None + + for idx, raw_slot in enumerate(_load_raw_slots(raw_slots)[:FAVORITE_SLOT_COUNT]): + if not isinstance(raw_slot, dict): + continue + + key = raw_slot.get("key") + if key is not None: + key = str(key).strip() or None + + if key and ((eligible is not None and key not in eligible) or (params is not None and not is_bool_param(params, key))): + key = None + + label = str(raw_slot.get("label") or "").strip() + if len(label) > 32: + label = label[:32].rstrip() + + slots[idx] = { + "enabled": bool(raw_slot.get("enabled", False)), + "show_onroad": bool(raw_slot.get("show_onroad", False)), + "key": key, + "label": label if key else "", + } + + return slots + + +def load_favorite_slots(params: Params | None = None, eligible_keys: Iterable[str] | None = None) -> list[dict[str, Any]]: + params = params or Params(return_defaults=True) + try: + raw_slots = params.get(FAVORITE_SLOTS_PARAM) + except Exception: + raw_slots = None + return normalize_favorite_slots(raw_slots, params=params, eligible_keys=eligible_keys) + + +def save_favorite_slots(slots: list[dict[str, Any]], params: Params | None = None) -> list[dict[str, Any]]: + params = params or Params(return_defaults=True) + normalized = normalize_favorite_slots(slots, params=params) + params.put(FAVORITE_SLOTS_PARAM, normalized) + return normalized + + +def request_starpilot_toggle_refresh(params_memory: Params | None = None) -> None: + params_memory = params_memory or Params(memory=True) + params_memory.put_bool("StarPilotTogglesUpdated", True) + + +def toggle_favorite_slot(slot_index: int, params: Params | None = None, params_memory: Params | None = None) -> bool: + if slot_index < 0 or slot_index >= FAVORITE_SLOT_COUNT: + return False + + params = params or Params(return_defaults=True) + slots = load_favorite_slots(params) + slot = slots[slot_index] + key = slot.get("key") + if not slot.get("enabled") or not is_bool_param(params, key): + return False + + next_value = not params.get_bool(key) + put_bool = getattr(params, "put_bool_nonblocking", None) or getattr(params, "put_bool", None) + if put_bool is None: + return False + + put_bool(key, next_value) + request_starpilot_toggle_refresh(params_memory) + return True diff --git a/starpilot/common/starpilot_variables.py b/starpilot/common/starpilot_variables.py index f0f763db9..4d72cb2d8 100644 --- a/starpilot/common/starpilot_variables.py +++ b/starpilot/common/starpilot_variables.py @@ -137,6 +137,9 @@ BUTTON_FUNCTIONS = { "BOOKMARK": 8, "AOL_TOGGLE": 9, "SLC_ADOPT": 10, + "FAVORITE_1": 11, + "FAVORITE_2": 12, + "FAVORITE_3": 13, } CANCEL_BUTTON_MIGRATION_KEY = "CancelButtonControlsMigrated" @@ -482,6 +485,11 @@ class StarPilotVariables: # runtime behavior to defaults after the driver has configured them. return self.get_value(key, cast=float, condition=condition, respect_tuning_level=False) + @staticmethod + def set_favorite_button_flags(toggle, suffix, button_control): + for slot_number in range(1, 4): + setattr(toggle, f"favorite_{slot_number}_via_{suffix}", button_control == BUTTON_FUNCTIONS[f"FAVORITE_{slot_number}"]) + def _sync_stock_param(self, key, stock_key, live_value): try: live_value = float(live_value) @@ -876,6 +884,7 @@ class StarPilotVariables: toggle.switchback_mode_via_distance = distance_button_control == BUTTON_FUNCTIONS["SWITCHBACK_MODE"] toggle.traffic_mode_via_distance = toggle.openpilot_longitudinal and distance_button_control == BUTTON_FUNCTIONS["TRAFFIC_MODE"] toggle.bookmark_via_distance = distance_button_control == BUTTON_FUNCTIONS["BOOKMARK"] + self.set_favorite_button_flags(toggle, "distance", distance_button_control) distance_button_control_long = self.get_button_function("LongDistanceButtonControl") toggle.experimental_mode_via_distance_long = toggle.openpilot_longitudinal and distance_button_control_long == BUTTON_FUNCTIONS["EXPERIMENTAL_MODE"] @@ -887,6 +896,7 @@ class StarPilotVariables: toggle.switchback_mode_via_distance_long = distance_button_control_long == BUTTON_FUNCTIONS["SWITCHBACK_MODE"] toggle.traffic_mode_via_distance_long = toggle.openpilot_longitudinal and distance_button_control_long == BUTTON_FUNCTIONS["TRAFFIC_MODE"] toggle.bookmark_via_distance_long = distance_button_control_long == BUTTON_FUNCTIONS["BOOKMARK"] + self.set_favorite_button_flags(toggle, "distance_long", distance_button_control_long) distance_button_control_very_long = self.get_button_function("VeryLongDistanceButtonControl") toggle.experimental_mode_via_distance_very_long = toggle.openpilot_longitudinal and distance_button_control_very_long == BUTTON_FUNCTIONS["EXPERIMENTAL_MODE"] @@ -898,6 +908,7 @@ class StarPilotVariables: toggle.switchback_mode_via_distance_very_long = distance_button_control_very_long == BUTTON_FUNCTIONS["SWITCHBACK_MODE"] toggle.traffic_mode_via_distance_very_long = toggle.openpilot_longitudinal and distance_button_control_very_long == BUTTON_FUNCTIONS["TRAFFIC_MODE"] toggle.bookmark_via_distance_very_long = distance_button_control_very_long == BUTTON_FUNCTIONS["BOOKMARK"] + self.set_favorite_button_flags(toggle, "distance_very_long", distance_button_control_very_long) cancel_button_control = self.get_button_function("CancelButtonControl", condition=toggle.remap_cancel_to_distance) toggle.experimental_mode_via_cancel = toggle.openpilot_longitudinal and cancel_button_control == BUTTON_FUNCTIONS["EXPERIMENTAL_MODE"] @@ -909,6 +920,7 @@ class StarPilotVariables: toggle.switchback_mode_via_cancel = cancel_button_control == BUTTON_FUNCTIONS["SWITCHBACK_MODE"] toggle.traffic_mode_via_cancel = toggle.openpilot_longitudinal and cancel_button_control == BUTTON_FUNCTIONS["TRAFFIC_MODE"] toggle.bookmark_via_cancel = cancel_button_control == BUTTON_FUNCTIONS["BOOKMARK"] + self.set_favorite_button_flags(toggle, "cancel", cancel_button_control) cancel_button_control_long = self.get_button_function("LongCancelButtonControl", condition=toggle.remap_cancel_to_distance) toggle.experimental_mode_via_cancel_long = toggle.openpilot_longitudinal and cancel_button_control_long == BUTTON_FUNCTIONS["EXPERIMENTAL_MODE"] @@ -920,6 +932,7 @@ class StarPilotVariables: toggle.switchback_mode_via_cancel_long = cancel_button_control_long == BUTTON_FUNCTIONS["SWITCHBACK_MODE"] toggle.traffic_mode_via_cancel_long = toggle.openpilot_longitudinal and cancel_button_control_long == BUTTON_FUNCTIONS["TRAFFIC_MODE"] toggle.bookmark_via_cancel_long = cancel_button_control_long == BUTTON_FUNCTIONS["BOOKMARK"] + self.set_favorite_button_flags(toggle, "cancel_long", cancel_button_control_long) cancel_button_control_very_long = self.get_button_function("VeryLongCancelButtonControl", condition=toggle.remap_cancel_to_distance) toggle.experimental_mode_via_cancel_very_long = toggle.openpilot_longitudinal and cancel_button_control_very_long == BUTTON_FUNCTIONS["EXPERIMENTAL_MODE"] @@ -931,6 +944,7 @@ class StarPilotVariables: toggle.switchback_mode_via_cancel_very_long = cancel_button_control_very_long == BUTTON_FUNCTIONS["SWITCHBACK_MODE"] toggle.traffic_mode_via_cancel_very_long = toggle.openpilot_longitudinal and cancel_button_control_very_long == BUTTON_FUNCTIONS["TRAFFIC_MODE"] toggle.bookmark_via_cancel_very_long = cancel_button_control_very_long == BUTTON_FUNCTIONS["BOOKMARK"] + self.set_favorite_button_flags(toggle, "cancel_very_long", cancel_button_control_very_long) toggle.frogsgomoo_tweak = self.get_value("FrogsGoMoosTweak", condition=toggle.openpilot_longitudinal and toggle.car_make == "toyota") toggle.stoppingDecelRate = 0.01 if toggle.frogsgomoo_tweak else toggle.stoppingDecelRate @@ -986,6 +1000,7 @@ class StarPilotVariables: toggle.switchback_mode_via_lkas = lkas_button_control == BUTTON_FUNCTIONS["SWITCHBACK_MODE"] toggle.traffic_mode_via_lkas = toggle.openpilot_longitudinal and lkas_button_control == BUTTON_FUNCTIONS["TRAFFIC_MODE"] toggle.bookmark_via_lkas = lkas_button_control == BUTTON_FUNCTIONS["BOOKMARK"] + self.set_favorite_button_flags(toggle, "lkas", lkas_button_control) has_canfd_media_buttons = toggle.car_make == "hyundai" and bool(CP.flags & HyundaiFlags.CANFD) mode_button_control = self.get_button_function("ModeButtonControl", condition=has_canfd_media_buttons) @@ -998,6 +1013,7 @@ class StarPilotVariables: toggle.switchback_mode_via_mode = mode_button_control == BUTTON_FUNCTIONS["SWITCHBACK_MODE"] toggle.traffic_mode_via_mode = toggle.openpilot_longitudinal and mode_button_control == BUTTON_FUNCTIONS["TRAFFIC_MODE"] toggle.bookmark_via_mode = mode_button_control == BUTTON_FUNCTIONS["BOOKMARK"] + self.set_favorite_button_flags(toggle, "mode", mode_button_control) mode_button_control_long = self.get_button_function("LongModeButtonControl", condition=has_canfd_media_buttons) toggle.experimental_mode_via_mode_long = toggle.openpilot_longitudinal and mode_button_control_long == BUTTON_FUNCTIONS["EXPERIMENTAL_MODE"] @@ -1009,6 +1025,7 @@ class StarPilotVariables: toggle.switchback_mode_via_mode_long = mode_button_control_long == BUTTON_FUNCTIONS["SWITCHBACK_MODE"] toggle.traffic_mode_via_mode_long = toggle.openpilot_longitudinal and mode_button_control_long == BUTTON_FUNCTIONS["TRAFFIC_MODE"] toggle.bookmark_via_mode_long = mode_button_control_long == BUTTON_FUNCTIONS["BOOKMARK"] + self.set_favorite_button_flags(toggle, "mode_long", mode_button_control_long) mode_button_control_very_long = self.get_button_function("VeryLongModeButtonControl", condition=has_canfd_media_buttons) toggle.experimental_mode_via_mode_very_long = toggle.openpilot_longitudinal and mode_button_control_very_long == BUTTON_FUNCTIONS["EXPERIMENTAL_MODE"] @@ -1020,6 +1037,7 @@ class StarPilotVariables: toggle.switchback_mode_via_mode_very_long = mode_button_control_very_long == BUTTON_FUNCTIONS["SWITCHBACK_MODE"] toggle.traffic_mode_via_mode_very_long = toggle.openpilot_longitudinal and mode_button_control_very_long == BUTTON_FUNCTIONS["TRAFFIC_MODE"] toggle.bookmark_via_mode_very_long = mode_button_control_very_long == BUTTON_FUNCTIONS["BOOKMARK"] + self.set_favorite_button_flags(toggle, "mode_very_long", mode_button_control_very_long) star_button_control = self.get_button_function("StarButtonControl", condition=has_canfd_media_buttons) toggle.experimental_mode_via_star = toggle.openpilot_longitudinal and star_button_control == BUTTON_FUNCTIONS["EXPERIMENTAL_MODE"] @@ -1031,6 +1049,7 @@ class StarPilotVariables: toggle.switchback_mode_via_star = star_button_control == BUTTON_FUNCTIONS["SWITCHBACK_MODE"] toggle.traffic_mode_via_star = toggle.openpilot_longitudinal and star_button_control == BUTTON_FUNCTIONS["TRAFFIC_MODE"] toggle.bookmark_via_star = star_button_control == BUTTON_FUNCTIONS["BOOKMARK"] + self.set_favorite_button_flags(toggle, "star", star_button_control) star_button_control_long = self.get_button_function("LongStarButtonControl", condition=has_canfd_media_buttons) toggle.experimental_mode_via_star_long = toggle.openpilot_longitudinal and star_button_control_long == BUTTON_FUNCTIONS["EXPERIMENTAL_MODE"] @@ -1042,6 +1061,7 @@ class StarPilotVariables: toggle.switchback_mode_via_star_long = star_button_control_long == BUTTON_FUNCTIONS["SWITCHBACK_MODE"] toggle.traffic_mode_via_star_long = toggle.openpilot_longitudinal and star_button_control_long == BUTTON_FUNCTIONS["TRAFFIC_MODE"] toggle.bookmark_via_star_long = star_button_control_long == BUTTON_FUNCTIONS["BOOKMARK"] + self.set_favorite_button_flags(toggle, "star_long", star_button_control_long) star_button_control_very_long = self.get_button_function("VeryLongStarButtonControl", condition=has_canfd_media_buttons) toggle.experimental_mode_via_star_very_long = toggle.openpilot_longitudinal and star_button_control_very_long == BUTTON_FUNCTIONS["EXPERIMENTAL_MODE"] @@ -1053,6 +1073,7 @@ class StarPilotVariables: toggle.switchback_mode_via_star_very_long = star_button_control_very_long == BUTTON_FUNCTIONS["SWITCHBACK_MODE"] toggle.traffic_mode_via_star_very_long = toggle.openpilot_longitudinal and star_button_control_very_long == BUTTON_FUNCTIONS["TRAFFIC_MODE"] toggle.bookmark_via_star_very_long = star_button_control_very_long == BUTTON_FUNCTIONS["BOOKMARK"] + self.set_favorite_button_flags(toggle, "star_very_long", star_button_control_very_long) toggle.has_canfd_media_buttons = has_canfd_media_buttons toggle.lock_doors_timer = self.get_value("LockDoorsTimer", cast=float, condition=(toggle.car_make == "toyota")) diff --git a/starpilot/common/tests/test_favorite_slots.py b/starpilot/common/tests/test_favorite_slots.py new file mode 100644 index 000000000..398bc9d9c --- /dev/null +++ b/starpilot/common/tests/test_favorite_slots.py @@ -0,0 +1,80 @@ +from openpilot.common.params import ParamKeyType +from openpilot.starpilot.common.favorite_slots import ( + FAVORITE_SLOTS_PARAM, + default_favorite_slots, + load_favorite_slots, + toggle_favorite_slot, +) + + +class FakeParams: + def __init__(self): + self.store = {} + self.types = { + FAVORITE_SLOTS_PARAM: ParamKeyType.JSON, + "RedneckCruise": ParamKeyType.BOOL, + "NotBool": ParamKeyType.INT, + } + + def get(self, key): + return self.store.get(key) + + def get_bool(self, key): + return bool(self.store.get(key, False)) + + def put(self, key, value): + self.store[key] = value + + def put_bool(self, key, value): + self.store[key] = bool(value) + + def put_bool_nonblocking(self, key, value): + self.put_bool(key, value) + + def get_type(self, key): + return self.types.get(key, ParamKeyType.STRING) + + +def test_load_favorite_slots_defaults_on_empty_payload(): + params = FakeParams() + + assert load_favorite_slots(params) == default_favorite_slots() + + +def test_load_favorite_slots_filters_non_bool_keys(): + params = FakeParams() + params.put(FAVORITE_SLOTS_PARAM, [ + {"enabled": True, "show_onroad": True, "key": "NotBool", "label": "Bad"}, + {"enabled": True, "show_onroad": False, "key": "RedneckCruise", "label": "Redneck Cruise"}, + ]) + + slots = load_favorite_slots(params) + + assert slots[0]["key"] is None + assert slots[0]["enabled"] is True + assert slots[1]["key"] == "RedneckCruise" + assert slots[1]["show_onroad"] is False + + +def test_toggle_favorite_slot_ignores_disabled_slot(): + params = FakeParams() + params.put("RedneckCruise", False) + params.put(FAVORITE_SLOTS_PARAM, [ + {"enabled": False, "show_onroad": True, "key": "RedneckCruise", "label": "Redneck Cruise"}, + ]) + + assert toggle_favorite_slot(0, params, FakeParams()) is False + assert params.get_bool("RedneckCruise") is False + + +def test_toggle_favorite_slot_flips_bool_and_requests_refresh(): + params = FakeParams() + memory = FakeParams() + params.put("RedneckCruise", False) + params.put(FAVORITE_SLOTS_PARAM, [ + {"enabled": True, "show_onroad": False, "key": "RedneckCruise", "label": "Redneck Cruise"}, + ]) + + assert toggle_favorite_slot(0, params, memory) is True + assert params.get_bool("RedneckCruise") is True + assert memory.get_bool("StarPilotTogglesUpdated") is True diff --git a/starpilot/common/tests/test_starpilot_variables.py b/starpilot/common/tests/test_starpilot_variables.py index 99063829f..ea1d3b7f1 100644 --- a/starpilot/common/tests/test_starpilot_variables.py +++ b/starpilot/common/tests/test_starpilot_variables.py @@ -105,6 +105,16 @@ def test_button_function_ignores_tuning_level_gate(): assert variables.get_button_function("LKASButtonControl") == spv.BUTTON_FUNCTIONS["AOL_TOGGLE"] +def test_favorite_button_flags_map_to_three_slots(): + toggle = SimpleNamespace() + + spv.StarPilotVariables.set_favorite_button_flags(toggle, "lkas", spv.BUTTON_FUNCTIONS["FAVORITE_2"]) + + assert toggle.favorite_1_via_lkas is False + assert toggle.favorite_2_via_lkas is True + assert toggle.favorite_3_via_lkas is False + + def test_set_speed_limit_available_on_openpilot_longitudinal(): assert spv.set_speed_limit_available(openpilot_longitudinal=True, has_cc_long=False, pcm_cruise_speed=True) is True diff --git a/starpilot/controls/starpilot_card.py b/starpilot/controls/starpilot_card.py index 58fef1677..a5a1a5120 100644 --- a/starpilot/controls/starpilot_card.py +++ b/starpilot/controls/starpilot_card.py @@ -14,6 +14,7 @@ from openpilot.starpilot.common.experimental_state import ( sync_manual_cc_state, sync_manual_ce_state, ) +from openpilot.starpilot.common.favorite_slots import toggle_favorite_slot from openpilot.starpilot.common.starpilot_utilities import is_FrogsGoMoo from openpilot.starpilot.common.starpilot_variables import ERROR_LOGS_PATH, GearShifter, NON_DRIVING_GEARS @@ -82,6 +83,11 @@ class StarPilotCard: self.params_memory.put_bool("SwitchbackModeEnabled", self.switchback_mode_enabled) elif sm["carControl"].longActive and getattr(starpilot_toggles, f"traffic_mode_via_{key}"): self.traffic_mode_enabled = not self.traffic_mode_enabled + else: + for slot_index in range(3): + if getattr(starpilot_toggles, f"favorite_{slot_index + 1}_via_{key}", False): + toggle_favorite_slot(slot_index, self.params, self.params_memory) + break def handle_bookmark(self): counter = self.params_memory.get_int("WheelButtonBookmarkCounter") diff --git a/starpilot/controls/tests/test_starpilot_card.py b/starpilot/controls/tests/test_starpilot_card.py index 014a145aa..8bb7b4924 100644 --- a/starpilot/controls/tests/test_starpilot_card.py +++ b/starpilot/controls/tests/test_starpilot_card.py @@ -2,12 +2,21 @@ from types import SimpleNamespace from opendbc.car.chrysler.values import CAR as CHRYSLER_CAR +from openpilot.common.params import ParamKeyType +from openpilot.starpilot.common.favorite_slots import FAVORITE_SLOTS_PARAM from openpilot.starpilot.controls import starpilot_card as spc class FakeParams: def __init__(self, *args, **kwargs): self._store = {} + self._types = { + FAVORITE_SLOTS_PARAM: ParamKeyType.JSON, + "RedneckCruise": ParamKeyType.BOOL, + } + + def get(self, key): + return self._store.get(key) def get_bool(self, key): return bool(self._store.get(key, False)) @@ -15,6 +24,9 @@ class FakeParams: def put_bool(self, key, value): self._store[key] = bool(value) + def put(self, key, value): + self._store[key] = value + def get_int(self, key, default=0): return int(self._store.get(key, default)) @@ -24,6 +36,9 @@ class FakeParams: def put_bool_nonblocking(self, key, value): self.put_bool(key, value) + def get_type(self, key): + return self._types.get(key, ParamKeyType.STRING) + class FakeSM(dict): def __init__(self, *args, updated=None, **kwargs): @@ -407,3 +422,20 @@ def test_cancel_button_short_press_can_run_independent_mapping(monkeypatch, tmp_ assert ret.cancelLongPressed is False assert ret.cancelVeryLongPressed is False assert card.params_memory.get_int("WheelButtonBookmarkCounter") == 1 + + +def test_favorite_wheel_action_toggles_hidden_onroad_slot(monkeypatch, tmp_path): + monkeypatch.setattr(spc, "Params", FakeParams) + monkeypatch.setattr(spc, "is_FrogsGoMoo", lambda: False) + monkeypatch.setattr(spc, "ERROR_LOGS_PATH", tmp_path) + + card = spc.StarPilotCard(SimpleNamespace(brand="gm"), SimpleNamespace(alternativeExperience=0)) + card.params.put("RedneckCruise", False) + card.params.put(FAVORITE_SLOTS_PARAM, [ + {"enabled": True, "show_onroad": False, "key": "RedneckCruise", "label": "Redneck Cruise"}, + ]) + + card.handle_button_event("lkas", make_sm(), make_toggles(favorite_1_via_lkas=True)) + + assert card.params.get_bool("RedneckCruise") is True + assert card.params_memory.get_bool("StarPilotTogglesUpdated") is True diff --git a/starpilot/system/the_pond/assets/components/router.js b/starpilot/system/the_pond/assets/components/router.js index 933b5db61..1cd94e8b3 100644 --- a/starpilot/system/the_pond/assets/components/router.js +++ b/starpilot/system/the_pond/assets/components/router.js @@ -1,7 +1,7 @@ import { html, reactive } from "/assets/vendor/arrow-core.js" import { createBrowserHistory, createRouter } from "/assets/vendor/remix-router-1.3.1.js" import { hideSidebar } from "/assets/js/utils.js" -import { DeviceSettings } from "/assets/components/tools/device_settings.js" +import { DeviceSettings } from "/assets/components/tools/device_settings.js?v=favorite-slots-2" import { ErrorLogs } from "/assets/components/tools/error_logs.js" import { VehicleFeatures } from "/assets/components/tools/vehicle_features.js" import { GalaxyPairing } from "/assets/components/tools/galaxy.js" diff --git a/starpilot/system/the_pond/assets/components/tools/device_settings.css b/starpilot/system/the_pond/assets/components/tools/device_settings.css index ca86d1b38..386246e31 100644 --- a/starpilot/system/the_pond/assets/components/tools/device_settings.css +++ b/starpilot/system/the_pond/assets/components/tools/device_settings.css @@ -470,6 +470,77 @@ opacity: var(--disabled-opacity); } +/* ――― Favorite Slots ――― */ +.ds-favorites-panel { + display: grid; + gap: var(--gap-base); + padding: var(--padding-base); +} + +.ds-favorite-card { + background: var(--input-bg); + border: var(--border-style-main); + border-radius: var(--border-radius-base); + padding: var(--padding-base); +} + +.ds-favorite-card-header, +.ds-favorite-controls, +.ds-favorite-live-row, +.ds-favorite-switch { + align-items: center; + display: flex; +} + +.ds-favorite-card-header, +.ds-favorite-live-row { + justify-content: space-between; + gap: var(--gap-base); +} + +.ds-favorite-controls { + flex-wrap: wrap; + gap: var(--gap-base); + margin-top: var(--margin-base); +} + +.ds-favorite-field { + display: flex; + flex: 1; + flex-direction: column; + gap: 0.35rem; + min-width: 0; +} + +.ds-favorite-field span, +.ds-favorite-switch span { + color: var(--text-muted); + font-size: var(--font-size-xs); +} + +.ds-favorite-select { + max-width: none; +} + +.ds-favorite-search { + max-width: none; +} + +.ds-favorite-switch { + flex-shrink: 0; + gap: 0.6rem; +} + +.ds-favorite-live-row { + border-top: 1px solid var(--sidebar-border-color); + margin-top: var(--margin-base); + padding-top: var(--padding-sm); +} + +.ds-favorite-live-copy { + min-width: 0; +} + /* ――― Mobile ――― */ @media only screen and (max-width: 768px) and (orientation: portrait) { .ds-wrapper { @@ -495,4 +566,15 @@ .ds-tabs::-webkit-scrollbar { display: none; } + + .ds-favorite-card-header, + .ds-favorite-controls, + .ds-favorite-live-row { + align-items: stretch; + flex-direction: column; + } + + .ds-favorite-switch { + justify-content: space-between; + } } diff --git a/starpilot/system/the_pond/assets/components/tools/device_settings.js b/starpilot/system/the_pond/assets/components/tools/device_settings.js index 37a276e55..af06b2c9a 100644 --- a/starpilot/system/the_pond/assets/components/tools/device_settings.js +++ b/starpilot/system/the_pond/assets/components/tools/device_settings.js @@ -27,6 +27,12 @@ const state = reactive({ fetched: false, activeSectionSlug: "", numericUpdating: {}, + favoriteLoading: false, + favoriteSaving: false, + favoriteOptions: [], + favoriteSlots: [], + favoriteFilters: ["", "", ""], + favoriteValues: {}, }) function slugifySectionName(name) { @@ -218,6 +224,33 @@ function syncInputs() { syncSelectValue(el, key) } } + + const favoriteSlots = normalizeFavoriteSlots(state.favoriteSlots) + for (const el of document.querySelectorAll("[data-favorite-slot][data-favorite-field]")) { + const slotIndex = Number.parseInt(el.dataset.favoriteSlot, 10) + const field = el.dataset.favoriteField + const slot = favoriteSlots[slotIndex] + if (!slot) continue + + if (el.tagName === "SELECT" && field === "key") { + el.value = slot.key || "" + el.disabled = !!state.favoriteSaving + } else if (el.tagName === "INPUT" && field === "search") { + el.value = state.favoriteFilters[slotIndex] || "" + el.disabled = !!state.favoriteSaving + } else if (el.tagName === "INPUT" && el.type === "checkbox") { + el.checked = !!slot[field] + if (field === "enabled") { + el.disabled = !!state.favoriteSaving + } else if (field === "show_onroad") { + el.disabled = !!state.favoriteSaving || !slot.enabled || !slot.key + } + } + } + + for (const el of document.querySelectorAll("input[type='checkbox'][data-favorite-value-key]")) { + el.checked = !!state.values[el.dataset.favoriteValueKey] + } } async function fetchDefaultValues() { @@ -252,7 +285,7 @@ async function fetchLayoutAndParams() { state.loadingValues = true try { - const layoutRes = await fetch("/assets/components/tools/device_settings_layout.json") + const layoutRes = await fetch("/assets/components/tools/device_settings_layout.json?v=favorite-slots-2", { cache: "no-store" }) const rawLayoutData = await layoutRes.json() const layoutData = rawLayoutData @@ -293,6 +326,9 @@ async function fetchLayoutAndParams() { console.error("Failed to fetch param values:", e) state.defaultValues = {} } + + await fetchFavoriteSlots() + state.loadingValues = false // Resolve slug now that layout is available (uses stored route params) @@ -396,6 +432,145 @@ function coerceValueByType(rawValue, dataType) { return rawValue } +function defaultFavoriteSlots() { + return [0, 1, 2].map(() => ({ + enabled: false, + show_onroad: false, + key: null, + label: "", + })) +} + +function normalizeFavoriteSlots(slots) { + const normalized = defaultFavoriteSlots() + if (!Array.isArray(slots)) return normalized + + slots.slice(0, 3).forEach((slot, index) => { + if (!slot || typeof slot !== "object") return + const key = slot.key ? String(slot.key) : null + normalized[index] = { + enabled: !!slot.enabled, + show_onroad: !!slot.show_onroad, + key, + label: key ? String(slot.label || key) : "", + } + }) + + return normalized +} + +async function fetchFavoriteSlots() { + state.favoriteLoading = true + try { + const res = await fetch("/api/favorites/slots") + const data = await res.json() + if (res.ok) { + state.favoriteOptions = Array.isArray(data.options) ? data.options : [] + state.favoriteSlots = normalizeFavoriteSlots(data.slots) + state.favoriteValues = data.values || {} + state.values = { ...state.values, ...state.favoriteValues, StarPilotFavoriteSlots: state.favoriteSlots } + } + } catch (e) { + console.error("Failed to fetch favorite slots:", e) + } + state.favoriteLoading = false +} + +async function saveFavoriteSlots(slots) { + if (state.favoriteSaving) return + + const previousSlots = state.favoriteSlots + state.favoriteSlots = normalizeFavoriteSlots(slots) + state.favoriteSaving = true + + try { + const res = await fetch("/api/favorites/slots", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ slots: state.favoriteSlots }), + }) + const data = await res.json() + + if (res.ok) { + state.favoriteOptions = Array.isArray(data.options) ? data.options : state.favoriteOptions + state.favoriteSlots = normalizeFavoriteSlots(data.slots) + state.favoriteValues = data.values || {} + state.values = { ...state.values, ...state.favoriteValues, StarPilotFavoriteSlots: state.favoriteSlots } + showParamSnackbar(data.message || "Favorite slots saved.") + scheduleSyncInputs() + } else { + state.favoriteSlots = previousSlots + showParamSnackbar(data.error || "Failed to save favorite slots", "error") + } + } catch (e) { + state.favoriteSlots = previousSlots + showParamSnackbar("Network error — is the device reachable?", "error") + } + + state.favoriteSaving = false +} + +function updateFavoriteSlot(index, patch) { + const slots = normalizeFavoriteSlots(state.favoriteSlots) + const current = slots[index] || defaultFavoriteSlots()[0] + const nextSlot = { ...current, ...patch } + + if (!nextSlot.key) { + nextSlot.label = "" + } else { + const option = state.favoriteOptions.find(opt => opt.key === nextSlot.key) + nextSlot.label = option?.label || nextSlot.key + } + + slots[index] = nextSlot + saveFavoriteSlots(slots) +} + +function updateFavoriteFilter(index, event) { + const filters = Array.isArray(state.favoriteFilters) ? [...state.favoriteFilters] : ["", "", ""] + filters[index] = getEventValue(event) + state.favoriteFilters = filters.slice(0, 3) + scheduleSyncInputs() +} + +function favoriteOptionMatchesFilter(option, filter) { + if (!filter) return true + const q = filter.toLowerCase() + return [option.label, option.key, option.section, option.description] + .some(value => String(value || "").toLowerCase().includes(q)) +} + +async function updateFavoriteValue(key, checked) { + const current = state.values[key] + state.values = { ...state.values, [key]: checked } + state.favoriteValues = { ...state.favoriteValues, [key]: checked } + + try { + const res = await fetch("/api/params", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ key, value: checked }), + }) + const data = await res.json() + + if (res.ok) { + const updated = (data.updated && typeof data.updated === "object") ? data.updated : {} + state.values = { ...state.values, [key]: checked, ...updated } + state.favoriteValues = { ...state.favoriteValues, [key]: state.values[key] } + showParamSnackbar(data.message || `Parameter '${key}' updated.`) + scheduleSyncInputs() + } else { + state.values = { ...state.values, [key]: current } + state.favoriteValues = { ...state.favoriteValues, [key]: current } + showParamSnackbar(data.error || "Failed to update parameter", "error") + } + } catch (e) { + state.values = { ...state.values, [key]: current } + state.favoriteValues = { ...state.favoriteValues, [key]: current } + showParamSnackbar("Network error — is the device reachable?", "error") + } +} + function stepPrecision(step, explicitPrecision) { if (explicitPrecision !== undefined && explicitPrecision !== null && explicitPrecision !== "") { const parsed = Number.parseInt(explicitPrecision, 10) @@ -598,6 +773,11 @@ async function resetNumericParam(param) { } async function updateParam(key, elType) { + if (String(key).toLowerCase() === "starpilotfavoriteslots") { + await saveFavoriteSlots(state.favoriteSlots) + return + } + const current = state.values[key] const el = document.getElementById(`ds-${key}`) if (!el) return @@ -767,7 +947,113 @@ function handleSectionTabClick(sectionSlug, event) { } } +function renderFavoriteSlotsPanel() { + if (state.favoriteLoading) { + return html`
Loading favorite slots...
` + } + + const slots = normalizeFavoriteSlots(state.favoriteSlots) + const options = state.favoriteOptions || [] + + return html` +
+ ${slots.map((slot, index) => { + const selectedOption = options.find(opt => opt.key === slot.key) + const selectedKey = slot.key || "" + const selectedValue = selectedKey ? !!state.values[selectedKey] : false + const favoriteFilter = state.favoriteFilters[index] || "" + let filteredOptions = options.filter(opt => favoriteOptionMatchesFilter(opt, favoriteFilter)) + if (selectedOption && !filteredOptions.some(opt => opt.key === selectedOption.key)) { + filteredOptions = [selectedOption, ...filteredOptions] + } + + return html` +
+
+
+
Favorite #${index + 1}
+
${selectedOption?.section || "No toggle selected"}
+
+ +
+ +
+ + + + + +
+ + ${selectedKey ? html` +
+
+ ${selectedOption?.label || slot.label || selectedKey} + ${selectedOption?.description ? html`
${selectedOption.description}
` : ""} +
+ +
+ ` : ""} +
+ ` + })} +
+ ` +} + function renderSettingRow(p) { + if (p.ui_type === "favorites") { + return renderFavoriteSlotsPanel() + } + if (p.parent_key && !state.filter) { if (!isParamEnabledForChildren(p.parent_key)) return "" if (!state.expanded[p.parent_key]) return "" diff --git a/starpilot/system/the_pond/assets/components/tools/device_settings_layout.json b/starpilot/system/the_pond/assets/components/tools/device_settings_layout.json index 36cdc29dc..565aad390 100644 --- a/starpilot/system/the_pond/assets/components/tools/device_settings_layout.json +++ b/starpilot/system/the_pond/assets/components/tools/device_settings_layout.json @@ -1,4 +1,17 @@ [ + { + "name": "Favorites", + "icon": "bi-star", + "params": [ + { + "key": "StarPilotFavoriteSlots", + "label": "Favorite Slots", + "description": "Configure up to three favorite toggles for on-road buttons and steering-wheel bindings.", + "data_type": "json", + "ui_type": "favorites" + } + ] + }, { "name": "Lateral (Steering)", "icon": "bi-arrows-move", @@ -307,7 +320,7 @@ { "key": "CustomAccelProfile0MPH", "label": "0 mph", - "description": "Max acceleration in m/s\u00b2 at the fixed 0 mph breakpoint.", + "description": "Max acceleration in m/s² at the fixed 0 mph breakpoint.", "data_type": "float", "ui_type": "numeric", "min": 0.0, @@ -319,7 +332,7 @@ { "key": "CustomAccelProfile11MPH", "label": "11 mph", - "description": "Max acceleration in m/s\u00b2 at the fixed 11 mph breakpoint.", + "description": "Max acceleration in m/s² at the fixed 11 mph breakpoint.", "data_type": "float", "ui_type": "numeric", "min": 0.0, @@ -331,7 +344,7 @@ { "key": "CustomAccelProfile22MPH", "label": "22 mph", - "description": "Max acceleration in m/s\u00b2 at the fixed 22 mph breakpoint.", + "description": "Max acceleration in m/s² at the fixed 22 mph breakpoint.", "data_type": "float", "ui_type": "numeric", "min": 0.0, @@ -343,7 +356,7 @@ { "key": "CustomAccelProfile34MPH", "label": "34 mph", - "description": "Max acceleration in m/s\u00b2 at the fixed 34 mph breakpoint.", + "description": "Max acceleration in m/s² at the fixed 34 mph breakpoint.", "data_type": "float", "ui_type": "numeric", "min": 0.0, @@ -355,7 +368,7 @@ { "key": "CustomAccelProfile45MPH", "label": "45 mph", - "description": "Max acceleration in m/s\u00b2 at the fixed 45 mph breakpoint.", + "description": "Max acceleration in m/s² at the fixed 45 mph breakpoint.", "data_type": "float", "ui_type": "numeric", "min": 0.0, @@ -367,7 +380,7 @@ { "key": "CustomAccelProfile56MPH", "label": "56 mph", - "description": "Max acceleration in m/s\u00b2 at the fixed 56 mph breakpoint.", + "description": "Max acceleration in m/s² at the fixed 56 mph breakpoint.", "data_type": "float", "ui_type": "numeric", "min": 0.0, @@ -379,7 +392,7 @@ { "key": "CustomAccelProfile89MPH", "label": "89 mph", - "description": "Max acceleration in m/s\u00b2 at the fixed 89 mph breakpoint.", + "description": "Max acceleration in m/s² at the fixed 89 mph breakpoint.", "data_type": "float", "ui_type": "numeric", "min": 0.0, @@ -1053,7 +1066,7 @@ { "key": "CustomCruise", "label": "Cruise Interval", - "description": "How much the set speed increases or decreases for each + or \u2013 cruise control button press.", + "description": "How much the set speed increases or decreases for each + or – cruise control button press.", "data_type": "float", "ui_type": "numeric", "min": 1.0, @@ -1063,7 +1076,7 @@ { "key": "CustomCruiseLong", "label": "Cruise Interval (Hold)", - "description": "How much the set speed increases or decreases while holding the + or \u2013 cruise control buttons.", + "description": "How much the set speed increases or decreases while holding the + or – cruise control buttons.", "data_type": "float", "ui_type": "numeric", "min": 1.0, @@ -1562,7 +1575,7 @@ }, { "key": "Offset1", - "label": "Speed Offset (0\u201324 mph)", + "label": "Speed Offset (0–24 mph)", "description": "How much to offset posted speed-limits between 0 and 24 mph.", "data_type": "float", "ui_type": "numeric", @@ -1572,7 +1585,7 @@ }, { "key": "Offset2", - "label": "Speed Offset (25\u201334 mph)", + "label": "Speed Offset (25–34 mph)", "description": "How much to offset posted speed-limits between 25 and 34 mph.", "data_type": "float", "ui_type": "numeric", @@ -1582,7 +1595,7 @@ }, { "key": "Offset3", - "label": "Speed Offset (35\u201344 mph)", + "label": "Speed Offset (35–44 mph)", "description": "How much to offset posted speed-limits between 35 and 44 mph.", "data_type": "float", "ui_type": "numeric", @@ -1592,7 +1605,7 @@ }, { "key": "Offset4", - "label": "Speed Offset (45\u201354 mph)", + "label": "Speed Offset (45–54 mph)", "description": "How much to offset posted speed-limits between 45 and 54 mph.", "data_type": "float", "ui_type": "numeric", @@ -1602,7 +1615,7 @@ }, { "key": "Offset5", - "label": "Speed Offset (55\u201364 mph)", + "label": "Speed Offset (55–64 mph)", "description": "How much to offset posted speed-limits between 55 and 64 mph.", "data_type": "float", "ui_type": "numeric", @@ -1612,7 +1625,7 @@ }, { "key": "Offset6", - "label": "Speed Offset (65\u201374 mph)", + "label": "Speed Offset (65–74 mph)", "description": "How much to offset posted speed-limits between 65 and 74 mph.", "data_type": "float", "ui_type": "numeric", @@ -1622,7 +1635,7 @@ }, { "key": "Offset7", - "label": "Speed Offset (75\u201399 mph)", + "label": "Speed Offset (75–99 mph)", "description": "How much to offset posted speed-limits between 75 and 99 mph.", "data_type": "float", "ui_type": "numeric", @@ -2236,7 +2249,7 @@ { "key": "WarningImmediateVolume", "label": "Warning Immediate Volume", - "description": "Set the volume for the loudest warnings that require urgent attention.\n\nExamples include: \"DISENGAGE IMMEDIATELY \u2014 Driver Distracted\", \"DISENGAGE IMMEDIATELY \u2014 Driver Unresponsive\".", + "description": "Set the volume for the loudest warnings that require urgent attention.\n\nExamples include: \"DISENGAGE IMMEDIATELY — Driver Distracted\", \"DISENGAGE IMMEDIATELY — Driver Unresponsive\".", "data_type": "int", "ui_type": "numeric", "min": 25.0, @@ -2594,6 +2607,18 @@ { "value": 8, "label": "Create Bookmark" + }, + { + "value": 11, + "label": "Favorite #1" + }, + { + "value": 12, + "label": "Favorite #2" + }, + { + "value": 13, + "label": "Favorite #3" } ] }, @@ -2639,6 +2664,18 @@ { "value": 8, "label": "Create Bookmark" + }, + { + "value": 11, + "label": "Favorite #1" + }, + { + "value": 12, + "label": "Favorite #2" + }, + { + "value": 13, + "label": "Favorite #3" } ] }, @@ -2684,6 +2721,18 @@ { "value": 8, "label": "Create Bookmark" + }, + { + "value": 11, + "label": "Favorite #1" + }, + { + "value": 12, + "label": "Favorite #2" + }, + { + "value": 13, + "label": "Favorite #3" } ] }, @@ -2729,6 +2778,18 @@ { "value": 8, "label": "Create Bookmark" + }, + { + "value": 11, + "label": "Favorite #1" + }, + { + "value": 12, + "label": "Favorite #2" + }, + { + "value": 13, + "label": "Favorite #3" } ] }, @@ -2774,6 +2835,18 @@ { "value": 8, "label": "Create Bookmark" + }, + { + "value": 11, + "label": "Favorite #1" + }, + { + "value": 12, + "label": "Favorite #2" + }, + { + "value": 13, + "label": "Favorite #3" } ] }, @@ -2819,6 +2892,18 @@ { "value": 8, "label": "Create Bookmark" + }, + { + "value": 11, + "label": "Favorite #1" + }, + { + "value": 12, + "label": "Favorite #2" + }, + { + "value": 13, + "label": "Favorite #3" } ] }, @@ -2868,6 +2953,18 @@ { "value": 9, "label": "Toggle Always On Lateral" + }, + { + "value": 11, + "label": "Favorite #1" + }, + { + "value": 12, + "label": "Favorite #2" + }, + { + "value": 13, + "label": "Favorite #3" } ] }, @@ -2889,6 +2986,18 @@ { "value": 10, "label": "Adopt Current Speed Limit" + }, + { + "value": 11, + "label": "Favorite #1" + }, + { + "value": 12, + "label": "Favorite #2" + }, + { + "value": 13, + "label": "Favorite #3" } ] }, @@ -2934,6 +3043,18 @@ { "value": 8, "label": "Create Bookmark" + }, + { + "value": 11, + "label": "Favorite #1" + }, + { + "value": 12, + "label": "Favorite #2" + }, + { + "value": 13, + "label": "Favorite #3" } ] }, @@ -2979,6 +3100,18 @@ { "value": 8, "label": "Create Bookmark" + }, + { + "value": 11, + "label": "Favorite #1" + }, + { + "value": 12, + "label": "Favorite #2" + }, + { + "value": 13, + "label": "Favorite #3" } ] }, @@ -3024,6 +3157,18 @@ { "value": 8, "label": "Create Bookmark" + }, + { + "value": 11, + "label": "Favorite #1" + }, + { + "value": 12, + "label": "Favorite #2" + }, + { + "value": 13, + "label": "Favorite #3" } ] }, @@ -3069,6 +3214,18 @@ { "value": 8, "label": "Create Bookmark" + }, + { + "value": 11, + "label": "Favorite #1" + }, + { + "value": 12, + "label": "Favorite #2" + }, + { + "value": 13, + "label": "Favorite #3" } ] }, @@ -3114,6 +3271,18 @@ { "value": 8, "label": "Create Bookmark" + }, + { + "value": 11, + "label": "Favorite #1" + }, + { + "value": 12, + "label": "Favorite #2" + }, + { + "value": 13, + "label": "Favorite #3" } ] }, @@ -3159,6 +3328,18 @@ { "value": 8, "label": "Create Bookmark" + }, + { + "value": 11, + "label": "Favorite #1" + }, + { + "value": 12, + "label": "Favorite #2" + }, + { + "value": 13, + "label": "Favorite #3" } ] } diff --git a/starpilot/system/the_pond/templates/index.html b/starpilot/system/the_pond/templates/index.html index 91a9e540e..072e0442d 100644 --- a/starpilot/system/the_pond/templates/index.html +++ b/starpilot/system/the_pond/templates/index.html @@ -44,7 +44,7 @@