mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-10-28 09:03:46 +08:00
269 lines
16 KiB
Python
269 lines
16 KiB
Python
"""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
|