mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-06 20:43:46 +08:00
d8708b65c7
Implement conditional driving modes with explicit scene, button and traffic state ownership. Co-authored-by: Dom <168790843+firestar5683@users.noreply.github.com> Co-authored-by: Harald Schäfer <harald.the.engineer@gmail.com> Co-authored-by: whoisdomi <dcantu83@gmail.com>
398 lines
17 KiB
Python
398 lines
17 KiB
Python
"""Allowlisted conditional-mode document edits and guarded Params commits.
|
|
|
|
Presentation callers bind exact saved bytes, units and parked vehicle context.
|
|
The ordinary edit replaces one document; manual persistence first clears the
|
|
selected remembered code under the planner's lease and Params lock.
|
|
"""
|
|
|
|
from dataclasses import dataclass, replace
|
|
from collections.abc import Callable
|
|
import fcntl
|
|
import math
|
|
import os
|
|
from pathlib import Path
|
|
import tempfile
|
|
|
|
from openpilot.starpilot.conditional_mode.policy import ModeChoice
|
|
from openpilot.starpilot.conditional_mode.manual_saved import (
|
|
KEY as MANUAL_KEY, SavedCodes, encode as encode_manual,
|
|
manual_lock_path, read_codes,
|
|
)
|
|
from openpilot.starpilot.conditional_mode.preferences import (
|
|
CCMOptions, CEMOptions, MAX_DOCUMENT_BYTES, MPH_TO_MPS,
|
|
PreferenceError, SavedPreferences, decode_preferences, encode_preferences,
|
|
)
|
|
from openpilot.starpilot.saved_source import read_saved
|
|
|
|
|
|
DOCUMENT_KEY = "ConditionalModeConfig"
|
|
PREFIX = "conditional:"
|
|
RESET = PREFIX + "reset"
|
|
MANUAL_RESET = PREFIX + "manual_reset"
|
|
MODE = PREFIX + "mode"
|
|
SPEED_FIELDS = frozenset(("speed_mps", "speed_with_lead_mps", "signal_speed_mps", "set_speed_margin_mps"))
|
|
BOOLEAN_FIELDS = {
|
|
"cem": frozenset(("open_road", "curves", "curves_with_lead", "lead", "slower_lead", "stopped_lead",
|
|
"stop_lights", "signal_lane_detection")),
|
|
"ccm": frozenset(("lead", "launch_assist")),
|
|
}
|
|
NUMBER_FIELDS = {
|
|
"cem": frozenset(("speed_mps", "speed_with_lead_mps", "signal_speed_mps", "model_stop_s", "signal_lane_width_m")),
|
|
"ccm": frozenset(("speed_mps", "speed_with_lead_mps", "set_speed_margin_mps")),
|
|
}
|
|
|
|
|
|
def field_limit(section: str, field: str, metric: bool) -> float:
|
|
if field == "model_stop_s":
|
|
return 9.0
|
|
if field == "signal_lane_width_m":
|
|
return 15.0
|
|
if field == "set_speed_margin_mps":
|
|
return 30.0 if metric else 15.0
|
|
if section in ("cem", "ccm") and field in SPEED_FIELDS:
|
|
return 150.0 if metric else 99.0
|
|
raise PreferenceError("unknown conditional numeric option")
|
|
|
|
|
|
def display_number(field: str, value: float, metric: bool) -> float:
|
|
if field == "signal_lane_width_m":
|
|
return value if metric else value / 0.3048
|
|
if field in SPEED_FIELDS:
|
|
return value * (3.6 if metric else 1 / MPH_TO_MPS)
|
|
return value
|
|
|
|
|
|
def stored_number(field: str, value: float, metric: bool) -> float:
|
|
if field == "signal_lane_width_m":
|
|
return value if metric else value * 0.3048
|
|
if field in SPEED_FIELDS:
|
|
return value / 3.6 if metric else value * MPH_TO_MPS
|
|
return value
|
|
|
|
|
|
def edit(preferences: SavedPreferences, key: str, value: str, *, metric: bool) -> bytes:
|
|
"""Return a validated whole document, rejecting hidden/unknown option edits."""
|
|
if key == MODE:
|
|
modes = {"Stock": ModeChoice.STOCK, "Conditional Experimental": ModeChoice.CEM,
|
|
"Conditional Chill": ModeChoice.CCM}
|
|
if value not in modes:
|
|
raise PreferenceError("unknown conditional mode")
|
|
updated = replace(preferences, mode=modes[value])
|
|
else:
|
|
parts = key.split(":")
|
|
if len(parts) != 3 or parts[0] != "conditional" or parts[1] not in ("cem", "ccm"):
|
|
raise PreferenceError("unknown conditional option")
|
|
section, field = parts[1:]
|
|
option = preferences.cem if section == "cem" else preferences.ccm
|
|
if field in BOOLEAN_FIELDS[section]:
|
|
if value not in ("On", "Off"):
|
|
raise PreferenceError("invalid conditional boolean")
|
|
changed = replace(option, **{field: value == "On"})
|
|
elif field in NUMBER_FIELDS[section]:
|
|
try:
|
|
number = float(value)
|
|
except (ValueError, OverflowError) as exc:
|
|
raise PreferenceError("invalid conditional number") from exc
|
|
# A unit change may put a valid SI value above the edit cap; allow only steps down.
|
|
maximum = max(field_limit(section, field, metric), display_number(field, getattr(option, field), metric))
|
|
if not math.isfinite(number) or not 0.0 <= number <= maximum:
|
|
raise PreferenceError("conditional number outside display range")
|
|
changed = replace(option, **{field: stored_number(field, number, metric)})
|
|
else:
|
|
raise PreferenceError("unsupported conditional option")
|
|
updated = replace(preferences, **{section: changed})
|
|
return encode_preferences(updated)
|
|
|
|
|
|
def stock_document() -> bytes:
|
|
return encode_preferences(SavedPreferences(mode=ModeChoice.STOCK, cem=CEMOptions(), ccm=CCMOptions()))
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CommitResult:
|
|
committed: bool
|
|
verified: bool
|
|
manual_cleared: bool = False
|
|
rollback_failed: bool = False
|
|
|
|
|
|
def commit(params, raw: bytes, expected: bytes | None, expected_units: bytes | None,
|
|
authorized: Callable[[], bool]) -> CommitResult:
|
|
"""One atomic Params document write after an exact, locked source check.
|
|
|
|
Uses the same Params-root flock as Params.put and the Curve editor. The
|
|
caller checks the full vehicle context in ``authorized`` both before and
|
|
within this lock. Readers never create or repair saved files.
|
|
"""
|
|
try:
|
|
if encode_preferences(decode_preferences(raw)) != raw:
|
|
return CommitResult(False, False)
|
|
except PreferenceError:
|
|
return CommitResult(False, False)
|
|
if len(raw) > MAX_DOCUMENT_BYTES or not authorized():
|
|
return CommitResult(False, False)
|
|
document, readable = read_saved(params, DOCUMENT_KEY, MAX_DOCUMENT_BYTES)
|
|
units, unit_readable = read_saved(params, "IsMetric", 8)
|
|
if not readable or not unit_readable or document != expected or units != expected_units:
|
|
return CommitResult(False, False)
|
|
temporary = None
|
|
lock_fd = None
|
|
committed = False
|
|
try:
|
|
destination = Path(params.get_param_path(DOCUMENT_KEY))
|
|
root = destination.parent.parent
|
|
with tempfile.NamedTemporaryFile(prefix=".tmp_conditional_", dir=root, delete=False) as staging:
|
|
temporary = staging.name
|
|
staging.write(raw)
|
|
staging.flush()
|
|
os.fsync(staging.fileno())
|
|
lock_fd = os.open(root / ".lock", os.O_CREAT | os.O_RDONLY, 0o775)
|
|
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
if not authorized():
|
|
return CommitResult(False, False)
|
|
document, readable = read_saved(params, DOCUMENT_KEY, MAX_DOCUMENT_BYTES)
|
|
units, unit_readable = read_saved(params, "IsMetric", 8)
|
|
if not readable or not unit_readable or document != expected or units != expected_units:
|
|
return CommitResult(False, False)
|
|
if not authorized():
|
|
return CommitResult(False, False)
|
|
document, readable = read_saved(params, DOCUMENT_KEY, MAX_DOCUMENT_BYTES)
|
|
units, unit_readable = read_saved(params, "IsMetric", 8)
|
|
if not readable or not unit_readable or document != expected or units != expected_units:
|
|
return CommitResult(False, False)
|
|
os.replace(temporary, destination)
|
|
temporary = None
|
|
committed = True
|
|
directory_fd = os.open(destination.parent, os.O_RDONLY)
|
|
try:
|
|
os.fsync(directory_fd)
|
|
finally:
|
|
os.close(directory_fd)
|
|
observed, readable = read_saved(params, DOCUMENT_KEY, MAX_DOCUMENT_BYTES)
|
|
return CommitResult(True, readable and observed == raw)
|
|
except (OSError, ValueError, TypeError):
|
|
return CommitResult(committed, False)
|
|
finally:
|
|
if lock_fd is not None:
|
|
os.close(lock_fd)
|
|
if temporary is not None:
|
|
try:
|
|
os.unlink(temporary)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
|
|
def commit_mode(params, raw: bytes, baseline: bytes, *, expected: bytes | None, expected_baseline: bytes | None,
|
|
expected_units: bytes | None, expected_safe: bytes | None, authorized: Callable[[], bool]) -> CommitResult:
|
|
"""Commit the selector's document and baseline together for locked readers.
|
|
|
|
Params.put and ordinary document commits use this same lock. If either
|
|
replacement fails, restore both original sources before releasing it.
|
|
"""
|
|
if baseline not in (b"0", b"1") or expected_baseline not in (None, b"0", b"1") or expected_safe not in (None, b"0", b"1"):
|
|
return CommitResult(False, False)
|
|
try:
|
|
if encode_preferences(decode_preferences(raw)) != raw or not authorized():
|
|
return CommitResult(False, False)
|
|
except PreferenceError:
|
|
return CommitResult(False, False)
|
|
|
|
def sources_match() -> bool:
|
|
return all(read_saved(params, key, limit) == (source, True) for key, limit, source in (
|
|
(DOCUMENT_KEY, MAX_DOCUMENT_BYTES, expected), ("ExperimentalMode", 8, expected_baseline),
|
|
("IsMetric", 8, expected_units), ("SafeMode", 8, expected_safe)))
|
|
|
|
temporary: list[str] = []
|
|
lock_fd = None
|
|
replaced = False
|
|
try:
|
|
destinations = (Path(params.get_param_path(DOCUMENT_KEY)), Path(params.get_param_path("ExperimentalMode")))
|
|
root = destinations[0].parent.parent
|
|
|
|
def stage(value: bytes | None) -> str | None:
|
|
if value is None:
|
|
return None
|
|
with tempfile.NamedTemporaryFile(prefix=".tmp_conditional_mode_", dir=root, delete=False) as source:
|
|
temporary.append(source.name)
|
|
source.write(value)
|
|
source.flush()
|
|
os.fsync(source.fileno())
|
|
return source.name
|
|
|
|
staged = (stage(raw), stage(baseline))
|
|
rollback = (stage(expected), stage(expected_baseline))
|
|
lock_fd = os.open(root / ".lock", os.O_CREAT | os.O_RDONLY, 0o775)
|
|
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
if not authorized() or not sources_match():
|
|
return CommitResult(False, False)
|
|
directory_fd = os.open(destinations[0].parent, os.O_RDONLY)
|
|
try:
|
|
try:
|
|
for source, destination in zip(staged, destinations, strict=True):
|
|
assert source is not None
|
|
os.replace(source, destination)
|
|
replaced = True
|
|
os.fsync(directory_fd)
|
|
if not authorized() or read_saved(params, DOCUMENT_KEY, MAX_DOCUMENT_BYTES) != (raw, True) or \
|
|
read_saved(params, "ExperimentalMode", 8) != (baseline, True):
|
|
raise OSError("Mode commit verification failed")
|
|
return CommitResult(True, True)
|
|
except (OSError, ValueError, TypeError):
|
|
if not replaced:
|
|
return CommitResult(False, False)
|
|
restored = True
|
|
for source, destination in zip(rollback, destinations, strict=True):
|
|
try:
|
|
if source is None:
|
|
destination.unlink(missing_ok=True)
|
|
else:
|
|
os.replace(source, destination)
|
|
except OSError:
|
|
restored = False
|
|
try:
|
|
os.fsync(directory_fd)
|
|
restored = restored and sources_match()
|
|
except OSError:
|
|
restored = False
|
|
return CommitResult(False, False, rollback_failed=not restored)
|
|
finally:
|
|
os.close(directory_fd)
|
|
except (OSError, ValueError, TypeError):
|
|
return CommitResult(False, False)
|
|
finally:
|
|
if lock_fd is not None:
|
|
os.close(lock_fd)
|
|
for source in temporary:
|
|
try:
|
|
os.unlink(source)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def commit_manual(params, *, expected_config: bytes | None, expected_units: bytes | None,
|
|
expected_safe: bytes | None, expected_manual: bytes | None,
|
|
authorized: Callable[[], bool], choice: ModeChoice | None,
|
|
config_raw: bytes | None) -> CommitResult:
|
|
"""Clear selected saved manual code before a persist toggle, or reset corrupt codes.
|
|
|
|
The drive writer owns the manual lease for its session. Taking that lease
|
|
before Params `.lock` makes a parked edit exclude a live writer; both sides
|
|
then compare exact config, manual, and SafeMode bytes. A failed second write
|
|
may leave a cleared manual code, which is safe and explicitly reported.
|
|
"""
|
|
if (choice is not None and (type(choice) is not ModeChoice or choice not in (ModeChoice.CEM, ModeChoice.CCM))) or \
|
|
(choice is None) == (config_raw is not None) or not authorized():
|
|
return CommitResult(False, False)
|
|
if config_raw is not None:
|
|
try:
|
|
if encode_preferences(decode_preferences(config_raw)) != config_raw:
|
|
return CommitResult(False, False)
|
|
except PreferenceError:
|
|
return CommitResult(False, False)
|
|
initial = read_codes(params)
|
|
if initial.raw != expected_manual or initial.status not in (("valid", "absent") if choice is not None else ("invalid",)):
|
|
return CommitResult(False, False)
|
|
if choice is None:
|
|
next_manual = encode_manual(SavedCodes())
|
|
else:
|
|
assert initial.codes is not None
|
|
next_manual = encode_manual(initial.codes.with_code(choice, 0)) if initial.codes.code(choice) else None
|
|
|
|
def sources_match(manual_raw: bytes | None) -> bool:
|
|
document, document_ok = read_saved(params, DOCUMENT_KEY, MAX_DOCUMENT_BYTES)
|
|
units, units_ok = read_saved(params, "IsMetric", 8)
|
|
safe, safe_ok = read_saved(params, "SafeMode", 8)
|
|
manual = read_codes(params)
|
|
return bool(document_ok and units_ok and safe_ok and document == expected_config and
|
|
units == expected_units and safe == expected_safe and safe in (None, b"0", b"1") and
|
|
manual.raw == manual_raw and manual.status == ("invalid" if choice is None else initial.status))
|
|
|
|
if not sources_match(expected_manual):
|
|
return CommitResult(False, False)
|
|
manual_staging = config_staging = None
|
|
lease_fd = lock_fd = None
|
|
manual_cleared = config_committed = False
|
|
try:
|
|
config_destination = Path(params.get_param_path(DOCUMENT_KEY))
|
|
root = config_destination.parent.parent
|
|
manual_destination = Path(params.get_param_path(MANUAL_KEY))
|
|
if next_manual is not None:
|
|
with tempfile.NamedTemporaryFile(prefix=".tmp_conditional_manual_ui_", dir=root, delete=False) as staged:
|
|
manual_staging = staged.name
|
|
staged.write(next_manual)
|
|
staged.flush()
|
|
os.fsync(staged.fileno())
|
|
if config_raw is not None:
|
|
with tempfile.NamedTemporaryFile(prefix=".tmp_conditional_ui_", dir=root, delete=False) as staged:
|
|
config_staging = staged.name
|
|
staged.write(config_raw)
|
|
staged.flush()
|
|
os.fsync(staged.fileno())
|
|
lease_fd = os.open(manual_lock_path(params), os.O_CREAT | os.O_RDONLY, 0o600)
|
|
fcntl.flock(lease_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
lock_fd = os.open(root / ".lock", os.O_CREAT | os.O_RDONLY, 0o775)
|
|
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
if not authorized() or not sources_match(expected_manual):
|
|
return CommitResult(False, False)
|
|
if next_manual is not None:
|
|
assert manual_staging is not None
|
|
os.replace(manual_staging, manual_destination)
|
|
manual_staging = None
|
|
manual_cleared = True
|
|
directory_fd = os.open(manual_destination.parent, os.O_RDONLY)
|
|
try:
|
|
os.fsync(directory_fd)
|
|
finally:
|
|
os.close(directory_fd)
|
|
observed = read_codes(params)
|
|
if observed.status != "valid" or observed.raw != next_manual:
|
|
return CommitResult(False, False, True)
|
|
if config_raw is None:
|
|
return CommitResult(manual_cleared, manual_cleared, manual_cleared)
|
|
# Keep the clear if a later write fails so the old override cannot return.
|
|
if not authorized():
|
|
return CommitResult(False, False, manual_cleared)
|
|
document, document_ok = read_saved(params, DOCUMENT_KEY, MAX_DOCUMENT_BYTES)
|
|
units, units_ok = read_saved(params, "IsMetric", 8)
|
|
safe, safe_ok = read_saved(params, "SafeMode", 8)
|
|
manual = read_codes(params)
|
|
if (not document_ok or not units_ok or not safe_ok or document != expected_config or units != expected_units or
|
|
safe != expected_safe or manual.raw != (next_manual if manual_cleared else expected_manual) or
|
|
manual.status not in ("valid", "absent")):
|
|
return CommitResult(False, False, manual_cleared)
|
|
if not authorized():
|
|
return CommitResult(False, False, manual_cleared)
|
|
document, document_ok = read_saved(params, DOCUMENT_KEY, MAX_DOCUMENT_BYTES)
|
|
units, units_ok = read_saved(params, "IsMetric", 8)
|
|
safe, safe_ok = read_saved(params, "SafeMode", 8)
|
|
manual = read_codes(params)
|
|
if (not document_ok or not units_ok or not safe_ok or document != expected_config or units != expected_units or
|
|
safe != expected_safe or manual.raw != (next_manual if manual_cleared else expected_manual) or
|
|
manual.status not in ("valid", "absent")):
|
|
return CommitResult(False, False, manual_cleared)
|
|
assert config_staging is not None
|
|
os.replace(config_staging, config_destination)
|
|
config_staging = None
|
|
config_committed = True
|
|
directory_fd = os.open(config_destination.parent, os.O_RDONLY)
|
|
try:
|
|
os.fsync(directory_fd)
|
|
finally:
|
|
os.close(directory_fd)
|
|
written, readable = read_saved(params, DOCUMENT_KEY, MAX_DOCUMENT_BYTES)
|
|
return CommitResult(True, readable and written == config_raw, manual_cleared)
|
|
except (OSError, ValueError, TypeError):
|
|
return CommitResult(config_committed if config_raw is not None else manual_cleared, False, manual_cleared)
|
|
finally:
|
|
if lock_fd is not None:
|
|
os.close(lock_fd)
|
|
if lease_fd is not None:
|
|
os.close(lease_fd)
|
|
for staging in (manual_staging, config_staging):
|
|
if staging is not None:
|
|
try:
|
|
os.unlink(staging)
|
|
except OSError:
|
|
pass
|