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": "