StarPort: Music Pipe, Network Join And PoleStar Support

This commit is contained in:
Danny
2026-11-10 12:58:26 -08:00
parent d8534a0eb1
commit e095271b2a
21 changed files with 342 additions and 36 deletions
@@ -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()
@@ -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:
@@ -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()
@@ -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'])
@@ -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
@@ -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
@@ -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");
@@ -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
@@ -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()
+11 -3
View File
@@ -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")
@@ -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.
@@ -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
@@ -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())
@@ -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": {}}))
@@ -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)
@@ -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
@@ -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':
@@ -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"
@@ -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("")
+12 -4
View File
@@ -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]: