diff --git a/openpilot/starpilot/galaxy/starport_send.py b/openpilot/starpilot/galaxy/starport_send.py index 2be1c6d37d..91e668a32c 100644 --- a/openpilot/starpilot/galaxy/starport_send.py +++ b/openpilot/starpilot/galaxy/starport_send.py @@ -11,15 +11,14 @@ from __future__ import annotations import base64 import hashlib import json -import os import re -import tempfile import threading import time from collections.abc import Callable from pathlib import Path from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit +from openpilot.common.utils import atomic_write from openpilot.starpilot.system.starport import identity as identity_store MESSAGE_LIMIT = 60 @@ -148,15 +147,8 @@ def _response_json(response) -> dict: def _save_thread_id(path: Path, key: str, thread_id: str) -> None: path.parent.mkdir(parents=True, exist_ok=True) # A power loss during a write must not truncate the previous cache entry. - with tempfile.NamedTemporaryFile(mode="w", dir=path.parent, delete=False) as temporary: - temporary_path = Path(temporary.name) - try: - json.dump({"key": key, "thread_id": thread_id}, temporary) - temporary.flush() - os.fsync(temporary.fileno()) - os.replace(temporary_path, path) - finally: - temporary_path.unlink(missing_ok=True) + with atomic_write(str(path), overwrite=True) as handle: + json.dump({"key": key, "thread_id": thread_id}, handle) def send(data: bytes, name: str, note: str, summary: dict, url: str | None = None, post=None, diff --git a/openpilot/starpilot/galaxy/tests/test_starport.mjs b/openpilot/starpilot/galaxy/tests/test_starport.mjs index f2ef23f936..a29a6b9c7b 100644 --- a/openpilot/starpilot/galaxy/tests/test_starport.mjs +++ b/openpilot/starpilot/galaxy/tests/test_starport.mjs @@ -17,7 +17,7 @@ assert.equal(validPromptInput({ kind: "pin" }, "1234"), true) assert.equal(validPromptInput({ kind: "pin" }, "\n"), false) assert.equal(validPromptInput({ kind: "passkey" }, "1234567"), false) assert.equal(validPromptInput(prompt, ""), true) -assert.match(StarPortPage.template, /Check that this code matches your car/) +assert.match(StarPortPage.template, /Check that this code matches your/) assert.match(StarPortPage.template, /Cancel Search \/ Pairing/) assert.doesNotMatch(StarPortPage.template, /:row="wiredRow"/) assert.match(StarPortPage.template, /Read the Android Auto troubleshooting guide/) @@ -484,6 +484,9 @@ typed.runtime = { car_type: 'adapter' } assert.equal(typed.adapterSetup, true) assert.equal(typed.pairsHere, true) assert.equal(typed.findLabel, 'adapter') +assert.equal(typed.deviceWord, 'adapter', 'the pairing prompts name the adapter, not the car') +typed.runtime = { car_type: 'builtin' } +assert.equal(typed.deviceWord, 'car') typed.runtime = { car_type: 'usb' } assert.equal(typed.pairsHere, false, 'a USB cable needs no Bluetooth pairing') typed.runtime = { running: false, connection: 'wired' } diff --git a/openpilot/starpilot/galaxy/web/css/galaxy.css b/openpilot/starpilot/galaxy/web/css/galaxy.css index 63bbb1f223..a995d57cdd 100644 --- a/openpilot/starpilot/galaxy/web/css/galaxy.css +++ b/openpilot/starpilot/galaxy/web/css/galaxy.css @@ -2039,7 +2039,6 @@ progress { max-width: 100%; accent-color: var(--primary); } .gx-guided__step-label { font-size: 13px; font-weight: var(--fw-bold); color: var(--g-muted); } .gx-guided__step.is-current .gx-guided__step-label { color: var(--g-ink); } @keyframes gx-guided-pop { 0% { transform: scale(.7); } 60% { transform: scale(1.12); } 100% { transform: scale(1); } } -.gx-guided__sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } .gx-guided__header { display: grid; gap: 6px; max-width: 34rem; } .gx-guided__title { margin: 0; font-size: 30px; line-height: 1.12; font-weight: var(--fw-bold); letter-spacing: -.02em; text-wrap: balance; } diff --git a/openpilot/starpilot/galaxy/web/js/starport-guided.js b/openpilot/starpilot/galaxy/web/js/starport-guided.js index f703c348f6..ade56ed1e6 100644 --- a/openpilot/starpilot/galaxy/web/js/starport-guided.js +++ b/openpilot/starpilot/galaxy/web/js/starport-guided.js @@ -134,7 +134,7 @@ export const StarPortGuidedSetup = {
  • - {{ step.label }}{{ step.state === 'done' ? ', done' : step.state === 'current' ? ', current step' : '' }} + {{ step.label }}{{ step.state === 'done' ? ', done' : step.state === 'current' ? ', current step' : '' }}
  • diff --git a/openpilot/starpilot/system/starport/bootstrap.py b/openpilot/starpilot/system/starport/bootstrap.py index 9e23f0d0c4..2836bb6089 100644 --- a/openpilot/starpilot/system/starport/bootstrap.py +++ b/openpilot/starpilot/system/starport/bootstrap.py @@ -275,7 +275,6 @@ class WirelessBootstrap: 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() self.initial_kick_delay = initial_kick_delay self.join_ping_interval = join_ping_interval @@ -347,11 +346,6 @@ class WirelessBootstrap: self.log("bootstrap_version_reply", style="modern", channel_type=selected, hex=reply.hex()) return reply - def silent_after_modern_version(self, error: BaseException) -> bool: - """A v4+ head unit took our version reply but never started; the other reply style may suit it.""" - return (isinstance(error, BootstrapTimeout) and error.stage == "wifi_start" and self.version_major >= MODERN_MAJOR - and not self.car_started) - def service(self, message_id: int, payload: bytes) -> bool: """Handle stage-independent frames; returns True when consumed.""" if message_id == WIFI_PING_REQUEST: @@ -423,7 +417,6 @@ class WirelessBootstrap: if start_request_at is None: start_request_at = time.monotonic() + self.start_request_delay elif message_id == WIFI_START_REQUEST: - self.car_started = True parsed = parse_endpoint(payload) if parsed is None: raise BootstrapError(self.stage, "WifiStartRequest has no valid IPv4 endpoint") @@ -431,7 +424,6 @@ class WirelessBootstrap: if "start_response_first" in self.flags: self.send(WIFI_START_RESPONSE, field(3, STATUS_SUCCESS)) elif message_id == WIFI_START_RESPONSE: - self.car_started = True # The car's answer to our WifiStartRequest (2025 Honda): ip=1, port=2, status=3 once it is # ready, or a negative status alone before then (it may follow up on its own; ask again anyway). status = signed(one(parse_fields(payload), 3, STATUS_SUCCESS)) diff --git a/openpilot/starpilot/system/starport/bugsink_diagnosis.py b/openpilot/starpilot/system/starport/bugsink_diagnosis.py index f9a3d97dae..b7630d0187 100644 --- a/openpilot/starpilot/system/starport/bugsink_diagnosis.py +++ b/openpilot/starpilot/system/starport/bugsink_diagnosis.py @@ -99,7 +99,7 @@ def attempt_records(events): STREAM_LOSS = { "shared_radio": ("comma's own Wi-Fi", "The car stopped acknowledging video while the comma's own Wi-Fi joined or roamed; both share one radio.", - ["Check the other_wifi_changed and ack_grace events for the network and how long acks stopped.", + ["Check the other_wifi_changed and ack_grace events for when it changed and how long acks stopped.", "Repeated drops at home or on a hotspot mean the 8 s grace is too short or the car takes its screen back first."]), "stalled": ("Video acknowledgement older than", "The car kept the link but stopped acknowledging video for too long, so the stream was ended.", ["Compare the Wi-Fi signal, frame age and encoder recoveries around the stall.", diff --git a/openpilot/starpilot/system/starport/network.py b/openpilot/starpilot/system/starport/network.py index 9609b64492..1611016419 100644 --- a/openpilot/starpilot/system/starport/network.py +++ b/openpilot/starpilot/system/starport/network.py @@ -109,37 +109,6 @@ def recover_autoconnect(log: Callable[..., None], marker: Path = AUTOCONNECT_MAR return True -SETTINGS_PROFILE_PREFIX = "openpilot connection " # system/ui/lib/wifi_manager.py names saved networks this way - - -def profile_network_name(profile: str) -> str: - """A saved connection's network name: "openpilot connection Home" -> "Home"; other profiles as they are.""" - return profile[len(SETTINGS_PROFILE_PREFIX):] if profile.startswith(SETTINGS_PROFILE_PREFIX) else profile - - -def wifi_network_name(interface: str = "wlan0") -> str: - """The network the comma's own Wi-Fi is on or joining, for telling the driver; "" if none or unknown. - - Its own D-Bus connection: never the car lease's, whose lock the link check and reconnect wait on.""" - lease = NetworkLease(lambda *args, **kwargs: None, interface) - try: - for path, name in lease._wifi_devices(): - if name != interface: - continue - access_point = lease._get(path, NM_WIRELESS_IFACE, "ActiveAccessPoint") - if access_point and access_point != "/": - ssid = bytes(lease._get(access_point, NM_AP_IFACE, "Ssid")).decode("utf-8", "replace").strip() - if ssid: - return ssid - # Still joining (no access point yet): the profile's name, which the comma's Wi-Fi settings prefix. - active = lease._get(path, NM_DEVICE_IFACE, "ActiveConnection") - return profile_network_name(str(lease._get(active, NM_ACTIVE_IFACE, "Id"))) if active and active != "/" else "" - return "" - finally: - if lease.router is not None: - lease.router.close() - - def connection_settings(credentials: WifiCredentials, interface: str) -> dict: wireless = { "ssid": ("ay", credentials.ssid.encode("utf-8")), diff --git a/openpilot/starpilot/system/starport/new_setup.py b/openpilot/starpilot/system/starport/new_setup.py index 49708eecb1..6cca56c184 100644 --- a/openpilot/starpilot/system/starport/new_setup.py +++ b/openpilot/starpilot/system/starport/new_setup.py @@ -5,6 +5,7 @@ read and change with the StarPort service down or broken, and it needs no device """ from pathlib import Path +from openpilot.common.utils import atomic_write from openpilot.starpilot.system.starport.identity import DATA_DIR FLAG_PATH = DATA_DIR / "new_setup" @@ -22,9 +23,8 @@ def set_enabled(value: bool, path: Path | None = None) -> bool: target = path or FLAG_PATH try: target.parent.mkdir(mode=0o700, parents=True, exist_ok=True) - temporary = target.with_name(f".{target.name}.tmp") - temporary.write_text("1" if value else "0") - temporary.replace(target) + with atomic_write(str(target), overwrite=True) as handle: + handle.write("1" if value else "0") except OSError: return False return enabled(target) == value diff --git a/openpilot/starpilot/system/starport/reconnect_notice.py b/openpilot/starpilot/system/starport/reconnect_notice.py index 9862e9e66d..0cf7a42792 100644 --- a/openpilot/starpilot/system/starport/reconnect_notice.py +++ b/openpilot/starpilot/system/starport/reconnect_notice.py @@ -3,31 +3,22 @@ from __future__ import annotations import json -import os import time from pathlib import Path +from openpilot.common.utils import atomic_write from openpilot.starpilot.system.starport import identity as identity_store NOTICE_PATH = identity_store.DATA_DIR / "reconnect_notice.json" NOTICE_FRESH_S = 120.0 # a notice older than this is about an earlier drive or reconnect (monotonic: same boot only) -NAME_MAX = 24 - - -def shared_radio_text(network: str) -> str: - """The car-screen line for a drop caused by the comma's own Wi-Fi joining or roaming.""" - name = network.strip() - if len(name) > NAME_MAX: - name = name[:NAME_MAX - 3].rstrip() + "..." # plain dots: the car-screen fonts may lack an ellipsis glyph - return f"Reconnected: comma Wi-Fi Joined {name}" if name else "Reconnected: comma Wi-Fi Was Switching Networks" +SHARED_RADIO_TEXT = "Reconnected: comma Wi-Fi Was Switching Networks" # on the car screen after a drop caused by the comma's own Wi-Fi def write(text: str, path: Path = NOTICE_PATH, now: float | None = None) -> None: try: path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) - temporary = path.with_suffix(".tmp") - temporary.write_text(json.dumps({"text": text, "written": time.monotonic() if now is None else now})) - os.replace(temporary, path) + with atomic_write(str(path), overwrite=True) as handle: + json.dump({"text": text, "written": time.monotonic() if now is None else now}, handle) except OSError: pass # the notice is a courtesy; projection never depends on it diff --git a/openpilot/starpilot/system/starport/session.py b/openpilot/starpilot/system/starport/session.py index f7d19ff8f9..f577caca8f 100644 --- a/openpilot/starpilot/system/starport/session.py +++ b/openpilot/starpilot/system/starport/session.py @@ -118,12 +118,6 @@ class PeerRequestedStop(EOFError): class SharedRadioStall(TimeoutError): """The car stopped acknowledging video while the comma's other Wi-Fi network was joining or roaming.""" - - def __init__(self, message: str, network: str): - super().__init__(message) - self.network = network - - START_CODE = re.compile(b"\x00\x00(?:\x00)?\x01") @@ -659,12 +653,10 @@ class ProjectionSession(Session): MAX_WINDOW = 2 nav = None grace_frame: float | None = None # send time of the frame whose late ack is being waited out - grace_network = "" grace_focus_lost = False - # Replaced by the supervisor: the comma's other Wi-Fi network's name if it joined or roamed in the last few - # seconds ("" when unnamed), else None. - radio_change: Callable[..., str | None] = staticmethod(lambda: None) + # Replaced by the supervisor: whether the comma's other Wi-Fi joined or roamed in the last few seconds. + radio_change: Callable[..., bool] = staticmethod(lambda: False) # HeadunitPad acknowledges every fragment (codec config included) rather than every frame. fragment_acks = False @@ -965,7 +957,7 @@ class ProjectionSession(Session): self.acked += count self.epoch_acked += count if self.grace_frame is not None and (not self.pending or self.pending[0] != self.grace_frame): - self.event("ack_grace_recovered", network=self.grace_network, gap_ms=round((now - self.grace_frame) * 1000)) + self.event("ack_grace_recovered", gap_ms=round((now - self.grace_frame) * 1000)) self.grace_frame = None self._confirm_video() @@ -991,7 +983,7 @@ class ProjectionSession(Session): measured = now is None now = time.monotonic() if measured else now if self.grace_focus_lost: - raise SharedRadioStall("The car took its screen back while the comma's Wi-Fi was switching networks", self.grace_network) + raise SharedRadioStall("The car took its screen back while the comma's Wi-Fi was switching networks") if self._ack_overdue(now): # ACKs that arrived while this loop was busy (a slow encode, a CPU burst) still sit in the socket; the # streaming loop drains only a bounded batch per pass, so read what is there before calling the car silent. @@ -1010,18 +1002,13 @@ class ProjectionSession(Session): def _wait_out_radio_change(self, now: float) -> None: """The oldest frame is past ACK_TIMEOUT: give up, unless the comma's other Wi-Fi just changed networks.""" oldest = self.pending[0] - if self.grace_frame == oldest: - network = self.grace_network - else: - changed = self.radio_change() - if changed is None: + if self.grace_frame != oldest: + if not self.radio_change(): raise TimeoutError(f"Video acknowledgement older than {self.ACK_TIMEOUT:.1f} s") - self.grace_frame, self.grace_network = oldest, changed - self.event("ack_grace", network=changed, after_ms=round((now - oldest) * 1000)) - network = changed + self.grace_frame = oldest + self.event("ack_grace", after_ms=round((now - oldest) * 1000)) if now - oldest > self.ACK_GRACE_TIMEOUT: - message = f"Video acknowledgement older than {self.ACK_GRACE_TIMEOUT:.1f} s while the comma's Wi-Fi was switching networks" - raise SharedRadioStall(message, network) + raise SharedRadioStall(f"Video acknowledgement older than {self.ACK_GRACE_TIMEOUT:.1f} s while the comma's Wi-Fi was switching networks") def send_frame(self, data: bytes, timestamp_us: int, *, keyframe: bool) -> None: if not self.can_send(): diff --git a/openpilot/starpilot/system/starport/setup_ladder.py b/openpilot/starpilot/system/starport/setup_ladder.py index 11ab9c6a1a..40b5187b2c 100644 --- a/openpilot/starpilot/system/starport/setup_ladder.py +++ b/openpilot/starpilot/system/starport/setup_ladder.py @@ -15,12 +15,11 @@ The first pass for a car is exactly what ran before (rung ``standard``); the lat from __future__ import annotations import json -import os -import tempfile import time from dataclasses import dataclass from pathlib import Path +from openpilot.common.utils import atomic_write from openpilot.starpilot.system.starport.bootstrap import CHANNEL_24GHZ, CHANNEL_DUAL, MODERN_MAJOR, REFUSAL_PATIENCE from openpilot.starpilot.system.starport.identity import DATA_DIR @@ -152,17 +151,8 @@ class SetupLadder: 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) + with atomic_write(str(self.path), overwrite=True) as handle: + json.dump({"version": STATE_VERSION, "cars": ordered}, handle, separators=(",", ":")) except OSError: return False # progress is a convenience; a read-only disk must never stop a connection return True diff --git a/openpilot/starpilot/system/starport/supervisor.py b/openpilot/starpilot/system/starport/supervisor.py index 90dbe7a196..2ba3b6da0b 100644 --- a/openpilot/starpilot/system/starport/supervisor.py +++ b/openpilot/starpilot/system/starport/supervisor.py @@ -258,19 +258,14 @@ class LinkWatch: class SharedRadioWatch: """Notices the comma's own Wi-Fi joining or roaming while the car is on aa0; both share one radio. - The link going down or up bumps carrier_changes, a plain sysfs read. The network's name comes from - NetworkManager on this thread, never the streaming one. + The link going down or up bumps carrier_changes, a plain sysfs read. The network's name is never read or logged. """ def __init__(self, log: Callable[..., None], interface: str = "wlan0", interval: float = RADIO_CHECK_INTERVAL, - counter: Path | None = None, name: Callable[[str], str] | None = None): + counter: Path | None = None): self.log, self.interface, self.interval = log, interface, interval - if name is None: - from openpilot.starpilot.system.starport.network import wifi_network_name as name - self.name = name self.counter = counter or Path(f"/sys/class/net/{interface}/carrier_changes") self.changed_at: float | None = None - self.network = "" self._stop = threading.Event() self._thread = threading.Thread(target=self._run, name="aa_radio_watch", daemon=True) @@ -281,11 +276,11 @@ class SharedRadioWatch: def stop(self) -> None: self._stop.set() - def recent(self, now: float | None = None) -> str | None: - """The other network's name if it changed within RADIO_CHANGE_WINDOW ("" if unnamed), else None.""" + def recent(self, now: float | None = None) -> bool: + """Whether the other network joined or roamed within RADIO_CHANGE_WINDOW.""" changed_at = self.changed_at now = time.monotonic() if now is None else now - return self.network if changed_at is not None and now - changed_at <= RADIO_CHANGE_WINDOW else None + return changed_at is not None and now - changed_at <= RADIO_CHANGE_WINDOW def _read(self) -> str | None: try: @@ -300,13 +295,8 @@ class SharedRadioWatch: if current is None or current == last: continue last = current - self.network = "" # named below; D-Bus can be slow, and the change must count from now self.changed_at = time.monotonic() - try: - self.network = self.name(self.interface) - except Exception: - self.network = "" - self.log("other_wifi_changed", interface=self.interface, network=self.network) + self.log("other_wifi_changed", interface=self.interface) class EventLog: @@ -597,11 +587,11 @@ class Supervisor: 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): + learned = self._ladder.forget(address) + if not (chosen or companion or cached or learned): 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() @@ -846,7 +836,7 @@ class Supervisor: return self._adopt_at = now try: - car, candidates = choose_only_car(self._phone().devices()) + car, candidates = choose_only_car(self.devices()) except Exception as error: self._adopt_note("car_adopt_unavailable", kind=type(error).__name__, error=str(error)[:120]) return @@ -860,7 +850,7 @@ class Supervisor: return try: self.select_receiver(car["address"], car.get("name") or "") - except (RuntimeError, ValueError) as error: + except Exception as error: self._adopt_note("car_adopt_failed", error=str(error)) return finally: @@ -1369,7 +1359,7 @@ class Supervisor: reach = reachability.classify(stage, type(error).__name__, f"{message} {error}") self.log("attempt_failed", stage=stage, error=message, kind=type(error).__name__, where=_where(error), reach=reach) if isinstance(error, SharedRadioStall): - reconnect_notice.write(reconnect_notice.shared_radio_text(error.network)) + reconnect_notice.write(reconnect_notice.SHARED_RADIO_TEXT) if isinstance(error, StrayLinkError): self._clear_stray_link() car_first = True # the car pages right back once its link drops; let it, rather than crossing it again @@ -1414,8 +1404,7 @@ class Supervisor: if isinstance(error, AuthenticationRejected): return "The car rejected the Android Auto identity; it may have expired" if isinstance(error, SharedRadioStall): - joined = f"joined {error.network}" if error.network else "was switching networks" - return f"The car's Wi-Fi stalled while the comma's own Wi-Fi {joined}; reconnecting" + return "The car's Wi-Fi stalled while the comma's own Wi-Fi was switching networks; reconnecting" text = str(error) or type(error).__name__ if stage == "authenticating" and "certificate" in text.lower(): text += " (set verify_head_unit false in config.json to test without verifying the car)" diff --git a/openpilot/starpilot/system/starport/tests/test_bootstrap_receivers.py b/openpilot/starpilot/system/starport/tests/test_bootstrap_receivers.py index 4c41072a91..4622c9bcd2 100644 --- a/openpilot/starpilot/system/starport/tests/test_bootstrap_receivers.py +++ b/openpilot/starpilot/system/starport/tests/test_bootstrap_receivers.py @@ -185,16 +185,3 @@ def test_version_four_head_units_get_a_phone_shaped_version_reply(): def test_older_head_units_and_the_first_try_keep_the_reply_that_works_today(): for reply in (version_reply(FORD_V16), version_reply(KIA_V41, modern=False)): assert reply[4] == [0] and 5 not in reply and 6 not in reply - - -def test_only_a_silent_v4_head_unit_asks_for_the_other_reply(): - timeout = bs.BootstrapTimeout("wifi_start", "head unit did not answer in time") - boot, _ = scripted([]) - boot.version_major = 4 - assert boot.silent_after_modern_version(timeout) - boot.car_started = True - assert not boot.silent_after_modern_version(timeout) - boot.car_started, boot.version_major = False, 1 - assert not boot.silent_after_modern_version(timeout) - boot.version_major = 4 - assert not boot.silent_after_modern_version(bs.BootstrapTimeout("wifi_info", "head unit did not answer in time")) diff --git a/openpilot/starpilot/system/starport/tests/test_car_adoption.py b/openpilot/starpilot/system/starport/tests/test_car_adoption.py index 1c10590dc3..5c7364f364 100644 --- a/openpilot/starpilot/system/starport/tests/test_car_adoption.py +++ b/openpilot/starpilot/system/starport/tests/test_car_adoption.py @@ -109,3 +109,13 @@ def test_an_unreadable_bluetooth_service_is_reported_once(sup): for tick in range(4): sup._adopt_only_car(1000.0 + 25 * tick) assert names(sup).count("car_adopt_unavailable") == 1 + + +def test_a_bluetooth_error_while_choosing_the_car_is_reported_not_raised(sup): + sup._phone().paired_devices = [(CAR, "Civic", True)] + + def rejected(address, name=""): + raise Exception("Bluetooth rejected") + sup.select_receiver = rejected + sup._adopt_only_car(1000.0) + assert "car_adopt_failed" in names(sup) and sup.config["receiver_address"] == "" diff --git a/openpilot/starpilot/system/starport/tests/test_forget_car.py b/openpilot/starpilot/system/starport/tests/test_forget_car.py index d20bf89df7..4bb28a128c 100644 --- a/openpilot/starpilot/system/starport/tests/test_forget_car.py +++ b/openpilot/starpilot/system/starport/tests/test_forget_car.py @@ -77,3 +77,11 @@ def test_the_version_reply_a_car_started_with_is_kept_and_forgotten_with_it(sup) 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 + + +def test_a_car_known_only_to_the_setup_ladder_is_still_forgotten(sup): + sup._ladder.strategy(OTHER, False) + sup._ladder.failed(OTHER, sup._ladder.strategy(OTHER, False), ladder_failure=True, video_seen=False, version_major=4, adapter=False) + assert OTHER.upper() in sup._ladder.cars + daemon.handle(sup, {"command": "forget_receiver", "address": OTHER}) + assert OTHER.upper() not in sup._ladder.cars diff --git a/openpilot/starpilot/system/starport/tests/test_shared_radio_grace.py b/openpilot/starpilot/system/starport/tests/test_shared_radio_grace.py index 33c02df4c6..ee3cbce44e 100644 --- a/openpilot/starpilot/system/starport/tests/test_shared_radio_grace.py +++ b/openpilot/starpilot/system/starport/tests/test_shared_radio_grace.py @@ -17,42 +17,41 @@ def identity(tmp_path_factory): return make_identity(tmp_path_factory.mktemp("identity")) -def stalled(identity, network: str | None): +def stalled(identity, changed: bool): hu = FakeHeadUnit(identity) session = streaming_session(hu, identity) - session.radio_change = lambda: network + session.radio_change = lambda: changed hu.hold_acks.set() return hu, session, sent_frame(hu, session) def test_no_radio_change_keeps_the_standard_limit(identity): - hu, session, sent = stalled(identity, None) + hu, session, sent = stalled(identity, False) with pytest.raises(TimeoutError, match="2.5 s") as raised: session.check_progress(now=sent + ProjectionSession.ACK_TIMEOUT + 0.05) assert not isinstance(raised.value, SharedRadioStall) end_session(session, hu) -def test_radio_change_waits_up_to_the_grace_limit_then_names_the_network(identity): - hu, session, sent = stalled(identity, "Huckleberrys House") +def test_radio_change_waits_up_to_the_grace_limit(identity): + hu, session, sent = stalled(identity, True) session.check_progress(now=sent + ProjectionSession.ACK_TIMEOUT + 0.05) session.check_progress(now=sent + ProjectionSession.ACK_GRACE_TIMEOUT - 0.1) - with pytest.raises(SharedRadioStall) as raised: + with pytest.raises(SharedRadioStall): session.check_progress(now=sent + ProjectionSession.ACK_GRACE_TIMEOUT + 0.05) - assert raised.value.network == "Huckleberrys House" end_session(session, hu) def test_grace_started_by_a_change_survives_the_change_ageing_out(identity): - hu, session, sent = stalled(identity, "Home") + hu, session, sent = stalled(identity, True) session.check_progress(now=sent + ProjectionSession.ACK_TIMEOUT + 0.05) - session.radio_change = lambda: None # same stall: the decision was made when it began + session.radio_change = lambda: False # same stall: the decision was made when it began session.check_progress(now=sent + ProjectionSession.ACK_GRACE_TIMEOUT - 0.1) end_session(session, hu) def test_acks_resuming_during_the_grace_keep_the_session_and_log_the_gap(identity): - hu, session, sent = stalled(identity, "Home") + hu, session, sent = stalled(identity, True) events = [] session.event = lambda name, **fields: events.append((name, fields)) session.check_progress(now=sent + ProjectionSession.ACK_TIMEOUT + 0.05) @@ -66,7 +65,7 @@ def test_acks_resuming_during_the_grace_keep_the_session_and_log_the_gap(identit def test_car_taking_its_screen_back_during_the_grace_reconnects(identity): - hu, session, sent = stalled(identity, "Home") + hu, session, sent = stalled(identity, True) session.check_progress(now=sent + ProjectionSession.ACK_TIMEOUT + 0.05) hu.set_focus(False) pump_until(session, lambda: not session.focused) @@ -84,64 +83,35 @@ def test_unfocus_outside_a_grace_is_the_normal_reverse_camera(identity): end_session(session, hu) -def home(interface): - return "Huckleberrys House" - - -def test_watch_reports_a_carrier_change_with_the_network_name(tmp_path, monkeypatch): +def test_watch_reports_a_carrier_change_without_naming_the_network(tmp_path): counter = tmp_path / "carrier_changes" counter.write_text("4\n") logged = [] - watch = SharedRadioWatch(lambda name, **fields: logged.append((name, fields)), interval=0.01, counter=counter, name=home).start() + watch = SharedRadioWatch(lambda name, **fields: logged.append((name, fields)), interval=0.01, counter=counter).start() try: time.sleep(0.05) - assert watch.recent() is None # the starting value is not a change + assert not watch.recent() # the starting value is not a change counter.write_text("5\n") deadline = time.monotonic() + 2 - while watch.recent() != "Huckleberrys House": + while not watch.recent(): assert time.monotonic() < deadline time.sleep(0.01) - assert logged == [("other_wifi_changed", {"interface": "wlan0", "network": "Huckleberrys House"})] + assert logged == [("other_wifi_changed", {"interface": "wlan0"})] assert watch.changed_at is not None - assert watch.recent(now=watch.changed_at + supervisor_module.RADIO_CHANGE_WINDOW + 0.1) is None + assert not watch.recent(now=watch.changed_at + supervisor_module.RADIO_CHANGE_WINDOW + 0.1) finally: watch.stop() def test_watch_without_the_counter_never_reports(tmp_path): - watch = SharedRadioWatch(lambda *a, **k: None, interval=0.01, counter=tmp_path / "missing", name=home).start() + watch = SharedRadioWatch(lambda *a, **k: None, interval=0.01, counter=tmp_path / "missing").start() try: time.sleep(0.05) - assert watch.recent() is None + assert not watch.recent() finally: watch.stop() -def test_name_lookup_failure_still_counts_the_change(tmp_path): - def broken(interface): - raise RuntimeError("NetworkManager timed out") - - counter = tmp_path / "carrier_changes" - counter.write_text("1") - watch = SharedRadioWatch(lambda *a, **k: None, interval=0.01, counter=counter, name=broken).start() - try: - counter.write_text("2") - deadline = time.monotonic() + 2 - while watch.recent() is None: - assert time.monotonic() < deadline - time.sleep(0.01) - assert watch.recent() == "" - finally: - watch.stop() - - -def test_notice_text_is_short_and_plain(): - assert reconnect_notice.shared_radio_text("Huckleberrys House") == "Reconnected: comma Wi-Fi Joined Huckleberrys House" - assert reconnect_notice.shared_radio_text("") == "Reconnected: comma Wi-Fi Was Switching Networks" - long = reconnect_notice.shared_radio_text("A" * 60) - assert long.endswith("...") and len(long) <= len("Reconnected: comma Wi-Fi Joined ") + reconnect_notice.NAME_MAX - - def test_notice_is_shown_once_and_only_while_fresh(tmp_path): path = tmp_path / "notice.json" reconnect_notice.write("Reconnected", path, now=1000.0) @@ -153,15 +123,7 @@ def test_notice_is_shown_once_and_only_while_fresh(tmp_path): def test_galaxy_status_says_what_happened(): - text = Supervisor._describe_error("streaming", SharedRadioStall("stalled", "Huckleberrys House")) - assert text == "The car's Wi-Fi stalled while the comma's own Wi-Fi joined Huckleberrys House; reconnecting" - assert "switching networks" in Supervisor._describe_error("streaming", SharedRadioStall("stalled", "")) + text = Supervisor._describe_error("streaming", SharedRadioStall("stalled")) + assert text == "The car's Wi-Fi stalled while the comma's own Wi-Fi was switching networks; reconnecting" assert stream_loss_kind(text) == "shared_radio" # bug reports file it apart from ordinary stalls assert stream_loss_kind("projecting: Video acknowledgement older than 2.5 s") == "stalled" - - - -def test_saved_profile_names_become_the_network_name(): - from openpilot.starpilot.system.starport.network import profile_network_name - assert profile_network_name("openpilot connection Huckleberrys House") == "Huckleberrys House" - assert profile_network_name("iPhone") == "iPhone"