Unify Bluetooth and favourites with a searchable action picker

This commit is contained in:
AngusBell97
2026-09-10 21:12:20 +01:00
parent b135a43d97
commit e82b1f0f7a
20 changed files with 1150 additions and 155 deletions
@@ -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]
+81
View File
@@ -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)
+25 -6
View File
@@ -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
+10 -6
View File
@@ -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: