Files
2026-10-22 17:50:08 -05:00

269 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Shared saved controller preference for Galaxy and native driving settings."""
from __future__ import annotations
from dataclasses import replace
import math
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.lateral.learning_preferences import (
KEY as LEARNING_PREFERENCES_KEY, MAX_BYTES as LEARNING_PREFERENCES_LIMIT, read_preference, replace_preference,
)
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), LEARNING_PREFERENCES_KEY: LEARNING_PREFERENCES_LIMIT,
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 learning_capability(self, CP=None) -> tuple | None:
CP = self.vehicle_params() if CP is None else CP
try:
from opendbc.car.values import PLATFORMS
platform = PLATFORMS.get(str(CP.carFingerprint))
if (platform is None or platform.__class__.__module__.split('.')[-2] != str(CP.brand) or
CP.passive or CP.dashcamOnly or CP.notCar or str(CP.steerControlType) != 'torque'):
return None
if CP.lateralTuning.which() == 'torque':
tune = CP.lateralTuning.torque
basis = (float(tune.latAccelFactor), float(tune.latAccelOffset), float(tune.friction),
float(tune.steeringAngleDeadzoneDeg), float(CP.steerActuatorDelay), float(CP.steerLimitTimer))
if not all(math.isfinite(value) for value in basis) or basis[0] <= 0 or any(value < 0 for value in basis[2:]):
return None
elif CP.lateralTuning.which() == 'pid':
tune = CP.lateralTuning.pid
curves = tuple(tuple(float(value) for value in getattr(tune, key)) for key in ('kpBP', 'kpV', 'kiBP', 'kiV'))
scalars = (float(tune.kf), float(CP.steerActuatorDelay), float(CP.steerLimitTimer))
if (any(not 1 <= len(row) <= 16 for row in curves) or sum(map(len, curves)) + 4 > 32 or
len(curves[0]) != len(curves[1]) or len(curves[2]) != len(curves[3]) or
any(b <= a for row in (curves[0], curves[2]) for a, b in zip(row, row[1:], strict=False)) or
not all(math.isfinite(value) and value >= 0 for value in (*scalars, *(v for row in curves for v in row)))):
return None
# Actual PID configuration only. This authorizes the shared learning
# preference; it does not expose torque-only numeric/manual editors.
basis = (*scalars, *curves)
else:
return None
firmware = tuple((str(fw.ecu), bytes(fw.fwVersion)) for fw in CP.carFw)
return (str(CP.brand), str(CP.carFingerprint), str(CP.steerControlType), str(CP.lateralTuning.which()),
int(CP.flags), int(CP.alternativeExperience), *basis, str(CP.carVin), firmware)
except (AttributeError, TypeError, ValueError, OverflowError):
return None
def _setup_sources(self) -> dict[str, bytes | None] | None:
from openpilot.starpilot.ui.tuning_preparation import KEY as JOURNAL_KEY, LIMIT, decode
limits = SETUP_LIMITS
journal_raw, readable = read_saved(self.params, JOURNAL_KEY, LIMIT)
legacy_limits = {key: limit for key, limit in limits.items() if key != LEARNING_PREFERENCES_KEY}
# Existing preparations restore the exact keys they originally journaled.
if readable and journal_raw is not None and isinstance(decode(journal_raw, legacy_limits), dict):
limits = legacy_limits
sources = {}
for key, limit in 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)
learning = ({LEARNING_PREFERENCES_KEY: replace_preference(sources[LEARNING_PREFERENCES_KEY], CP, False)}
if LEARNING_PREFERENCES_KEY in sources else {})
return {**SETUP_DEFAULTS, **learning, 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, sources if sources is not None else 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=(active or valid and not conflict) and sources is not None and readable and journal is not False 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
'Turn off to restore previous settings while keeping changes made after preparation.'),
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) if request.value == 'On' else {}
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)
preference = read_preference(self.params, CP, legacy_snapshot=(learning, learning_readable))
learning_valid = learning_readable and preference.valid
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), (LEARNING_PREFERENCES_KEY, preference.raw)),
default_value=CHOICES[0] if selection_from_bytes(CP, None).mode == ControllerMode.STANDARD else CHOICES[1])
def learning_row(self, CP=None) -> FeatureRow | None:
CP = self.vehicle_params() if CP is None else CP
capability = self.learning_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)
legacy, legacy_readable = read_saved(self.params, LEARNING_OFF_KEY, 1)
preference = read_preference(self.params, CP, legacy_snapshot=(legacy, legacy_readable))
valid = preference.valid and legacy_readable and controller_readable and selection.source != 'invalid'
value = ('On' if preference.enabled else 'Off') if valid else 'Invalid saved preference'
reason = 'StarPilot’s FLM tuning will attempt to automatically correct torque profiles as you drive.'
return FeatureRow(LEARNING_OFF_KEY, 'Use StarPilot Auto Learn', value, preference.raw, ('Off', 'On') if valid else (),
available=valid and self.authority('preferences'),
reason=reason if valid else 'Saved controller or learning preference is unreadable or invalid.',
capability=capability, dependencies=((DOCUMENT_KEY, controller), (LEARNING_OFF_KEY, legacy)),
default_value="On")
def apply_learning(self, request: FeatureSettingsRequest) -> bool:
CP = self.vehicle_params()
if CP is None:
return False
row = self.learning_row(CP)
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)
try:
encoded = replace_preference(request.expected, CP, request.value == 'On')
except (AttributeError, TypeError, ValueError, UnicodeError):
return False
result = commit_exact(self.params, key=LEARNING_PREFERENCES_KEY, max_bytes=LEARNING_PREFERENCES_LIMIT,
raw=encoded, 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)
preference = read_preference(self.params, CP, legacy_snapshot=(learning, learning_readable))
if (not readable or raw != request.expected or not self.authority('preferences') or
not learning_readable or not preference.valid or
request.dependencies != ((LEARNING_OFF_KEY, learning), (LEARNING_PREFERENCES_KEY, preference.raw))):
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 and
read_preference(self.params, CP, legacy_snapshot=(current, readable)).raw == preference.raw)
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