mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-09-11 18:53:47 +08:00
Unify Bluetooth and favourites with a searchable action picker
This commit is contained in:
@@ -9,6 +9,7 @@ from typing import Any
|
||||
|
||||
import pyray as rl
|
||||
|
||||
from openpilot.starpilot.common.controller_actions import CONTROLLER_ACTION_SET_SPEED
|
||||
from openpilot.starpilot.common.favorite_slots import (
|
||||
FAVORITE_SLOT_COUNT,
|
||||
get_favorite_enum_state,
|
||||
@@ -257,6 +258,9 @@ class FavoriteRadialMenu:
|
||||
key = slot.get("key")
|
||||
if key in self._available_option_labels:
|
||||
slot["label"] = self._available_option_labels[key]
|
||||
if key == CONTROLLER_ACTION_SET_SPEED and slot.get("value") is not None:
|
||||
unit = "km/h" if self._params.get_bool("IsMetric") else "mph"
|
||||
slot["label"] = f"Set Speed To {slot['value']:g} {unit}"
|
||||
self._layout_slot_rects()
|
||||
self._layout_picker_rects()
|
||||
|
||||
@@ -590,7 +594,9 @@ class FavoriteRadialMenu:
|
||||
|
||||
def _open_picker(self, slot_index: int) -> None:
|
||||
options = self._refresh_option_catalog()
|
||||
self._picker_options = options or []
|
||||
# This on-road picker has no numeric editor. Keep speed actions in the
|
||||
# catalogue for activating saved slots, but configure their value in Galaxy.
|
||||
self._picker_options = [option for option in (options or []) if option.get("value_type") != "speed"]
|
||||
self._selected_slot = slot_index
|
||||
self._editing_slot = None
|
||||
self._picker_page = 0
|
||||
@@ -602,7 +608,7 @@ class FavoriteRadialMenu:
|
||||
return
|
||||
|
||||
key = str(option.get("key") or "").strip()
|
||||
if not key:
|
||||
if not key or key == CONTROLLER_ACTION_SET_SPEED or option.get("value_type") == "speed":
|
||||
return
|
||||
|
||||
slots = load_favorite_slots(self._params, eligible_keys=self._available_option_keys)
|
||||
@@ -1013,7 +1019,7 @@ class FavoriteRadialMenu:
|
||||
title = f"Assign Favorite {self._selected_slot + 1}" if self._selected_slot is not None else "Assign Favorite"
|
||||
title_pos = rl.Vector2(self._drawer_rect.x + 36 * scale, self._drawer_rect.y + 26 * scale)
|
||||
self._draw_text(self._font(bold=True), title, title_pos, int(50 * scale), self._TEXT)
|
||||
subtitle = "Choose a shortcut"
|
||||
subtitle = "Set Speed: configure in New Galaxy"
|
||||
self._draw_text(
|
||||
self._font(bold=False), subtitle,
|
||||
rl.Vector2(title_pos.x, title_pos.y + 72 * scale), int(26 * scale), self._MUTED_TEXT,
|
||||
|
||||
@@ -933,3 +933,53 @@ def test_render_boolean_toggle_switch_and_picker_badges(monkeypatch):
|
||||
assert "TOGGLE" in drawn_texts
|
||||
assert "2 STATES" in drawn_texts
|
||||
assert "ACTION" in drawn_texts
|
||||
|
||||
|
||||
|
||||
def test_speed_assignment_requires_new_galaxy_without_disabling_existing_slot():
|
||||
from openpilot.starpilot.common.controller_actions import CONTROLLER_ACTION_SET_SPEED
|
||||
|
||||
speed_option = {"key": CONTROLLER_ACTION_SET_SPEED, "label": "Set Speed To", "value_type": "speed", "default_value": 30}
|
||||
menu, params, _memory = _menu([0.0], options=[speed_option, {"key": "FeatureToggle", "label": "Feature Toggle"}])
|
||||
params.put(FAVORITE_SLOTS_PARAM, [{"enabled": True, "show_onroad": True, "key": "FeatureToggle", "label": "Feature Toggle"}])
|
||||
menu._open_picker(0)
|
||||
|
||||
assert CONTROLLER_ACTION_SET_SPEED in menu._available_option_keys
|
||||
assert [option["key"] for option in menu._picker_options] == ["FeatureToggle"]
|
||||
menu._assign_option(speed_option)
|
||||
assert params.get(FAVORITE_SLOTS_PARAM)[0]["key"] == "FeatureToggle"
|
||||
assert menu.state == FavoriteRadialMenu.STATE_PICKER
|
||||
|
||||
|
||||
def test_saved_speed_favorite_retains_value_and_displays_current_unit():
|
||||
from openpilot.starpilot.common.controller_actions import CONTROLLER_ACTION_SET_SPEED
|
||||
|
||||
menu, params, _memory = _menu([0.0], options=[{"key": CONTROLLER_ACTION_SET_SPEED, "label": "Set Speed To", "value_type": "speed"}])
|
||||
params.put(FAVORITE_SLOTS_PARAM, [{"enabled": True, "show_onroad": True, "key": CONTROLLER_ACTION_SET_SPEED, "label": "Set Speed To", "value": 42}])
|
||||
menu._open_radial()
|
||||
for metric, unit in [(False, "mph"), (True, "km/h")]:
|
||||
params.put_bool("IsMetric", metric)
|
||||
menu._layout(rl.Rectangle(0, 0, 2160, 1080))
|
||||
slot = menu._slots[0]
|
||||
assert menu._slot_is_configured(slot)
|
||||
assert slot["value"] == 42
|
||||
assert slot["label"] == f"Set Speed To 42 {unit}"
|
||||
assert params.get(FAVORITE_SLOTS_PARAM)[0]["label"] == "Set Speed To"
|
||||
|
||||
|
||||
|
||||
def test_native_saved_speed_favorite_tap_dispatches_configured_value(monkeypatch):
|
||||
from openpilot.starpilot.common.controller_actions import CONTROLLER_ACTION_SET_SPEED
|
||||
from openpilot.starpilot.system.wheel_controls import wheel_controlsd
|
||||
|
||||
menu, params, memory = _menu([0.0], options=[{"key": CONTROLLER_ACTION_SET_SPEED, "label": "Set Speed To", "value_type": "speed"}])
|
||||
slot = {"enabled": True, "show_onroad": True, "key": CONTROLLER_ACTION_SET_SPEED, "label": "Set Speed To", "value": 42}
|
||||
params.put(FAVORITE_SLOTS_PARAM, [slot])
|
||||
dispatched = []
|
||||
monkeypatch.setattr(wheel_controlsd, "execute_controller_key", lambda key, params, memory, **kwargs: dispatched.append((key, kwargs["value"])) or True)
|
||||
rect = rl.Rectangle(0, 0, 2160, 1080)
|
||||
_tap(menu, rect, menu.corner_center(rect))
|
||||
_tap(menu, rect, menu.slot_centers(rect)[0])
|
||||
|
||||
assert dispatched == [(CONTROLLER_ACTION_SET_SPEED, 42)]
|
||||
assert params.get(FAVORITE_SLOTS_PARAM) == [slot]
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""One action catalogue for native favourites and Bluetooth controls."""
|
||||
CONTROLLER_ACTION_CYCLE_PERSONALITY = "__starpilot_controller_action__:cycle_driving_personality"
|
||||
CONTROLLER_ACTION_SET_SPEED = "__starpilot_controller_action__:set_speed"
|
||||
CONTROLLER_ACTION_SELFIE = "__starpilot_controller_action__:selfie"
|
||||
CONTROLLER_ACTION_BOOKMARK = "__starpilot_controller_action__:bookmark"
|
||||
CONTROLLER_ACTION_PULSE_AND_GLIDE = "__starpilot_controller_action__:pulse_and_glide"
|
||||
CONTROLLER_ACTION_FORCE_COAST = "__starpilot_controller_action__:force_coast"
|
||||
CONTROLLER_ACTION_TOGGLE_AOL = "__starpilot_controller_action__:toggle_aol"
|
||||
CONTROLLER_ACTION_ENGAGE = "__starpilot_controller_action__:engage_openpilot"
|
||||
CONTROLLER_ACTION_DISENGAGE = "__starpilot_controller_action__:disengage_openpilot"
|
||||
CONTROLLER_ACTION_COUNTERS = {
|
||||
CONTROLLER_ACTION_BOOKMARK: "WheelButtonBookmarkCounter",
|
||||
CONTROLLER_ACTION_PULSE_AND_GLIDE: "WheelControlPulseGlideCounter",
|
||||
CONTROLLER_ACTION_FORCE_COAST: "WheelControlForceCoastCounter",
|
||||
CONTROLLER_ACTION_TOGGLE_AOL: "WheelControlAOLCounter",
|
||||
CONTROLLER_ACTION_ENGAGE: "WheelControlEngageCounter",
|
||||
CONTROLLER_ACTION_DISENGAGE: "WheelControlDisengageCounter",
|
||||
}
|
||||
CONTROLLER_ACTION_OPTIONS = (
|
||||
{
|
||||
"key": CONTROLLER_ACTION_CYCLE_PERSONALITY,
|
||||
"label": "Cycle Driving Personality",
|
||||
"description": "Cycles Aggressive → Standard → Relaxed. Requires longitudinal control and Safe Mode off; leaves Traffic Mode unchanged.",
|
||||
"section": "Controller Actions",
|
||||
},
|
||||
{
|
||||
"key": CONTROLLER_ACTION_SET_SPEED,
|
||||
"label": "Set Speed To",
|
||||
"description": "Immediately changes the software-controlled cruise set speed while engaged.",
|
||||
"section": "Controller Actions",
|
||||
"value_type": "speed",
|
||||
"default_value": 30,
|
||||
},
|
||||
{
|
||||
"key": CONTROLLER_ACTION_SELFIE,
|
||||
"label": "Take Comma Selfie",
|
||||
"description": "Captures the driver camera and saves it in Sentry history.",
|
||||
"section": "Controller Actions",
|
||||
},
|
||||
{
|
||||
"key": CONTROLLER_ACTION_BOOKMARK,
|
||||
"label": "Bookmark",
|
||||
"description": "Creates a driving bookmark without changing the on-screen Favorites.",
|
||||
"section": "Controller Actions",
|
||||
},
|
||||
{
|
||||
"key": CONTROLLER_ACTION_PULSE_AND_GLIDE,
|
||||
"label": "Pulse and Glide",
|
||||
"description": "Toggles Pulse and Glide using the same transient control as a mapped vehicle button.",
|
||||
"section": "Controller Actions",
|
||||
},
|
||||
{
|
||||
"key": CONTROLLER_ACTION_FORCE_COAST,
|
||||
"label": "Force Coasting",
|
||||
"description": "Toggles forced coasting using the same transient control as a mapped vehicle button.",
|
||||
"section": "Controller Actions",
|
||||
},
|
||||
{
|
||||
"key": CONTROLLER_ACTION_TOGGLE_AOL,
|
||||
"label": "Toggle AOL",
|
||||
"description": "Toggles Always On Lateral like the vehicle LKAS button; it does not change the AOL setting.",
|
||||
"section": "Controller Actions",
|
||||
},
|
||||
{
|
||||
"key": CONTROLLER_ACTION_ENGAGE,
|
||||
"label": "Engage Openpilot",
|
||||
"description": "Requests engagement through the normal openpilot readiness and safety checks.",
|
||||
"section": "Controller Actions",
|
||||
},
|
||||
{
|
||||
"key": CONTROLLER_ACTION_DISENGAGE,
|
||||
"label": "Disengage Openpilot",
|
||||
"description": "Immediately disengages openpilot like the vehicle cancel button.",
|
||||
"section": "Controller Actions",
|
||||
},
|
||||
)
|
||||
CONTROLLER_ACTION_KEYS = {option["key"] for option in CONTROLLER_ACTION_OPTIONS}
|
||||
|
||||
|
||||
def controller_speed_bounds(is_metric: bool) -> tuple[int, int]:
|
||||
return (8, 145) if is_metric else (5, 90)
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import json
|
||||
import math
|
||||
from collections.abc import Callable, Iterable, Mapping
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -14,6 +15,9 @@ from openpilot.starpilot.common.longitudinal_personality_profiles import (
|
||||
)
|
||||
|
||||
|
||||
from openpilot.starpilot.common.controller_actions import CONTROLLER_ACTION_OPTIONS, CONTROLLER_ACTION_KEYS, CONTROLLER_ACTION_SET_SPEED
|
||||
|
||||
|
||||
FAVORITE_SLOTS_PARAM = "StarPilotFavoriteSlots"
|
||||
FAVORITE_SLOT_COUNT = 3
|
||||
FAVORITE_ACTION_PREFIX = "__starpilot_favorite_action__:"
|
||||
@@ -24,6 +28,7 @@ FAVORITE_ACTION_DECEL_COUNTER = "FavoriteVirtualDecelCruiseCounter"
|
||||
FAVORITE_ACTION_ACCEL_COUNTER = "FavoriteVirtualAccelCruiseCounter"
|
||||
FAVORITE_ACTION_TRAFFIC_MODE_COUNTER = "FavoriteTrafficModeCounter"
|
||||
FAVORITE_ACTION_OPTIONS = (
|
||||
*({**option, "action": "controller"} for option in CONTROLLER_ACTION_OPTIONS),
|
||||
{
|
||||
"key": FAVORITE_ACTION_DISTANCE_DECREASE,
|
||||
"label": "Distance - / SET",
|
||||
@@ -442,16 +447,27 @@ def normalize_favorite_slots(raw_slots: Any, params: Params | None = None,
|
||||
):
|
||||
key = None
|
||||
|
||||
label = str(raw_slot.get("label") or FAVORITE_ACTION_LABELS.get(key, "")).strip()
|
||||
label = str(raw_slot.get("label") or FAVORITE_ACTION_LABELS.get(key or "", "")).strip()
|
||||
if len(label) > 32:
|
||||
label = label[:32].rstrip()
|
||||
|
||||
speed_value = None
|
||||
if key == CONTROLLER_ACTION_SET_SPEED:
|
||||
try:
|
||||
candidate = float(raw_slot.get("value"))
|
||||
if math.isfinite(candidate) and candidate > 0:
|
||||
speed_value = candidate
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
slots[idx] = {
|
||||
"enabled": bool(raw_slot.get("enabled", False)),
|
||||
"enabled": bool(raw_slot.get("enabled", False)) and (key != CONTROLLER_ACTION_SET_SPEED or speed_value is not None),
|
||||
"show_onroad": bool(raw_slot.get("show_onroad", False)),
|
||||
"key": key,
|
||||
"label": label if key else "",
|
||||
}
|
||||
if key == CONTROLLER_ACTION_SET_SPEED:
|
||||
slots[idx]["value"] = speed_value
|
||||
|
||||
return slots
|
||||
|
||||
@@ -478,7 +494,10 @@ def request_starpilot_toggle_refresh(params_memory: Params | None = None) -> Non
|
||||
params_memory.put_bool("StarPilotTogglesUpdated", True)
|
||||
|
||||
|
||||
def trigger_favorite_action(key: str | None, params_memory: Params | None = None) -> bool:
|
||||
def trigger_favorite_action(key: str | None, params_memory: Params | None = None, *, params: Params | None = None, value=None) -> bool:
|
||||
if key in CONTROLLER_ACTION_KEYS:
|
||||
from openpilot.starpilot.system.wheel_controls.wheel_controlsd import execute_controller_key
|
||||
return execute_controller_key(key, params or Params(return_defaults=True), params_memory or Params(memory=True), value=value)
|
||||
if not is_favorite_action_key(key):
|
||||
return False
|
||||
|
||||
@@ -493,7 +512,7 @@ def trigger_favorite_action(key: str | None, params_memory: Params | None = None
|
||||
|
||||
|
||||
def execute_favorite_key(key: str | None, params: Params | None = None, params_memory: Params | None = None, *,
|
||||
eligible_keys: Iterable[str] | None = None) -> bool:
|
||||
eligible_keys: Iterable[str] | None = None, value=None) -> bool:
|
||||
params = params or Params(return_defaults=True)
|
||||
eligible_keys = set(eligible_keys) if eligible_keys is not None else None
|
||||
if not favorite_key_is_valid(params, key, eligible_keys=eligible_keys):
|
||||
@@ -502,7 +521,7 @@ def execute_favorite_key(key: str | None, params: Params | None = None, params_m
|
||||
return False
|
||||
|
||||
if is_favorite_action_key(key):
|
||||
return trigger_favorite_action(key, params_memory)
|
||||
return trigger_favorite_action(key, params_memory, params=params, value=value)
|
||||
|
||||
if is_enum_param(key):
|
||||
return cycle_enum_parameter(key, params, params_memory)
|
||||
@@ -533,7 +552,7 @@ def toggle_favorite_slot(slot_index: int, params: Params | None = None, params_m
|
||||
key = slot.get("key")
|
||||
if not slot.get("enabled") or not key:
|
||||
return False
|
||||
return execute_favorite_key(key, params, params_memory, eligible_keys=eligible_keys)
|
||||
return execute_favorite_key(key, params, params_memory, eligible_keys=eligible_keys, value=slot.get("value"))
|
||||
|
||||
|
||||
def unassign_favorite_slot(slot_index: int, params: Params | None = None, params_memory: Params | None = None, *,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from openpilot.starpilot.common import favorite_slots as favorites
|
||||
from openpilot.starpilot.common.controller_actions import CONTROLLER_ACTION_KEYS, CONTROLLER_ACTION_SET_SPEED
|
||||
from openpilot.starpilot.common.tests.test_favorite_slots import FakeParams
|
||||
from openpilot.starpilot.system.wheel_controls import wheel_controlsd as wheel
|
||||
|
||||
|
||||
def test_favourites_and_bluetooth_have_identical_unique_options():
|
||||
options = favorites.build_favorite_slot_options(lambda _: True, alpha_longitudinal_available=True)
|
||||
source = Path(__file__).parents[2] / 'system/the_galaxy/the_galaxy.py'
|
||||
fn = next(n for n in ast.parse(source.read_text()).body if isinstance(n, ast.FunctionDef) and n.name == '_get_available_controller_action_options')
|
||||
env = {'_get_available_favorite_slot_options': lambda: options}
|
||||
exec(compile(ast.Module(body=[fn], type_ignores=[]), str(source), 'exec'), env)
|
||||
assert env[fn.name]() == options
|
||||
keys = [o['key'] for o in options]
|
||||
assert len(keys) == len(set(keys))
|
||||
assert CONTROLLER_ACTION_KEYS <= set(keys)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('key', sorted(CONTROLLER_ACTION_KEYS))
|
||||
def test_every_controller_action_works_through_native_favourite_dispatch(key, monkeypatch):
|
||||
params, memory, calls = FakeParams(), FakeParams(), []
|
||||
slot = {'key': key, 'label': 'Assigned', 'enabled': True, 'show_onroad': True}
|
||||
if key == CONTROLLER_ACTION_SET_SPEED: slot['value'] = 42
|
||||
params.put(favorites.FAVORITE_SLOTS_PARAM, [slot])
|
||||
monkeypatch.setattr(wheel, 'execute_controller_key', lambda k, p, m, **kw: calls.append((k, p, m, kw['value'])) or True)
|
||||
assert favorites.toggle_favorite_slot(0, params, memory)
|
||||
assert calls == [(key, params, memory, slot.get('value'))]
|
||||
assert params.get(favorites.FAVORITE_SLOTS_PARAM) == [slot]
|
||||
slot['enabled'] = False
|
||||
assert not favorites.toggle_favorite_slot(0, params, memory)
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize('onroad,engaged,value,success', [(False,False,30,False),(True,False,30,False),(True,True,30,True),(True,True,500,False),(True,True,float('nan'),False)])
|
||||
def test_favourite_set_speed_keeps_controller_bounds_and_engagement_guard(onroad,engaged,value,success):
|
||||
from openpilot.starpilot.system.wheel_controls.tests.test_wheel_controlsd import FakeParams as WheelParams
|
||||
params, memory = WheelParams({'IsOnroad':onroad,'IsEngaged':engaged,'IsMetric':False}), WheelParams()
|
||||
assert favorites.trigger_favorite_action(CONTROLLER_ACTION_SET_SPEED,memory,params=params,value=value) is success
|
||||
assert bool(memory.values) is success
|
||||
@@ -0,0 +1,84 @@
|
||||
.controllerActionDialog {
|
||||
--picker-bg: var(--sidebar-bg, #191923);
|
||||
--picker-field: var(--main-bg, #101018);
|
||||
--picker-border: var(--sidebar-border-color, #454052);
|
||||
--picker-radius: var(--border-radius-lg, 12px);
|
||||
--picker-accent: #bda2ff;
|
||||
box-sizing: border-box;
|
||||
width: min(720px, calc(100% - 24px));
|
||||
max-width: calc(100% - 24px);
|
||||
max-height: calc(100dvh - 24px);
|
||||
padding: 20px;
|
||||
border: 1px solid var(--picker-border);
|
||||
border-radius: var(--picker-radius);
|
||||
background: var(--picker-bg);
|
||||
color: var(--text-color, #e8e8f0);
|
||||
font: inherit;
|
||||
overflow: hidden;
|
||||
}
|
||||
.controllerActionDialog[open] { display: flex; flex-direction: column; gap: 12px; }
|
||||
.controllerActionDialog::backdrop { background: rgba(0, 0, 0, .65); backdrop-filter: blur(4px); }
|
||||
.controllerActionDialog--dipper {
|
||||
--picker-bg: var(--surface-container-high, #211b32);
|
||||
--picker-field: var(--surface-container);
|
||||
--picker-border: var(--outline-variant);
|
||||
--picker-radius: var(--radius-lg);
|
||||
--picker-accent: var(--primary);
|
||||
color: var(--on-surface);
|
||||
}
|
||||
.controllerActionDialog header { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
|
||||
.controllerActionDialog h3, .controllerActionDialog p { margin: 0; }
|
||||
.controllerActionDialog h3 { font-size: 1.15em; }
|
||||
.controllerActionDialog [data-current] { overflow-wrap: anywhere; }
|
||||
.controllerActionDialog label { display: grid; gap: 6px; font-size: .9em; }
|
||||
.controllerActionDialog input, .controllerActionDialog select {
|
||||
box-sizing: border-box; width: 100%; min-width: 0; min-height: 44px;
|
||||
background: var(--picker-field); color: inherit; border: 1px solid var(--picker-border);
|
||||
border-radius: var(--border-radius-md, var(--radius-sm, 8px)); padding: 10px; font: inherit;
|
||||
}
|
||||
/* Native option popups do not consistently inherit the select background. */
|
||||
.controllerActionDialog select { color-scheme: dark; }
|
||||
.controllerActionDialog select option {
|
||||
background-color: var(--picker-bg, #211b32);
|
||||
color: var(--on-surface, var(--text-color, #e8e8f0));
|
||||
}
|
||||
.controllerActionDialog button { cursor: pointer; font: inherit; min-height: 44px; }
|
||||
.controllerActionDialog--classic button {
|
||||
border: 1px solid var(--picker-border); border-radius: var(--border-radius-md, 8px);
|
||||
background: var(--picker-field); color: inherit; padding: 10px 14px;
|
||||
}
|
||||
.controllerActionResults { min-height: 0; overflow-y: auto; overscroll-behavior: contain; display: flex; flex-direction: column; gap: 8px; }
|
||||
.controllerActionDialog .controllerActionOption {
|
||||
flex: none; display: flex; flex-direction: column; align-items: stretch; white-space: normal;
|
||||
text-align: left; width: 100%; height: auto; gap: 5px; padding: 12px; overflow-wrap: anywhere;
|
||||
}
|
||||
.controllerActionOption small { font-size: .85em; opacity: .72; font-weight: normal; }
|
||||
.controllerActionDialog--dipper .controllerActionOption { color: var(--on-surface); border-radius: var(--radius-sm); }
|
||||
.controllerActionDialog .controllerActionOption[aria-pressed="true"] { border: 2px solid var(--picker-accent); }
|
||||
.controllerActionDialog :focus-visible { outline: 2px solid var(--picker-accent); outline-offset: -2px; }
|
||||
.controllerActionDialog button:disabled { cursor: not-allowed; opacity: .5; }
|
||||
.controllerActionDialog [data-count] { font-size: .85em; opacity: .72; }
|
||||
@media (max-width: 480px), (max-height: 500px) {
|
||||
.controllerActionDialog { padding: 12px; }
|
||||
.controllerActionDialog[open] { gap: 8px; }
|
||||
}
|
||||
|
||||
.controllerActionDialog .controllerActionCategory[hidden] { display: none; }
|
||||
.controllerActionGroup { flex: none; border: 1px solid var(--picker-border); border-radius: var(--radius-sm, 8px); }
|
||||
.controllerActionGroup summary { padding: 12px; cursor: pointer; min-height: 44px; box-sizing: border-box; overflow-wrap: anywhere; }
|
||||
.controllerActionGroupItems { display: grid; gap: 8px; padding: 0 8px 8px; }
|
||||
|
||||
.controllerActionDialog--dipper .controllerActionSubgroup { background: var(--surface-container, #211c31); }
|
||||
.controllerActionDialog--dipper .controllerActionSubgroup > summary { font-size: .95em; }
|
||||
|
||||
/* Clear parent/child hierarchy in both shared New Galaxy pickers. */
|
||||
.controllerActionDialog--dipper .controllerActionResults > .controllerActionGroup > summary { font-weight: 650; }
|
||||
.controllerActionDialog--dipper .controllerActionGroupItems { padding-inline-start: 20px; }
|
||||
.controllerActionDialog--dipper .controllerActionSubgroup {
|
||||
border-inline-start: 3px solid color-mix(in srgb, var(--picker-accent) 48%, var(--picker-border));
|
||||
background: var(--surface-container-low, #191522);
|
||||
}
|
||||
.controllerActionDialog--dipper .controllerActionSubgroup > summary { font-weight: 450; color: var(--on-surface-variant); }
|
||||
@media (max-width: 480px) {
|
||||
.controllerActionDialog--dipper .controllerActionGroupItems { padding-inline-start: 14px; }
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import { longitudinalModeLayout, LONGITUDINAL_MODE_KEY } from "./longitudinal_mode.mjs"
|
||||
|
||||
// Presentation-only: the caller owns eligibility, persistence and speed values.
|
||||
const stylesheet = new URL("./controller_action_picker.css", import.meta.url).href
|
||||
|
||||
// Use the same catalogue as both Toggles menus, not the API's alphabetic order.
|
||||
const layoutUrl = "/assets/components/tools/device_settings_layout.json?v=settings-tier-1"
|
||||
|
||||
function settingsAnchor(key) {
|
||||
if (["ExperimentalMode", "ConditionalExperimental", "ConditionalChill"].includes(key)) return LONGITUDINAL_MODE_KEY
|
||||
if (["__starpilot_controller_action__:cycle_driving_personality", "__starpilot_favorite_action__:toggle_traffic_mode"].includes(key)) return "CustomPersonalities"
|
||||
return key
|
||||
}
|
||||
|
||||
function settingOwner(option, layout) {
|
||||
const key = settingsAnchor(option.key || "")
|
||||
const sections = longitudinalModeLayout(layout)
|
||||
const section = sections.findLast(section => section.params?.some(param => param.key === key))
|
||||
return { key, section }
|
||||
}
|
||||
|
||||
export function actionCategory(option, layout = []) {
|
||||
const { section } = settingOwner(option, layout)
|
||||
if (section) return section.name
|
||||
return option.section === "Actions" ? "Controller Actions" : String(option.section || "Other")
|
||||
}
|
||||
|
||||
export function actionHierarchy(option, layout = []) {
|
||||
const { key, section } = settingOwner(option, layout)
|
||||
if (!section) return []
|
||||
const byKey = new Map(section.params.map(param => [param.key, param]))
|
||||
const own = byKey.get(key)
|
||||
let node = own?.is_parent_toggle || key !== option.key ? own : byKey.get(own?.parent_key)
|
||||
const path = [], seen = new Set()
|
||||
while (node && !seen.has(node.key)) {
|
||||
seen.add(node.key)
|
||||
path.unshift({ key: node.key, label: node.label || node.key })
|
||||
node = byKey.get(node.parent_key)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
export function actionGroups(options, layout = []) {
|
||||
const root = { items: [], groups: [] }
|
||||
for (const option of options) {
|
||||
let node = root
|
||||
for (const part of actionHierarchy(option, layout)) {
|
||||
let child = node.groups.find(group => group.key === part.key)
|
||||
if (!child) { child = { ...part, count: 0, items: [], groups: [] }; node.groups.push(child) }
|
||||
child.count++
|
||||
node = child
|
||||
}
|
||||
node.items.push(option)
|
||||
}
|
||||
const order = new Map(longitudinalModeLayout(layout).flatMap(section => section.params).map((param, i) => [param.key, i]))
|
||||
order.set(LONGITUDINAL_MODE_KEY, -1)
|
||||
const sort = node => {
|
||||
node.groups.sort((a, b) => (order.get(a.key) ?? Infinity) - (order.get(b.key) ?? Infinity))
|
||||
node.groups.forEach(sort)
|
||||
}
|
||||
sort(root)
|
||||
return root
|
||||
}
|
||||
|
||||
export function actionCategories(options, layout = []) {
|
||||
const available = new Set(options.map(option => actionCategory(option, layout)))
|
||||
const ordered = layout.map(section => section.name).filter(name => available.has(name))
|
||||
return [...new Set([...ordered, ...available])]
|
||||
}
|
||||
|
||||
export function filterActions(options, query = "", category = "", layout = []) {
|
||||
const words = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean)
|
||||
return options.filter(option => (!category || actionCategory(option, layout) === category) &&
|
||||
words.every(word => [option.label, option.key, option.description, option.picker_label,
|
||||
option.picker_description, actionCategory(option, layout), ...actionHierarchy(option, layout).map(part => part.label)].join(" ").toLocaleLowerCase().includes(word)))
|
||||
}
|
||||
|
||||
// Native modal supplies focus containment, inert background and Escape handling.
|
||||
// Read options/guard/selection live: polling may change availability while open.
|
||||
export function openControllerActionPicker({ theme = "classic", index, trigger, getOptions, getSlot, isDisabled, onSelect, title = `Controller Action #${index + 1}`, slotAttribute = "data-controller-action-slot", noun = "actions" }) {
|
||||
if (isDisabled()) return () => {}
|
||||
if (!document.querySelector('link[data-controller-picker]')) {
|
||||
const link = document.createElement("link")
|
||||
link.rel = "stylesheet"
|
||||
link.href = stylesheet
|
||||
link.dataset.controllerPicker = ""
|
||||
document.head.append(link)
|
||||
}
|
||||
const dialog = document.createElement("dialog")
|
||||
dialog.className = `controllerActionDialog ${theme === "dipper" ? "controllerActionDialog--dipper" : "controllerActionDialog--classic"}`
|
||||
dialog.setAttribute("aria-labelledby", "controllerActionTitle")
|
||||
dialog.innerHTML = `<header><h3 id="controllerActionTitle">Controller Action #${index + 1}</h3><button type="button" data-cancel>Cancel</button></header>
|
||||
<p data-current></p>
|
||||
<label class="controllerActionSearch">Search all actions<input type="search" placeholder="Search by name, setting or description" autocomplete="off"></label>
|
||||
<label class="controllerActionCategory">Category<select aria-label="Category"></select></label>
|
||||
<p data-count role="status" aria-live="polite"></p>
|
||||
<div class="controllerActionResults"></div>`
|
||||
dialog.querySelector("h3").textContent = title
|
||||
dialog.querySelector(".controllerActionSearch").firstChild.textContent = `Search all ${noun}`
|
||||
const grouped = theme === "dipper"
|
||||
const expanded = new Set()
|
||||
if (grouped) dialog.querySelector(".controllerActionCategory").hidden = true
|
||||
const input = dialog.querySelector("input")
|
||||
const category = dialog.querySelector("select")
|
||||
const results = dialog.querySelector(".controllerActionResults")
|
||||
const current = dialog.querySelector("[data-current]")
|
||||
const count = dialog.querySelector("[data-count]")
|
||||
const buttonClass = theme === "dipper" ? "gx-btn gx-btn--tonal" : ""
|
||||
dialog.querySelector("[data-cancel]").className = buttonClass
|
||||
if (theme === "dipper") {
|
||||
input.className = "gx-field"
|
||||
category.className = "gx-field"
|
||||
}
|
||||
let signature = ""
|
||||
let layout = []
|
||||
let closed = false
|
||||
let timer
|
||||
const fitViewport = () => {
|
||||
// CSS zoom scales vh units too. Bound the modal in unzoomed layout pixels.
|
||||
let zoom = 1
|
||||
for (let node = dialog; node; node = node.parentElement) zoom *= Number.parseFloat(getComputedStyle(node).zoom) || 1
|
||||
const viewport = window.visualViewport
|
||||
dialog.style.maxHeight = `${(viewport?.height || window.innerHeight) / zoom - 24}px`
|
||||
dialog.style.maxWidth = `${(viewport?.width || window.innerWidth) / zoom - 24}px`
|
||||
}
|
||||
const close = () => {
|
||||
if (closed) return
|
||||
closed = true
|
||||
clearInterval(timer)
|
||||
window.removeEventListener("resize", fitViewport)
|
||||
window.visualViewport?.removeEventListener("resize", fitViewport)
|
||||
dialog.close()
|
||||
dialog.remove()
|
||||
// Classic polling can replace the original card while the modal is open.
|
||||
const target = trigger?.isConnected ? trigger : document.querySelector(`[${slotAttribute}="${index}"]`)
|
||||
target?.focus({ preventScroll: true })
|
||||
}
|
||||
dialog.addEventListener("cancel", event => { event.preventDefault(); close() })
|
||||
dialog.addEventListener("keydown", event => {
|
||||
if (event.key !== "Tab") return
|
||||
const controls = [...dialog.querySelectorAll("button:not(:disabled), input, select, summary")].filter(node => node.getClientRects().length)
|
||||
const first = controls[0]
|
||||
const last = controls[controls.length - 1]
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault(); last.focus()
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault(); first.focus()
|
||||
}
|
||||
})
|
||||
dialog.querySelector("[data-cancel]").addEventListener("click", close)
|
||||
const render = () => {
|
||||
const options = getOptions()
|
||||
const slot = getSlot() || {}
|
||||
const key = slot.key || ""
|
||||
const disabled = isDisabled()
|
||||
const next = JSON.stringify([options, key, slot.label, disabled, input.value, category.value])
|
||||
if (signature === next) return
|
||||
signature = next
|
||||
const categories = actionCategories(options, layout)
|
||||
const selectedCategory = category.value
|
||||
category.replaceChildren(new Option("All categories", ""), ...categories.map(name => new Option(name, name)))
|
||||
category.value = categories.includes(selectedCategory) ? selectedCategory : ""
|
||||
const selected = options.find(option => option.key === key)
|
||||
current.textContent = `Current: ${selected?.label || slot.label || key || "Not configured"}${key && !selected ? " (unavailable)" : ""}`
|
||||
const matches = filterActions(options, input.value, category.value, layout)
|
||||
count.textContent = disabled ? "Selection is currently unavailable." : `${matches.length} ${matches.length === 1 ? noun.replace(/s$/, "") : noun}`
|
||||
const focusedKey = results.contains(document.activeElement) ? document.activeElement.dataset.actionKey : null
|
||||
results.replaceChildren()
|
||||
const add = (option, categoryName, container = results) => {
|
||||
const button = document.createElement("button")
|
||||
button.type = "button"
|
||||
button.className = `controllerActionOption ${buttonClass}`
|
||||
button.dataset.actionKey = option.key
|
||||
button.setAttribute("aria-pressed", String(option.key === key))
|
||||
button.disabled = disabled
|
||||
const title = document.createElement("strong")
|
||||
title.textContent = `${option.key === key ? "✓ " : ""}${option.label}`
|
||||
const detail = document.createElement("small")
|
||||
detail.textContent = [categoryName, option.description].filter(Boolean).join(" · ")
|
||||
button.append(title, detail)
|
||||
button.addEventListener("click", () => {
|
||||
if (isDisabled() || (option.key && !getOptions().some(item => item.key === option.key))) { render(); return }
|
||||
close()
|
||||
onSelect(option.key)
|
||||
})
|
||||
container.append(button)
|
||||
if (focusedKey === option.key) button.focus({ preventScroll: true })
|
||||
}
|
||||
// The original empty option remains available independently of filters.
|
||||
add({ key: "", label: "Not configured", description: "Clear this assignment" }, "")
|
||||
if (grouped) {
|
||||
for (const name of categories) {
|
||||
const items = matches.filter(option => actionCategory(option, layout) === name)
|
||||
if (!items.length) continue
|
||||
const group = document.createElement("details")
|
||||
group.className = "controllerActionGroup"
|
||||
group.open = !!input.value.trim() || expanded.has(name)
|
||||
const summary = document.createElement("summary")
|
||||
summary.textContent = `${name} (${items.length})`
|
||||
const body = document.createElement("div")
|
||||
body.className = "controllerActionGroupItems"
|
||||
group.append(summary, body)
|
||||
group.addEventListener("toggle", () => {
|
||||
if (!input.value.trim()) group.open ? expanded.add(name) : expanded.delete(name)
|
||||
})
|
||||
results.append(group)
|
||||
const populate = (node, container, path) => {
|
||||
for (const option of node.items) add(option, path.join(" · "), container)
|
||||
for (const child of node.groups) {
|
||||
const id = [...path, child.key].join("/")
|
||||
const subgroup = document.createElement("details")
|
||||
subgroup.className = "controllerActionGroup controllerActionSubgroup"
|
||||
subgroup.open = !!input.value.trim() || expanded.has(id)
|
||||
const heading = document.createElement("summary")
|
||||
heading.textContent = `${child.label} (${child.count})`
|
||||
const content = document.createElement("div")
|
||||
content.className = "controllerActionGroupItems"
|
||||
subgroup.append(heading, content)
|
||||
subgroup.addEventListener("toggle", () => {
|
||||
if (!input.value.trim()) subgroup.open ? expanded.add(id) : expanded.delete(id)
|
||||
})
|
||||
container.append(subgroup)
|
||||
populate(child, content, [...path, child.label])
|
||||
}
|
||||
}
|
||||
populate(actionGroups(items, layout), body, [name])
|
||||
}
|
||||
} else {
|
||||
for (const option of matches) add(option, actionCategory(option, layout))
|
||||
}
|
||||
if (!matches.length) {
|
||||
const empty = document.createElement("p")
|
||||
empty.textContent = "No matching actions. Try another search or category."
|
||||
results.append(empty)
|
||||
}
|
||||
}
|
||||
input.addEventListener("input", () => {
|
||||
// Search always covers the complete eligible catalogue, not the last category.
|
||||
category.value = ""
|
||||
render()
|
||||
results.scrollTop = 0
|
||||
})
|
||||
category.addEventListener("change", () => { render(); results.scrollTop = 0 })
|
||||
document.body.append(dialog)
|
||||
fitViewport()
|
||||
window.addEventListener("resize", fitViewport)
|
||||
window.visualViewport?.addEventListener("resize", fitViewport)
|
||||
render()
|
||||
dialog.showModal()
|
||||
input.focus()
|
||||
// A missing layout must never hide eligible actions or block assignment.
|
||||
// Retry on the next open; don't cache a transient failure or stale catalogue.
|
||||
fetch(layoutUrl, { cache: "no-store" }).then(response => {
|
||||
if (!response.ok) throw new Error("Settings catalogue unavailable")
|
||||
return response.json()
|
||||
}).then(data => {
|
||||
if (closed || !Array.isArray(data)) return
|
||||
layout = data.filter(section => section && typeof section.name === "string" && Array.isArray(section.params))
|
||||
signature = ""
|
||||
render()
|
||||
}).catch(() => {})
|
||||
timer = setInterval(() => {
|
||||
if (!document.querySelector(`[${slotAttribute}="${index}"]`)) close()
|
||||
else render()
|
||||
}, 250)
|
||||
return close
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { html, reactive } from "/assets/vendor/arrow-core.js"
|
||||
import { api } from "/assets/mobile/js/api.js"
|
||||
import {
|
||||
formatProfileSpeed,
|
||||
personalityProfileParamKey,
|
||||
@@ -765,6 +766,7 @@ function normalizeFavoriteSlots(slots) {
|
||||
if (!slot || typeof slot !== "object") return
|
||||
const key = slot.key ? String(slot.key) : null
|
||||
normalized[index] = {
|
||||
...slot,
|
||||
enabled: !!slot.enabled,
|
||||
show_onroad: !!slot.show_onroad,
|
||||
key,
|
||||
@@ -803,7 +805,9 @@ function isFavoriteActionOption(option) {
|
||||
|
||||
function filteredFavoriteOptions(index) {
|
||||
const filter = state.favoriteFilters[index] || ""
|
||||
return normalizeFavoriteOptions(state.favoriteOptions).filter(opt => favoriteOptionMatchesFilter(opt, filter))
|
||||
const selectedKey = state.favoriteSlots[index]?.key
|
||||
return normalizeFavoriteOptions(state.favoriteOptions).filter(opt =>
|
||||
(opt.value_type !== "speed" || opt.key === selectedKey) && favoriteOptionMatchesFilter(opt, filter))
|
||||
}
|
||||
|
||||
function populateFavoriteSelect(index, selectEl = null) {
|
||||
@@ -1005,7 +1009,14 @@ async function saveFavoriteSlots(slots) {
|
||||
function updateFavoriteSlot(index, patch) {
|
||||
const slots = normalizeFavoriteSlots(state.favoriteSlots)
|
||||
const current = slots[index] || defaultFavoriteSlots()[0]
|
||||
const option = state.favoriteOptions.find(opt => opt.key === patch.key)
|
||||
if (patch.key !== current.key && option?.value_type === "speed") {
|
||||
showParamSnackbar("Configure Set Speed favorites in New Galaxy.", "error")
|
||||
scheduleSyncInputs()
|
||||
return
|
||||
}
|
||||
const nextSlot = { ...current, ...patch }
|
||||
if (patch.key !== undefined && patch.key !== current.key) delete nextSlot.value
|
||||
|
||||
if (!nextSlot.key) {
|
||||
nextSlot.label = ""
|
||||
@@ -1075,22 +1086,12 @@ async function updateFavoriteValue(key, checked, sourceEl = null) {
|
||||
}
|
||||
}
|
||||
|
||||
async function activateFavoriteAction(key) {
|
||||
async function activateFavoriteAction(key, value) {
|
||||
try {
|
||||
const res = await fetch("/api/favorites/action", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ key }),
|
||||
})
|
||||
const data = await res.json()
|
||||
|
||||
if (res.ok) {
|
||||
showParamSnackbar(data.message || "Favorite action sent.")
|
||||
} else {
|
||||
showParamSnackbar(data.error || "Failed to send favorite action", "error")
|
||||
}
|
||||
} catch (e) {
|
||||
showParamSnackbar("Network error — is the device reachable?", "error")
|
||||
const data = await api.activateFavoriteAction(key, value)
|
||||
showParamSnackbar(data.message || "Favorite action sent.")
|
||||
} catch (error) {
|
||||
showParamSnackbar(error.message || "Failed to send favorite action", "error")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2339,6 +2340,7 @@ function renderFavoriteSlotsPanel() {
|
||||
|
||||
return html`
|
||||
<div class="ds-favorites-panel">
|
||||
<div class="ds-row-desc">Configure Set Speed favorites in New Galaxy. Saved speeds use the current mph/km/h setting.</div>
|
||||
${quickFavorites.length ? html`
|
||||
<div class="ds-favorite-quick-grid">
|
||||
${quickFavorites.map(favorite => {
|
||||
@@ -2349,7 +2351,7 @@ function renderFavoriteSlotsPanel() {
|
||||
const quickCopy = html`
|
||||
<div class="ds-favorite-quick-copy">
|
||||
<span class="ds-favorite-quick-slot">Favorite #${favorite.index + 1}</span>
|
||||
<span class="ds-favorite-quick-title">${selectedOption.label || favorite.slot.label || selectedKey}</span>
|
||||
<span class="ds-favorite-quick-title">${selectedOption.label || favorite.slot.label || selectedKey}${selectedOption.value_type === "speed" ? ` ${favorite.slot.value} ${vehicleSpeedUnit(state.values)}` : ""}</span>
|
||||
${selectedOption.section ? html`<span class="ds-favorite-quick-section">${selectedOption.section}</span>` : ""}
|
||||
${selectedOption.description ? html`<span class="ds-favorite-quick-desc">${selectedOption.description}</span>` : ""}
|
||||
</div>
|
||||
@@ -2360,7 +2362,7 @@ function renderFavoriteSlotsPanel() {
|
||||
<button
|
||||
type="button"
|
||||
class="ds-favorite-quick-card ds-favorite-action-card"
|
||||
@click="${() => activateFavoriteAction(selectedKey)}">
|
||||
@click="${() => activateFavoriteAction(selectedKey, favorite.slot.value)}">
|
||||
${quickCopy}
|
||||
<span class="ds-favorite-action-chip">Press</span>
|
||||
</button>
|
||||
@@ -2386,14 +2388,14 @@ function renderFavoriteSlotsPanel() {
|
||||
const selectedOption = optionByKey.get(slot.key)
|
||||
const selectedKey = slot.key || ""
|
||||
const favoriteFilter = state.favoriteFilters[index] || ""
|
||||
const filteredOptions = options.filter(opt => favoriteOptionMatchesFilter(opt, favoriteFilter))
|
||||
const filteredOptions = filteredFavoriteOptions(index)
|
||||
|
||||
return html`
|
||||
<div class="ds-favorite-card">
|
||||
<div class="ds-favorite-card-header">
|
||||
<div>
|
||||
<div class="ds-row-label">Favorite #${index + 1}</div>
|
||||
<div class="ds-row-desc">${selectedOption?.section || "No toggle selected"}</div>
|
||||
<div class="ds-row-desc">${selectedOption?.section || "No toggle selected"}${selectedOption?.value_type === "speed" ? ` · ${slot.value} ${vehicleSpeedUnit(state.values)}` : ""}</div>
|
||||
</div>
|
||||
<label class="ds-favorite-switch">
|
||||
<span>Enabled</span>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { html, reactive } from "/assets/vendor/arrow-core.js"
|
||||
import { openControllerActionPicker } from "./controller_action_picker.js"
|
||||
|
||||
const state = reactive({
|
||||
loading: true,
|
||||
@@ -135,12 +136,17 @@ function controllerSlotCard(slot, index) {
|
||||
const selectedOption = state.controllerOptions.find(option => option.key === selectedKey)
|
||||
const mappings = () => state.mappings.filter(mapping => mapping.slot === targetIndex)
|
||||
const learning = () => state.learning && state.learningSlot === targetIndex
|
||||
const selectAction = event => {
|
||||
const key = event.currentTarget.value
|
||||
const option = state.controllerOptions.find(candidate => candidate.key === key)
|
||||
const value = option?.value_type === "speed" ? Number(slot?.value || option.default_value || 30) : null
|
||||
request("action", { slot: index, key, value })
|
||||
}
|
||||
const selectAction = event => openControllerActionPicker({
|
||||
index, trigger: event.currentTarget,
|
||||
getOptions: () => state.controllerOptions,
|
||||
getSlot: () => state.controllerSlots[index],
|
||||
isDisabled: () => !state.offroad || !!state.busy,
|
||||
onSelect: key => {
|
||||
const option = state.controllerOptions.find(candidate => candidate.key === key)
|
||||
const value = option?.value_type === "speed" ? Number(state.controllerSlots[index]?.value || option.default_value || 30) : null
|
||||
request("action", { slot: index, key, value })
|
||||
},
|
||||
})
|
||||
return html`
|
||||
<section class="wheelCard wheelControllerCard">
|
||||
<div class="wheelCardHeader">
|
||||
@@ -156,13 +162,10 @@ function controllerSlotCard(slot, index) {
|
||||
</div>
|
||||
<label class="wheelActionPicker">
|
||||
<span>Action</span>
|
||||
<select disabled="${() => !state.offroad || !!state.busy}"
|
||||
@change="${selectAction}">
|
||||
<option value="" selected="${() => selectedKey === ""}">Not configured</option>
|
||||
${state.controllerOptions.map(option => html`
|
||||
<option value="${option.key}" selected="${() => selectedKey === option.key}">${option.label}</option>
|
||||
`)}
|
||||
</select>
|
||||
<button type="button" data-controller-action-slot="${index}" aria-haspopup="dialog"
|
||||
disabled="${() => !state.offroad || !!state.busy}" @click="${selectAction}">
|
||||
${selectedOption?.label || slot?.label || selectedKey || "Not configured"} · Choose action
|
||||
</button>
|
||||
</label>
|
||||
${selectedOption?.value_type === "speed" ? html`
|
||||
<label class="wheelActionPicker wheelSpeedPicker">
|
||||
|
||||
@@ -72,7 +72,7 @@ export const api = {
|
||||
getFlmWorkspace() { return requestOk("/api/flm/workspace", { cache: "no-store" }) },
|
||||
getFavoritesSlots() { return request("/api/favorites/slots", { cache: "no-store" }) },
|
||||
saveFavoritesSlots(slots) { return request("/api/favorites/slots", { method: "PUT", data: { slots } }) },
|
||||
activateFavoriteAction(key) { return request("/api/favorites/action", { method: "POST", data: { key } }) },
|
||||
activateFavoriteAction(key, value) { return request("/api/favorites/action", { method: "POST", data: { key, ...(value == null ? {} : { value }) } }) },
|
||||
|
||||
getDeviceStatus() { return requestOk("/api/device/status") },
|
||||
getStats() { return requestOk("/api/stats") },
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { api, showSnackbar } from "../api.js"
|
||||
import { openControllerActionPicker } from "../../../components/tools/controller_action_picker.js"
|
||||
|
||||
const FAVORITE_COUNT = 3
|
||||
const ACTION_PREFIX = "__starpilot_favorite_action__:"
|
||||
const SET_SPEED = "__starpilot_controller_action__:set_speed"
|
||||
|
||||
function sortOptions(options) {
|
||||
return (options || []).slice().sort((a, b) =>
|
||||
@@ -23,6 +25,7 @@ function normalizeSlots(slots) {
|
||||
enabled: !!slot.enabled,
|
||||
show_onroad: !!slot.show_onroad,
|
||||
key,
|
||||
...(key === SET_SPEED ? { value: slot.value } : {}),
|
||||
label: key ? String(slot.label || key) : "",
|
||||
}
|
||||
})
|
||||
@@ -36,9 +39,10 @@ export const FavoritesEditor = {
|
||||
loading: true,
|
||||
saving: false,
|
||||
slots: [],
|
||||
savedSlots: [],
|
||||
options: [],
|
||||
values: {},
|
||||
filters: ["", "", ""],
|
||||
isMetric: false,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -52,13 +56,23 @@ export const FavoritesEditor = {
|
||||
.filter((f) => f.slot.enabled && f.slot.key && f.opt)
|
||||
},
|
||||
},
|
||||
beforeUnmount() { this.closeActionPicker?.() },
|
||||
methods: {
|
||||
normalizeSlots,
|
||||
filteredOptions(index) {
|
||||
const q = (this.filters[index] || "").toLowerCase()
|
||||
return this.options.filter((o) =>
|
||||
!q || [o.label, o.key, o.section, o.description].some((v) => String(v || "").toLowerCase().includes(q))
|
||||
)
|
||||
chooseControl(index, event) {
|
||||
this.closeActionPicker?.()
|
||||
this.closeActionPicker = openControllerActionPicker({
|
||||
theme: "dipper", index, trigger: event.currentTarget,
|
||||
title: `Favorite #${index + 1}`, slotAttribute: "data-favorite-control-slot", noun: "controls",
|
||||
getOptions: () => this.options, getSlot: () => this.slots[index],
|
||||
isDisabled: () => this.loading || this.saving,
|
||||
onSelect: key => this.updateSlot(index, { key: key || null }),
|
||||
})
|
||||
},
|
||||
isSpeedSlot(slot) { return slot.key === SET_SPEED },
|
||||
controlLabel(slot) {
|
||||
const label = this.optionByKey.get(slot.key)?.label || slot.label || slot.key || "Not configured"
|
||||
return this.isSpeedSlot(slot) ? `${label} ${slot.value ?? 30} ${this.isMetric ? 'km/h' : 'mph'}` : label
|
||||
},
|
||||
isActionSlot(slot) {
|
||||
const opt = this.optionByKey.get(slot.key || "")
|
||||
@@ -70,7 +84,8 @@ export const FavoritesEditor = {
|
||||
const data = await api.getFavoritesSlots()
|
||||
this.options = sortOptions(data?.options)
|
||||
this.slots = normalizeSlots(data?.slots)
|
||||
this.values = { ...this.values, ...(data?.values || {}) }
|
||||
this.savedSlots = normalizeSlots(data?.slots)
|
||||
this.values = { ...this.values, ...(data?.values || {}) }; this.isMetric = !!data?.is_metric
|
||||
} catch (e) {
|
||||
showSnackbar("Failed to load favorite slots.", "error")
|
||||
} finally {
|
||||
@@ -83,18 +98,25 @@ export const FavoritesEditor = {
|
||||
try {
|
||||
const data = await api.saveFavoritesSlots(this.slots)
|
||||
this.slots = normalizeSlots(data?.slots)
|
||||
this.savedSlots = normalizeSlots(data?.slots)
|
||||
if (Array.isArray(data?.options)) this.options = sortOptions(data.options)
|
||||
if (data?.values) this.values = { ...this.values, ...data.values }
|
||||
showSnackbar(data?.message || "Favorite slots saved.")
|
||||
} catch (e) {
|
||||
this.slots = normalizeSlots(this.savedSlots)
|
||||
showSnackbar(e?.message || "Failed to save favorite slots.", "error")
|
||||
} finally {
|
||||
this.saving = false
|
||||
}
|
||||
},
|
||||
updateSlot(index, patch) {
|
||||
if (this.saving) return
|
||||
const slots = this.slots.slice()
|
||||
slots[index] = { ...slots[index], ...patch }
|
||||
if (Object.hasOwn(patch, "key")) {
|
||||
if (patch.key === SET_SPEED) slots[index].value = slots[index].key === this.slots[index].key ? this.slots[index].value : 30
|
||||
else delete slots[index].value
|
||||
}
|
||||
if (!slots[index].key) {
|
||||
slots[index].label = ""
|
||||
} else {
|
||||
@@ -115,9 +137,9 @@ export const FavoritesEditor = {
|
||||
showSnackbar(e?.message || "Network error — is the device reachable?", "error")
|
||||
}
|
||||
},
|
||||
async runAction(key) {
|
||||
async runAction(key, value) {
|
||||
try {
|
||||
const data = await api.activateFavoriteAction(key)
|
||||
const data = await api.activateFavoriteAction(key, value)
|
||||
showSnackbar(data?.message || "Favorite action sent.")
|
||||
} catch (e) {
|
||||
showSnackbar(e?.message || "Failed to send favorite action.", "error")
|
||||
@@ -134,9 +156,9 @@ export const FavoritesEditor = {
|
||||
<div v-for="f in quickFavorites" :key="f.slot.key"
|
||||
style="display:flex; flex-direction:column; gap:4px; padding:var(--sp-2) var(--sp-3); border:1px solid var(--outline-variant); border-radius:var(--radius-md);">
|
||||
<small style="color:var(--text-muted);">Favorite #{{ f.index + 1 }}</small>
|
||||
<strong>{{ f.opt.label || f.slot.key }}</strong>
|
||||
<strong>{{ controlLabel(f.slot) }}</strong>
|
||||
<span style="color:var(--text-muted); font-size:var(--fs-sm);">{{ f.opt.section || '' }}</span>
|
||||
<button v-if="isActionSlot(f.slot)" type="button" class="gx-btn" :disabled="saving" @click.prevent="runAction(f.slot.key)">
|
||||
<button v-if="isActionSlot(f.slot)" type="button" class="gx-btn" :disabled="saving" @click.prevent="runAction(f.slot.key, f.slot.value)">
|
||||
Press
|
||||
</button>
|
||||
<label v-else class="gx-switch" style="align-self:flex-start;">
|
||||
@@ -157,16 +179,14 @@ export const FavoritesEditor = {
|
||||
</label>
|
||||
</div>
|
||||
<div style="padding: var(--sp-3); display:grid; gap:12px;">
|
||||
<label style="display:grid; gap:4px;">
|
||||
<span style="font-size:var(--fs-sm); color:var(--text-muted);">Search</span>
|
||||
<input class="gx-field" type="search" :value="filters[index] || ''" :disabled="saving" placeholder="Search toggles..." @input="filters = filters.map((f,i)=> i===index ? $event.target.value : f)" />
|
||||
</label>
|
||||
<label style="display:grid; gap:4px;">
|
||||
<span style="font-size:var(--fs-sm); color:var(--text-muted);">Toggle</span>
|
||||
<select class="gx-field" :value="slot.key || ''" :disabled="saving" @change="updateSlot(index, { key: $event.target.value || null })">
|
||||
<option value="">Select a toggle...</option>
|
||||
<option v-for="opt in filteredOptions(index)" :key="opt.key" :value="opt.key">{{ opt.label }}</option>
|
||||
</select>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" style="white-space:normal; height:auto; min-height:44px;"
|
||||
:data-favorite-control-slot="index" aria-haspopup="dialog" :disabled="saving" @click="chooseControl(index, $event)">
|
||||
{{ controlLabel(slot) }} · Choose control
|
||||
</button>
|
||||
<label v-if="isSpeedSlot(slot)" style="display:grid; gap:4px;">
|
||||
<span>Set speed ({{ isMetric ? 'km/h' : 'mph' }})</span>
|
||||
<input class="gx-field" type="number" :min="isMetric ? 8 : 5" :max="isMetric ? 145 : 90" step="1" :value="slot.value" :disabled="saving"
|
||||
:aria-label="'Favorite #' + (index + 1) + ' set speed'" @change="updateSlot(index, { value: Number($event.target.value) })" />
|
||||
</label>
|
||||
<div style="display:flex; align-items:center; gap:8px;">
|
||||
<span style="flex:1; font-size:var(--fs-sm);">On-Road Button (C4: tap invisible third)</span>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { api } from "../api.js"
|
||||
import { openControllerActionPicker } from "../../../components/tools/controller_action_picker.js"
|
||||
import { usePolling } from "../composables.js"
|
||||
import { GxNotice } from "./GxNotice.js"
|
||||
|
||||
@@ -17,7 +18,7 @@ export const WheelControls = {
|
||||
}
|
||||
},
|
||||
created() { this.poll = usePolling(() => this.refresh(), { interval: 750 }); this.poll.start() },
|
||||
beforeUnmount() { this.poll?.destroy() },
|
||||
beforeUnmount() { this.closeActionPicker?.(); this.poll?.destroy() },
|
||||
methods: {
|
||||
async refresh() {
|
||||
try {
|
||||
@@ -68,6 +69,15 @@ export const WheelControls = {
|
||||
configured(slot) { return !!slot?.enabled && !!slot?.key },
|
||||
optionByKey(key) { return this.controllerOptions.find((o) => o.key === key) || null },
|
||||
isSpeedSlot(slot) { return this.optionByKey(slot?.key)?.value_type === "speed" },
|
||||
chooseAction(i, event) {
|
||||
this.closeActionPicker = openControllerActionPicker({
|
||||
theme: "dipper", index: i, trigger: event.currentTarget,
|
||||
getOptions: () => this.controllerOptions,
|
||||
getSlot: () => this.controllerSlots[i],
|
||||
isDisabled: () => this.disabled(),
|
||||
onSelect: key => this.onActionSelect(i, { target: { value: key } }),
|
||||
})
|
||||
},
|
||||
onActionSelect(i, e) {
|
||||
if (this.disabled()) return
|
||||
const key = String(e.target.value || "")
|
||||
@@ -158,10 +168,10 @@ export const WheelControls = {
|
||||
</div>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!slot.enabled || disabled() || testing" @click="learn(actionSlotIndex(i))">{{ listenLabel(actionSlotIndex(i)) }}</button>
|
||||
</div>
|
||||
<select class="gx-field gx-field--full" :value="String(slot.key || '')" :disabled="disabled()" @change="onActionSelect(i, $event)">
|
||||
<option value="">Not configured</option>
|
||||
<option v-for="opt in controllerOptions" :key="opt.key" :value="opt.key">{{ opt.label }}</option>
|
||||
</select>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" style="white-space:normal; height:auto; min-height:44px;"
|
||||
:data-controller-action-slot="i" aria-haspopup="dialog" :disabled="disabled()" @click="chooseAction(i, $event)">
|
||||
{{ optionByKey(slot.key)?.label || slot.label || slot.key || 'Not configured' }} · Choose action
|
||||
</button>
|
||||
<div v-if="isSpeedSlot(slot)" class="gx-row" style="border:none; padding:0;">
|
||||
<div class="gx-row__info">
|
||||
<span class="gx-row__label">Set speed ({{ speedUnit }})</span>
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import vm from 'node:vm'
|
||||
import test from 'node:test'
|
||||
import { api } from '../assets/mobile/js/api.js'
|
||||
import { vehicleSpeedUnit } from '../assets/mobile/js/params.js'
|
||||
|
||||
const source = fs.readFileSync(new URL('../assets/components/tools/device_settings.js', import.meta.url), 'utf8')
|
||||
const extract = (start, end) => source.slice(source.indexOf(start), source.indexOf(end, source.indexOf(start)))
|
||||
const speed = '__starpilot_controller_action__:set_speed'
|
||||
const savedSpeed = {enabled: true, show_onroad: true, key: speed, label: 'Set Speed To', value: 42}
|
||||
|
||||
function setup() {
|
||||
const requests = [], messages = []
|
||||
const snapshot = {locked: false, action_expires_at: 123, values: {ExperimentalMode: false, ConditionalExperimental: false, ConditionalChill: false}}
|
||||
const state = {favoriteSlots: [structuredClone(savedSpeed)], favoriteFilters: ['', '', ''], favoriteOptions: [
|
||||
{key: speed, label: 'Set Speed To', action: 'controller', value_type: 'speed'},
|
||||
{key: 'FeatureToggle', label: 'Feature Toggle'},
|
||||
], favoriteValues: {}, values: {}}
|
||||
const fetch = async (url, init = {}) => {
|
||||
requests.push({url, init})
|
||||
return {ok: true, json: async () => url === '/api/longitudinal_mode' ? snapshot : {slots: state.favoriteSlots}}
|
||||
}
|
||||
const context = vm.createContext({state, api, fetch, performance, Number, messages,
|
||||
FAVORITE_OPTION_COLLATOR: new Intl.Collator(), FAVORITE_ACTION_PREFIX: '__starpilot_favorite_action__:',
|
||||
showParamSnackbar: (...args) => messages.push(args), scheduleSyncInputs() {},
|
||||
window: {setTimeout() {}, location: {pathname: '/device_settings'}},
|
||||
vehicleSpeedUnit, html: (strings, ...values) => ({strings, values}),
|
||||
})
|
||||
vm.runInContext(extract('function defaultFavoriteSlots()', 'function populateFavoriteSelect(') +
|
||||
extract('async function saveFavoriteSlots(', 'function updateFavoriteFilter(') +
|
||||
extract('async function activateFavoriteAction(', 'function stepPrecision(') +
|
||||
extract('function renderFavoriteSlotsPanel()', 'function renderSettingRow('), context)
|
||||
return {context, state, requests, messages, snapshot, fetch}
|
||||
}
|
||||
|
||||
test('classic unrelated saves preserve configured speed and other slot metadata', async () => {
|
||||
const {context, state, requests} = setup()
|
||||
state.favoriteSlots[0].futureMetadata = {unit: 'current'}
|
||||
const slots = context.normalizeFavoriteSlots(state.favoriteSlots)
|
||||
slots[1] = {enabled: false, show_onroad: true, key: 'FeatureToggle', label: 'Feature Toggle'}
|
||||
await context.saveFavoriteSlots(slots)
|
||||
const sent = JSON.parse(requests[0].init.body).slots
|
||||
assert.equal(sent[0].value, 42)
|
||||
assert.deepEqual(sent[0].futureMetadata, {unit: 'current'})
|
||||
assert.equal(sent[1].show_onroad, true)
|
||||
})
|
||||
|
||||
test('classic keeps configured speed selectable but cannot create one without its required value', () => {
|
||||
const {context, state} = setup()
|
||||
assert(context.filteredFavoriteOptions(0).some(option => option.key === speed))
|
||||
assert(!context.filteredFavoriteOptions(1).some(option => option.key === speed))
|
||||
context.updateFavoriteSlot(1, {key: speed})
|
||||
assert.equal(state.favoriteSlots[1]?.key ?? null, null)
|
||||
})
|
||||
|
||||
test('classic Set Speed activation sends the saved speed', async () => {
|
||||
const {context, requests, fetch} = setup()
|
||||
const previous = globalThis.fetch
|
||||
try {
|
||||
globalThis.fetch = fetch
|
||||
await context.activateFavoriteAction(speed, 42)
|
||||
assert.deepEqual(JSON.parse(requests[0].init.body), {key: speed, value: 42})
|
||||
} finally { globalThis.fetch = previous }
|
||||
})
|
||||
|
||||
test('classic rendered speed buttons show current units and dispatch each slot value', async () => {
|
||||
const {context, state, requests, fetch} = setup()
|
||||
state.favoriteSlots.push({...savedSpeed, value: 55})
|
||||
const buttons = []
|
||||
function renderText(node) {
|
||||
if (Array.isArray(node)) return node.map(renderText).join('')
|
||||
if (node?.strings) {
|
||||
return node.strings.map((part, index) => {
|
||||
const value = node.values[index]
|
||||
if (part.includes('ds-favorite-action-card') && typeof value === 'function') buttons.push(value)
|
||||
return part + (typeof value === 'function' ? '' : renderText(value))
|
||||
}).join('')
|
||||
}
|
||||
return node == null ? '' : String(node)
|
||||
}
|
||||
for (const [metric, unit] of [[false, 'mph'], [true, 'km/h']]) {
|
||||
state.values.IsMetric = metric
|
||||
buttons.length = 0
|
||||
const output = renderText(context.renderFavoriteSlotsPanel())
|
||||
assert(output.includes(`Set Speed To 42 ${unit}`))
|
||||
assert(output.includes(`Set Speed To 55 ${unit}`))
|
||||
assert.equal(buttons.length, 2)
|
||||
}
|
||||
const previous = globalThis.fetch
|
||||
try {
|
||||
globalThis.fetch = fetch
|
||||
for (const click of buttons) await click()
|
||||
assert.deepEqual(requests.map(request => JSON.parse(request.init.body).value), [42, 55])
|
||||
} finally { globalThis.fetch = previous }
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
// Run with node; optional source-derived full option fixture from browser test.
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import { actionCategory, actionCategories, filterActions } from '../assets/components/tools/controller_action_picker.js'
|
||||
|
||||
const layout = JSON.parse(fs.readFileSync(new URL('../../../common/assets/device_settings_layout.json', import.meta.url)))
|
||||
const mapping = new Map(layout.flatMap(s => s.params.map(p => [p.key, s.name])))
|
||||
const options = process.argv[2] ? JSON.parse(fs.readFileSync(process.argv[2])) :
|
||||
[...mapping].map(([key, section]) => ({ key, section, label: key }))
|
||||
const before = JSON.stringify(options)
|
||||
for (const option of options) {
|
||||
const expected = mapping.get(option.key) ?? (option.section === 'Actions' ? 'Controller Actions' : option.section || 'Other')
|
||||
assert.equal(actionCategory(option, layout), expected, option.key)
|
||||
assert.ok(filterActions(options, option.key, '', layout).includes(option))
|
||||
if (mapping.has(option.key)) assert.equal(actionCategory({ ...option, section: 'Incorrect API label' }, layout), expected)
|
||||
}
|
||||
const available = new Set(options.map(o => mapping.get(o.key) ?? (o.section === 'Actions' ? 'Controller Actions' : o.section || 'Other')))
|
||||
const expectedOrder = [...new Set([...layout.map(s => s.name).filter(n => available.has(n)), ...available])]
|
||||
assert.deepEqual(actionCategories(options, layout), expectedOrder)
|
||||
const collected = expectedOrder.flatMap(c => filterActions(options, '', c, layout))
|
||||
assert.equal(collected.length, options.length)
|
||||
assert.equal(new Set(collected.map(o => o.key)).size, options.length)
|
||||
assert.deepEqual(new Set(collected), new Set(options))
|
||||
assert.deepEqual(filterActions(options, '', '', layout), options)
|
||||
assert.equal(JSON.stringify(options), before, 'Presentation must not mutate catalogue')
|
||||
assert.equal(actionCategory({ key: 'RemapCancelToDistance' }, layout), 'Wheel Controls')
|
||||
assert.equal(actionCategory({ key: 'SLCMapboxFiller' }, layout), 'Visual (Display & UI)')
|
||||
assert.equal(actionCategory({ key: 'unknown', section: 'Future section' }, layout), 'Future section')
|
||||
assert.equal(actionCategory({ key: 'unknown' }, layout), 'Other')
|
||||
assert.deepEqual(filterActions(options), options, 'Missing layout must retain every eligible option')
|
||||
assert.deepEqual(actionCategories([], layout), [])
|
||||
assert.deepEqual(actionCategories([{ key: 'virtual', section: 'Actions' }, { key: 'command', section: 'Controller Actions' }], layout), ['Controller Actions'])
|
||||
console.log(JSON.stringify({ result: 'PASS', options: options.length, categories: expectedOrder, duplicateOwnership: 'PASS', mutation: 'NONE' }, null, 2))
|
||||
@@ -0,0 +1,42 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import vm from 'node:vm'
|
||||
import test from 'node:test'
|
||||
const source = fs.readFileSync(new URL('../assets/mobile/js/components/FavoritesEditor.js', import.meta.url), 'utf8')
|
||||
const speed = '__starpilot_controller_action__:set_speed'
|
||||
function setup(api = {}) {
|
||||
const context = vm.createContext({api, showSnackbar() {}, openControllerActionPicker() {}})
|
||||
vm.runInContext(source.replace(/^import .*$/gm, '').replace('export const FavoritesEditor =', 'globalThis.component ='), context)
|
||||
const component = context.component
|
||||
const instance = {...component.data(), ...component.methods}
|
||||
for (const [name, getter] of Object.entries(component.computed)) Object.defineProperty(instance, name, {get: getter})
|
||||
return instance
|
||||
}
|
||||
test('loading and unrelated saves retain speed, assignment and visibility', async () => {
|
||||
const slot = {key: speed, label: 'Set Speed To', value: 42, enabled: true, show_onroad: true}
|
||||
let sent
|
||||
const instance = setup({getFavoritesSlots: async () => ({slots:[slot],options:[{key:speed,label:'Set Speed To',action:'controller'}],is_metric:true}),
|
||||
saveFavoritesSlots: async slots => { sent = slots; return {slots} }})
|
||||
await instance.load()
|
||||
assert.equal(instance.controlLabel(instance.slots[0]), 'Set Speed To 42 km/h')
|
||||
instance.updateSlot(1, {key: 'NewToggle', enabled:true})
|
||||
await new Promise(resolve => setTimeout(resolve,0))
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(sent[0])),slot)
|
||||
assert.equal(instance.slots[0].value,42)
|
||||
})
|
||||
test('save failure restores accepted assignment and saved speed', async () => {
|
||||
const slot = {key:speed,label:'Set Speed To',value:42,enabled:true,show_onroad:true}
|
||||
const instance = setup({getFavoritesSlots:async()=>({slots:[slot]}),saveFavoritesSlots:async()=>{throw Error('No connection')}})
|
||||
await instance.load()
|
||||
instance.updateSlot(0,{value:55})
|
||||
await new Promise(resolve=>setTimeout(resolve,0))
|
||||
assert.equal(instance.slots[0].value,42)
|
||||
assert.equal(instance.slots[0].enabled,true)
|
||||
})
|
||||
test('activation forwards the chosen slot value', async () => {
|
||||
const sent=[]
|
||||
const instance=setup({activateFavoriteAction:async(...args)=>{sent.push(args);return {}}})
|
||||
await instance.runAction(speed,42)
|
||||
await instance.runAction(speed,55)
|
||||
assert.deepEqual(sent,[[speed,42],[speed,55]])
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import { actionCategory, actionHierarchy, actionGroups, filterActions } from '../assets/components/tools/controller_action_picker.js'
|
||||
const layout=JSON.parse(fs.readFileSync(new URL('../../../common/assets/device_settings_layout.json',import.meta.url)))
|
||||
const mode={key:'ConditionalExperimental',label:'Conditional Experimental'}
|
||||
const personality={key:'__starpilot_controller_action__:cycle_driving_personality',label:'Cycle Driving Personality'}
|
||||
assert.deepEqual(actionHierarchy(mode,layout).map(p=>p.label),['Longitudinal control mode'])
|
||||
assert.deepEqual(actionHierarchy(personality,layout).map(p=>p.label),['Driving Personalities'])
|
||||
assert.equal(actionCategory(personality,layout),'Longitudinal (Speed & Following)')
|
||||
const options=layout.flatMap(s=>s.params.map(p=>({...p,section:s.name}))).concat(mode,personality)
|
||||
const groups=actionGroups(options,layout)
|
||||
const collect=node=>node.items.concat(...node.groups.map(collect))
|
||||
assert.deepEqual(collect(groups).map(o=>o.key).sort(),options.map(o=>o.key).sort())
|
||||
for(const o of options) assert.ok(filterActions(options,o.key,'',layout).includes(o))
|
||||
assert.ok(filterActions(options,'driving personalities','',layout).includes(personality))
|
||||
assert.ok(filterActions(options,'longitudinal control mode','',layout).includes(mode))
|
||||
const cyclic=[{name:'Test',params:[{key:'a',parent_key:'b'},{key:'b',parent_key:'a'}]}]
|
||||
assert.ok(actionHierarchy({key:'a'},cyclic).length<=2)
|
||||
assert.deepEqual(actionGroups([{key:'missing'}],[]).items,[{key:'missing'}])
|
||||
console.log('Nested settings hierarchy, virtual actions, search, lossless fallback and cycle guard passed.')
|
||||
@@ -0,0 +1,37 @@
|
||||
import pytest
|
||||
from test_navigation_params import _params_client, the_galaxy
|
||||
from openpilot.starpilot.common.controller_actions import CONTROLLER_ACTION_SET_SPEED, CONTROLLER_ACTION_CYCLE_PERSONALITY
|
||||
from openpilot.starpilot.common.favorite_slots import FAVORITE_SLOTS_PARAM, normalize_favorite_slots
|
||||
|
||||
|
||||
@pytest.mark.parametrize('metric,value,status', [(False,30,200),(True,100,200),(False,4,400),(False,91,400),(True,146,400),(False,True,400),(False,None,400),(False,'30',400)])
|
||||
def test_shared_speed_slot_validates_value_and_preserves_flags(monkeypatch,metric,value,status):
|
||||
client,params=_params_client(monkeypatch,{'IsMetric':metric},'tici')
|
||||
options=[{'key':CONTROLLER_ACTION_SET_SPEED,'label':'Set Speed To','action':'controller'}]
|
||||
monkeypatch.setattr(the_galaxy,'FAVORITE_SLOTS_PARAM',FAVORITE_SLOTS_PARAM)
|
||||
monkeypatch.setattr(the_galaxy,'normalize_favorite_slots',normalize_favorite_slots)
|
||||
monkeypatch.setattr(the_galaxy,'_favorite_slot_values',lambda _: {})
|
||||
monkeypatch.setattr(the_galaxy,'_get_available_favorite_slot_options',lambda:options)
|
||||
monkeypatch.setattr(the_galaxy,'update_starpilot_toggles',lambda:None)
|
||||
slot={'key':CONTROLLER_ACTION_SET_SPEED,'label':'Set Speed To','value':value,'enabled':True,'show_onroad':True}
|
||||
response=client.put('/api/favorites/slots',json={'slots':[slot]})
|
||||
assert response.status_code==status,response.json
|
||||
if status==200:
|
||||
assert response.json['slots'][0]==slot
|
||||
assert response.json['is_metric']==metric
|
||||
assert params.values[FAVORITE_SLOTS_PARAM][0]==slot
|
||||
else:assert not params.writes
|
||||
|
||||
|
||||
def test_personality_action_can_be_assigned_to_favourites(monkeypatch):
|
||||
client,params=_params_client(monkeypatch,{},'tici')
|
||||
options=[{'key':CONTROLLER_ACTION_CYCLE_PERSONALITY,'label':'Cycle Driving Personality','action':'controller'}]
|
||||
monkeypatch.setattr(the_galaxy,'FAVORITE_SLOTS_PARAM',FAVORITE_SLOTS_PARAM)
|
||||
monkeypatch.setattr(the_galaxy,'normalize_favorite_slots',normalize_favorite_slots)
|
||||
monkeypatch.setattr(the_galaxy,'_favorite_slot_values',lambda _: {})
|
||||
monkeypatch.setattr(the_galaxy,'_get_available_favorite_slot_options',lambda:options)
|
||||
monkeypatch.setattr(the_galaxy,'update_starpilot_toggles',lambda:None)
|
||||
slot={'key':CONTROLLER_ACTION_CYCLE_PERSONALITY,'enabled':True,'show_onroad':False,'label':'Cycle Driving Personality'}
|
||||
response=client.put('/api/favorites/slots',json={'slots':[slot]})
|
||||
assert response.status_code==200,response.json
|
||||
assert response.json['slots'][0]==slot
|
||||
@@ -3371,11 +3371,7 @@ def _get_available_favorite_slot_options():
|
||||
|
||||
|
||||
def _get_available_controller_action_options():
|
||||
options = [*_get_available_favorite_slot_options(), *(dict(option) for option in CONTROLLER_ACTION_OPTIONS)]
|
||||
return sorted(options, key=lambda option: (
|
||||
str(option.get("section") or "").casefold(),
|
||||
str(option.get("label") or option.get("key") or "").casefold(),
|
||||
))
|
||||
return _get_available_favorite_slot_options()
|
||||
|
||||
|
||||
def _favorite_slot_values(options):
|
||||
@@ -5795,6 +5791,12 @@ def setup(app):
|
||||
if not isinstance(raw_slot, dict):
|
||||
continue
|
||||
key = str(raw_slot.get("key") or "").strip()
|
||||
if key == CONTROLLER_ACTION_SET_SPEED:
|
||||
from openpilot.starpilot.common.controller_actions import controller_speed_bounds
|
||||
minimum, maximum = controller_speed_bounds(params.get_bool("IsMetric"))
|
||||
value = raw_slot.get("value")
|
||||
if type(value) not in (int, float) or not minimum <= value <= maximum:
|
||||
return jsonify(error=f"Favorite #{idx + 1} speed must be between {minimum} and {maximum}."), 400
|
||||
if key and key not in eligible_keys:
|
||||
return jsonify(error=f"Favorite #{idx + 1} must use a Galaxy-exposed toggle or action."), 400
|
||||
|
||||
@@ -5813,6 +5815,7 @@ def setup(app):
|
||||
"slots": slots,
|
||||
"options": options,
|
||||
"values": _favorite_slot_values(options),
|
||||
"is_metric": params.get_bool("IsMetric"),
|
||||
}), 200
|
||||
|
||||
slots = normalize_favorite_slots(params.get(FAVORITE_SLOTS_PARAM), params=params, eligible_keys=eligible_keys)
|
||||
@@ -5825,6 +5828,7 @@ def setup(app):
|
||||
"slots": slots,
|
||||
"options": options,
|
||||
"values": _favorite_slot_values(options),
|
||||
"is_metric": params.get_bool("IsMetric"),
|
||||
}), 200
|
||||
|
||||
@app.route("/api/favorites/values", methods=["GET"])
|
||||
@@ -5840,7 +5844,7 @@ def setup(app):
|
||||
key = str(data.get("key") or "").strip()
|
||||
if not is_favorite_action_key(key):
|
||||
return jsonify({"error": "Unknown favorite action."}), 400
|
||||
if not trigger_favorite_action(key, params_memory):
|
||||
if not trigger_favorite_action(key, params_memory, params=params, value=data.get("value")):
|
||||
return jsonify({"error": "Favorite action failed."}), 400
|
||||
return jsonify({"message": "Favorite action sent."}), 200
|
||||
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
"""Real catalogue/HID dispatcher with isolated Params and serialized CarParams.
|
||||
|
||||
Card/reader seams are source-extracted to avoid requiring native messaging builds.
|
||||
"""
|
||||
import ast
|
||||
import os
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from cereal import car, log
|
||||
|
||||
from openpilot.starpilot.common import favorite_slots as favorites
|
||||
from openpilot.starpilot.common.longitudinal_personality_profiles import active_personality_id
|
||||
from openpilot.starpilot.common.tests.test_favorite_slots import FakeParams
|
||||
from openpilot.starpilot.system.wheel_controls import wheel_controlsd as wheel
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[4]
|
||||
ACTION = wheel.CONTROLLER_ACTION_CYCLE_PERSONALITY
|
||||
|
||||
|
||||
class DiskSelectionParams(FakeParams):
|
||||
def __init__(self, root):
|
||||
super().__init__()
|
||||
self.root = root
|
||||
self.writes = []
|
||||
self.store.update(IsOnroad=True, IsOffroad=False)
|
||||
self.set_cp()
|
||||
self.set_selection(b"0")
|
||||
|
||||
def set_cp(self, longitudinal=True, alpha=False):
|
||||
cp = car.CarParams.new_message(openpilotLongitudinalControl=longitudinal, alphaLongitudinalAvailable=alpha)
|
||||
cp_bytes = cp.to_bytes()
|
||||
self.store.update(CarParams=cp_bytes, CarParamsPersistent=cp_bytes)
|
||||
|
||||
def get_param_path(self, key):
|
||||
return str(self.root / key)
|
||||
|
||||
def set_selection(self, token):
|
||||
(self.root / "LongitudinalPersonality").write_bytes(token)
|
||||
|
||||
def get(self, key, **kwargs):
|
||||
if key == "LongitudinalPersonality":
|
||||
return int((self.root / key).read_bytes())
|
||||
return self.store.get(key, kwargs.get("default"))
|
||||
|
||||
def put_int(self, key, value):
|
||||
self.writes.append((key, value))
|
||||
if key == "LongitudinalPersonality":
|
||||
self.set_selection(str(value).encode())
|
||||
else:
|
||||
super().put_int(key, value)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def params(tmp_path):
|
||||
params = DiskSelectionParams(tmp_path)
|
||||
wheel.set_controller_action_slot(0, ACTION, "Cycle Driving Personality", params, eligible_keys={ACTION})
|
||||
return params
|
||||
|
||||
|
||||
def extracted(filename, name, namespace, class_name=None):
|
||||
tree = ast.parse((ROOT / filename).read_text())
|
||||
nodes = tree.body
|
||||
if class_name:
|
||||
nodes = next(n for n in nodes if isinstance(n, ast.ClassDef) and n.name == class_name).body
|
||||
node = next(n for n in nodes if isinstance(n, ast.FunctionDef) and n.name == name)
|
||||
exec(compile(ast.Module(body=[node], type_ignores=[]), filename, "exec"), namespace)
|
||||
return namespace[name]
|
||||
|
||||
|
||||
def test_unique_shared_catalogue_and_both_theme_status_consumers():
|
||||
options = favorites.build_favorite_slot_options(lambda _: True, alpha_longitudinal_available=True)
|
||||
assert ACTION in {o['key'] for o in options}
|
||||
assert favorites.is_favorite_action_key(ACTION)
|
||||
selected = [o for o in wheel.CONTROLLER_ACTION_OPTIONS if o['key'] == ACTION]
|
||||
assert len(selected) == 1
|
||||
assert selected[0]['label'] == 'Cycle Driving Personality'
|
||||
namespace = {'_get_available_favorite_slot_options': lambda: options, 'CONTROLLER_ACTION_OPTIONS': wheel.CONTROLLER_ACTION_OPTIONS}
|
||||
get_options = extracted('starpilot/system/the_galaxy/the_galaxy.py', '_get_available_controller_action_options', namespace)
|
||||
assert len([o for o in get_options() if o['key'] == ACTION]) == 1
|
||||
# Neither theme maintains a second action list: both use this status catalogue.
|
||||
for filename in ('assets/components/tools/wheel_controls.js', 'assets/mobile/js/components/WheelControls.js'):
|
||||
source = (ROOT / 'starpilot/system/the_galaxy' / filename).read_text()
|
||||
assert 'controller_options' in source
|
||||
assert 'LongitudinalPersonality' not in {o['key'] for o in options}
|
||||
|
||||
|
||||
def test_existing_registry_and_persistence(params):
|
||||
registry = (ROOT / 'common/params_keys.h').read_text()
|
||||
assert registry.count('{"LongitudinalPersonality",') == 1
|
||||
assert ACTION not in registry
|
||||
controller = wheel.set_controller_action_slot(0, ACTION, 'Cycle Driving Personality', params, eligible_keys={ACTION})
|
||||
assert wheel.load_controller_action_slots(params, {ACTION}) == controller
|
||||
assert wheel.execute_controller_action(0, params, FakeParams())
|
||||
assert params.writes == [('LongitudinalPersonality', 1)]
|
||||
|
||||
|
||||
def test_exact_enum_cycle_and_no_cruise_side_effect(params):
|
||||
assert log.LongitudinalPersonality.schema.enumerants == {'aggressive': 0, 'standard': 1, 'relaxed': 2}
|
||||
memory = FakeParams()
|
||||
wheel.set_controller_action_slot(0, ACTION, 'Cycle Driving Personality', params, eligible_keys={ACTION})
|
||||
for expected in (1, 2, 0, 1):
|
||||
assert wheel.execute_mapping_slot(favorites.FAVORITE_SLOT_COUNT, params, memory)
|
||||
assert params.get('LongitudinalPersonality') == expected
|
||||
assert params.writes == [('LongitudinalPersonality', n) for n in (1, 2, 0, 1)]
|
||||
assert memory.store == {} # no speed, distance-gesture, Traffic or toggle counters
|
||||
|
||||
|
||||
@pytest.mark.parametrize('token', [b'', b'bad', b'-1', b'3', b'1.0', b'1.5', b'NaN', b'Infinity', b'true', b' 1', b'01', b'\xff'])
|
||||
def test_malformed_selection_is_not_coerced(params, token):
|
||||
params.set_selection(token)
|
||||
assert not wheel.execute_controller_action(0, params, FakeParams())
|
||||
assert params.writes == []
|
||||
assert Path(params.get_param_path('LongitudinalPersonality')).read_bytes() == token
|
||||
|
||||
|
||||
def test_missing_selection_is_noop(params):
|
||||
Path(params.get_param_path('LongitudinalPersonality')).unlink()
|
||||
assert not wheel.execute_controller_action(0, params, FakeParams())
|
||||
assert params.writes == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize('onroad', [False, True])
|
||||
@pytest.mark.parametrize('safe,longitudinal,alpha,enabled,allowed', [
|
||||
(False, True, False, False, True),
|
||||
(True, True, False, False, False),
|
||||
(False, False, False, True, False),
|
||||
(False, True, True, False, False),
|
||||
(False, False, True, True, True),
|
||||
])
|
||||
def test_native_capability_safe_mode_and_onroad_selection(params, onroad, safe, longitudinal, alpha, enabled, allowed):
|
||||
params.set_cp(longitudinal, alpha)
|
||||
params.store.update(SafeMode=safe, IsOnroad=onroad, IsOffroad=not onroad, AlphaLongitudinalEnabled=enabled)
|
||||
assert wheel.execute_controller_action(0, params, FakeParams()) is allowed
|
||||
assert params.writes == ([('LongitudinalPersonality', 1)] if allowed else [])
|
||||
|
||||
|
||||
@pytest.mark.parametrize('cp', [None, b'bad'])
|
||||
def test_no_stale_parked_capability_fallback_onroad(params, cp):
|
||||
params.store['CarParams'] = cp
|
||||
assert not wheel.execute_controller_action(0, params, FakeParams())
|
||||
assert params.writes == []
|
||||
|
||||
|
||||
def test_disabled_assignment(params):
|
||||
wheel.set_controller_action_slot(0, ACTION, 'Cycle Driving Personality', params, eligible_keys={ACTION})
|
||||
# Controller slots disable by unassigning; enabled is derived from the key.
|
||||
wheel.set_controller_action_slot(0, None, '', params, eligible_keys={ACTION})
|
||||
assert not wheel.execute_controller_action(0, params, FakeParams())
|
||||
assert params.writes == []
|
||||
|
||||
|
||||
def test_one_hid_press_one_change_release_and_autorepeat_ignored(params, monkeypatch):
|
||||
memory = FakeParams()
|
||||
wheel.set_controller_action_slot(0, ACTION, 'Cycle Driving Personality', params, eligible_keys={ACTION})
|
||||
source = wheel.InputSource('/dev/input/test', 'test-controller', 'Test', 3, 1, 2)
|
||||
wheel.upsert_mapping(source, 30, favorites.FAVORITE_SLOT_COUNT, params)
|
||||
daemon = wheel.WheelControlsDaemon(params, memory)
|
||||
daemon.sources[123] = source
|
||||
daemon.buffers[123] = bytearray()
|
||||
stream = b''.join(wheel.INPUT_EVENT.pack(0, 0, wheel.EV_KEY, 30, value) for value in (1, 2, 2, 0))
|
||||
monkeypatch.setattr(os, 'read', lambda *_: stream)
|
||||
daemon._read_events(123)
|
||||
assert params.writes == [('LongitudinalPersonality', 1)]
|
||||
assert memory.store == {}
|
||||
|
||||
|
||||
def test_traffic_override_unchanged_and_native_reader_observes_selection(params):
|
||||
memory = FakeParams()
|
||||
counter = favorites.FAVORITE_ACTION_TRAFFIC_MODE_COUNTER
|
||||
memory.put_int(counter, 0)
|
||||
card = SimpleNamespace(params_memory=memory, _favorite_traffic_mode_counter=0, traffic_mode_enabled=True)
|
||||
consume = extracted('starpilot/controls/starpilot_card.py', '_handle_favorite_traffic_mode_action',
|
||||
{'FAVORITE_ACTION_TRAFFIC_MODE_COUNTER': counter}, 'StarPilotCard')
|
||||
assert wheel.execute_controller_action(0, params, FakeParams())
|
||||
consume(card, {'carControl': SimpleNamespace(longActive=True)})
|
||||
assert card.traffic_mode_enabled is True
|
||||
assert active_personality_id(True, params.get('LongitudinalPersonality')) == 'traffic'
|
||||
assert active_personality_id(False, params.get('LongitudinalPersonality')) == 'standard'
|
||||
# Execute one iteration of the existing selfdrived reader, not an invented consumer.
|
||||
reader = extracted('selfdrive/selfdrived/selfdrived.py', 'params_thread', {
|
||||
'REPLAY': False, 'request_mode_refresh': lambda *_: None,
|
||||
'log': log, 'time': SimpleNamespace(sleep=lambda _: None),
|
||||
}, 'SelfdriveD')
|
||||
state = SimpleNamespace(params=params, params_memory=memory, starpilot_toggles=SimpleNamespace())
|
||||
checks = iter((False, True))
|
||||
reader(state, SimpleNamespace(is_set=lambda: next(checks)))
|
||||
assert state.personality == log.LongitudinalPersonality.standard
|
||||
@@ -31,75 +31,21 @@ ENABLED_PARAM = "WheelControlsEnabled"
|
||||
JOYSTICK_DEVICE_PARAM = "JoystickControlDevice"
|
||||
CONTROLLER_ACTION_SLOT_COUNT = 10
|
||||
MAPPING_SLOT_COUNT = FAVORITE_SLOT_COUNT + CONTROLLER_ACTION_SLOT_COUNT
|
||||
CONTROLLER_ACTION_SET_SPEED = "__starpilot_controller_action__:set_speed"
|
||||
CONTROLLER_ACTION_SELFIE = "__starpilot_controller_action__:selfie"
|
||||
CONTROLLER_ACTION_BOOKMARK = "__starpilot_controller_action__:bookmark"
|
||||
CONTROLLER_ACTION_PULSE_AND_GLIDE = "__starpilot_controller_action__:pulse_and_glide"
|
||||
CONTROLLER_ACTION_FORCE_COAST = "__starpilot_controller_action__:force_coast"
|
||||
CONTROLLER_ACTION_TOGGLE_AOL = "__starpilot_controller_action__:toggle_aol"
|
||||
CONTROLLER_ACTION_ENGAGE = "__starpilot_controller_action__:engage_openpilot"
|
||||
CONTROLLER_ACTION_DISENGAGE = "__starpilot_controller_action__:disengage_openpilot"
|
||||
CONTROLLER_ACTION_COUNTERS = {
|
||||
CONTROLLER_ACTION_BOOKMARK: "WheelButtonBookmarkCounter",
|
||||
CONTROLLER_ACTION_PULSE_AND_GLIDE: "WheelControlPulseGlideCounter",
|
||||
CONTROLLER_ACTION_FORCE_COAST: "WheelControlForceCoastCounter",
|
||||
CONTROLLER_ACTION_TOGGLE_AOL: "WheelControlAOLCounter",
|
||||
CONTROLLER_ACTION_ENGAGE: "WheelControlEngageCounter",
|
||||
CONTROLLER_ACTION_DISENGAGE: "WheelControlDisengageCounter",
|
||||
}
|
||||
CONTROLLER_ACTION_OPTIONS = (
|
||||
{
|
||||
"key": CONTROLLER_ACTION_SET_SPEED,
|
||||
"label": "Set Speed To",
|
||||
"description": "Immediately changes the software-controlled cruise set speed while engaged.",
|
||||
"section": "Controller Actions",
|
||||
"value_type": "speed",
|
||||
"default_value": 30,
|
||||
},
|
||||
{
|
||||
"key": CONTROLLER_ACTION_SELFIE,
|
||||
"label": "Take Comma Selfie",
|
||||
"description": "Captures the driver camera and saves it in Sentry history.",
|
||||
"section": "Controller Actions",
|
||||
},
|
||||
{
|
||||
"key": CONTROLLER_ACTION_BOOKMARK,
|
||||
"label": "Bookmark",
|
||||
"description": "Creates a driving bookmark without changing the on-screen Favorites.",
|
||||
"section": "Controller Actions",
|
||||
},
|
||||
{
|
||||
"key": CONTROLLER_ACTION_PULSE_AND_GLIDE,
|
||||
"label": "Pulse and Glide",
|
||||
"description": "Toggles Pulse and Glide using the same transient control as a mapped vehicle button.",
|
||||
"section": "Controller Actions",
|
||||
},
|
||||
{
|
||||
"key": CONTROLLER_ACTION_FORCE_COAST,
|
||||
"label": "Force Coasting",
|
||||
"description": "Toggles forced coasting using the same transient control as a mapped vehicle button.",
|
||||
"section": "Controller Actions",
|
||||
},
|
||||
{
|
||||
"key": CONTROLLER_ACTION_TOGGLE_AOL,
|
||||
"label": "Toggle AOL",
|
||||
"description": "Toggles Always On Lateral like the vehicle LKAS button; it does not change the AOL setting.",
|
||||
"section": "Controller Actions",
|
||||
},
|
||||
{
|
||||
"key": CONTROLLER_ACTION_ENGAGE,
|
||||
"label": "Engage Openpilot",
|
||||
"description": "Requests engagement through the normal openpilot readiness and safety checks.",
|
||||
"section": "Controller Actions",
|
||||
},
|
||||
{
|
||||
"key": CONTROLLER_ACTION_DISENGAGE,
|
||||
"label": "Disengage Openpilot",
|
||||
"description": "Immediately disengages openpilot like the vehicle cancel button.",
|
||||
"section": "Controller Actions",
|
||||
},
|
||||
from openpilot.starpilot.common.controller_actions import (
|
||||
CONTROLLER_ACTION_CYCLE_PERSONALITY,
|
||||
CONTROLLER_ACTION_SET_SPEED,
|
||||
CONTROLLER_ACTION_SELFIE,
|
||||
CONTROLLER_ACTION_BOOKMARK,
|
||||
CONTROLLER_ACTION_PULSE_AND_GLIDE,
|
||||
CONTROLLER_ACTION_FORCE_COAST,
|
||||
CONTROLLER_ACTION_TOGGLE_AOL,
|
||||
CONTROLLER_ACTION_ENGAGE,
|
||||
CONTROLLER_ACTION_DISENGAGE,
|
||||
CONTROLLER_ACTION_COUNTERS,
|
||||
CONTROLLER_ACTION_OPTIONS,
|
||||
CONTROLLER_ACTION_KEYS,
|
||||
controller_speed_bounds,
|
||||
)
|
||||
CONTROLLER_ACTION_KEYS = {option["key"] for option in CONTROLLER_ACTION_OPTIONS}
|
||||
LEARN_TIMEOUT_SECONDS = 20.0
|
||||
DEVICE_SCAN_INTERVAL_SECONDS = 1.0
|
||||
STATUS_INTERVAL_SECONDS = 0.5
|
||||
@@ -283,10 +229,6 @@ def set_controller_action_slot(index: int, key: str | None, label: str, params:
|
||||
return save_controller_action_slots(slots, params, eligible_keys=eligible_keys)
|
||||
|
||||
|
||||
def controller_speed_bounds(is_metric: bool) -> tuple[int, int]:
|
||||
return (8, 145) if is_metric else (5, 90)
|
||||
|
||||
|
||||
def set_controller_cruise_speed(value: Any, params: Params, params_memory: Params) -> bool:
|
||||
if not params.get_bool("IsOnroad") or not params.get_bool("IsEngaged"):
|
||||
return False
|
||||
@@ -489,23 +431,70 @@ def execute_favorite_slot(slot: int, params: Params, params_memory: Params) -> b
|
||||
return toggle_favorite_slot(slot, params, params_memory)
|
||||
|
||||
|
||||
def cycle_driving_personality(params: Params) -> bool:
|
||||
"""Select the next native personality, without synthesizing cruise/Traffic inputs."""
|
||||
from cereal import car, log
|
||||
|
||||
if params.get_bool("SafeMode"):
|
||||
return False
|
||||
try:
|
||||
# Match native UI capability selection; never use parked CP while on-road.
|
||||
cp_bytes = params.get("CarParams" if params.get_bool("IsOnroad") else "CarParamsPersistent")
|
||||
if not cp_bytes:
|
||||
return False
|
||||
with car.CarParams.from_bytes(cp_bytes) as cp:
|
||||
available = params.get_bool("AlphaLongitudinalEnabled") if cp.alphaLongitudinalAvailable else cp.openpilotLongitudinalControl
|
||||
if not available:
|
||||
return False
|
||||
|
||||
# Typed Params decoding truncates fractional ints and defaults corrupt text.
|
||||
# Inspect the stored token so malformed selections cannot become Aggressive.
|
||||
current = Path(params.get_param_path("LongitudinalPersonality")).read_bytes()
|
||||
profiles = tuple(int(profile) for profile in (
|
||||
log.LongitudinalPersonality.aggressive,
|
||||
log.LongitudinalPersonality.standard,
|
||||
log.LongitudinalPersonality.relaxed,
|
||||
))
|
||||
tokens = tuple(str(profile).encode("ascii") for profile in profiles)
|
||||
if current not in tokens:
|
||||
return False
|
||||
next_personality = profiles[(tokens.index(current) + 1) % len(profiles)]
|
||||
except Exception:
|
||||
# Missing/malformed CP or selection: do not change effective driving state.
|
||||
return False
|
||||
|
||||
if params.get_bool("SafeMode"):
|
||||
return False
|
||||
# Synchronous persistence lets successive HID events see the preceding change.
|
||||
# selfdrived's existing Params reader publishes this selection; Traffic retains
|
||||
# its independent override. No toggle refresh or virtual speed input is needed.
|
||||
params.put_int("LongitudinalPersonality", next_personality)
|
||||
return True
|
||||
|
||||
|
||||
def execute_controller_action(index: int, params: Params, params_memory: Params) -> bool:
|
||||
from openpilot.starpilot.common.favorite_slots import execute_favorite_key
|
||||
slots = load_controller_action_slots(params)
|
||||
if not 0 <= index < len(slots):
|
||||
return False
|
||||
slot = slots[index]
|
||||
if not slot.get("enabled"):
|
||||
return False
|
||||
if slot.get("key") == CONTROLLER_ACTION_SET_SPEED:
|
||||
return set_controller_cruise_speed(slot.get("value"), params, params_memory)
|
||||
if slot.get("key") == CONTROLLER_ACTION_SELFIE:
|
||||
return execute_controller_key(slot.get("key"), params, params_memory, value=slot.get("value"))
|
||||
|
||||
|
||||
def execute_controller_key(key, params: Params, params_memory: Params, *, value=None) -> bool:
|
||||
from openpilot.starpilot.common.favorite_slots import execute_favorite_key
|
||||
if key == CONTROLLER_ACTION_CYCLE_PERSONALITY:
|
||||
return cycle_driving_personality(params)
|
||||
if key == CONTROLLER_ACTION_SET_SPEED:
|
||||
return set_controller_cruise_speed(value, params, params_memory)
|
||||
if key == CONTROLLER_ACTION_SELFIE:
|
||||
return request_comma_selfie()
|
||||
if slot.get("key") in (CONTROLLER_ACTION_ENGAGE, CONTROLLER_ACTION_DISENGAGE) and not params.get_bool("IsOnroad"):
|
||||
if key in (CONTROLLER_ACTION_ENGAGE, CONTROLLER_ACTION_DISENGAGE) and not params.get_bool("IsOnroad"):
|
||||
return False
|
||||
if slot.get("key") in CONTROLLER_ACTION_COUNTERS:
|
||||
return trigger_controller_action(slot["key"], params_memory)
|
||||
return execute_favorite_key(slot.get("key"), params, params_memory)
|
||||
if key in CONTROLLER_ACTION_COUNTERS:
|
||||
return trigger_controller_action(key, params_memory)
|
||||
return execute_favorite_key(key, params, params_memory)
|
||||
|
||||
|
||||
def execute_mapping_slot(slot: int, params: Params, params_memory: Params) -> bool:
|
||||
|
||||
Reference in New Issue
Block a user