diff --git a/openpilot/starpilot/system/starport/identity.py b/openpilot/starpilot/system/starport/identity.py index 425ea86308..6859db0cf3 100644 --- a/openpilot/starpilot/system/starport/identity.py +++ b/openpilot/starpilot/system/starport/identity.py @@ -146,6 +146,7 @@ DEFAULT_CONFIG = { "phone_class": True, # while pairing/connecting, present as a phone: HFP gateway + smartphone Class of Device "auto_connect": True, # start projection on its own when the chosen car is on (onroad, or it reaches the comma) "rfcomm_cache": {}, # car address -> Android Auto RFCOMM channel learned over SDP, to skip discovery next time + "modern_reply_cache": {}, # car address -> True when its v4+ head unit only started after the phone-style version reply "car_type": "", # wireless setup shown in Galaxy: "builtin" or "adapter"; "" = work it out from the saved companion car "car_profile_override": "", # "" = pick by fingerprint (a wireless adapter must choose); "default" = standard; otherwise a car_profiles id "car_fingerprint": {}, # what the chosen car's head unit reported last time, used to pick a profile before it connects @@ -193,6 +194,8 @@ def load_config(path: Path | None = None) -> dict: config["render_profile_kb"] = max(16, min(4096, int(config["render_profile_kb"]))) config["rfcomm_cache"] = {str(address).upper(): channel for address, channel in config["rfcomm_cache"].items() if type(channel) is int and 1 <= channel <= 30} + config["modern_reply_cache"] = {str(address).upper(): True for address, modern in config["modern_reply_cache"].items() + if modern is True} return config diff --git a/openpilot/starpilot/system/starport/supervisor.py b/openpilot/starpilot/system/starport/supervisor.py index 246202f3ab..e7673e2b69 100644 --- a/openpilot/starpilot/system/starport/supervisor.py +++ b/openpilot/starpilot/system/starport/supervisor.py @@ -35,7 +35,7 @@ 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, system_slice from openpilot.starpilot.system.starport.auto_connect import AutoConnectPolicy -from openpilot.starpilot.system.starport.bootstrap import NAMES, BootstrapError, WirelessBootstrap +from openpilot.starpilot.system.starport.bootstrap import MODERN_MAJOR, NAMES, BootstrapError, 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) @@ -498,11 +498,13 @@ class Supervisor: self.stop() with self._lock: companion = self.config.get("companion_address", "").upper() == address - cache = dict(self.config["rfcomm_cache"]) + cache, replies = dict(self.config["rfcomm_cache"]), dict(self.config.get("modern_reply_cache", {})) cached = cache.pop(address, None) is not None + cached = replies.pop(address, None) is not None or cached if not (chosen or companion or cached): return False # nothing of this device was kept - self.config["rfcomm_cache"] = cache + self.config.update(rfcomm_cache=cache, modern_reply_cache=replies) + self._modern_reply.pop(address, None) if chosen: if self._bluez is not None: self._bluez.release() @@ -987,6 +989,21 @@ class Supervisor: else: bluez.restore_class() + def _remember_reply(self, address: str, modern: bool) -> None: + """Keep the version reply a v4+ head unit started with, so the next session leads with it.""" + with self._lock: + self._modern_reply[address] = modern + replies = dict(self.config.get("modern_reply_cache", {})) + if modern == replies.get(address, False): + return + if modern: + replies[address] = True + else: + replies.pop(address, None) + self.config["modern_reply_cache"] = replies + identity_store.save_config(self.config) + self.log("bootstrap_reply_remembered", modern=modern) + def _remember_channel(self, address: str, channel: int | None) -> None: with self._lock: cache = dict(self.config["rfcomm_cache"]) @@ -1242,14 +1259,14 @@ class Supervisor: 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, False)) + modern_reply=self._modern_reply.get(address, config.get("modern_reply_cache", {}).get(address, False))) 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 self._modern_reply.get(address, False) # try the other version reply next + self._modern_reply[address] = not boot.modern_reply # try the other version reply next self.log("bootstrap_reply_switched", modern=self._modern_reply[address]) if source == "cache" and not self._stop.is_set() and self._status["last_stage"] in ("rfcomm", "wifi_start") and car_never_answered(error): self._remember_channel(address, None) # the car never answered there; ask it over SDP next time @@ -1257,6 +1274,8 @@ class Supervisor: raise if source == "sdp": self._remember_channel(address, channel) + if boot.version_major >= MODERN_MAJOR: + self._remember_reply(address, boot.modern_reply) self._set(head_unit=result.head_unit) self._remember_fingerprint(result.head_unit) bluez.restore_class() # the car has accepted the comma; stop looking like a phone to everything else diff --git a/openpilot/starpilot/system/starport/tests/test_forget_car.py b/openpilot/starpilot/system/starport/tests/test_forget_car.py index 541594d3cb..d20bf89df7 100644 --- a/openpilot/starpilot/system/starport/tests/test_forget_car.py +++ b/openpilot/starpilot/system/starport/tests/test_forget_car.py @@ -67,3 +67,13 @@ def test_forget_car_edits_the_saved_config_when_the_daemon_is_not_running(tmp_pa assert saved()["companion_address"] == "" and saved()["receiver_address"] == ADAPTER assert protocol.forget_car(ADAPTER.lower(), client) is True assert saved()["receiver_address"] == "" and saved()["rfcomm_cache"] == {} + + +def test_the_version_reply_a_car_started_with_is_kept_and_forgotten_with_it(sup): + sup._remember_reply(ADAPTER, True) + sup._remember_reply(OTHER, True) + assert saved()["modern_reply_cache"] == {ADAPTER: True, OTHER: True} + sup._remember_reply(OTHER, False) + assert saved()["modern_reply_cache"] == {ADAPTER: True} + daemon.handle(sup, {"command": "forget_receiver", "address": ADAPTER}) + assert saved()["modern_reply_cache"] == {} and ADAPTER not in sup._modern_reply diff --git a/openpilot/starpilot/system/starport/tests/test_identity_migration.py b/openpilot/starpilot/system/starport/tests/test_identity_migration.py index 05731ea7cc..837adf77f4 100644 --- a/openpilot/starpilot/system/starport/tests/test_identity_migration.py +++ b/openpilot/starpilot/system/starport/tests/test_identity_migration.py @@ -28,3 +28,11 @@ def test_existing_identity_survives_directory_migration(tmp_path, monkeypatch, o assert directory == (legacy if outcome == 'denied' else current) assert (directory / 'certificate').read_bytes() == b'existing identity' + + +def test_remembered_version_replies_load_only_as_true_per_address(tmp_path): + path = tmp_path / "config.json" + path.write_text('{"config_version": 7, "modern_reply_cache": {"aa:bb:cc:dd:ee:01": true, "AA:BB:CC:DD:EE:02": false, "x": 1}}') + assert identity.load_config(path)["modern_reply_cache"] == {"AA:BB:CC:DD:EE:01": True} + path.write_text('{"config_version": 7}') + assert identity.load_config(path)["modern_reply_cache"] == {}