Files
StarPilot/openpilot/starpilot/ui/controller_feature.py
T
2026-10-04 21:28:43 -05:00

223 lines
13 KiB
Python

"""Shared saved controller preference for Galaxy and native driving settings."""
from __future__ import annotations
from dataclasses import replace
import json
from openpilot.starpilot.lateral.controller_selection import (
DOCUMENT_KEY, LEARNING_OFF_KEY, MAX_DOCUMENT_BYTES, ControllerMode, policy_for, replace_mode, selection_from_bytes,
)
from openpilot.starpilot.saved_document import commit_exact
from openpilot.starpilot.saved_source import read_saved
from openpilot.starpilot.lateral.lane_change_preferences import (
KEY as LANE_CHANGE_KEY, MAX_BYTES as LANE_CHANGE_LIMIT, LaneChangePolicy, decode as decode_lane_change, to_value,
)
from openpilot.starpilot.ui.feature_settings_state import FeatureRow, FeatureSettingsRequest
CHOICES = ('Stock Controller', 'StarPilot Controller')
MODES = dict(zip(CHOICES, (ControllerMode.STANDARD, ControllerMode.STARPILOT), strict=True))
TUNING_GUIDANCE = ("StarPilot's torque controller can provide higher low-speed steering accuracy, but may require additional tuning. " +
"Join the StarPilot Discord and open a tuning request: https://firestar.link/discord")
SETUP_ACTION = 'torque_prepare_firestar'
SETUP_QUESTION = 'Change tuning preparation? Turning it off restores your previous steering preferences.'
SETUP_DEFAULTS = {'TurnAssist': b'0', LEARNING_OFF_KEY: b'1', 'LaneCentering': b'0',
'LaneCenterOffset': b'0.0', 'LaneCenteringE2EAuthority': b'1.0', 'LaneCenteringPauseOnSignal': b'1'}
SETUP_LIMITS = {**dict.fromkeys(SETUP_DEFAULTS, 128), LANE_CHANGE_KEY: LANE_CHANGE_LIMIT, DOCUMENT_KEY: MAX_DOCUMENT_BYTES}
class ControllerFeature:
def __init__(self, owner):
self.params = owner.params
self.authority = owner.authority
self.vehicle_fingerprint = owner.vehicle_fingerprint
self.vehicle_params = owner.vehicle_params
def capability(self, CP=None) -> tuple | None:
CP = self.vehicle_params() if CP is None else CP
if CP is None or CP.notCar:
return None
try:
policy = policy_for(CP)
if policy is None:
return None
tune = CP.lateralTuning.torque
firmware = tuple((str(fw.ecu), bytes(fw.fwVersion)) for fw in CP.carFw)
return (str(CP.brand), str(CP.carFingerprint), policy, str(CP.steerControlType),
str(CP.lateralTuning.which()), bool(CP.passive), bool(CP.dashcamOnly), bool(CP.notCar),
int(CP.flags), int(CP.alternativeExperience), float(tune.latAccelFactor),
float(tune.latAccelOffset), float(tune.friction), float(tune.steeringAngleDeadzoneDeg),
float(CP.steerActuatorDelay), float(CP.steerLimitTimer), str(CP.carVin), firmware)
except (AttributeError, TypeError, ValueError, OverflowError):
return None
def _setup_sources(self) -> dict[str, bytes | None] | None:
sources = {}
for key, limit in SETUP_LIMITS.items():
raw, readable = read_saved(self.params, key, limit)
if not readable:
return None
sources[key] = raw
return sources
@staticmethod
def _setup_values(CP, sources: dict[str, bytes | None]) -> dict[str, bytes]:
lane_raw = sources[LANE_CHANGE_KEY]
lane = LaneChangePolicy() if lane_raw is None else decode_lane_change(lane_raw)
if lane is None:
raise ValueError('Invalid lane-change preferences')
# Gap reduction is longitudinal; retain it while resetting lateral behavior.
lane = replace(LaneChangePolicy(), close_gap=lane.close_gap, close_gap_seconds=lane.close_gap_seconds)
return {**SETUP_DEFAULTS, LANE_CHANGE_KEY: json.dumps(to_value(lane), sort_keys=True, separators=(',', ':')).encode(),
DOCUMENT_KEY: replace_mode(sources[DOCUMENT_KEY], CP, ControllerMode.STARPILOT)}
def setup_row(self) -> FeatureRow | None:
CP = self.vehicle_params()
capability = self.capability(CP)
if capability is None:
return None
sources = self._setup_sources()
valid = sources is not None
if sources is not None:
try:
self._setup_values(CP, sources)
except (ValueError, TypeError, UnicodeError, OverflowError):
valid = False
from openpilot.starpilot.ui.tuning_preparation import KEY, LIMIT, decode
journal_raw, readable = read_saved(self.params, KEY, LIMIT)
journal = decode(journal_raw, SETUP_LIMITS)
active = isinstance(journal, dict) and journal['vehicle'] == str(CP.carFingerprint)
complete = active and sources == journal['prepared']
conflict = isinstance(journal, dict) and (journal['vehicle'] != str(CP.carFingerprint) or
any(raw not in (journal['prior'][key], journal['prepared'][key]) for key, raw in (sources or {}).items()))
return FeatureRow(SETUP_ACTION, 'Prep My Vehicle for Tuning', 'On' if active else 'Off',
journal_raw, choices=('Off', 'On'), default_value='Off',
available=valid and readable and journal is not False and not conflict and
self.authority('parked_preferences') and self.authority('preferences'),
reason=('Enable before recording a route for a tuning request on the StarPilot Discord. ' +
'Turn off to restore your previous tune.' if not conflict and (not active or complete) else
'Preparation was interrupted. Turn off to restore your previous tune.' if not conflict else
'Settings changed during preparation; your saved tune has been kept.'),
capability=capability, dependencies=tuple((key, raw) for key, raw in (sources or {}).items()))
def apply_setup(self, request: FeatureSettingsRequest) -> bool:
from openpilot.starpilot.ui.tuning_preparation import transition
row = self.setup_row()
if (row is None or not row.available or request.key != SETUP_ACTION or request.value not in row.choices or
request.expected != row.source or request.capability != row.capability or request.dependencies != row.dependencies or
request.vehicle_fingerprint != self.vehicle_fingerprint()):
return False
CP = self.vehicle_params()
sources = dict(row.dependencies)
try:
desired = self._setup_values(CP, sources)
except (ValueError, TypeError, UnicodeError, OverflowError):
return False
capability = row.capability
def authorized():
return (self.authority('parked_preferences') and self.authority('preferences') and
self.vehicle_fingerprint() == request.vehicle_fingerprint and self.capability() == capability)
return transition(self.params, str(CP.carFingerprint), sources, desired, request.expected,
request.value == 'On', authorized)
def row(self) -> FeatureRow | None:
CP = self.vehicle_params()
capability = self.capability(CP)
if capability is None:
return None
raw, readable = read_saved(self.params, DOCUMENT_KEY, MAX_DOCUMENT_BYTES)
selected = selection_from_bytes(CP, raw) if readable else None
valid = readable and selected is not None and selected.source != 'invalid'
value = CHOICES[0] if valid and selected.mode == ControllerMode.STANDARD else \
CHOICES[1] if valid else 'Invalid saved controller choice'
learning, learning_readable = read_saved(self.params, LEARNING_OFF_KEY, 1)
learning_valid = learning_readable and learning in (None, b'0', b'1')
allowed = valid and learning_valid and self.authority('preferences')
reason = ('Choose the steering controller for your next drive. ' + TUNING_GUIDANCE if valid and learning_valid else
'Saved steering preferences are unreadable or invalid; no change was made.')
return FeatureRow(DOCUMENT_KEY, 'Steering Controller', value, raw, CHOICES if valid else (),
available=allowed, reason=reason, capability=capability, dependencies=((LEARNING_OFF_KEY, learning),),
default_value=CHOICES[0] if selection_from_bytes(CP, None).mode == ControllerMode.STANDARD else CHOICES[1])
def learning_row(self) -> FeatureRow | None:
CP = self.vehicle_params()
capability = self.capability(CP)
if capability is None:
return None
controller, controller_readable = read_saved(self.params, DOCUMENT_KEY, MAX_DOCUMENT_BYTES)
selection = selection_from_bytes(CP, controller)
raw, readable = read_saved(self.params, LEARNING_OFF_KEY, 1)
valid = readable and raw in (None, b'0', b'1') and controller_readable and selection.source != 'invalid'
starpilot = selection.mode == ControllerMode.STARPILOT
value = ('Off' if starpilot or raw == b'1' else 'On') if valid else 'Invalid saved preference'
reason = ('StarPilot uses the vehicle tune. ' + TUNING_GUIDANCE
if starpilot else 'Saved for the next drive. Turn on only if you want Stock Controller to learn torque values.')
return FeatureRow(LEARNING_OFF_KEY, 'Automatic Steering Learning', value, raw, ('Off', 'On') if valid else (),
available=valid and not starpilot and self.authority('preferences'),
reason=reason if valid else 'Saved controller or learning preference is unreadable or invalid.',
capability=capability, dependencies=((DOCUMENT_KEY, controller),), default_value="On")
def apply_learning(self, request: FeatureSettingsRequest) -> bool:
row = self.learning_row()
if (row is None or not row.available or request.key != LEARNING_OFF_KEY or request.value not in row.choices or
request.capability != row.capability or request.expected != row.source or request.dependencies != row.dependencies or
request.related_source is not None or request.display_unit or request.direction or
not request.vehicle_fingerprint or request.vehicle_fingerprint != self.vehicle_fingerprint()):
return False
def authorized() -> bool:
current = self.learning_row()
return (self.vehicle_fingerprint() == request.vehicle_fingerprint and current is not None and current.available and
current.capability == request.capability and current.dependencies == request.dependencies)
result = commit_exact(self.params, key=LEARNING_OFF_KEY, max_bytes=1,
raw=b'0' if request.value == 'On' else b'1', expected=request.expected,
authorized=authorized, temp_prefix='.torque-learning-')
return result.verified
def apply(self, request: FeatureSettingsRequest) -> bool:
if (request.key != DOCUMENT_KEY or request.value not in MODES or request.capability is None or
request.related_source is not None or request.display_unit or request.direction or
not request.vehicle_fingerprint or request.vehicle_fingerprint != self.vehicle_fingerprint()):
return False
CP = self.vehicle_params()
if CP is None or request.capability != self.capability(CP):
return False
raw, readable = read_saved(self.params, DOCUMENT_KEY, MAX_DOCUMENT_BYTES)
learning, learning_readable = read_saved(self.params, LEARNING_OFF_KEY, 1)
if (not readable or raw != request.expected or not self.authority('preferences') or
not learning_readable or learning not in (None, b'0', b'1') or request.dependencies != ((LEARNING_OFF_KEY, learning),)):
return False
try:
selected = selection_from_bytes(CP, raw)
if selected.source == 'invalid':
return False
encoded = replace_mode(raw, CP, MODES[request.value])
except (UnicodeError, ValueError, TypeError, OverflowError):
return False
def authorized() -> bool:
current, readable = read_saved(self.params, LEARNING_OFF_KEY, 1)
return (self.authority('preferences') and self.vehicle_fingerprint() == request.vehicle_fingerprint and
self.capability() == request.capability and readable and current == learning)
if learning != b'1' and (MODES[request.value] == ControllerMode.STARPILOT or selected.mode == ControllerMode.STARPILOT):
def authorize_learning_off() -> bool:
current, readable = read_saved(self.params, DOCUMENT_KEY, MAX_DOCUMENT_BYTES)
return authorized() and readable and current == request.expected
# Persist the conservative side first, including a return from a default
# StarPilot choice. An interrupted save can leave learning off, but never
# enables it or saves StarPilot with an enabled learning preference.
result = commit_exact(self.params, key=LEARNING_OFF_KEY, max_bytes=1, raw=b'1', expected=learning,
authorized=authorize_learning_off, temp_prefix='.torque-learning-')
if not result.verified:
return False
learning = b'1'
result = commit_exact(self.params, key=DOCUMENT_KEY, max_bytes=MAX_DOCUMENT_BYTES,
raw=encoded, expected=request.expected, authorized=authorized,
temp_prefix='.lateral-controller-')
return result.verified