StarPort: Review fixes for the stall notice, car forget and car adoption

Reuse atomic_write and the sr-only class, drop dead code, update stale tests
This commit is contained in:
Danny
2026-11-02 19:15:18 -08:00
parent 6d99039382
commit 80f707aa3d
16 changed files with 78 additions and 199 deletions
+3 -11
View File
@@ -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,
@@ -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' }
@@ -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; }
@@ -134,7 +134,7 @@ export const StarPortGuidedSetup = {
<li v-for="(step, index) in state.steps" :key="step.id" class="gx-guided__step" :class="'is-' + step.state"
:aria-current="step.state === 'current' ? 'step' : undefined">
<span class="gx-guided__node" aria-hidden="true"><i v-if="step.state === 'done'" class="bi bi-check-lg"></i><template v-else>{{ index + 1 }}</template></span>
<span class="gx-guided__step-label">{{ step.label }}<span class="gx-guided__sr">{{ step.state === 'done' ? ', done' : step.state === 'current' ? ', current step' : '' }}</span></span>
<span class="gx-guided__step-label">{{ step.label }}<span class="gx-sr-only">{{ step.state === 'done' ? ', done' : step.state === 'current' ? ', current step' : '' }}</span></span>
</li>
</ol>
@@ -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))
@@ -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.",
@@ -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")),
@@ -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
@@ -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
+9 -22
View File
@@ -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():
@@ -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
@@ -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)"
@@ -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"))
@@ -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"] == ""
@@ -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
@@ -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"