Files
StarPilot/openpilot/starpilot/ui/conditional_feature.py
T
2026-10-19 10:14:04 -05:00

330 lines
20 KiB
Python

"""Shared native and Galaxy presentation of saved conditional modes."""
from __future__ import annotations
from dataclasses import fields, replace
import hashlib
from openpilot.starpilot.conditional_mode.button_actions import (
BUTTON_PREFIX, MEDIA_KEYS, DISTANCE_KEYS, capture_sources, commit_assignment, display_action, media_capability,
distance_capability, assignment_capability, runtime_map_ready,
)
from openpilot.starpilot.conditional_mode.actions import (
BOOLEAN_FIELDS, DOCUMENT_KEY, MANUAL_RESET, MODE, NUMBER_FIELDS, PREFIX, RESET, commit, commit_manual, commit_mode,
display_number, edit, field_limit, stock_document,
)
from openpilot.starpilot.conditional_mode.preferences import (
MAX_DOCUMENT_BYTES, PreferenceError, SavedPreferences, decode_preferences,
)
from openpilot.starpilot.saved_source import read_saved
from openpilot.starpilot.conditional_mode.manual_saved import KEY as MANUAL_KEY, read_codes
from openpilot.starpilot.conditional_mode.preferences import encode_preferences
from openpilot.starpilot.conditional_mode.runtime_settings import experimental_available, read_mode_sources
from openpilot.starpilot.conditional_mode.policy import ModeChoice
from openpilot.starpilot.ui.feature_settings_state import FeatureRow, FeatureSettingsRequest
LABELS = {
"speed_mps": "Speed threshold", "speed_with_lead_mps": "Speed with lead", "signal_speed_mps": "Signal speed",
"open_road": "Open road", "curves": "Curves", "curves_with_lead": "Curves with lead",
"lead": "Lead vehicle", "slower_lead": "Slower lead", "stopped_lead": "Stopped lead",
"stop_lights": "Stop lights", "model_stop_s": "Model stop time",
"signal_lane_detection": "Signal lane detection", "signal_lane_width_m": "Signal lane width",
"set_speed_margin_mps": "Set speed margin", "launch_assist": "Launch assist",
}
MODES = ("Chill", "Experimental", "Conditional Experimental", "Conditional Chill")
SIGNAL_LANE_FIELDS = frozenset(("signal_lane_detection", "signal_lane_width_m"))
SIGNAL_LANE_HELP = {
"signal_lane_detection": "Distinguish a turn from a lane change using the space beside your car. With this on, a turn signal requests " +
"Experimental only when there is no adjacent lane.",
"signal_lane_width_m": "Minimum width of the space beside your car that counts as a lane, rather than a turn.",
}
MEDIA_LABELS = ("MODE press", "MODE long press", "MODE very long press",
"Star button", "Star button long press", "Star button very long press")
def _mode_capability(cp) -> tuple | None:
if cp is None:
return None
try:
reader = cp.as_reader() if hasattr(cp, "as_reader") else cp
encoded = reader.as_builder().to_bytes()
return (str(cp.carFingerprint), hashlib.sha256(encoded).hexdigest())
except (AttributeError, TypeError, ValueError, OverflowError):
return None
def _display(number: float) -> str:
return f"{number:.2f}".rstrip("0").rstrip(".") if number else "0"
def _unit(field: str, metric: bool) -> str:
if field == "model_stop_s":
return "s"
if field == "signal_lane_width_m":
return "m" if metric else "ft"
return "km/h" if metric else "mph"
def confirmation_question(request: FeatureSettingsRequest, label: str = "", unit: str = "") -> str:
if request.key == RESET:
return "Replace the invalid conditional mode settings with Chill defaults?"
if request.key == MANUAL_RESET:
return "Reset both remembered manual choices to Automatic?"
value = f"{request.value} {unit}".rstrip()
target = f"{label.lower()} to" if label else "to"
tail = " This resets the mode's remembered choice to Automatic." if request.key.endswith(":persist_manual") else ""
return f"Set {target} {value}?{tail}"
class ConditionalFeature:
def __init__(self, owner):
self.owner = owner
def _source(self):
document, readable = read_saved(self.owner.params, DOCUMENT_KEY, MAX_DOCUMENT_BYTES)
units, unit_readable = read_saved(self.owner.params, "IsMetric", 8)
metric = units is None or units == b"0" or units == b"1"
return document, units, readable and unit_readable, metric, units == b"1"
def _media_rows(self, document: bytes | None, safe: bytes | None, safe_readable: bool,
*, config_valid: bool) -> list[FeatureRow]:
cp = self.owner.vehicle_params()
gm = distance_capability(cp)
switchback_only = not gm and cp is not None and not cp.openpilotLongitudinalControl
capability = gm or media_capability(cp, switchback_only=switchback_only)
keys = DISTANCE_KEYS if gm else MEDIA_KEYS
labels = ("Distance press", "Distance long press", "Distance very long press") if gm else MEDIA_LABELS
if capability is None:
return []
sources = capture_sources(self.owner.params, document, safe)
enabled = (self.owner.authority("switchback_wheel" if switchback_only else "conditional_wheel") and config_valid and
safe_readable and safe in (None, b"0", b"1") and sources.readable)
ready = runtime_map_ready(sources, cp=cp) and all(display_action(sources.raw(key))[0] != "Unsupported saved action"
for key in keys)
rows = [] if ready else [FeatureRow("", "Button assignments",
"Review saved actions",
reason="Some saved actions are not supported. Choose Off for those buttons before assigning a new action.")]
for key, label in zip(keys, labels, strict=True):
raw = sources.raw(key)
value, choices, repair = display_action(raw)
if gm:
if raw not in (None, b"0", b"6"):
value, choices, repair = "Unsupported saved action", (), "Off"
else:
choices = ("Off", "Toggle traffic mode")
if switchback_only:
choices = ("Off", "Switchback Mode") if sources.readable else ()
rows.append(FeatureRow(BUTTON_PREFIX + key, label, value if sources.readable else "Unavailable saved action",
raw, choices if sources.readable else (), available=enabled,
reason=("Toggle Traffic Mode from this button. May take effect this drive." if gm else
"Choose this button action. May take effect this drive.") if enabled else
"Reload to read saved button actions",
vehicle_fingerprint=self.owner.vehicle_fingerprint(), capability=capability,
dependencies=sources.dependencies, repair_value=repair if sources.readable else "", default_value="Off"))
return rows
def wheel_rows(self) -> list[FeatureRow]:
document, _, readable, _, _ = self._source()
safe, safe_readable = read_saved(self.owner.params, "SafeMode", 8)
try:
if document is not None:
decode_preferences(document)
valid = readable
except PreferenceError:
valid = False
return self._media_rows(document, safe, safe_readable, config_valid=valid)
def rows(self, page: str, parked: bool) -> list[FeatureRow]:
document, units, readable, valid_units, metric = self._source()
allowed = self.owner.authority("preferences")
repair_allowed = parked and self.owner.authority("parked_preferences")
unavailable = "Saved settings are unavailable"
dependencies = (("IsMetric", units),)
safe, safe_readable = read_saved(self.owner.params, "SafeMode", 8) if page == "conditional" else (None, True)
if not readable:
return [FeatureRow("", "Saved choices", "Unavailable", reason="Saved source cannot be read; no changes allowed")]
try:
preferences = SavedPreferences() if document is None else decode_preferences(document)
except PreferenceError:
return [FeatureRow("", "Saved choices", "Invalid", reason="Existing bytes remain unchanged until explicit reset"),
FeatureRow(RESET, "Restore Chill defaults", "", document,
available=repair_allowed, reason="Replaces the invalid document after review",
dependencies=dependencies)]
rows = []
manual = read_codes(self.owner.params)
if page != "conditional":
safe, safe_readable = read_saved(self.owner.params, "SafeMode", 8)
manual_dependencies = dependencies + ((MANUAL_KEY, manual.raw), ("SafeMode", safe))
manual_available = repair_allowed and safe_readable and safe in (None, b"0", b"1")
if page == "conditional":
(mode_document, mode_readable), (baseline, baseline_readable), (mode_safe, mode_safe_readable) = read_mode_sources(self.owner.params)
mode_valid = (mode_readable and mode_document == document and baseline_readable and baseline in (None, b"0", b"1") and
mode_safe_readable and mode_safe in (None, b"0", b"1"))
choice = {"stock": "Experimental" if baseline == b"1" else "Chill",
"conditional_experimental": "Conditional Experimental", "conditional_chill": "Conditional Chill"}[preferences.mode.value]
cp = self.owner.vehicle_params()
choices = MODES if choice == "Experimental" or experimental_available(cp) and mode_safe in (None, b"0") else tuple(
mode for mode in MODES if mode != "Experimental")
rows.append(FeatureRow(MODE, "Saved driving mode", choice if mode_valid else "Unavailable", document,
choices if mode_valid else (), default_value=MODES[0], available=allowed and mode_valid,
reason="Choose Chill, Experimental, or automatic switching from your saved conditions." if allowed and mode_valid else unavailable,
vehicle_fingerprint=self.owner.vehicle_fingerprint(), capability=_mode_capability(cp),
dependencies=dependencies + (("ExperimentalMode", baseline), ("SafeMode", mode_safe))))
rows.append(FeatureRow("", "Experimental conditions", "Saved options", page="conditional/cem", available=True))
rows.append(FeatureRow("", "Chill conditions", "Saved options", page="conditional/ccm", available=True))
if manual.status in ("invalid", "read_error"):
rows.append(FeatureRow("", "Remembered manual choices", "Invalid" if manual.status == "invalid" else "Unavailable"))
if manual.status == "invalid":
rows.append(FeatureRow(MANUAL_RESET, "Reset remembered manual choices", "Both modes to Automatic", document,
available=manual_available,
dependencies=manual_dependencies))
return rows
lane_page = page == "lane_change"
section = "cem" if page in ("conditional/cem", "lane_change") else "ccm"
option = preferences.cem if section == "cem" else preferences.ccm
for field in fields(option):
name = field.name
if (name in SIGNAL_LANE_FIELDS) != lane_page:
continue
if name == "persist_manual":
rows.append(FeatureRow(PREFIX + section + ":persist_manual", "Remember manual choice",
"On" if getattr(option, name) else "Off", document, ("Off", "On"),
default_value="On" if getattr(getattr(decode_preferences(stock_document()), section), name) else "Off",
available=manual_available and manual.status in ("valid", "absent"),
reason="Keeps your manual choice across drives. Changing this setting first resets the choice to Automatic."
if manual_available and
manual.status in ("valid", "absent") else
"Saved manual choices need repair" if manual_available else unavailable,
dependencies=manual_dependencies))
continue
value = getattr(option, name)
key = PREFIX + section + ":" + name
if name in BOOLEAN_FIELDS[section]:
rows.append(FeatureRow(key, LABELS[name], "On" if value else "Off", document, ("Off", "On"),
default_value="On" if getattr(getattr(decode_preferences(stock_document()), section), name) else "Off",
available=allowed, reason=SIGNAL_LANE_HELP.get(name, "") if allowed else unavailable,
dependencies=dependencies))
elif name in NUMBER_FIELDS[section]:
displayed = display_number(name, value, metric)
unit = _unit(name, metric)
rows.append(FeatureRow(key, LABELS[name], _display(displayed) if valid_units else "Invalid saved units", document,
default_value=_display(display_number(name, getattr(getattr(decode_preferences(stock_document()), section), name), metric)),
step=0.1 if name == "model_stop_s" else 1.0 if valid_units else 0.0,
minimum=0.0, maximum=max(field_limit(section, name, metric), float(_display(displayed))),
unit=unit if valid_units else "",
available=allowed and valid_units, reason=SIGNAL_LANE_HELP.get(name, "") if allowed and valid_units else
"Invalid saved units" if not valid_units else unavailable,
dependencies=dependencies, display_unit=unit if valid_units else ""))
return rows
def apply(self, request: FeatureSettingsRequest) -> bool:
if request.key.startswith(BUTTON_PREFIX):
key = request.key.removeprefix(BUTTON_PREFIX)
if key not in (*MEDIA_KEYS, *DISTANCE_KEYS) or not request.vehicle_fingerprint or request.capability is None:
return False
cp = self.owner.vehicle_params()
switchback_only = key in MEDIA_KEYS and cp is not None and not cp.openpilotLongitudinalControl
if switchback_only and request.value not in ("Off", "Switchback Mode"):
return False
def authorized_button() -> bool:
return bool(self.owner.vehicle_fingerprint() == request.vehicle_fingerprint and
(media_capability(self.owner.vehicle_params(), switchback_only=True) if switchback_only else
assignment_capability(self.owner.vehicle_params(), key)) == request.capability and
self.owner.authority("switchback_wheel" if switchback_only else "conditional_wheel"))
result = commit_assignment(self.owner.params, key=key, choice=request.value,
expected=request.expected, dependencies=request.dependencies,
authorized=authorized_button)
return result.committed and result.verified
if request.key == MODE:
(document, readable), (baseline, baseline_readable), (safe, safe_readable) = read_mode_sources(self.owner.params)
units, units_readable = read_saved(self.owner.params, "IsMetric", 8)
dependencies = (("IsMetric", units), ("ExperimentalMode", baseline), ("SafeMode", safe))
if (not readable or not baseline_readable or not safe_readable or not units_readable or
document != request.expected or request.dependencies != dependencies or
baseline not in (None, b"0", b"1") or safe not in (None, b"0", b"1") or request.value not in MODES):
return False
def authorized_mode() -> bool:
cp = self.owner.vehicle_params()
return bool(self.owner.authority("preferences") and
(request.value != "Experimental" or request.capability is not None and
_mode_capability(cp) == request.capability and self.owner.vehicle_fingerprint() == request.capability[0] and
experimental_available(cp) and safe in (None, b"0")))
if not authorized_mode():
return False
try:
preferences = SavedPreferences() if document is None else decode_preferences(document)
value = "Stock" if request.value in ("Chill", "Experimental") else request.value
raw = edit(preferences, MODE, value, metric=units == b"1")
except PreferenceError:
return False
next_baseline = b"1" if request.value == "Experimental" else b"0" if request.value == "Chill" else baseline or b"0"
result = commit_mode(self.owner.params, raw, next_baseline, expected=document, expected_baseline=baseline,
expected_units=units, expected_safe=safe, authorized=authorized_mode)
return result.committed and result.verified
if (request.key in (RESET, MANUAL_RESET) and not request.confirmation) or \
request.capability is not None or request.vehicle_fingerprint is not None:
return False
manual_action = request.key == MANUAL_RESET or request.key in (
PREFIX + "cem:persist_manual", PREFIX + "ccm:persist_manual")
def authorized() -> bool:
group = "parked_preferences" if manual_action or request.key == RESET else "preferences"
return self.owner.authority(group)
if not authorized():
return False
document, units, readable, valid_units, metric = self._source()
if not readable or document != request.expected:
return False
manual = read_codes(self.owner.params) if manual_action else None
safe, safe_readable = read_saved(self.owner.params, "SafeMode", 8) if manual_action else (None, True)
dependencies = (("IsMetric", units),) + (((MANUAL_KEY, manual.raw), ("SafeMode", safe)) if manual is not None else ())
if request.dependencies != dependencies or not safe_readable or safe not in (None, b"0", b"1"):
return False
try:
if request.key == MANUAL_RESET:
if request.value != "confirm" or manual is None or manual.status != "invalid":
return False
result = commit_manual(self.owner.params, expected_config=document, expected_units=units,
expected_safe=safe, expected_manual=manual.raw, authorized=authorized,
choice=None, config_raw=None)
return result.committed and result.verified
if request.key == RESET:
if request.value != "confirm" or document is None:
return False
try:
decode_preferences(document)
return False
except PreferenceError:
raw = stock_document()
else:
preferences = SavedPreferences() if document is None else decode_preferences(document)
if request.key in (PREFIX + "cem:persist_manual", PREFIX + "ccm:persist_manual"):
if request.value not in ("On", "Off") or manual is None or manual.status not in ("valid", "absent"):
return False
section = request.key.split(":")[1]
option = preferences.cem if section == "cem" else preferences.ccm
raw = encode_preferences(replace(preferences, **{section: replace(option, persist_manual=request.value == "On")}))
choice = ModeChoice.CEM if section == "cem" else ModeChoice.CCM
result = commit_manual(self.owner.params, expected_config=document, expected_units=units,
expected_safe=safe, expected_manual=manual.raw, authorized=authorized,
choice=choice, config_raw=raw)
return result.committed and result.verified
if request.key.startswith(PREFIX) and request.key != MODE and not valid_units:
return False
if request.key.startswith(PREFIX) and request.key != MODE:
field = request.key.split(":")[-1]
expected_unit = (_unit(field, metric) if field in
("model_stop_s", "signal_lane_width_m", "speed_mps", "speed_with_lead_mps",
"signal_speed_mps", "set_speed_margin_mps") else "")
if request.display_unit != expected_unit:
return False
value = "Stock" if request.key == MODE and request.value == "Chill" else request.value
raw = edit(preferences, request.key, value, metric=metric)
except (PreferenceError, ValueError, TypeError, OverflowError):
return False
result = commit(self.owner.params, raw, document, units, authorized)
return result.committed and result.verified