From e095271b2ae3a250bae66db0f77047e3722adfcb Mon Sep 17 00:00:00 2001 From: Danny Date: Tue, 10 Nov 2026 12:58:26 -0800 Subject: [PATCH] StarPort: Music Pipe, Network Join And PoleStar Support --- .../system/starport/car_interface.py | 22 +++++- .../system/starport/compat_report.py | 20 ++++- openpilot/starpilot/system/starport/daemon.py | 2 +- .../system/starport/display_profile.py | 2 +- .../starpilot/system/starport/encoder.py | 4 +- .../starpilot/system/starport/frame_source.py | 9 ++- .../system/starport/hw/libstarport_encoder.so | Bin 247280 -> 247464 bytes .../system/starport/hw/starport_encoder.cc | 3 +- .../starpilot/system/starport/hw_encoder.py | 3 +- .../starpilot/system/starport/media_audio.py | 38 +++++++++- .../starpilot/system/starport/session.py | 14 +++- .../starpilot/system/starport/supervisor.py | 28 +++++-- .../starpilot/system/starport/system_slice.py | 14 ++-- .../starport/tests/test_car_interface.py | 45 +++++++++++- .../tests/test_diagnostics_logging.py | 15 ++++ .../starport/tests/test_main_display.py | 69 ++++++++++++++++++ .../system/starport/tests/test_media_audio.py | 44 +++++++++++ .../system/starport/tests/test_placement.py | 2 +- .../starport/tests/test_shared_radio_grace.py | 13 +++- .../starport/tests/test_system_slice.py | 15 +++- openpilot/starpilot/system/starport/touch.py | 16 +++- 21 files changed, 342 insertions(+), 36 deletions(-) create mode 100644 openpilot/starpilot/system/starport/tests/test_main_display.py diff --git a/openpilot/starpilot/system/starport/car_interface.py b/openpilot/starpilot/system/starport/car_interface.py index 96fa012501..e89601be6f 100644 --- a/openpilot/starpilot/system/starport/car_interface.py +++ b/openpilot/starpilot/system/starport/car_interface.py @@ -28,6 +28,8 @@ LEASE_TIMEOUT_S = 10.0 READY_TIMEOUT_S = 5.0 # The car's network never appeared this many times on aa0; finish the session on wlan0 in case aa0 is the reason. DEDICATED_TIMEOUTS = 2 +HOME_RETRY_S = 60.0 +HOME_RETRY_MAX_S = 300.0 class DedicatedInterface: @@ -102,6 +104,8 @@ class DedicatedLease: self._cancel = threading.Event() self.home = None self._home_lock = threading.Lock() + self._home_retry: threading.Timer | None = None + self._home_holds = 0 @property def local_ip(self): @@ -157,7 +161,7 @@ class DedicatedLease: self.lease = self.lease_factory(self.log, self.fallback_interface) def hold_home_wifi(self) -> None: - """Keep wlan0 from searching on the shared radio until the session ends.""" + """Hold wlan0 off the shared radio; each hold lasts longer, up to HOME_RETRY_MAX_S.""" with self._home_lock: if self.legacy or self._cancel.is_set() or self.home is not None: return @@ -171,11 +175,21 @@ class DedicatedLease: home.release(restore=True) return self.home = home - self.log("wifi_home_held", interface=self.fallback_interface) + after_s = min(HOME_RETRY_S * 2 ** min(self._home_holds, 5), HOME_RETRY_MAX_S) + self._home_holds += 1 + self._home_retry = threading.Timer(after_s, self._release_home) + self._home_retry.daemon = True + self._home_retry.start() + self.log("wifi_home_held", interface=self.fallback_interface, retry_after_s=after_s) + + def stop_home_join(self) -> None: + """Stop a wlan0 join that stalls the car.""" + self.hold_home_wifi() def _release_home(self) -> None: with self._home_lock: home, self.home = self.home, None + self._home_retry = None if home is not None: try: home.release(restore=True) @@ -257,6 +271,10 @@ class DedicatedLease: if self.lease is not None: self.lease.release(restore=restore) if restore: + with self._home_lock: + retry, self._home_retry, self._home_holds = self._home_retry, None, 0 + if retry is not None: + retry.cancel() self._release_home() if self.iface is not None: self.iface.close() diff --git a/openpilot/starpilot/system/starport/compat_report.py b/openpilot/starpilot/system/starport/compat_report.py index c529b2e49e..6e37502499 100644 --- a/openpilot/starpilot/system/starport/compat_report.py +++ b/openpilot/starpilot/system/starport/compat_report.py @@ -22,7 +22,7 @@ from collections.abc import Callable from pathlib import Path from openpilot.starpilot.system.starport import identity as identity_store, reachability -from openpilot.starpilot.system.starport import system_snapshot +from openpilot.starpilot.system.starport import system_slice, system_snapshot from openpilot.starpilot.system.starport.session import describe_video_config SESSION_GLOB = "session-*.jsonl" @@ -381,6 +381,22 @@ def _diagnosis_report(logs: list[Path]) -> bytes: return b"" +def _captures(path: Path) -> list[tuple[str, bytes]]: + """The device logs saved beside a session log (the whole session and any stall), without network or device names.""" + found = [] + for tag in ("", ".stall"): + side = system_slice.sidecar_path(path, tag) + try: + capture = json.loads(read_log(side, MAX_LOG_BYTES)) + for source in capture["sources"].values(): + if "lines" in source: + source["lines"] = [system_slice.scrub_line(line) for line in source["lines"]] + except (OSError, ValueError, KeyError, TypeError, AttributeError): + continue + found.append((side.name, json.dumps(capture, indent=1).encode())) + return found + + def bundle(log_dir: Path | None = None, config_path: Path | None = None, *, snapshot: Callable[[float], dict[str, bytes]] | None = system_snapshot.collect) -> bytes: """A zip for a bug report: every session log with its report, the settings, the renderer logs and a snapshot of the @@ -408,6 +424,8 @@ def bundle(log_dir: Path | None = None, config_path: Path | None = None, *, summaries.append(render_text(report, path.name)) add(f"logs/{path.name}", data) add(f"reports/{path.stem}.json", json.dumps(report, indent=2, default=str).encode()) + for name, capture in _captures(path): + add(f"logs/{name}", capture) add("REPORT.txt", ("\n".join(summaries) or "No Android Auto sessions have been logged yet.\n").encode()) report = _diagnosis_report(logs) if report: diff --git a/openpilot/starpilot/system/starport/daemon.py b/openpilot/starpilot/system/starport/daemon.py index 7e68ccf76a..f7b52ab7b7 100644 --- a/openpilot/starpilot/system/starport/daemon.py +++ b/openpilot/starpilot/system/starport/daemon.py @@ -223,7 +223,7 @@ def main() -> int: from openpilot.common.hardware import COMMA_HARDWARE placement = None if COMMA_HARDWARE: - os.setpriority(os.PRIO_PROCESS, 0, 5) + os.setpriority(os.PRIO_PROCESS, 0, 0) from openpilot.starpilot.system.starport.placement import RendererPlacement placement = RendererPlacement(idle=False, report=lambda event: print(json.dumps(event), flush=True)) placement.start() diff --git a/openpilot/starpilot/system/starport/display_profile.py b/openpilot/starpilot/system/starport/display_profile.py index 1bb71e95d3..ecf5baa0b9 100644 --- a/openpilot/starpilot/system/starport/display_profile.py +++ b/openpilot/starpilot/system/starport/display_profile.py @@ -18,7 +18,7 @@ def validate_screen(value): raise ValueError('Invalid projection screen') if any(type(value[key]) is not int for key in FIELDS): raise ValueError('Invalid projection dimensions') - if ((value['width'], value['height']) not in ((800, 480), (1280, 720), (1920, 1080)) or + if ((value['width'], value['height']) not in ((800, 480), (1280, 720), (1920, 1080), (720, 1280)) or value['fps'] not in (30, 60) or not 0 <= value['config_index'] <= 255): raise ValueError('Unsupported projection screen') projection_geometry(value['width'], value['height'], value['margin_width'], value['margin_height']) diff --git a/openpilot/starpilot/system/starport/encoder.py b/openpilot/starpilot/system/starport/encoder.py index e83ed1df5a..2e289b7715 100644 --- a/openpilot/starpilot/system/starport/encoder.py +++ b/openpilot/starpilot/system/starport/encoder.py @@ -66,14 +66,14 @@ class H264Encoder: backend = "libx264" def __init__(self, width: int, height: int, fps: int = 30, bitrate_kbps: int = 4000): - if not (0 < width <= 1920 and 0 < height <= 1080) or width % 2 or height % 2: + if not (0 < min(width, height) <= 1080 and max(width, height) <= 1920) or width % 2 or height % 2: raise ValueError("H.264 requires even dimensions no larger than 1920x1080") av = import_av() self.av = av self.width, self.height, self.fps = width, height, fps self.frame_index = 0 self.last_encode_ms = 0.0 - level = "4.0" if height > 720 else "3.1" + level = "4.0" if width * height > 1280 * 720 else "3.1" codec = av.CodecContext.create("libx264", "w") codec.width = width codec.height = height diff --git a/openpilot/starpilot/system/starport/frame_source.py b/openpilot/starpilot/system/starport/frame_source.py index f8ab36abe9..a591efaa25 100644 --- a/openpilot/starpilot/system/starport/frame_source.py +++ b/openpilot/starpilot/system/starport/frame_source.py @@ -35,6 +35,11 @@ MAGIC = 0x53464141 # "AAFS" VERSION = 1 HEADER_SIZE = 4096 MAX_WIDTH, MAX_HEIGHT = 1920, 1080 + + +def supported_size(width: int, height: int) -> bool: + """Landscape up to 1920x1080, or the same bounds turned portrait.""" + return 0 < min(width, height) <= MAX_HEIGHT and 0 < max(width, height) <= MAX_WIDTH FILE_SIZE = HEADER_SIZE + MAX_WIDTH * MAX_HEIGHT * 4 FORMAT_RGBA, FORMAT_NV12 = 0, 1 @@ -137,7 +142,7 @@ class FrameConsumer: _SENT.pack_into(self.mm, _SENT_OFFSET, 0) def configure(self, request: FrameRequest) -> None: - if not (0 < request.width <= MAX_WIDTH and 0 < request.height <= MAX_HEIGHT) or request.width % 2 or request.height % 2: + if not supported_size(request.width, request.height) or request.width % 2 or request.height % 2: raise ValueError("Unsupported frame size") if not (0 <= request.margin_w < request.width and 0 <= request.margin_h < request.height) or request.interval_us <= 0: raise ValueError("Unsupported frame geometry") @@ -254,7 +259,7 @@ class FrameProducer: explicit = bool(fields[12] & FLAG_CONTENT_ORIGIN) request = FrameRequest(fields[8], fields[9], fields[10], fields[11], fields[14], fields[12] & ~FLAG_CONTENT_ORIGIN, fields[15] & 0xffff if explicit else None, fields[15] >> 16 if explicit else None) - if not (0 < request.width <= MAX_WIDTH and 0 < request.height <= MAX_HEIGHT) or request.interval_us <= 0 or \ + if not supported_size(request.width, request.height) or request.interval_us <= 0 or \ request.margin_w >= request.width or request.margin_h >= request.height or \ not 0 <= request.left <= request.margin_w or not 0 <= request.top <= request.margin_h: return None diff --git a/openpilot/starpilot/system/starport/hw/libstarport_encoder.so b/openpilot/starpilot/system/starport/hw/libstarport_encoder.so index 791e7ae260cccb410c180906cc90a37fac2572d6..e6b535e4e022734dbbb2d101e2c876579c9c1e03 100755 GIT binary patch delta 32904 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 delta 32779 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 ah`Qn#Q$4DUP-hjJqrv!JMAjeGdjCJ}f#0V9 diff --git a/openpilot/starpilot/system/starport/hw/starport_encoder.cc b/openpilot/starpilot/system/starport/hw/starport_encoder.cc index e28c322f66..15d2fb8f4b 100644 --- a/openpilot/starpilot/system/starport/hw/starport_encoder.cc +++ b/openpilot/starpilot/system/starport/hw/starport_encoder.cc @@ -299,7 +299,8 @@ void *starport_encoder_create(int width, int height, int fps, int bitrate, int m // that offers nothing else. const bool hevc = options & AA_ENCODER_HEVC; const int max_width = hevc ? 1920 : 1280, max_height = hevc ? 1080 : 720; - if (width < 2 || width > max_width || height < 2 || height > max_height || width % 2 || height % 2 || + const int short_side = std::min(width, height), long_side = std::max(width, height); + if (short_side < 2 || short_side > max_height || long_side > max_width || width % 2 || height % 2 || fps != 30 || bitrate < 1000000 || bitrate > 20000000 || margin_height < 0 || margin_height >= height || margin_height % 4 || (options & ~(AA_ENCODER_CBR | AA_ENCODER_HEVC))) throw std::runtime_error("Unsupported encoder configuration"); diff --git a/openpilot/starpilot/system/starport/hw_encoder.py b/openpilot/starpilot/system/starport/hw_encoder.py index 279fd56d1d..cce37f94fb 100644 --- a/openpilot/starpilot/system/starport/hw_encoder.py +++ b/openpilot/starpilot/system/starport/hw_encoder.py @@ -98,7 +98,8 @@ class HardwareH264Encoder: def __init__(self, width: int, height: int, fps: int = HW_FPS, bitrate_kbps: int = 6000, margin_height: int = 0, library: Path = LIBRARY, rate_control: str = "cbr"): - if not (0 < width <= self.max_width and 0 < height <= self.max_height) or width % 2 or height % 2 or fps != HW_FPS: + if not (0 < min(width, height) <= self.max_height and max(width, height) <= self.max_width) or width % 2 or height % 2 or \ + fps != HW_FPS: raise ValueError(f"Hardware encoder supports even sizes up to {self.max_width}x{self.max_height} at 30 fps") if margin_height % 4: margin_height = 0 # only chroma-aligned black margins may be skipped diff --git a/openpilot/starpilot/system/starport/media_audio.py b/openpilot/starpilot/system/starport/media_audio.py index 2a3269eab5..1e512144c2 100644 --- a/openpilot/starpilot/system/starport/media_audio.py +++ b/openpilot/starpilot/system/starport/media_audio.py @@ -13,9 +13,11 @@ from __future__ import annotations import array import errno +import fcntl import os import stat import struct +import termios import threading import time from collections.abc import Callable @@ -49,6 +51,8 @@ MIN_CHUNK = BYTES_PER_SECOND // 50 # 20 ms MAX_CHUNK = 32768 # ~170 ms, so a one-packet ACK window still keeps up BUFFER_PLAYING = BYTES_PER_SECOND # cap while streaming; older audio is dropped, never delayed further BUFFER_IDLE = BYTES_PER_SECOND // 2 # pre-roll kept while asking for focus +PIPE_SIZE = 1048576 # ~5.5 s, so a late read does not make the audio server drop sound +PIPE_PAGE = 4096 MAX_WINDOW = 16 SILENCE_LEVEL = 16 # peak sample magnitude treated as silence RELEASE_AFTER = 8.0 # seconds of silence before giving the speakers back @@ -77,6 +81,8 @@ class PipeSource: self.checked = 0.0 self.active_value = False self.generation = None + self.capacity = 65536 + self.overruns = 0 def active(self, now: float) -> bool: if now - self.checked >= 1.0: @@ -100,6 +106,9 @@ class PipeSource: self.fd = os.open(self.path, os.O_RDONLY | os.O_NONBLOCK) except OSError: return b'' + self.capacity = self._grow(self.fd) + elif self._queued(self.fd) >= self.capacity - PIPE_PAGE: + self.overruns += 1 # full between reads: the audio server was discarding sound chunks, total = [], 0 while total < limit: try: @@ -115,6 +124,24 @@ class PipeSource: total += len(data) return b''.join(chunks) + @staticmethod + def _grow(fd: int) -> int: + try: + fcntl.fcntl(fd, fcntl.F_SETPIPE_SZ, PIPE_SIZE) + except (AttributeError, OSError): + pass + try: + return fcntl.fcntl(fd, fcntl.F_GETPIPE_SZ) + except (AttributeError, OSError): + return 65536 + + @staticmethod + def _queued(fd: int) -> int: + try: + return struct.unpack('i', fcntl.ioctl(fd, termios.FIONREAD, b'\0\0\0\0'))[0] + except OSError: + return 0 + def close(self) -> None: if self.fd is not None: os.close(self.fd) @@ -157,6 +184,8 @@ class MediaAudio: self.base_us = 0 self.last_sound = -1e9 self.car_paused = False + self.last_service: float | None = None + self.max_gap = 0.0 self.source_generation = getattr(source, 'generation', None) self.stats: dict[str, Any] = {'chunks_sent': 0, 'acked': 0, 'dropped_ms': 0, 'focus': None} self.track_info = None # MediaStatus for the car's own displays, when the head unit offers it @@ -185,6 +214,9 @@ class MediaAudio: if self.track_info is not None: self.track_info.service() now = self.clock() + if self.state == 'playing' and self.last_service is not None: + self.max_gap = max(self.max_gap, now - self.last_service) + self.last_service = now active = self.source.active(now) if not active: self.buffer.clear() @@ -325,7 +357,11 @@ class MediaAudio: self.pause() def status(self) -> dict: - return {'state': self.state, 'window': self.window, 'pending': self.unacked, **self.stats} + return {'state': self.state, 'window': self.window, 'pending': self.unacked, **self.stats, + 'pipe_overruns': getattr(self.source, 'overruns', 0), 'max_service_gap_ms': round(self.max_gap * 1000)} + + def reset_peaks(self) -> None: + self.max_gap = 0.0 def close(self) -> None: self.source.close() diff --git a/openpilot/starpilot/system/starport/session.py b/openpilot/starpilot/system/starport/session.py index d6a813fbba..c118b9e272 100644 --- a/openpilot/starpilot/system/starport/session.py +++ b/openpilot/starpilot/system/starport/session.py @@ -89,7 +89,7 @@ FOCUS_REASON_LAUNCH_NATIVE = 2 FOCUS_REASON_USER_SELECTION = 4 SHUTDOWN_REASON_USER_SELECTION = 1 -RESOLUTIONS = {1: (800, 480), 2: (1280, 720), 3: (1920, 1080)} +RESOLUTIONS = {1: (800, 480), 2: (1280, 720), 3: (1920, 1080), 6: (720, 1280)} # Named in errors and reports only: 1440p, 4K and portrait screens also offer 800x480, which is mandatory. RESOLUTION_NAMES = {**{index: f"{w}x{h}" for index, (w, h) in RESOLUTIONS.items()}, 4: "2560x1440", 5: "3840x2160", 6: "720x1280", 7: "1080x1920", 8: "1440x2560", 9: "2160x3840"} @@ -101,7 +101,7 @@ IGNORED_LOG_EVERY = 100 FRAME_RATES = {1: 60, 2: 30} # Software H.264 on the comma is the constraint: prefer 720p, then 480p. 1080p # is accepted only when nothing smaller is offered. -RESOLUTION_PREFERENCE = (2, 1, 3) +RESOLUTION_PREFERENCE = (2, 1, 3, 6) # A real phone pings periodically; answering pings is mandatory, sending them # is harmless and keeps impatient receivers from declaring the link idle. @@ -236,6 +236,8 @@ def choose_video_mode(channels: list[dict], allow_hevc: bool = False) -> VideoMo candidates = [] hevc_candidates = [] for channel in channels: + if channel.get("display_type") not in (None, 0): + continue # a cluster or auxiliary screen: the car waits on the main display for index, config in enumerate(channel.get("video_configs", [])): resolution = one(config, 1) codec = one(config, 10, CODEC_H264_BP) @@ -265,6 +267,8 @@ def choose_video_mode(channels: list[dict], allow_hevc: bool = False) -> VideoMo candidates = hevc_candidates if not candidates: hint = "; this car offers H.265, which can be turned on in Developer > StarPort" if hevc_candidates else "" + if not hint and any(channel.get("display_type") not in (None, 0) and channel.get("video_configs") for channel in channels): + hint = "; only a secondary display offered a supported mode, which cannot carry the main projection" raise ValueError(f"Head unit did not advertise a supported H.264 video mode (offered {describe_video_configs(channels)}){hint}") return min(candidates, key=lambda item: item[:3])[3] @@ -661,6 +665,8 @@ class ProjectionSession(Session): # 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) + # Replaced by the supervisor: stops a join still under way on the comma's other Wi-Fi. + radio_stalled: Callable[..., None] = staticmethod(lambda: None) # HeadunitPad acknowledges every fragment (codec config included) rather than every frame. fragment_acks = False @@ -755,7 +761,8 @@ class ProjectionSession(Session): def open_input(self) -> None: """Open the touch/key channel so input is acknowledged; failures are not fatal.""" - channel = next((ch for ch in self.channels if ch.get("input")), None) + inputs = [ch for ch in self.channels if ch.get("input")] + channel = next((ch for ch in inputs if ch.get("display_id") in (None, 0)), inputs[0] if inputs else None) if channel is None: return try: @@ -1078,6 +1085,7 @@ class ProjectionSession(Session): raise TimeoutError(f"Video acknowledgement older than {self.ACK_TIMEOUT:.1f} s") self.grace_frame = oldest self.event("ack_grace", after_ms=round((now - oldest) * 1000)) + self.radio_stalled() if now - oldest > self.ACK_GRACE_TIMEOUT: raise SharedRadioStall(f"Video acknowledgement older than {self.ACK_GRACE_TIMEOUT:.1f} s while the comma's Wi-Fi was switching networks") diff --git a/openpilot/starpilot/system/starport/supervisor.py b/openpilot/starpilot/system/starport/supervisor.py index 2b5aab8052..4c63d6301a 100644 --- a/openpilot/starpilot/system/starport/supervisor.py +++ b/openpilot/starpilot/system/starport/supervisor.py @@ -76,6 +76,7 @@ LINK_CHECK_SLOW = 1.0 # a link check slower than this is logged (NetworkM STREAM_STALL_LOG = 1.0 # a streaming loop pass slower than this is logged RADIO_CHECK_INTERVAL = 0.5 # how often the comma's own Wi-Fi is checked for a join or roam (a sysfs read) RADIO_CHANGE_WINDOW = 15.0 # a join or roam this recent can still stall the car (11-02: stall came 7.5 s after a roam) +STALL_CAPTURE_S = 20.0 # device logs kept from before a shared-radio stall is reported REPORTED_SETTINGS = ("connection", "usb_mode", "view", "encoder", "fps", "bitrate_kbps", "rate_control", "gpu_nv12", "async_readback", "wifi_interface", "dedicated_wifi_interface", "verify_head_unit", "version_status", "rfcomm_channel", "phone_class", "auto_connect", "allow_hevc", "head_unit_experiments", @@ -280,11 +281,13 @@ class SharedRadioWatch: """ def __init__(self, log: Callable[..., None], interface: str = "wlan0", interval: float = RADIO_CHECK_INTERVAL, - counter: Path | None = None, carrier: Path | None = None, on_down: Callable[[], None] | None = None): + counter: Path | None = None, carrier: Path | None = None, on_down: Callable[[], None] | None = None, + on_stall: Callable[[], None] | None = None): self.log, self.interface, self.interval = log, interface, interval self.counter = counter or Path(f"/sys/class/net/{interface}/carrier_changes") self.carrier = carrier or Path(f"/sys/class/net/{interface}/carrier") self.on_down = on_down # the other network was lost, not just roamed + self.on_stall = on_stall # the car's acks stopped after a change self.changed_at: float | None = None self._stop = threading.Event() self._thread = threading.Thread(target=self._run, name="aa_radio_watch", daemon=True) @@ -317,11 +320,13 @@ class SharedRadioWatch: last = current self.changed_at = time.monotonic() self.log("other_wifi_changed", interface=self.interface) - if self.on_down is not None and not self._carrier(): - try: + if not self._carrier(): + if self.on_down is not None: self.on_down() - except Exception as error: # the watch must keep running for the ACK grace window - self.log("other_wifi_hold_failed", error=str(error) or type(error).__name__) + + def stalled(self) -> None: + if self.on_stall is not None: + self.on_stall() def _carrier(self) -> bool: try: @@ -402,6 +407,12 @@ class EventLog: if self.path is not None: system_slice.write_sidecar_async(self.path, self.started, datetime.now(UTC).timestamp()) + def capture_stall(self) -> None: + """Save the last seconds of device logs now; the session's own capture keeps only its newest lines.""" + if self.path is not None: + now = datetime.now(UTC).timestamp() + system_slice.write_sidecar_async(self.path, now - STALL_CAPTURE_S, now, tag=".stall") + def _line(self, record: dict) -> str: return json.dumps(record, default=str) + "\n" @@ -1522,6 +1533,7 @@ class Supervisor: frames=self._attempt_frames, hung_up=hung_up, hangups=self._hangups) if isinstance(error, SharedRadioStall): reconnect_notice.write(reconnect_notice.SHARED_RADIO_TEXT) + self.log.capture_stall() 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 @@ -2047,8 +2059,10 @@ class Supervisor: watch = LinkWatch(lease, self.log).start() radio = None if getattr(lease, "dedicated", False) and getattr(lease, "interface", "wlan0") != "wlan0": - radio = SharedRadioWatch(self.log, on_down=getattr(lease, "hold_home_wifi", None)).start() + radio = SharedRadioWatch(self.log, on_down=getattr(lease, "hold_home_wifi", None), + on_stall=getattr(lease, "stop_home_join", None)).start() session.radio_change = radio.recent + session.radio_stalled = radio.stalled try: from openpilot.starpilot.system.starport.media_audio import MediaAudio, music_control from openpilot.starpilot.system.starport.media_status import MediaStatus @@ -2267,6 +2281,8 @@ class Supervisor: if int(now - started) % 30 == 0: self.log("stats", **{**device_health.sample(getattr(watch.lease, "interface", "wlan0")), **stats}) encode_peak = 0.0 # the peak covers each logged 30 s window + if audio is not None: + audio.reset_peaks() spent = time.monotonic() - pass_started if spent > STREAM_STALL_LOG: # Nothing was read from the car meanwhile: pings, focus and ACKs all waited on this pass. diff --git a/openpilot/starpilot/system/starport/system_slice.py b/openpilot/starpilot/system/starport/system_slice.py index b7bf23fbe0..115d223d07 100644 --- a/openpilot/starpilot/system/starport/system_slice.py +++ b/openpilot/starpilot/system/starport/system_slice.py @@ -22,7 +22,7 @@ SSID = re.compile(r"(?i)\b(e?ssid)\b[=: ]+\S+") def _timestamp(value: float) -> str: - return datetime.fromtimestamp(value, UTC).strftime("%Y-%m-%d %H:%M:%S") + return datetime.fromtimestamp(value, UTC).strftime("%Y-%m-%d %H:%M:%S UTC") # journalctl reads a bare time as local def commands(start: float, end: float) -> dict[str, list[str]]: @@ -75,15 +75,15 @@ def collect(start: float, end: float, *, collector=system_snapshot.collect) -> d return result -def sidecar_path(log_path: Path) -> Path: +def sidecar_path(log_path: Path, tag: str = "") -> Path: match = re.fullmatch(r"session-(\d{6})-.*\.jsonl", log_path.name) - return log_path.with_name(f"session-{match[1] if match else log_path.stem}{SIDECAR_SUFFIX}") + return log_path.with_name(f"session-{match[1] if match else log_path.stem}{tag}{SIDECAR_SUFFIX}") -def write_sidecar(log_path: Path, start: float, end: float, *, collector=system_snapshot.collect) -> None: +def write_sidecar(log_path: Path, start: float, end: float, *, collector=system_snapshot.collect, tag: str = "") -> None: """Capture the window and save it beside the session log; never raises.""" try: - path = sidecar_path(log_path) + path = sidecar_path(log_path, tag) text = json.dumps(collect(start, end, collector=collector)) temporary = path.with_name(path.name + ".tmp") fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) @@ -94,8 +94,8 @@ def write_sidecar(log_path: Path, start: float, end: float, *, collector=system_ pass -def write_sidecar_async(log_path: Path, start: float, end: float) -> threading.Thread: - thread = threading.Thread(target=write_sidecar, args=(log_path, start, end), name="aa_system_slice", daemon=True) +def write_sidecar_async(log_path: Path, start: float, end: float, tag: str = "") -> threading.Thread: + thread = threading.Thread(target=write_sidecar, args=(log_path, start, end), kwargs={"tag": tag}, name="aa_system_slice", daemon=True) thread.start() return thread diff --git a/openpilot/starpilot/system/starport/tests/test_car_interface.py b/openpilot/starpilot/system/starport/tests/test_car_interface.py index 84a623bf0a..7b92a25fb5 100644 --- a/openpilot/starpilot/system/starport/tests/test_car_interface.py +++ b/openpilot/starpilot/system/starport/tests/test_car_interface.py @@ -158,7 +158,7 @@ def test_aa0_that_cannot_be_rebuilt_finishes_on_wlan0(): assert lease.legacy and not lease.dedicated and not lease.aa0_failed -def test_lost_home_wifi_is_held_until_the_session_ends(): +def test_lost_home_wifi_is_held_across_attempts_until_the_session_ends(): lease, leases, log = make() lease.acquire(credentials()) lease.hold_home_wifi() @@ -172,6 +172,49 @@ def test_lost_home_wifi_is_held_until_the_session_ends(): assert log.names().count("wifi_home_held") == 1 and "wifi_home_released" in log.names() +def wait_released(lease): + deadline = time.monotonic() + 2 + while lease.home is not None: + assert time.monotonic() < deadline + time.sleep(0.01) + + +def test_every_hold_ends_on_its_own_and_backs_off_to_a_cap(monkeypatch): + monkeypatch.setattr(car_interface, "HOME_RETRY_S", 0.02) + monkeypatch.setattr(car_interface, "HOME_RETRY_MAX_S", 0.05) + lease, leases, log = make() + lease.acquire(credentials()) + waits = [] + for hold in (lease.hold_home_wifi, lease.stop_home_join, lease.stop_home_join, lease.hold_home_wifi): + hold() + assert lease.home is not None and leases[-1].held + waits.append([fields for event, fields in log.events if event == "wifi_home_held"][-1]["retry_after_s"]) + wait_released(lease) + assert leases[-1].releases == [True] + assert waits == [0.02, 0.04, 0.05, 0.05] + lease.release(restore=True) + + +def test_a_held_join_is_not_held_twice_and_the_session_end_resets_the_backoff(monkeypatch): + monkeypatch.setattr(car_interface, "HOME_RETRY_S", 30.0) + lease, leases, log = make() + lease.acquire(credentials()) + lease.hold_home_wifi() + lease.stop_home_join() + assert log.names().count("wifi_home_held") == 1 + lease.release(restore=True) + assert lease.home is None and lease._home_retry is None and lease._home_holds == 0 + + +def test_home_hold_backoff_is_capped_after_many_holds(): + lease, leases, log = make() + lease.acquire(credentials()) + lease._home_holds = 10000 + lease.hold_home_wifi() + assert lease._home_retry.interval == car_interface.HOME_RETRY_MAX_S + lease.release(restore=True) + + def test_home_wifi_is_never_held_while_the_car_uses_it(): lease, leases, log = make(fail=True) lease.acquire(credentials()) diff --git a/openpilot/starpilot/system/starport/tests/test_diagnostics_logging.py b/openpilot/starpilot/system/starport/tests/test_diagnostics_logging.py index 643d5c5dfc..ff56b4c75c 100644 --- a/openpilot/starpilot/system/starport/tests/test_diagnostics_logging.py +++ b/openpilot/starpilot/system/starport/tests/test_diagnostics_logging.py @@ -490,6 +490,21 @@ def test_bundle_without_a_ladder_state_has_no_setup_state_file(tmp_path): assert "setup_state.json" not in archive.namelist() +def test_bundle_carries_the_saved_device_captures_without_network_names(tmp_path): + logs = tmp_path / "logs" + logs.mkdir() + write_log(logs, "session-000025-20260928-141351.jsonl", CIVIC_SESSION) + capture = {"window": {}, "sources": {"network": {"lines": ["wlan0: Trying to associate with SSID 'Home Net'"]}, "kernel": {"error": "x"}}} + (logs / "session-000025.system.json").write_text(json.dumps(capture)) + (logs / "session-000025.stall.system.json").write_text(json.dumps(capture)) + (logs / "session-000024.stall.system.json").write_text("not json") + with zipfile.ZipFile(io.BytesIO(compat_report.bundle(logs, tmp_path / "config.json", snapshot=None))) as archive: + names = [name for name in archive.namelist() if name.endswith(".system.json")] + stall = archive.read("logs/session-000025.stall.system.json").decode() + assert names == ["logs/session-000025.system.json", "logs/session-000025.stall.system.json"] + assert "Home Net" not in stall and "Trying to associate" in stall + + def test_setup_state_export_uses_bounded_regular_file_reads(tmp_path, monkeypatch): path = tmp_path / "setup_state.json" path.write_text(json.dumps({"version": 1, "cars": {}})) diff --git a/openpilot/starpilot/system/starport/tests/test_main_display.py b/openpilot/starpilot/system/starport/tests/test_main_display.py new file mode 100644 index 0000000000..acc7023e24 --- /dev/null +++ b/openpilot/starpilot/system/starport/tests/test_main_display.py @@ -0,0 +1,69 @@ +"""A car with a portrait main screen and a landscape cluster projects to the main screen (Polestar 2).""" +import pytest + +from openpilot.starpilot.system.starport.display_profile import validate_screen +from openpilot.starpilot.system.starport.frame_source import FrameConsumer, FrameRequest +from openpilot.starpilot.system.starport.hw_encoder import HardwareH264Encoder, HardwareHevcEncoder +from openpilot.starpilot.system.starport.projection_geometry import projection_geometry +from openpilot.starpilot.system.starport.session import CODEC_H265, choose_video_mode +from openpilot.starpilot.system.starport.touch import InputConfig, TouchMapper + + +def config(resolution, margin_w=0, margin_h=0, codec=7): + return {1: [resolution], 2: [1], 3: [margin_w], 4: [margin_h], 10: [codec]} + + +POLESTAR = [ + {"id": 1, "display_id": 0, "display_type": 0, + "video_configs": [config(8, 288, 1378), config(7, 0, 811), config(6, 0, 541)]}, + {"id": 2, "display_id": 1, "display_type": 1, "video_configs": [config(3, 0, 360)]}, +] + + +def test_polestar_projects_720x1280_on_the_main_screen_not_the_cluster(): + mode = choose_video_mode(POLESTAR, True) + assert (mode.channel, mode.config_index, mode.codec) == (1, 2, CODEC_H265) + assert (mode.width, mode.height, mode.margin_width, mode.margin_height) == (720, 1280, 0, 541) + + +def test_a_cluster_only_offer_is_refused_with_a_hint(): + with pytest.raises(ValueError, match="secondary display"): + choose_video_mode([POLESTAR[0] | {"video_configs": [config(9)]}, POLESTAR[1]], True) + + +def test_portrait_frames_pass_every_size_check(tmp_path): + validate_screen({"version": 1, "width": 720, "height": 1280, "margin_width": 0, "margin_height": 541, "fps": 30, + "config_index": 2}) + geometry = projection_geometry(720, 1280, 0, 541) + assert (geometry.width, geometry.height, geometry.logical_width) == (720, 739, 1860) + consumer = FrameConsumer(str(tmp_path / "frame")) + try: + consumer.configure(FrameRequest(720, 1280, 0, 541, 33333, 0)) + with pytest.raises(ValueError): + consumer.configure(FrameRequest(1280, 1280, 0, 0, 33333, 0)) + finally: + consumer.close() + + +@pytest.mark.parametrize("encoder", [HardwareH264Encoder, HardwareHevcEncoder]) +def test_hardware_encoders_accept_portrait_and_reject_oversized(encoder, tmp_path): + with pytest.raises(ValueError, match="Hardware encoder supports"): + encoder(1280, 1280, library=tmp_path / "missing.so") + with pytest.raises(OSError): # past the size check, failing only on the missing library + encoder(720, 1280, library=tmp_path / "missing.so") + + +def test_touch_reported_over_the_visible_area_maps_to_the_whole_picture(): + mapper = TouchMapper(InputConfig((), 1152, 1182), 720, 1280, 0, 541) + assert mapper.content_space + assert mapper._normalize(0, 0) == (0, 0) + assert mapper._normalize(1152, 1182) == (1, 1) + assert mapper._normalize(576, 591) == pytest.approx((0.5, 0.5)) + assert mapper._inside(0, 0) and mapper._inside(1151, 1181) + + +def test_touch_reported_over_the_whole_frame_is_unchanged(): + mapper = TouchMapper(InputConfig((), 1280, 720), 1280, 720, 0, 240) + assert not mapper.content_space + assert mapper._normalize(0, 120) == (0, 0) + assert not mapper._inside(640, 100) diff --git a/openpilot/starpilot/system/starport/tests/test_media_audio.py b/openpilot/starpilot/system/starport/tests/test_media_audio.py index 216b269d44..9ac444a4db 100644 --- a/openpilot/starpilot/system/starport/tests/test_media_audio.py +++ b/openpilot/starpilot/system/starport/tests/test_media_audio.py @@ -188,6 +188,50 @@ def test_pipe_source_reads_a_fifo_without_blocking(tmp_path): assert not ma.PipeSource(tmp_path / 'missing', lambda: True).active(10.0) +def test_pipe_source_counts_reads_that_find_the_pipe_full(tmp_path): + path = tmp_path / 'aa.pcm' + os.mkfifo(path) + source = ma.PipeSource(path, lambda: True) + writer = os.open(path, os.O_RDWR | os.O_NONBLOCK) + try: + assert source.active(10.0) and source.read(1024) == b'' + assert source.capacity >= 4096 and source.overruns == 0 + os.write(writer, TONE[:1000]) + assert source.read(65536) == TONE[:1000] and source.overruns == 0 + source.capacity = ma.PIPE_PAGE + 1000 + os.write(writer, TONE[:1000]) + assert source.read(65536) == TONE[:1000] and source.overruns == 1 + source.close() + os.write(writer, TONE[:1000]) + assert source.read(65536) == TONE[:1000] and source.overruns == 1 # backlog left while nobody read + finally: + source.close() + os.close(writer) + + +def test_status_reports_pipe_overruns(audio): + stream, _, source, _, _ = audio + assert stream.status()['pipe_overruns'] == 0 + source.overruns = 3 + assert stream.status()['pipe_overruns'] == 3 + + +def test_longest_service_gap_while_playing_lasts_until_reset(audio): + stream, session, source, now, _ = audio + ready(stream, session) + now[0] += 5.0 + play(stream, session, source, now) + assert stream.status()['max_service_gap_ms'] == 0 + now[0] += 0.25 + stream.service() + now[0] += 0.05 + stream.service() + assert stream.status()['max_service_gap_ms'] == 250 + assert stream.status()['max_service_gap_ms'] == 250 + stream.reset_peaks() + assert stream.status()['max_service_gap_ms'] == 0 + + def test_source_switch_discards_old_audio_and_releases_focus(audio): stream, session, source, now, _ = audio source.generation = 1 diff --git a/openpilot/starpilot/system/starport/tests/test_placement.py b/openpilot/starpilot/system/starport/tests/test_placement.py index 4b3bd0cbcd..f243c4613b 100644 --- a/openpilot/starpilot/system/starport/tests/test_placement.py +++ b/openpilot/starpilot/system/starport/tests/test_placement.py @@ -208,7 +208,7 @@ class TestStarPortStartup(unittest.TestCase): with self.assertRaises(PlacementReached): run_main() if device != 'pc': - self.assertEqual(order, [('priority', (0, 0, 5)), 'start']) + self.assertEqual(order, [('priority', (0, 0, 0)), 'start']) else: fake_os.setpriority.assert_not_called() if device != 'pc': 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 3b0d604945..1381573eae 100644 --- a/openpilot/starpilot/system/starport/tests/test_shared_radio_grace.py +++ b/openpilot/starpilot/system/starport/tests/test_shared_radio_grace.py @@ -42,6 +42,16 @@ def test_radio_change_waits_up_to_the_grace_limit(identity): end_session(session, hu) +def test_grace_stops_the_other_join_once(identity): + hu, session, sent = stalled(identity, True) + stops = [] + session.radio_stalled = lambda: stops.append(True) + session.check_progress(sent + session.ACK_TIMEOUT + 0.1) + session.check_progress(sent + session.ACK_TIMEOUT + 1.0) + assert stops == [True] + end_session(session, hu) + + def test_grace_started_by_a_change_survives_the_change_ageing_out(identity): hu, session, sent = stalled(identity, True) session.check_progress(now=sent + ProjectionSession.ACK_TIMEOUT + 0.05) @@ -134,7 +144,8 @@ def test_watch_without_the_counter_never_reports(tmp_path): watch.stop() -def test_notice_is_shown_once_and_only_while_fresh(tmp_path): +def test_notice_is_shown_once_and_only_while_fresh(tmp_path, monkeypatch): + monkeypatch.setattr(reconnect_notice.identity_store, "boot_id", lambda: "current") # no /proc off device path = tmp_path / "notice.json" reconnect_notice.write("Reconnected", path, now=1000.0) assert reconnect_notice.take(path, now=1010.0) == "Reconnected" diff --git a/openpilot/starpilot/system/starport/tests/test_system_slice.py b/openpilot/starpilot/system/starport/tests/test_system_slice.py index f18854b16c..85e6245fe0 100644 --- a/openpilot/starpilot/system/starport/tests/test_system_slice.py +++ b/openpilot/starpilot/system/starport/tests/test_system_slice.py @@ -25,7 +25,7 @@ def test_collect_turns_command_output_into_line_lists(): def test_commands_are_niced_and_cover_the_session_window(): command = system_slice.commands(1_000_000.0, 1_000_060.0)["network"] assert command[:3] == ["nice", "-n", "19"] and "--utc" in command - assert command[command.index("--since") + 1] == "1970-01-12 13:46:10" and "NetworkManager" in command + assert command[command.index("--since") + 1] == "1970-01-12 13:46:10 UTC" and "NetworkManager" in command def test_scrub_line_masks_names_ssids_and_addresses_and_caps_length(): @@ -45,6 +45,19 @@ def test_sidecar_round_trip_and_pruning(tmp_path): assert not stale.exists() and system_slice.sidecar_path(log).exists() +def test_stall_capture_sits_beside_the_session_capture_and_is_pruned_with_it(tmp_path): + log = tmp_path / "session-000007-20261016-120000.jsonl" + log.write_text("") + system_slice.write_sidecar(log, 1_000_000.0, 1_000_060.0, collector=fake_collector, tag=".stall") + stall = tmp_path / "session-000007.stall.system.json" + assert stall.exists() and not system_slice.sidecar_path(log).exists() + system_slice.prune_sidecars(tmp_path) + assert stall.exists() + log.unlink() + system_slice.prune_sidecars(tmp_path) + assert not stall.exists() + + def test_a_collector_that_raises_leaves_no_sidecar(tmp_path): log = tmp_path / "session-000001-20261016-120000.jsonl" log.write_text("") diff --git a/openpilot/starpilot/system/starport/touch.py b/openpilot/starpilot/system/starport/touch.py index 1f393addcf..9ec5228462 100644 --- a/openpilot/starpilot/system/starport/touch.py +++ b/openpilot/starpilot/system/starport/touch.py @@ -92,17 +92,25 @@ class TouchMapper: self.top = margin_height // 2 if content_top is None else content_top self.content_width, self.content_height = video_width - margin_width, video_height - margin_height self.pointer: int | None = None + self.content_space = bool(margin_width or margin_height) and self._same_shape(self.content_width, self.content_height) \ + and not self._same_shape(video_width, video_height) + + def _same_shape(self, width: int, height: int) -> bool: + return abs(self.touch_width * height / (self.touch_height * width) - 1) < 0.02 + + def _video_point(self, x: int, y: int) -> tuple[float, float]: + if self.content_space: + return self.left + x * self.content_width / self.touch_width, self.top + y * self.content_height / self.touch_height + return x * self.video_width / self.touch_width, y * self.video_height / self.touch_height def _normalize(self, x: int, y: int) -> tuple[float, float]: - vx = x * self.video_width / self.touch_width - vy = y * self.video_height / self.touch_height + vx, vy = self._video_point(x, y) nx = (vx - self.left) / self.content_width ny = (vy - self.top) / self.content_height return min(max(nx, 0.0), 1.0), min(max(ny, 0.0), 1.0) def _inside(self, x: int, y: int) -> bool: - vx = x * self.video_width / self.touch_width - vy = y * self.video_height / self.touch_height + vx, vy = self._video_point(x, y) return self.left <= vx < self.left + self.content_width and self.top <= vy < self.top + self.content_height def reset(self) -> list[TouchEvent]: