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 791e7ae260..e6b535e4e0 100755 Binary files a/openpilot/starpilot/system/starport/hw/libstarport_encoder.so and b/openpilot/starpilot/system/starport/hw/libstarport_encoder.so differ 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]: