mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-04 19:43:49 +08:00
Add the setup ladder: try handshakes in order, remember the one that worked
One module now decides how to answer a car's handshake, and it is also the learned profile: the strategy that last worked is the one tried first. The first pass is today's behavior. Later rungs are knobs that already existed: the other version reply (the flip the dongle work added, now owned here), the phone-style reply with both bands and with 2.4 GHz only, a longer wait for a car that keeps saying not ready, and, for adapters only, the handshake variations. A failure advances the ladder only when the car answered the version exchange, then went silent or refused the Wi-Fi setup, before any video was acknowledged in the session. A sleeping car, this comma's own Bluetooth trouble and anything after video (a car shutting off) never move it. Three passes per boot at most, resuming at the first untried rung on the next ignition; a learned strategy is dropped after two handshake failures; a session that streams for a minute makes its strategy the learned one. State is a small per-car file beside the config (setup_state.json, newest 16 cars, damaged or unreadable means a fresh start, never blocks a connection). The status exposes the ladder for the setup screen, and reports carry what was tried, how each rung ended and how long the car took to be ready.
This commit is contained in:
@@ -266,11 +266,14 @@ class WirelessBootstrap:
|
||||
def __init__(self, sock, log: Callable[..., None], *, device_serial: str = "starpilot",
|
||||
version_status: int = STATUS_SUCCESS, stage_timeout: float = 20.0, start_request_delay: float = 5.0,
|
||||
initial_kick_delay: float = INITIAL_KICK_SECONDS, join_ping_interval: float = JOIN_PING_INTERVAL,
|
||||
experiment: str = "", modern_reply: bool = False):
|
||||
experiment: str = "", modern_reply: bool = False, patience: int = REFUSAL_PATIENCE,
|
||||
channel_override: int | None = None):
|
||||
self.sock = sock
|
||||
self.experiment = experiment if valid_experiment(experiment) else ""
|
||||
self.lifetime_id = uuid.uuid4().hex
|
||||
self.modern_reply = modern_reply
|
||||
self.patience = patience # stage timeouts a car that keeps saying "not ready" gets (setup ladder)
|
||||
self.channel_override = channel_override # Wi-Fi band to answer with inside the phone-style reply; None keeps the car's own
|
||||
self.version_major = 0
|
||||
self.car_started = False
|
||||
self.flags = EXPERIMENTS[self.experiment][1] if self.experiment else set()
|
||||
@@ -337,7 +340,7 @@ class WirelessBootstrap:
|
||||
self.version_major = major
|
||||
if major < MODERN_MAJOR or not self.modern_reply:
|
||||
return field(1, major) + field(2, minor) + field(3, self.device_serial) + field(4, self.version_status)
|
||||
selected = channel_type(request)
|
||||
selected = self.channel_override or channel_type(request)
|
||||
session = field(1, self.device_serial) + field(2, self.lifetime_id)
|
||||
reply = (field(1, major) + field(2, minor) + field(3, self.device_serial) + field(4, MODERN_VERSION_SUCCESS) +
|
||||
field(5, selected) + field(6, session))
|
||||
@@ -376,7 +379,7 @@ class WirelessBootstrap:
|
||||
# exchange if the car has not started it; newer head units (2025 Honda) wait for it.
|
||||
start_request_at: float | None = None
|
||||
deadline = time.monotonic() + self.stage_timeout
|
||||
latest_deadline = time.monotonic() + REFUSAL_PATIENCE * self.stage_timeout
|
||||
latest_deadline = time.monotonic() + self.patience * self.stage_timeout
|
||||
initial_kick_at: float | None = time.monotonic() + self.initial_kick_delay
|
||||
hint_ready_at: float | None = None
|
||||
while endpoint is None:
|
||||
|
||||
@@ -202,6 +202,22 @@ def session_setup(events: list[dict]) -> dict:
|
||||
) if value}
|
||||
|
||||
|
||||
def session_ladder(events: list[dict]) -> list[dict]:
|
||||
"""The handshake strategies tried in a session, in order, and how each ended (advanced, wrapped, exhausted, counted,
|
||||
dropped, or worked). Kept for working pairings too: it is what shows which strategy a head unit likes."""
|
||||
tried: list[dict] = []
|
||||
for event in events:
|
||||
name = event["event"]
|
||||
if name == "ladder_strategy":
|
||||
tried.append({"rung": str(event.get("rung", ""))[:24], "learned": bool(event.get("learned")),
|
||||
"at_s": elapsed(events[0], event)})
|
||||
elif name == "ladder_failed" and tried:
|
||||
tried[-1]["outcome"] = str(event.get("outcome", ""))[:16]
|
||||
elif name == "ladder_worked" and tried:
|
||||
tried[-1].update(outcome="worked", streamed_s=event.get("streamed_s"), t_ready_s=event.get("t_ready_s"))
|
||||
return [{key: value for key, value in item.items() if value is not None} for item in tried[-12:]]
|
||||
|
||||
|
||||
def session_strategy(events: list[dict], setup: dict) -> dict:
|
||||
"""What a streaming session ran with; the raw material for "this head unit usually likes this profile"."""
|
||||
if not any(event["event"] == "video_acknowledged" for event in events):
|
||||
@@ -248,6 +264,7 @@ def session_record(path: Path) -> dict:
|
||||
# True when the comma's address went to the car in the pairing request; None when the car never opened that channel.
|
||||
"phone_address_sent": bool(opened.get("phone_address")) if opened is not None else None,
|
||||
"strategy": session_strategy(events, setup),
|
||||
"ladder": session_ladder(events),
|
||||
"software": {key: str(software.get(key) or "unknown") for key in ("commit", "branch")} if isinstance(software, dict) else {},
|
||||
"settings": settings if isinstance(settings, dict) else {},
|
||||
"seq": _session_number(path),
|
||||
@@ -464,6 +481,7 @@ def drive_event(sessions: list[dict]) -> dict:
|
||||
label = _head_unit_label(head_unit)
|
||||
setup = next((s["setup"] for s in reversed(sessions) if s.get("setup")), {})
|
||||
sent = next((s["phone_address_sent"] for s in reversed(sessions) if s.get("phone_address_sent") is not None), None)
|
||||
ladder = next((s["ladder"][-1] for s in reversed(sessions) if s.get("ladder")), {})
|
||||
failures = [failure_detail(failure) for failure in drive_failures(sessions)]
|
||||
failed = [session for session in sessions if session["seq"] in {failure["session"] for failure in failures}]
|
||||
heading = "failures" if failures else "sessions"
|
||||
@@ -499,6 +517,7 @@ def drive_event(sessions: list[dict]) -> dict:
|
||||
**({"aa.adapter_model": setup["profile"]} if setup.get("profile") else {}),
|
||||
**({"aa.profile_source": setup["profile_source"]} if setup.get("profile_source") else {}),
|
||||
**({"aa.phone_address_sent": "yes" if sent else "no"} if sent is not None else {}),
|
||||
**({"aa.ladder_rung": ladder["rung"], "aa.ladder_learned": "yes" if ladder.get("learned") else "no"} if ladder else {}),
|
||||
**({"aa.attention": ",".join(reasons)[:190]} if reasons else {}),
|
||||
**({"aa.cause": latest["cause"], "aa.stage": latest["stage"], "aa.error_kind": latest["error_kind"]} if latest else {}),
|
||||
**({"aa.reach_class": latest["reach"]} if latest and latest.get("reach") else {}),
|
||||
|
||||
@@ -672,6 +672,7 @@ class ProjectionSession(Session):
|
||||
self.config_ack_slack = 0 # codec-config messages a head unit may acknowledge like frames
|
||||
self.epoch_acked = 0
|
||||
self.video_confirmed = False
|
||||
self.video_confirmed_at = 0.0
|
||||
self.input_channel: int | None = None
|
||||
self.max_window = self.MAX_WINDOW # raised by the supervisor for adapters that need the car's full window
|
||||
self.allow_hevc = False # the owner's Allow H.265 video setting; used only when the car offers no H.264 mode
|
||||
@@ -950,6 +951,7 @@ class ProjectionSession(Session):
|
||||
# bound on acknowledged frames without changing flow-control credit.
|
||||
if not self.video_confirmed and self.epoch_acked > self.config_ack_slack:
|
||||
self.video_confirmed = True
|
||||
self.video_confirmed_at = time.monotonic()
|
||||
self.event("video_acknowledged", session=self.session_id, ack_ms=round(self.max_ack_seconds * 1000))
|
||||
|
||||
# ANDROID AUTO VIDEO PERFORMANCE GUARDRAIL: Receiver ACK flow control bounds in-flight video; extra buffering increases latency.
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
"""Which wireless handshake to try for a car, in what order, and which one last worked.
|
||||
|
||||
The ladder is the one place that decides this. It owns the flip between the standard and phone-style version reply that
|
||||
the dongle work added, and it is also the "learned profile": the rung that last worked is simply the strategy used first
|
||||
the next time.
|
||||
|
||||
One rule decides everything: **a failure advances the ladder only when the car answered the version exchange and no video
|
||||
was acknowledged yet in this session.** A sleeping car, this comma's own Bluetooth trouble, a car that shuts off after the
|
||||
drive has streamed, anything that is not the handshake being refused: none of these say anything about which strategy the
|
||||
car prefers, so none of them move it. Without that rule every ignition with an asleep car would walk the whole ladder and
|
||||
a car that works fine would end up running the dongle experiments.
|
||||
|
||||
The first pass for a car is exactly what ran before (rung ``standard``); the later rungs are knobs that already existed.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from openpilot.starpilot.system.starport.bootstrap import CHANNEL_24GHZ, CHANNEL_DUAL, MODERN_MAJOR, REFUSAL_PATIENCE
|
||||
from openpilot.starpilot.system.starport.identity import DATA_DIR
|
||||
|
||||
STATE_PATH = DATA_DIR / "setup_state.json"
|
||||
STATE_VERSION = 1
|
||||
MAX_CARS = 16
|
||||
MAX_PASSES = 3 # full runs through the ladder per boot (one boot is one ignition cycle); not yet confirmed against real cars
|
||||
DROP_AFTER = 2 # failures at the remembered strategy before it is forgotten and the ladder starts over
|
||||
CLEAN_SECONDS = 60.0 # a session that streamed this long without ending in a handshake failure counts as working
|
||||
LONG_PATIENCE = 2 * REFUSAL_PATIENCE
|
||||
|
||||
RUNGS = ("standard", "other_reply", "modern_dual", "modern_24ghz", "patient", "experiments")
|
||||
LABELS = {
|
||||
"standard": "Standard handshake",
|
||||
"other_reply": "The other version reply",
|
||||
"modern_dual": "Phone-style reply, both Wi-Fi bands",
|
||||
"modern_24ghz": "Phone-style reply, 2.4 GHz",
|
||||
"patient": "Waiting longer for the car",
|
||||
"experiments": "Adapter handshake variations",
|
||||
}
|
||||
MODERN_RUNGS = ("other_reply", "modern_dual", "modern_24ghz") # only mean something to a car that speaks protocol 4 or newer
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Strategy:
|
||||
rung: str
|
||||
modern_reply: bool
|
||||
channel_type: int | None = None # forces the Wi-Fi band reply inside the phone-style reply; None keeps the car's own preference
|
||||
patience: int = REFUSAL_PATIENCE
|
||||
experiments: bool = False
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {"rung": self.rung, "modern_reply": self.modern_reply, "channel_type": self.channel_type,
|
||||
"patience": self.patience, "experiments": self.experiments}
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
return LABELS.get(self.rung, self.rung)
|
||||
|
||||
|
||||
def resolve(rung: str, base_modern: bool) -> Strategy:
|
||||
"""The concrete strategy for a rung, relative to the car's remembered reply style (today's behavior)."""
|
||||
if rung == "other_reply":
|
||||
return Strategy(rung, not base_modern)
|
||||
if rung == "modern_dual":
|
||||
return Strategy(rung, True, CHANNEL_DUAL)
|
||||
if rung == "modern_24ghz":
|
||||
return Strategy(rung, True, CHANNEL_24GHZ)
|
||||
if rung == "patient":
|
||||
return Strategy(rung, base_modern, patience=LONG_PATIENCE)
|
||||
if rung == "experiments":
|
||||
return Strategy(rung, base_modern, experiments=True)
|
||||
return Strategy("standard", base_modern)
|
||||
|
||||
|
||||
def applicable(rung: str, version_major: int, adapter: bool) -> bool:
|
||||
if rung in MODERN_RUNGS:
|
||||
return version_major >= MODERN_MAJOR
|
||||
if rung == "experiments":
|
||||
return adapter
|
||||
return True
|
||||
|
||||
|
||||
def _boot_id() -> str:
|
||||
try:
|
||||
return Path("/proc/sys/kernel/random/boot_id").read_text().strip()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def _valid_strategy(value) -> Strategy | None:
|
||||
if not isinstance(value, dict) or value.get("rung") not in RUNGS:
|
||||
return None
|
||||
channel, patience = value.get("channel_type"), value.get("patience")
|
||||
if type(value.get("modern_reply")) is not bool or type(value.get("experiments")) is not bool:
|
||||
return None
|
||||
if channel not in (None, CHANNEL_24GHZ, CHANNEL_DUAL, 1) or type(patience) is not int or not 1 <= patience <= 12:
|
||||
return None
|
||||
return Strategy(value["rung"], value["modern_reply"], channel, patience, value["experiments"])
|
||||
|
||||
|
||||
def _fresh() -> dict:
|
||||
return {"rung": "standard", "results": {}, "passes": 0, "boot": "", "working": None, "working_failures": 0,
|
||||
"t_ready": [], "successes": 0, "updated": 0.0}
|
||||
|
||||
|
||||
def _clean(entry) -> dict:
|
||||
"""A saved entry, or a fresh one when anything about it is not what this module writes."""
|
||||
fresh = _fresh()
|
||||
if not isinstance(entry, dict):
|
||||
return fresh
|
||||
try:
|
||||
rung = entry.get("rung")
|
||||
results = entry.get("results")
|
||||
if rung in RUNGS:
|
||||
fresh["rung"] = rung
|
||||
if isinstance(results, dict):
|
||||
fresh["results"] = {key: value for key, value in results.items() if key in RUNGS and value in ("failed", "skipped", "worked")}
|
||||
for key in ("passes", "working_failures", "successes"):
|
||||
if type(entry.get(key)) is int and 0 <= entry[key] <= 10_000:
|
||||
fresh[key] = entry[key]
|
||||
fresh["boot"] = entry["boot"][:64] if isinstance(entry.get("boot"), str) else ""
|
||||
working = _valid_strategy(entry.get("working"))
|
||||
fresh["working"] = working.as_dict() if working else None
|
||||
fresh["t_ready"] = [round(float(value), 1) for value in entry.get("t_ready", []) if isinstance(value, (int, float)) and 0 <= value <= 3600][-8:]
|
||||
fresh["updated"] = float(entry["updated"]) if isinstance(entry.get("updated"), (int, float)) else 0.0
|
||||
except (TypeError, ValueError, KeyError):
|
||||
return _fresh()
|
||||
return fresh
|
||||
|
||||
|
||||
class SetupLadder:
|
||||
def __init__(self, path: Path | None = None, boot_id=_boot_id, clock=time.time):
|
||||
self.path = path or STATE_PATH
|
||||
self.boot_id, self.clock = boot_id, clock
|
||||
self.cars: dict[str, dict] = {}
|
||||
self.load()
|
||||
|
||||
def load(self) -> None:
|
||||
try:
|
||||
data = json.loads(self.path.read_text())
|
||||
cars = data["cars"] if isinstance(data, dict) and data.get("version") == STATE_VERSION and isinstance(data.get("cars"), dict) else {}
|
||||
except (OSError, ValueError, KeyError, TypeError, UnicodeError):
|
||||
cars = {}
|
||||
self.cars = {str(address).upper(): _clean(entry) for address, entry in list(cars.items())[:MAX_CARS]}
|
||||
|
||||
def save(self) -> bool:
|
||||
ordered = dict(sorted(self.cars.items(), key=lambda item: item[1]["updated"], reverse=True)[:MAX_CARS])
|
||||
self.cars = ordered
|
||||
try:
|
||||
self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
fd, temporary = tempfile.mkstemp(dir=self.path.parent, prefix=".setup-state-")
|
||||
try:
|
||||
with os.fdopen(fd, "w") as handle:
|
||||
json.dump({"version": STATE_VERSION, "cars": ordered}, handle, separators=(",", ":"))
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.chmod(temporary, 0o600)
|
||||
os.replace(temporary, self.path)
|
||||
finally:
|
||||
if os.path.exists(temporary):
|
||||
os.unlink(temporary)
|
||||
except OSError:
|
||||
return False # progress is a convenience; a read-only disk must never stop a connection
|
||||
return True
|
||||
|
||||
def _entry(self, address: str) -> dict:
|
||||
entry = self.cars.setdefault(address.upper(), _fresh())
|
||||
boot = self.boot_id()
|
||||
if entry["boot"] != boot: # a new ignition cycle gets its full passes again and resumes at the first untried rung
|
||||
entry["boot"], entry["passes"] = boot, 0
|
||||
return entry
|
||||
|
||||
def forget(self, address: str) -> bool:
|
||||
"""A car's pairing was deleted: drop what was learned about it."""
|
||||
removed = self.cars.pop(address.upper(), None) is not None
|
||||
if removed:
|
||||
self.save()
|
||||
return removed
|
||||
|
||||
def exhausted(self, address: str) -> bool:
|
||||
entry = self.cars.get(address.upper())
|
||||
return bool(entry) and entry["boot"] == self.boot_id() and entry["passes"] >= MAX_PASSES and not entry["working"]
|
||||
|
||||
def strategy(self, address: str, base_modern: bool) -> Strategy:
|
||||
"""What to try now: the strategy that worked, else the current rung (standard once the passes are used up)."""
|
||||
entry = self._entry(address)
|
||||
working = _valid_strategy(entry["working"])
|
||||
if working:
|
||||
return working
|
||||
if entry["passes"] >= MAX_PASSES:
|
||||
return resolve("standard", base_modern)
|
||||
return resolve(entry["rung"], base_modern)
|
||||
|
||||
def failed(self, address: str, strategy: Strategy, *, ladder_failure: bool, video_seen: bool, version_major: int,
|
||||
adapter: bool) -> str | None:
|
||||
"""Record a failed attempt; returns what changed ("advanced", "wrapped", "exhausted", "counted", "dropped") or None.
|
||||
|
||||
Only a handshake the car answered, before any video this session, counts: see the module docstring."""
|
||||
if not ladder_failure or video_seen:
|
||||
return None
|
||||
entry = self._entry(address)
|
||||
entry["updated"] = self.clock()
|
||||
working = _valid_strategy(entry["working"])
|
||||
if working and strategy.rung == working.rung:
|
||||
entry["working_failures"] += 1
|
||||
if entry["working_failures"] < DROP_AFTER:
|
||||
self.save()
|
||||
return "counted"
|
||||
entry.update(working=None, working_failures=0, rung="standard", results={})
|
||||
self.save()
|
||||
return "dropped"
|
||||
if entry["passes"] >= MAX_PASSES:
|
||||
return None
|
||||
entry["results"][strategy.rung] = "failed"
|
||||
start = RUNGS.index(strategy.rung) if strategy.rung in RUNGS else 0
|
||||
outcome = "wrapped"
|
||||
for rung in RUNGS[start + 1:]:
|
||||
if applicable(rung, version_major, adapter):
|
||||
entry["rung"], outcome = rung, "advanced"
|
||||
break
|
||||
entry["results"][rung] = "skipped"
|
||||
if outcome == "wrapped":
|
||||
entry["passes"] += 1
|
||||
entry["rung"], entry["results"] = "standard", {}
|
||||
if entry["passes"] >= MAX_PASSES:
|
||||
outcome = "exhausted"
|
||||
self.save()
|
||||
return outcome
|
||||
|
||||
def worked(self, address: str, strategy: Strategy, t_ready_s: float | None = None) -> None:
|
||||
entry = self._entry(address)
|
||||
entry.update(working=strategy.as_dict(), working_failures=0, rung=strategy.rung, results={strategy.rung: "worked"},
|
||||
passes=0, successes=entry["successes"] + 1, updated=self.clock())
|
||||
if t_ready_s is not None and 0 <= t_ready_s <= 3600:
|
||||
entry["t_ready"] = (entry["t_ready"] + [round(float(t_ready_s), 1)])[-8:]
|
||||
self.save()
|
||||
|
||||
def snapshot(self, address: str, adapter: bool = False) -> dict:
|
||||
"""What the setup screen and the reports show: every rung, its state, and whether this car has a learned strategy."""
|
||||
entry = self.cars.get(address.upper()) if address else None
|
||||
entry = entry or _fresh()
|
||||
working = _valid_strategy(entry["working"])
|
||||
current = working.rung if working else entry["rung"]
|
||||
passes = entry["passes"] if entry["boot"] == self.boot_id() else 0 # passes belong to one ignition cycle
|
||||
steps = []
|
||||
for rung in RUNGS:
|
||||
if rung == "experiments" and not adapter:
|
||||
continue
|
||||
state = entry["results"].get(rung) or ("current" if rung == current else "waiting")
|
||||
steps.append({"id": rung, "label": LABELS[rung], "state": state})
|
||||
return {"steps": steps, "current": current, "learned": bool(working), "passes": passes, "max_passes": MAX_PASSES,
|
||||
"exhausted": passes >= MAX_PASSES and not working, "successes": entry["successes"],
|
||||
"t_ready_s": entry["t_ready"][-1] if entry["t_ready"] else None}
|
||||
@@ -33,9 +33,9 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from openpilot.common.hardware import COMMA_HARDWARE
|
||||
from openpilot.starpilot.system.starport import car_profiles, device_health, identity as identity_store, reachability, system_slice
|
||||
from openpilot.starpilot.system.starport import car_profiles, device_health, identity as identity_store, new_setup, reachability, setup_ladder, system_slice
|
||||
from openpilot.starpilot.system.starport.auto_connect import AutoConnectPolicy
|
||||
from openpilot.starpilot.system.starport.bootstrap import MODERN_MAJOR, NAMES, BootstrapError, WirelessBootstrap
|
||||
from openpilot.starpilot.system.starport.bootstrap import MODERN_MAJOR, NAMES, BootstrapError, BootstrapTimeout, WirelessBootstrap
|
||||
from openpilot.starpilot.system.starport.bugsink_report import PairingAttempt
|
||||
from openpilot.starpilot.system.starport.frame_source import (DEFAULT_PATH as DEFAULT_FRAME_PATH, FLAG_ASYNC_READBACK, FLAG_NV12,
|
||||
FORMAT_NV12, FrameRequest)
|
||||
@@ -374,6 +374,10 @@ class Supervisor:
|
||||
self._car_seen_at = -CAR_LINK_HOLD
|
||||
self._hfp_link = threading.Event() # set when the chosen car opens the hands-free link
|
||||
self._modern_reply: dict[str, bool] = {} # car address -> answer its v4+ version request like a current phone
|
||||
self._ladder = setup_ladder.SetupLadder(identity_store.CONFIG_PATH.with_name("setup_state.json")) # which handshake to try for each car, and which one last worked
|
||||
self._strategy: setup_ladder.Strategy | None = None # the one this attempt runs
|
||||
self._video_seen = False # video was acknowledged in this session: later failures say nothing about the handshake
|
||||
self._run_started = 0.0
|
||||
self.auto = AutoConnectPolicy()
|
||||
self._companion_retry_at = 0.0
|
||||
self.log = EventLog()
|
||||
@@ -418,6 +422,8 @@ class Supervisor:
|
||||
status["dedicated_wifi_interface"] = bool(self.config["dedicated_wifi_interface"])
|
||||
status["allow_hevc"] = bool(self.config["allow_hevc"])
|
||||
status["car_candidates"] = list(getattr(self, "_candidates", []))[:8] # paired Android Auto devices to choose between
|
||||
status["new_setup"] = new_setup.enabled()
|
||||
status["setup_ladder"] = self._ladder.snapshot(self.config["receiver_address"], self.car_type() == "adapter")
|
||||
status["auto_paused"] = self.auto.suppressed
|
||||
status["recent"] = list(self.log.recent)[-8:]
|
||||
return status
|
||||
@@ -534,6 +540,7 @@ class Supervisor:
|
||||
return False # nothing of this device was kept
|
||||
self.config.update(rfcomm_cache=cache, modern_reply_cache=replies)
|
||||
self._modern_reply.pop(address, None)
|
||||
self._ladder.forget(address)
|
||||
if chosen:
|
||||
if self._bluez is not None:
|
||||
self._bluez.release()
|
||||
@@ -1190,6 +1197,7 @@ class Supervisor:
|
||||
connection = self.config["connection"]
|
||||
wireless = connection == "wireless"
|
||||
receiver = {"wired": "usb", "headunitpad": "headunitpad", "openheadunit": "openheadunit"}.get(connection, self.config["receiver_name"])
|
||||
self._video_seen, self._strategy, self._run_started = False, None, time.monotonic()
|
||||
from openpilot.starpilot.system.starport.bugsink_transport import device_metadata
|
||||
software = device_metadata()
|
||||
profile, profile_source = car_profiles.resolve(self.config)
|
||||
@@ -1285,6 +1293,7 @@ class Supervisor:
|
||||
config = self.config
|
||||
address = config["receiver_address"]
|
||||
ident = identity_store.load_identity()
|
||||
self._strategy = None
|
||||
|
||||
self._stage("connecting_bluetooth", config["receiver_name"])
|
||||
bluez = self._phone()
|
||||
@@ -1318,21 +1327,35 @@ class Supervisor:
|
||||
self.log("rfcomm_channel", channel=channel, source=source)
|
||||
|
||||
boot = None
|
||||
adapter = self.car_type() == "adapter"
|
||||
# The ladder decides how to answer this car: what worked before, else the first rung (today's behavior), moving on
|
||||
# only when a car that answered the handshake keeps refusing it before any video.
|
||||
base_modern = self._modern_reply.get(address, config.get("modern_reply_cache", {}).get(address, False))
|
||||
strategy = self._strategy = self._ladder.strategy(address, base_modern)
|
||||
learned = self._ladder.snapshot(address, adapter)["learned"]
|
||||
self.log("ladder_strategy", rung=strategy.rung, label=strategy.label, learned=learned, modern_reply=strategy.modern_reply,
|
||||
channel_type=strategy.channel_type, patience=strategy.patience, experiments=strategy.experiments)
|
||||
try:
|
||||
self._stage("rfcomm", f"channel {channel}")
|
||||
rfcomm = self._track(bt_sockets.connect_rfcomm(address, channel))
|
||||
boot = WirelessBootstrap(rfcomm, self._bootstrap_log, device_serial=config["device_name"],
|
||||
version_status=int(config.get("version_status", 0)),
|
||||
experiment="all" if car_profiles.tuning(config)["bootstrap_experiments"] else "",
|
||||
modern_reply=self._modern_reply.get(address, config.get("modern_reply_cache", {}).get(address, False)))
|
||||
experiment="all" if car_profiles.tuning(config)["bootstrap_experiments"] or strategy.experiments else "",
|
||||
modern_reply=strategy.modern_reply, patience=strategy.patience, channel_override=strategy.channel_type)
|
||||
self._set(state="wifi_start")
|
||||
result = boot.run(lambda credentials: lease.acquire(credentials, cancelled=boot.join_is_cancelled), cancelled=self._stop.is_set)
|
||||
except Exception as error:
|
||||
if _stray_link(error):
|
||||
raise StrayLinkError(f"Bluetooth link to the car is stuck: {error}") from error # says nothing about the cached channel
|
||||
if boot is not None and boot.silent_after_modern_version(error):
|
||||
self._modern_reply[address] = not boot.modern_reply # try the other version reply next
|
||||
self.log("bootstrap_reply_switched", modern=self._modern_reply[address])
|
||||
# A car that answered the version exchange and then went silent or refused the Wi-Fi setup says something about the
|
||||
# handshake; every other failure (asleep, local Bluetooth trouble, after video) says nothing and leaves the ladder alone.
|
||||
ladder_failure = (boot is not None and boot.version_major > 0 and isinstance(error, BootstrapTimeout)
|
||||
and error.stage in ("wifi_start", "wifi_info") and not self._stop.is_set())
|
||||
outcome = self._ladder.failed(address, strategy, ladder_failure=ladder_failure, video_seen=self._video_seen,
|
||||
version_major=boot.version_major if boot is not None else 0, adapter=adapter)
|
||||
if outcome:
|
||||
self.log("ladder_failed", outcome=outcome, rung=strategy.rung, next=self._ladder.strategy(address, base_modern).rung,
|
||||
version_major=boot.version_major)
|
||||
if source == "cache" and not self._stop.is_set() and drops_cached_channel(self._status["last_stage"], error):
|
||||
self._remember_channel(address, None) # the car never answered there; ask it over SDP next time
|
||||
self.log("rfcomm_cache_dropped", channel=channel)
|
||||
@@ -1632,6 +1655,7 @@ class Supervisor:
|
||||
try:
|
||||
self._stream(session, encoder, source, watch, interval)
|
||||
finally:
|
||||
self._note_streamed(session)
|
||||
if session.audio is not None:
|
||||
session.audio.close()
|
||||
if getattr(session, "nav", None) is not None:
|
||||
@@ -1645,6 +1669,20 @@ class Supervisor:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _note_streamed(self, session: ProjectionSession) -> None:
|
||||
"""Video was acknowledged: later failures in this session say nothing about the handshake. A session that streamed for
|
||||
a minute without a handshake failure makes the strategy it ran with the one this car starts with next time."""
|
||||
if not session.video_confirmed:
|
||||
return
|
||||
self._video_seen = True
|
||||
strategy, address = self._strategy, self.config["receiver_address"]
|
||||
streamed = time.monotonic() - session.video_confirmed_at
|
||||
if strategy is None or not address or streamed < setup_ladder.CLEAN_SECONDS:
|
||||
return
|
||||
self._ladder.worked(address, strategy, t_ready_s=session.video_confirmed_at - self._run_started)
|
||||
self.log("ladder_worked", rung=strategy.rung, streamed_s=round(streamed, 1),
|
||||
t_ready_s=round(session.video_confirmed_at - self._run_started, 1))
|
||||
|
||||
# ANDROID AUTO VIDEO PERFORMANCE GUARDRAIL: The bounded ACK/input drain and conditional waits keep video and touches responsive.
|
||||
# Altering this can degrade video smoothness or introduce lag on Android Auto.
|
||||
# Do not change it autonomously; require an explicit user request specifically for this video optimization.
|
||||
|
||||
@@ -492,3 +492,17 @@ def test_unknown_setup_values_are_dropped(tmp_path):
|
||||
events[0] = (0, 0, "session_start", {"setup": {"type": "<script>", "profile": "x" * 90, "profile_source": "guess"}})
|
||||
setup = br.session_record(write_session(tmp_path, 1, events))["setup"]
|
||||
assert "type" not in setup and "profile_source" not in setup and len(setup["profile"]) <= 40
|
||||
|
||||
|
||||
def test_report_shows_what_the_ladder_tried_and_what_worked(tmp_path):
|
||||
events = [list(item) for item in STREAMED]
|
||||
events.insert(1, (0, 1, "ladder_strategy", {"rung": "standard", "learned": False}))
|
||||
events.insert(2, (0, 5, "ladder_failed", {"outcome": "advanced", "rung": "standard", "next": "other_reply"}))
|
||||
events.insert(3, (0, 6, "ladder_strategy", {"rung": "other_reply", "learned": False}))
|
||||
events.append((1, 50, "ladder_worked", {"rung": "other_reply", "streamed_s": 62.0, "t_ready_s": 14.5}))
|
||||
record = br.session_record(write_session(tmp_path, 1, events))
|
||||
assert record["ladder"] == [{"rung": "standard", "learned": False, "at_s": 1.0, "outcome": "advanced"},
|
||||
{"rung": "other_reply", "learned": False, "at_s": 6.0, "outcome": "worked", "streamed_s": 62.0, "t_ready_s": 14.5}]
|
||||
tags = br.drive_event([record])["tags"]
|
||||
assert (tags["aa.ladder_rung"], tags["aa.ladder_learned"]) == ("other_reply", "no")
|
||||
assert br.session_record(write_session(tmp_path, 2, STREAMED))["ladder"] == [] # older logs report nothing, without error
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
"""The setup ladder moves only when a car that answered the handshake refuses it before any video; nothing else moves it."""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from openpilot.starpilot.system.starport import setup_ladder as sl
|
||||
from openpilot.starpilot.system.starport.bootstrap import CHANNEL_24GHZ, CHANNEL_DUAL, REFUSAL_PATIENCE
|
||||
|
||||
CAR = "AA:BB:CC:DD:EE:FF"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ladder(tmp_path):
|
||||
state = {"boot": "boot-1"}
|
||||
instance = sl.SetupLadder(tmp_path / "setup_state.json", boot_id=lambda: state["boot"], clock=lambda: 1000.0)
|
||||
instance.state = state
|
||||
return instance
|
||||
|
||||
|
||||
def refuse(ladder, strategy, **overrides):
|
||||
values = dict(ladder_failure=True, video_seen=False, version_major=4, adapter=False)
|
||||
values.update(overrides)
|
||||
return ladder.failed(CAR, strategy, **values)
|
||||
|
||||
|
||||
def test_the_first_strategy_is_exactly_what_ran_before(ladder):
|
||||
for base in (False, True):
|
||||
strategy = ladder.strategy(CAR, base)
|
||||
assert (strategy.rung, strategy.modern_reply, strategy.channel_type, strategy.patience, strategy.experiments) == (
|
||||
"standard", base, None, REFUSAL_PATIENCE, False)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,overrides", [
|
||||
("a car that never answered the handshake (asleep, refused, local trouble)", {"ladder_failure": False}),
|
||||
("anything after video was acknowledged in the session (car-off teardown)", {"video_seen": True}),
|
||||
])
|
||||
def test_failures_that_say_nothing_about_the_handshake_never_move_the_ladder(ladder, name, overrides):
|
||||
strategy = ladder.strategy(CAR, False)
|
||||
for _ in range(50):
|
||||
assert refuse(ladder, strategy, **overrides) is None
|
||||
assert ladder.strategy(CAR, False).rung == "standard" and not ladder.exhausted(CAR)
|
||||
assert not (ladder.path.exists() and json.loads(ladder.path.read_text())["cars"]) # nothing was even recorded
|
||||
|
||||
|
||||
def test_a_silent_car_walks_the_rungs_in_order_and_each_is_a_knob_that_already_existed(ladder):
|
||||
seen = []
|
||||
for step in range(5):
|
||||
strategy = ladder.strategy(CAR, False)
|
||||
seen.append(strategy)
|
||||
assert refuse(ladder, strategy) == ("advanced" if step < 4 else "wrapped") # the last rung ends the pass
|
||||
assert [s.rung for s in seen] == ["standard", "other_reply", "modern_dual", "modern_24ghz", "patient"]
|
||||
assert [s.modern_reply for s in seen] == [False, True, True, True, False]
|
||||
assert [s.channel_type for s in seen] == [None, None, CHANNEL_DUAL, CHANNEL_24GHZ, None]
|
||||
assert seen[4].patience == sl.LONG_PATIENCE and all(not s.experiments for s in seen)
|
||||
|
||||
|
||||
def test_the_other_reply_is_relative_to_what_the_car_was_using(ladder):
|
||||
assert ladder.strategy(CAR, True).modern_reply is True
|
||||
refuse(ladder, ladder.strategy(CAR, True))
|
||||
assert ladder.strategy(CAR, True).modern_reply is False
|
||||
|
||||
|
||||
def test_a_car_older_than_protocol_four_skips_the_phone_style_rungs(ladder):
|
||||
refuse(ladder, ladder.strategy(CAR, False), version_major=3)
|
||||
assert ladder.strategy(CAR, False).rung == "patient"
|
||||
assert ladder.snapshot(CAR)["steps"][1]["state"] == "skipped"
|
||||
|
||||
|
||||
def test_the_adapter_experiments_run_last_and_only_for_adapters(ladder):
|
||||
for _ in range(5):
|
||||
refuse(ladder, ladder.strategy(CAR, False), adapter=True)
|
||||
strategy = ladder.strategy(CAR, False)
|
||||
assert strategy.rung == "experiments" and strategy.experiments
|
||||
assert refuse(ladder, strategy, adapter=True) == "wrapped"
|
||||
other = sl.SetupLadder(ladder.path.with_name("other.json"), boot_id=lambda: "b")
|
||||
for _ in range(5):
|
||||
refuse(other, other.strategy(CAR, False))
|
||||
assert other.strategy(CAR, False).rung == "standard" # no adapter, no experiments rung: back to the start
|
||||
assert "experiments" not in [step["id"] for step in other.snapshot(CAR)["steps"]]
|
||||
|
||||
|
||||
def test_three_passes_per_boot_then_it_stops_trying_new_things(ladder):
|
||||
results = []
|
||||
for _ in range(5 * sl.MAX_PASSES):
|
||||
results.append(refuse(ladder, ladder.strategy(CAR, False)))
|
||||
assert results.count("exhausted") == 1 and ladder.exhausted(CAR)
|
||||
assert ladder.strategy(CAR, False).rung == "standard" # settles on today's behavior
|
||||
assert refuse(ladder, ladder.strategy(CAR, False)) is None
|
||||
assert ladder.snapshot(CAR)["exhausted"]
|
||||
|
||||
|
||||
def test_the_next_ignition_gets_its_passes_back_and_resumes_at_the_first_untried_rung(ladder):
|
||||
for _ in range(2):
|
||||
refuse(ladder, ladder.strategy(CAR, False))
|
||||
ladder.state["boot"] = "boot-2"
|
||||
assert ladder.strategy(CAR, False).rung == "modern_dual"
|
||||
assert ladder.snapshot(CAR)["passes"] == 0
|
||||
for _ in range(5 * sl.MAX_PASSES):
|
||||
refuse(ladder, ladder.strategy(CAR, False))
|
||||
assert ladder.exhausted(CAR)
|
||||
ladder.state["boot"] = "boot-3"
|
||||
assert not ladder.exhausted(CAR) and ladder.snapshot(CAR)["passes"] == 0
|
||||
|
||||
|
||||
def test_what_worked_is_used_first_next_time_even_for_a_different_remembered_reply(ladder):
|
||||
refuse(ladder, ladder.strategy(CAR, False))
|
||||
worked = ladder.strategy(CAR, False)
|
||||
assert worked.rung == "other_reply"
|
||||
ladder.worked(CAR, worked, t_ready_s=18.4)
|
||||
# The reply style is now remembered as the phone-style one; "the other reply" must not flip back to standard.
|
||||
again = ladder.strategy(CAR, True)
|
||||
assert (again.rung, again.modern_reply) == ("other_reply", True)
|
||||
snapshot = ladder.snapshot(CAR)
|
||||
assert snapshot["learned"] and snapshot["t_ready_s"] == 18.4 and snapshot["successes"] == 1
|
||||
assert [step["state"] for step in snapshot["steps"]][1] == "worked"
|
||||
|
||||
|
||||
def test_a_learned_strategy_is_forgotten_only_after_two_handshake_failures(ladder):
|
||||
ladder.worked(CAR, sl.resolve("modern_dual", False))
|
||||
learned = ladder.strategy(CAR, False)
|
||||
assert refuse(ladder, learned) == "counted" and ladder.strategy(CAR, False).rung == "modern_dual"
|
||||
assert refuse(ladder, learned, ladder_failure=False) is None and refuse(ladder, learned, video_seen=True) is None
|
||||
assert refuse(ladder, learned) == "dropped"
|
||||
assert ladder.strategy(CAR, False).rung == "standard" and not ladder.snapshot(CAR)["learned"]
|
||||
|
||||
|
||||
def test_state_survives_a_restart_and_a_damaged_file_means_a_fresh_start(tmp_path):
|
||||
path = tmp_path / "setup_state.json"
|
||||
first = sl.SetupLadder(path, boot_id=lambda: "b")
|
||||
refuse(first, first.strategy(CAR, False))
|
||||
first.worked(CAR, sl.resolve("patient", False), 12.0)
|
||||
second = sl.SetupLadder(path, boot_id=lambda: "b")
|
||||
assert second.strategy(CAR.lower(), False).rung == "patient" and path.stat().st_mode & 0o777 == 0o600
|
||||
for damage in ("not json", json.dumps({"version": 99, "cars": {}}), json.dumps({"version": 1, "cars": {CAR: {"working": {"rung": "x"}}}}),
|
||||
json.dumps({"version": 1, "cars": [1]}), ""):
|
||||
path.write_text(damage)
|
||||
assert sl.SetupLadder(path, boot_id=lambda: "b").strategy(CAR, False).rung == "standard"
|
||||
|
||||
|
||||
def test_a_read_only_disk_never_stops_a_connection(ladder):
|
||||
ladder.path = ladder.path / "blocked" / "x" # parent is a missing path under a file below
|
||||
ladder.path.parent.parent.parent.mkdir(exist_ok=True)
|
||||
(ladder.path.parent.parent).write_text("a file where a directory should be")
|
||||
refuse(ladder, ladder.strategy(CAR, False)) # must not raise
|
||||
assert ladder.strategy(CAR, False).rung == "other_reply"
|
||||
|
||||
|
||||
def test_only_sixteen_cars_are_kept_newest_first(tmp_path):
|
||||
clock = iter(range(1000, 2000))
|
||||
ladder = sl.SetupLadder(tmp_path / "s.json", boot_id=lambda: "b", clock=lambda: next(clock))
|
||||
for number in range(20):
|
||||
ladder.worked(f"AA:00:00:00:00:{number:02X}", sl.resolve("patient", False))
|
||||
reloaded = sl.SetupLadder(tmp_path / "s.json", boot_id=lambda: "b")
|
||||
assert len(reloaded.cars) == sl.MAX_CARS and "AA:00:00:00:00:13" in reloaded.cars and "AA:00:00:00:00:00" not in reloaded.cars
|
||||
|
||||
|
||||
# ---- the supervisor's use of it -------------------------------------------------------------------------------------
|
||||
import time as _time # noqa: E402
|
||||
from types import SimpleNamespace # noqa: E402
|
||||
|
||||
from openpilot.starpilot.system.starport.tests.fake_head_unit import make_identity # noqa: E402
|
||||
from openpilot.starpilot.system.starport.tests.test_starport import make_supervisor # noqa: E402
|
||||
|
||||
RECEIVER = "AA:BB:CC:DD:EE:01" # the car make_supervisor selects
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def identity(tmp_path_factory):
|
||||
return make_identity(tmp_path_factory.mktemp("identity"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sup(identity, tmp_path, monkeypatch):
|
||||
supervisor, _ = make_supervisor(identity, tmp_path, monkeypatch, lambda *a, **k: None)
|
||||
return supervisor
|
||||
|
||||
|
||||
def streamed(seconds):
|
||||
return SimpleNamespace(video_confirmed=True, video_confirmed_at=_time.monotonic() - seconds)
|
||||
|
||||
|
||||
def test_the_supervisor_keeps_its_ladder_beside_the_config_so_tests_never_touch_real_state(sup):
|
||||
from openpilot.starpilot.system.starport import identity as identity_store
|
||||
assert sup._ladder.path == identity_store.CONFIG_PATH.with_name("setup_state.json")
|
||||
|
||||
|
||||
def test_a_minute_of_video_makes_the_strategy_the_one_this_car_starts_with(sup):
|
||||
strategy = sl.resolve("other_reply", False)
|
||||
sup._strategy, sup._run_started = strategy, _time.monotonic() - 150
|
||||
sup._note_streamed(streamed(30)) # too short to count, but video was seen
|
||||
assert sup._video_seen and not sup._ladder.snapshot(RECEIVER)["learned"]
|
||||
sup._note_streamed(streamed(90))
|
||||
snapshot = sup._ladder.snapshot(RECEIVER)
|
||||
assert snapshot["learned"] and snapshot["current"] == "other_reply" and 50 < snapshot["t_ready_s"] < 70
|
||||
|
||||
|
||||
def test_a_session_that_never_got_video_learns_nothing(sup):
|
||||
sup._strategy = sl.resolve("patient", False)
|
||||
sup._note_streamed(SimpleNamespace(video_confirmed=False, video_confirmed_at=0.0))
|
||||
assert not sup._video_seen and not sup._ladder.snapshot(RECEIVER)["learned"]
|
||||
|
||||
|
||||
def test_status_shows_the_ladder_the_switch_and_the_cars_to_choose_between(sup):
|
||||
status = sup.status()
|
||||
assert status["new_setup"] in (True, False) and status["car_candidates"] == []
|
||||
assert [step["id"] for step in status["setup_ladder"]["steps"]][:2] == ["standard", "other_reply"]
|
||||
assert status["setup_ladder"]["max_passes"] == sl.MAX_PASSES and not status["setup_ladder"]["learned"]
|
||||
|
||||
|
||||
def test_forgetting_a_car_forgets_what_was_learned_about_it(sup):
|
||||
sup._ladder.worked(RECEIVER, sl.resolve("patient", False), 10.0)
|
||||
assert sup.forget_receiver(RECEIVER) is True
|
||||
assert not sup._ladder.snapshot(RECEIVER)["learned"] and RECEIVER not in sup._ladder.cars
|
||||
Reference in New Issue
Block a user