mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-06 12:33:51 +08:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user