Files
StarPilot/openpilot/starpilot/controllers/owner.py
T
firestar5683 347397ad38 Add exclusive parked SpacePilot mode and native Big UI
Use the supported Offroad lifecycle with fresh Park, receive-only CAN,
stopped-process and noOutput checks. Add offroad touch/controller play
and the native Big UI five-version-tap entry. Exiting never restores
driving mode automatically within the existing manager session.

Qualified by210 unique focused passing cases, lint/type checks, native
host rendering/input/resource checks and independent lifecycle review.
Actual vehicle lifecycle, hardware inputs and device headroom remain
unqualified; no deployment is part of this change.
2026-10-26 00:01:36 -05:00

341 lines
16 KiB
Python

"""Saved controller bindings and guarded physical button dispatch."""
import hashlib
import json
import time
from collections.abc import Callable, Mapping
from threading import RLock
from openpilot.starpilot.favorites.state import FavoriteAction, FavoriteRequest, FavoriteResult, FavoriteSnapshot
from openpilot.starpilot.saved_document import commit_exact
from openpilot.starpilot.saved_source import read_saved
CONTROLLER_BINDINGS_PARAM = "StarPilotControllerBindings"
MAX_BYTES = 16384
MAX_BINDINGS = 64
SLOT_COUNT = 13
OWN_SLOT_COUNT = 10
LEARN_NS = 20_000_000_000
TEST_NS = 20_000_000_000
PRESS_AGE_NS = 250_000_000
MAX_CODE = 65551
def _revision(raw: bytes | None) -> str:
return hashlib.sha256(b"\x00" if raw is None else b"\x01" + raw).hexdigest()
def _unique(pairs):
result = {}
for key, value in pairs:
if key in result:
raise ValueError("Duplicate controller field")
result[key] = value
return result
def _default_document():
return {"version": 1, "enabled": False, "slots": [None] * OWN_SLOT_COUNT, "bindings": []}
def _text(value, limit=256):
return type(value) is str and 0 < len(value) <= limit and all(char.isprintable() for char in value)
def _validate_document(value):
if type(value) is not dict or set(value) != {"version", "enabled", "slots", "bindings"} or value["version"] != 1 or type(value["version"]) is not int:
raise ValueError("Invalid controller document")
if type(value["enabled"]) is not bool or type(value["slots"]) is not list or len(value["slots"]) != OWN_SLOT_COUNT:
raise ValueError("Invalid controller slots")
if any(key is not None and not _text(key, 128) for key in value["slots"]):
raise ValueError("Invalid controller action")
bindings = value["bindings"]
if type(bindings) is not list or len(bindings) > MAX_BINDINGS:
raise ValueError("Invalid controller bindings")
seen = set()
for binding in bindings:
if type(binding) is not dict or set(binding) != {"deviceId", "name", "code", "slot"}:
raise ValueError("Invalid controller binding")
device_id, name, code, slot = (binding[field] for field in ("deviceId", "name", "code", "slot"))
if not _text(device_id) or not _text(name) or type(code) is not int or not 0 <= code <= MAX_CODE or type(slot) is not int or not 0 <= slot < SLOT_COUNT:
raise ValueError("Invalid controller binding")
identity = (device_id, code)
if identity in seen:
raise ValueError("Duplicate controller button")
seen.add(identity)
return value
def _read_document(params):
raw, readable = read_saved(params, CONTROLLER_BINDINGS_PARAM, MAX_BYTES)
if not readable:
return _default_document(), raw, False, False
if raw is None:
return _default_document(), raw, True, True
try:
return _validate_document(json.loads(raw, object_pairs_hook=_unique)), raw, True, True
except (ValueError, TypeError, UnicodeError, RecursionError):
return _default_document(), raw, True, False
def _field(value, key):
return value.get(key) if isinstance(value, Mapping) else getattr(value, key, None)
class ControllerOwner:
def __init__(self, params, *, parked: Callable[[], bool], actions: Callable[[], Mapping[str, FavoriteAction]],
favorites: Callable[[], FavoriteSnapshot], invoke_favorite: Callable[[FavoriteRequest], FavoriteResult],
clock: Callable[[], int] = time.monotonic_ns):
self.params = params
self.parked = parked
self.actions = actions
self.favorites = favorites
self.invoke_favorite = invoke_favorite
self.clock = clock
self._lock = RLock()
self._devices = {}
self._learning: dict | None = None
self._testing_until = 0
self._last_press = None
self._sequences = {}
self._blocked_before = 0
def set_devices(self, devices):
trusted = {}
for device in devices:
device_id = _field(device, "id")
name = _field(device, "name")
bus = _field(device, "bus")
if len(trusted) < 16 and _text(device_id) and _text(name) and type(bus) is int and bus in (3, 5):
trusted[device_id] = {"id": device_id, "name": name, "bus": bus}
with self._lock:
self._devices = trusted
self._sequences = {identity: timestamp for identity, timestamp in self._sequences.items() if identity[0] in trusted}
def tick(self, now_ns=None):
now = self.clock() if now_ns is None else now_ns
with self._lock:
session_before = self._learning is not None or bool(self._testing_until)
if (self._learning is not None or self._testing_until) and not self.parked():
self._learning = None
self._testing_until = 0
if self._learning is not None and now >= self._learning["until"]:
self._learning = None
if self._testing_until and now >= self._testing_until:
self._testing_until = 0
if session_before and self._learning is None and not self._testing_until:
self._blocked_before = max(self._blocked_before, now)
def snapshot(self, devices=None):
if devices is not None:
self.set_devices(devices)
now = self.clock()
self.tick(now)
document, raw, readable, valid = _read_document(self.params)
catalog = self.actions()
favorite = self.favorites()
slots = []
for index in range(SLOT_COUNT):
if index < 3:
source = favorite.slots[index]
slots.append({"index": index, "label": source.label or f"Favorite {index + 1}", "key": source.key, "available": bool(source.available)})
else:
key = document["slots"][index - 3]
action = catalog.get(key) if key else None
slots.append({"index": index, "label": action.label if action else key or f"Button {index - 2}",
"key": key, "available": bool(action and action.available and action.invoke is not None)})
options = [{"key": action.key, "label": action.label, "section": action.section} for action in
sorted(catalog.values(), key=lambda item: (item.section.casefold(), item.label.casefold(), item.key))]
with self._lock:
learning = None if self._learning is None else {"slot": self._learning["slot"],
"expiresIn": max(0, (self._learning["until"] - now + 999_999_999) // 1_000_000_000)}
devices_out = sorted(self._devices.values(), key=lambda device: (device["name"].casefold(), device["id"]))
testing = bool(self._testing_until)
last_press = dict(self._last_press) if self._last_press else None
return {"version": 1, "available": True, "editable": bool(readable and self.parked()),
"enabled": document["enabled"] if valid else False, "revision": _revision(raw), "valid": valid,
"devices": devices_out, "slots": slots, "options": options, "bindings": document["bindings"] if valid else [],
"learning": learning, "testing": testing, "lastPress": last_press}
def _save(self, document, raw):
encoded = json.dumps(_validate_document(document), separators=(",", ":"), allow_nan=False).encode()
if len(encoded) > MAX_BYTES:
raise ValueError("Controller bindings are too large")
result = commit_exact(self.params, key=CONTROLLER_BINDINGS_PARAM, max_bytes=MAX_BYTES, raw=encoded,
expected=raw, authorized=self.parked, temp_prefix=".controller-bindings-")
if not result.committed or not result.verified:
raise RuntimeError("Controller bindings changed or could not be saved")
def action(self, payload):
if type(payload) is not dict or type(payload.get("operation")) is not str:
raise ValueError("Invalid controller request")
operation = payload["operation"]
fields = {"save": {"operation", "revision", "enabled", "slots"},
"learn": {"operation", "revision", "slot"}, "cancel": {"operation"},
"test": {"operation", "enabled"}, "remove": {"operation", "revision", "deviceId", "code"}}
if operation not in fields or set(payload) != fields[operation]:
raise ValueError("Invalid controller request")
if not self.parked():
raise PermissionError("Turn off the vehicle to edit Controller Buttons")
with self._lock:
self.tick()
if operation == "cancel":
self._blocked_before = max(self._blocked_before, self.clock())
self._learning = None
self._testing_until = 0
elif operation == "test":
if type(payload["enabled"]) is not bool:
raise ValueError("Invalid test state")
self._blocked_before = max(self._blocked_before, self.clock())
self._learning = None
self._testing_until = self.clock() + TEST_NS if payload["enabled"] else 0
else:
document, raw, readable, valid = _read_document(self.params)
if not readable:
raise OSError("Controller bindings unavailable")
if type(payload["revision"]) is not str or payload["revision"] != _revision(raw):
raise RuntimeError("Controller bindings changed. Reload before editing")
if operation == "learn":
slot = payload["slot"]
if type(slot) is not int or not 0 <= slot < SLOT_COUNT:
raise ValueError("Invalid controller slot")
if not valid:
raise ValueError("Repair controller bindings before learning")
self._testing_until = 0
started = self.clock()
self._learning = {"slot": slot, "revision": _revision(raw), "started": started, "until": started + LEARN_NS}
elif operation == "save":
enabled, slots = payload["enabled"], payload["slots"]
if type(enabled) is not bool or type(slots) is not list or len(slots) != OWN_SLOT_COUNT:
raise ValueError("Invalid controller slots")
if any(key is not None and not _text(key, 128) for key in slots):
raise ValueError("Invalid controller action")
catalog = self.actions()
previous = document["slots"] if valid else [None] * OWN_SLOT_COUNT
for index, key in enumerate(slots):
if key is not None and key not in catalog and key != previous[index]:
raise ValueError("Unsupported controller action")
self._save({"version": 1, "enabled": enabled, "slots": slots,
"bindings": document["bindings"] if valid else []}, raw)
self._blocked_before = max(self._blocked_before, self.clock())
self._learning = None
self._testing_until = 0
else:
device_id, code = payload["deviceId"], payload["code"]
if not _text(device_id) or type(code) is not int or not 0 <= code <= MAX_CODE:
raise ValueError("Invalid controller button")
if not valid:
raise ValueError("Repair controller bindings before removing")
bindings = [binding for binding in document["bindings"] if (binding["deviceId"], binding["code"]) != (device_id, code)]
if len(bindings) == len(document["bindings"]):
raise ValueError("Controller button is not assigned")
self._save({**document, "bindings": bindings}, raw)
self._blocked_before = max(self._blocked_before, self.clock())
self._learning = None
return self.snapshot()
def _press_status(self, device_id, code, slot, executed, message):
with self._lock:
self._last_press = {"deviceId": device_id, "code": code, "slot": slot, "executed": executed, "message": message}
def feed(self, press, *, capture: Callable[[str | None, int], None] | None = None):
device_id, code = _field(press, "device_id"), _field(press, "code")
timestamp = _field(press, "timestamp_ns")
now = self.clock()
if not _text(device_id) or type(code) is not int or not 0 <= code <= MAX_CODE or type(timestamp) is not int or not 0 <= now - timestamp <= PRESS_AGE_NS:
return
with self._lock:
self.tick(now)
identity = (device_id, code)
if device_id not in self._devices or timestamp <= self._blocked_before or timestamp <= self._sequences.get(identity, -1):
return
self._sequences[identity] = timestamp
if len(self._sequences) > 256:
self._sequences = {key: seen for key, seen in self._sequences.items() if seen >= now - PRESS_AGE_NS}
learning = self._learning
testing = bool(self._testing_until)
if capture is not None:
# Native modal input owns this whole batch. Resolve the saved logical key
# without invoking its vehicle action or modifying a learn/test session.
document, _, readable, valid = _read_document(self.params)
key = None
if readable and valid and document["enabled"]:
binding = next((item for item in document["bindings"] if (item["deviceId"], item["code"]) == identity), None)
if binding is not None:
slot = binding["slot"]
key = self.favorites().slots[slot].key if slot < 3 else document["slots"][slot - 3]
capture(key, timestamp)
return
if learning is not None:
if timestamp < learning["started"]:
return
if not self.parked():
with self._lock:
self._learning = None
return
document, raw, readable, valid = _read_document(self.params)
if not readable or not valid or _revision(raw) != learning["revision"]:
with self._lock:
self._learning = None
return
with self._lock:
if self._learning is not learning or device_id not in self._devices or not self.parked():
return
binding = {"deviceId": device_id, "name": self._devices[device_id]["name"], "code": code, "slot": learning["slot"]}
bindings = [item for item in document["bindings"] if (item["deviceId"], item["code"]) != (device_id, code)]
bindings.append(binding)
try:
self._save({**document, "bindings": bindings}, raw)
except (OSError, RuntimeError, ValueError):
self._press_status(device_id, code, learning["slot"], False, "Button could not be learned")
return
if self._learning is learning:
self._learning = None
self._press_status(device_id, code, learning["slot"], False, "Button learned")
return
document, raw, readable, valid = _read_document(self.params)
binding = next((item for item in document["bindings"] if (item["deviceId"], item["code"]) == (device_id, code)), None) if valid else None
slot = binding["slot"] if binding else None
if testing:
self._press_status(device_id, code, slot, False, "Test press detected")
return
if not readable or not valid or not document["enabled"] or binding is None or slot is None:
return
if slot < 3:
favorite = self.favorites().slots[slot]
request = favorite.request
if not favorite.available or request is None:
self._press_status(device_id, code, slot, False, "Quick Select unavailable")
return
with self._lock:
if self._learning is not None or self._testing_until:
return
current, fresh = read_saved(self.params, CONTROLLER_BINDINGS_PARAM, MAX_BYTES)
renewed = self.favorites().slots[slot]
if not fresh or current != raw or not renewed.available or renewed.request != request:
self._press_status(device_id, code, slot, False, "Control changed")
return
result = self.invoke_favorite(request)
self._press_status(device_id, code, slot, result.success, result.message)
return
key = document["slots"][slot - 3]
first = self.actions().get(key) if key else None
if first is None or not first.available or first.invoke is None:
self._press_status(device_id, code, slot, False, "Control unavailable")
return
with self._lock:
if self._learning is not None or self._testing_until:
return
current, fresh = read_saved(self.params, CONTROLLER_BINDINGS_PARAM, MAX_BYTES)
second = self.actions().get(key)
if (not fresh or current != raw or second is None or not second.available or second.invoke is None or
second.key != first.key or second.token != first.token):
self._press_status(device_id, code, slot, False, "Control changed")
return
try:
success = bool(second.invoke())
except (OSError, RuntimeError, ValueError, TypeError):
success = False
self._press_status(device_id, code, slot, success, second.label if success else "Control unavailable")