From 57900f3d7367ab1c1809d9f39a4f82c2efb3c6e6 Mon Sep 17 00:00:00 2001 From: dirwin31 <83434411+dirwin31@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:12:37 -0800 Subject: [PATCH] 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. --- .../starpilot/system/starport/bootstrap.py | 9 +- .../system/starport/bugsink_report.py | 19 ++ .../starpilot/system/starport/session.py | 2 + .../starpilot/system/starport/setup_ladder.py | 257 ++++++++++++++++++ .../starpilot/system/starport/supervisor.py | 52 +++- .../starport/tests/test_bugsink_report.py | 14 + .../starport/tests/test_setup_ladder.py | 213 +++++++++++++++ 7 files changed, 556 insertions(+), 10 deletions(-) create mode 100644 openpilot/starpilot/system/starport/setup_ladder.py create mode 100644 openpilot/starpilot/system/starport/tests/test_setup_ladder.py diff --git a/openpilot/starpilot/system/starport/bootstrap.py b/openpilot/starpilot/system/starport/bootstrap.py index bd00289ec1..9e23f0d0c4 100644 --- a/openpilot/starpilot/system/starport/bootstrap.py +++ b/openpilot/starpilot/system/starport/bootstrap.py @@ -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: diff --git a/openpilot/starpilot/system/starport/bugsink_report.py b/openpilot/starpilot/system/starport/bugsink_report.py index c739af77e6..40d0d6e967 100644 --- a/openpilot/starpilot/system/starport/bugsink_report.py +++ b/openpilot/starpilot/system/starport/bugsink_report.py @@ -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 {}), diff --git a/openpilot/starpilot/system/starport/session.py b/openpilot/starpilot/system/starport/session.py index 9042b765cb..22f3ff284f 100644 --- a/openpilot/starpilot/system/starport/session.py +++ b/openpilot/starpilot/system/starport/session.py @@ -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. diff --git a/openpilot/starpilot/system/starport/setup_ladder.py b/openpilot/starpilot/system/starport/setup_ladder.py new file mode 100644 index 0000000000..cb68e094c6 --- /dev/null +++ b/openpilot/starpilot/system/starport/setup_ladder.py @@ -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} diff --git a/openpilot/starpilot/system/starport/supervisor.py b/openpilot/starpilot/system/starport/supervisor.py index 6e7d94c581..1e559175d5 100644 --- a/openpilot/starpilot/system/starport/supervisor.py +++ b/openpilot/starpilot/system/starport/supervisor.py @@ -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. diff --git a/openpilot/starpilot/system/starport/tests/test_bugsink_report.py b/openpilot/starpilot/system/starport/tests/test_bugsink_report.py index 3221f8114b..2e300c8974 100644 --- a/openpilot/starpilot/system/starport/tests/test_bugsink_report.py +++ b/openpilot/starpilot/system/starport/tests/test_bugsink_report.py @@ -492,3 +492,17 @@ def test_unknown_setup_values_are_dropped(tmp_path): events[0] = (0, 0, "session_start", {"setup": {"type": "