mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-10 14:33:42 +08:00
440 lines
19 KiB
Python
440 lines
19 KiB
Python
"""On-device Android Auto diagnostics: richer session logs, readable reports and the renderer profile."""
|
|
|
|
from collections import deque
|
|
import io
|
|
import json
|
|
import socket
|
|
import ssl
|
|
import sys
|
|
import time
|
|
import zipfile
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from openpilot.starpilot.system.android_auto import bootstrap as bs
|
|
from openpilot.starpilot.system.android_auto import compat_report
|
|
from openpilot.starpilot.system.android_auto.render_profile import RenderSampler, RenderSummary
|
|
from openpilot.starpilot.system.android_auto.session import ProjectionSession, choose_video_mode, redact_head_unit
|
|
from openpilot.starpilot.system.android_auto.tests.fake_head_unit import FakeHeadUnit, make_identity
|
|
from openpilot.starpilot.system.android_auto.wire import DESCRIBE_TEXT, describe, field, parse_fields
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def identity(tmp_path_factory):
|
|
return make_identity(tmp_path_factory.mktemp("identity"))
|
|
|
|
|
|
def connect(hu: FakeHeadUnit, identity, log, ca=None) -> ProjectionSession:
|
|
sock = socket.create_connection(("127.0.0.1", hu.port), timeout=5)
|
|
return ProjectionSession(sock, str(identity["phone_cert"]), str(identity["phone_key"]), log,
|
|
str(ca or identity["root"]))
|
|
|
|
|
|
# ------------------------------------------------------------- session logs
|
|
|
|
def test_describe_keeps_text_and_bounds_opaque_bytes():
|
|
described = describe(field(1, "Honda") + field(2, 7) + field(3, field(1, "ALPSALPINE")) + field(4, b"\xff" * 100))
|
|
assert described[1] == ["Honda"] and described[2] == [7] and described[3] == [{1: ["ALPSALPINE"]}]
|
|
assert described[4] == [{"hex": "ff" * 64, "bytes": 100}]
|
|
assert describe(field(1, "x" * (DESCRIBE_TEXT + 1)))[1] == ["x" * DESCRIBE_TEXT + "…"]
|
|
|
|
|
|
def test_ignored_messages_are_rate_limited():
|
|
counts = []
|
|
session = ProjectionSession.__new__(ProjectionSession)
|
|
session._log = lambda name, **values: counts.append(values["count"])
|
|
session.ignored_counts = {}
|
|
for _ in range(250):
|
|
session.ignored("channel_ignored", 9, 0x8001, b"\x08\x01")
|
|
assert counts == [1, 2, 3, 4, 5, 100, 200]
|
|
|
|
|
|
def test_no_supported_mode_error_lists_the_offer():
|
|
channels = [{"id": 3, "video_configs": [parse_fields(field(1, 4) + field(10, 3)), parse_fields(field(1, 2) + field(10, 7))]},
|
|
{"id": 5, "display_type": 1, "video_configs": [parse_fields(field(1, 1) + field(10, 5))]}]
|
|
with pytest.raises(ValueError, match=r"2560x1440 H\.264, 1280x720 H\.265, 800x480 VP9 \(display 1\)"):
|
|
choose_video_mode(channels)
|
|
|
|
|
|
def test_discovery_log_describes_the_car_and_its_channels(identity):
|
|
records = []
|
|
hu = FakeHeadUnit(identity)
|
|
session = connect(hu, identity, lambda name, **values: records.append((name, values)))
|
|
session.authenticate()
|
|
session.start("StarPilot", "comma.ai")
|
|
discovered = next(values for name, values in records if name == "discovered")
|
|
assert discovered["head_unit"][2] == ["Honda"] and discovered["head_unit"][3] == ["Civic"]
|
|
video = next(channel for channel in discovered["channels"] if channel.get("video_configs"))
|
|
assert video["descriptor"] and "display_type" in video
|
|
session.shutdown()
|
|
session.peer.close()
|
|
hu.thread.join(5)
|
|
|
|
|
|
def test_vehicle_ids_are_redacted_from_discovery_and_version_logs():
|
|
described = redact_head_unit({2: ["Honda"], 5: ["VIN-SECRET"], 17: [{1: ["Honda"], 4: ["VIN-SECRET"]}]})
|
|
assert described[2] == ["Honda"] and "VIN-SECRET" not in json.dumps(described)
|
|
version = bs.describe_version_request(field(1, 4) + field(2, 1) + field(5, field(1, "Honda") + field(4, "VIN-SECRET")) +
|
|
field(4, field(1, "192.168.1.1") + field(2, 5288) + field(4, 36)))
|
|
assert version[5][0] == {1: ["Honda"], 4: ["redacted"]}
|
|
assert version[4][0][4] == [36], "a channel number in the endpoint is kept"
|
|
|
|
|
|
def test_tls_failure_is_logged_with_its_reason(identity, tmp_path):
|
|
records = []
|
|
hu = FakeHeadUnit(identity)
|
|
other_root = make_identity(tmp_path)["root"] # the car's certificate does not chain to this root
|
|
session = connect(hu, identity, lambda name, **values: records.append((name, values)), ca=other_root)
|
|
with pytest.raises(ssl.SSLError):
|
|
session.authenticate()
|
|
failed = next(values for name, values in records if name == "tls_failed")
|
|
assert failed["reason"] and failed["error"]
|
|
session.peer.close()
|
|
hu.close()
|
|
|
|
|
|
def ack_session():
|
|
session = ProjectionSession.__new__(ProjectionSession)
|
|
session.retired_sessions = deque()
|
|
session.session_id = 1
|
|
session.config_ack_slack = 1
|
|
session.unacked = 1
|
|
session.pending = deque([time.monotonic()])
|
|
session.max_ack_seconds = 0
|
|
session.acked = session.epoch_acked = 0
|
|
session.video_confirmed = False
|
|
events = []
|
|
session._log = lambda name, **values: events.append(name)
|
|
return session, events
|
|
|
|
|
|
def test_configuration_ack_alone_does_not_confirm_video():
|
|
session, events = ack_session()
|
|
session._handle_ack(1, 1)
|
|
assert not events
|
|
assert session.unacked == 0 # preserve the existing flow-control behavior
|
|
session._handle_ack(1, 1) # the additional ACK proves at least one was a frame
|
|
assert events == ["video_acknowledged"]
|
|
|
|
|
|
@pytest.mark.parametrize("separate_config", [False, True])
|
|
def test_video_confirmation_with_optional_or_separate_config_ack(separate_config):
|
|
session, events = ack_session()
|
|
if separate_config:
|
|
session._handle_ack(0, 1)
|
|
assert not events
|
|
session._handle_ack(1, 1)
|
|
if not separate_config:
|
|
assert not events # one ACK is still ambiguous
|
|
session.unacked = 1
|
|
session.pending.append(time.monotonic())
|
|
session._handle_ack(1, 1)
|
|
assert events == ["video_acknowledged"]
|
|
|
|
|
|
def test_combined_config_and_frame_ack_confirms_video():
|
|
session, events = ack_session()
|
|
session._handle_ack(1, 2)
|
|
assert events == ["video_acknowledged"]
|
|
|
|
|
|
# ------------------------------------------------------------------ reports
|
|
|
|
# Shaped like the Civic's session 25 (2026-09-28): a refused start, then a projection that later dropped.
|
|
CIVIC_HEAD_UNIT = {"car_make": "Honda", "car_model": "T20", "car_year": "2025", "head_unit_make": "ALPSALPINE",
|
|
"head_unit_model": "8A501-T20-A1"}
|
|
CIVIC_SESSION = [
|
|
{"t": "2026-09-28T21:13:51+00:00", "event": "session_start", "receiver": "Honda CIVIC", "trigger": "onroad"},
|
|
{"event": "stage", "state": "connecting_bluetooth"},
|
|
{"event": "stage", "state": "rfcomm"},
|
|
{"event": "bootstrap_version", "major": 4, "minor": 1, "head_unit": CIVIC_HEAD_UNIT},
|
|
{"event": "bootstrap_start_refused", "status": -1, "endpoint": False},
|
|
{"event": "bootstrap_credentials", "ssid": "Car-4E91", "security": "wpa2/wpa3", "ap_type": 0, "key_length": 12},
|
|
{"event": "stage", "state": "joining_wifi"},
|
|
{"event": "wifi_joined", "interface": "wlan0", "local_ip": "192.168.180.183"},
|
|
{"event": "stage", "state": "authenticating"},
|
|
{"event": "version", "major": 4, "minor": 1, "reply": "6.1"},
|
|
{"event": "tls_established", "version": "TLSv1.2", "cipher": "ECDHE-RSA-AES256-GCM-SHA384"},
|
|
{"event": "discovered", "head_unit": {"2": ["Honda"], "3": ["Civic"], "5": ["redacted"]},
|
|
"channels": [{"id": 22, "services": [3], "display_type": 0, "video_configs": [{"1": [2], "10": [3]}, {"1": [1], "10": [3]}]}]},
|
|
{"event": "projection_ready", "mode": {"width": 1280, "height": 720, "fps": 60, "margin_width": 0, "margin_height": 0}},
|
|
{"event": "stage", "state": "streaming"},
|
|
{"event": "video_focus", "focused": True},
|
|
{"event": "video_acknowledged", "session": 1, "ack_ms": 29},
|
|
{"event": "channel_ignored", "channel": 9, "kind": 32769, "count": 1, "message": {"1": [1]}},
|
|
{"event": "attempt_failed", "stage": "streaming", "error": "projecting: Video acknowledgement older than 1.5 s"},
|
|
]
|
|
|
|
|
|
def test_report_summarizes_a_civic_session():
|
|
report = compat_report.summarize(CIVIC_SESSION)
|
|
assert report["transport"] == "wireless" and report["trigger"] == "onroad"
|
|
assert report["car"]["head_unit_make"] == "ALPSALPINE" and report["car"]["car_model"] == "Civic"
|
|
assert report["furthest_stage"] == "streaming" and report["wifi"]["security"] == "wpa2/wpa3"
|
|
assert report["video"]["offered"] == ["1280x720 H.264", "800x480 H.264"]
|
|
assert report["outcome"] == "projected, then failed: projecting: Video acknowledgement older than 1.5 s"
|
|
text = compat_report.render_text(report, "session-000025.jsonl")
|
|
assert "Outcome: projected, then failed" in text and "cipher: ECDHE-RSA-AES256-GCM-SHA384" in text
|
|
assert "Not handled: channel_ignored channel 9" in text
|
|
assert "Car-4E91" not in text and "192.168.180.183" not in text # no Wi-Fi name or address in a shared report
|
|
|
|
|
|
def test_successful_retry_clears_outcome_failure_but_keeps_history():
|
|
failure = {"event": "attempt_failed", "stage": "streaming", "error": "link lost"}
|
|
events = [{"event": "video_acknowledged"}, failure]
|
|
assert compat_report.summarize(events)["outcome"] == "projected, then failed: link lost"
|
|
events.append({"event": "video_acknowledged"})
|
|
report = compat_report.summarize(events)
|
|
assert report["outcome"] == "projected"
|
|
assert report["errors"] == [{"stage": "streaming", "error": "link lost"}]
|
|
|
|
|
|
def write_log(directory, name, events):
|
|
path = directory / name
|
|
path.write_text("".join(json.dumps(event) + "\n" for event in events) + '{"cut short')
|
|
return path
|
|
|
|
|
|
def test_session_logs_are_newest_first_by_number(tmp_path):
|
|
write_log(tmp_path, "session-000009-20260927-121412.jsonl", [])
|
|
write_log(tmp_path, "session-000010-20260728-080505.jsonl", []) # the clock had not synced yet
|
|
write_log(tmp_path, "session-20260101-000000.jsonl", [])
|
|
(tmp_path / "notes.txt").write_text("")
|
|
assert [path.name[:14] for path in compat_report.session_logs(tmp_path)] == \
|
|
["session-000010", "session-000009", "session-202601"]
|
|
assert compat_report.session_path("../config.json", tmp_path) is None
|
|
|
|
|
|
def test_bundle_has_logs_reports_and_settings_without_addresses(tmp_path):
|
|
logs = tmp_path / "logs"
|
|
logs.mkdir()
|
|
write_log(logs, "session-000025-20260928-141351.jsonl", CIVIC_SESSION)
|
|
(logs / "render_profile.txt").write_text("==== profile ====\n")
|
|
config = tmp_path / "config.json"
|
|
config.write_text(json.dumps({"config_version": 2, "receiver_address": "AA:BB:CC:DD:EE:FF", "receiver_name": "Honda CIVIC",
|
|
"rfcomm_cache": {"AA:BB:CC:DD:EE:FF": 5}}))
|
|
with zipfile.ZipFile(io.BytesIO(compat_report.bundle(logs, config))) as archive:
|
|
names = set(archive.namelist())
|
|
assert {"REPORT.txt", "config.json", "logs/session-000025-20260928-141351.jsonl",
|
|
"reports/session-000025-20260928-141351.json", "logs/render_profile.txt"} <= names
|
|
shared = archive.read("config.json").decode()
|
|
assert "AA:BB:CC:DD:EE:FF" not in shared and json.loads(shared)["rfcomm_cache"] == [5]
|
|
assert "projected, then failed" in archive.read("REPORT.txt").decode()
|
|
assert not any(name.endswith((".pem", ".key")) for name in names)
|
|
|
|
|
|
def test_report_tool_prints_a_log(tmp_path, monkeypatch, capsys):
|
|
from openpilot.tools.android_auto import compat_report as tool
|
|
path = write_log(tmp_path, "session-000025-20260928-141351.jsonl", CIVIC_SESSION)
|
|
monkeypatch.setattr(sys, "argv", ["compat_report.py", str(path)])
|
|
assert tool.main() == 0
|
|
assert "Outcome: projected, then failed" in capsys.readouterr().out
|
|
monkeypatch.setattr(sys, "argv", ["compat_report.py", str(path), "--json"])
|
|
assert tool.main() == 0
|
|
assert json.loads(capsys.readouterr().out)["car"]["head_unit_make"] == "ALPSALPINE"
|
|
|
|
|
|
# ----------------------------------------------------------- render profile
|
|
|
|
def hot_render_function(sampler):
|
|
sampler.sample(sys._getframe())
|
|
|
|
|
|
def test_render_sampler_counts_render_stacks_and_idle(tmp_path):
|
|
clock = [0.0]
|
|
sampler = RenderSampler(tmp_path / "render_profile.txt", clock=lambda: clock[0])
|
|
sampler.sample(sys._getframe()) # renderer idle
|
|
sampler.rendering, sampler.onroad = True, True
|
|
for _ in range(3):
|
|
hot_render_function(sampler)
|
|
sampler.summary = {"event": "render_stats", "fps": 29.5, "frame_ms": 21.0}
|
|
clock[0] = 60.0
|
|
text = sampler.report()
|
|
assert "onroad (100% onroad), rendering 75% of 4 samples" in text
|
|
assert "render_stats fps=29.5 frame_ms=21.0" in text
|
|
innermost = text.split("-- innermost function")[1].split("-- innermost line")[0]
|
|
assert "100.0% test_diagnostics_logging.py" in innermost and "hot_render_function" in innermost
|
|
sampler.flush()
|
|
assert (tmp_path / "render_profile.txt").read_text() == text
|
|
assert sampler.samples == 0 and sampler.report() == ""
|
|
|
|
|
|
def test_sampler_reports_parameter_keys_without_values(tmp_path):
|
|
sampler = RenderSampler(tmp_path / "profile.txt")
|
|
sampler.rendering = True
|
|
|
|
class Code:
|
|
co_name = "get"
|
|
co_filename = "/data/openpilot/openpilot/common/params.py"
|
|
co_firstlineno = 220
|
|
|
|
sampler.sample(SimpleNamespace(f_code=Code(), f_lineno=222, f_locals={"key": "BorderWidth", "value": "private-value"}, f_back=None))
|
|
report = sampler.report()
|
|
assert "100.0% get(BorderWidth)" in report and "private-value" not in report
|
|
|
|
|
|
def test_render_sampler_file_rotates_at_its_size_cap(tmp_path):
|
|
path = tmp_path / "render_profile.txt"
|
|
sampler = RenderSampler(path, max_bytes=300)
|
|
for index in range(5):
|
|
sampler._write(f"window {index} " + "x" * 100 + "\n")
|
|
assert path.read_text().startswith("window 4")
|
|
assert (tmp_path / "render_profile.1.txt").read_text().startswith("window 2")
|
|
assert path.stat().st_size <= 300 and not (tmp_path / "render_profile.2.txt").exists()
|
|
|
|
|
|
def test_render_sampler_thread_samples_the_render_thread(tmp_path):
|
|
sampler = RenderSampler(tmp_path / "render_profile.txt", interval=0.001, window=3600)
|
|
sampler.rendering = True
|
|
sampler.start()
|
|
deadline = time.monotonic() + 2.0
|
|
while sampler.samples < 5 and time.monotonic() < deadline:
|
|
sum(range(10_000))
|
|
sampler.close()
|
|
assert "test_render_sampler_thread_samples_the_render_thread" in (tmp_path / "render_profile.txt").read_text()
|
|
|
|
|
|
def test_render_summary_reports_frame_rate_and_time_per_window():
|
|
summary = RenderSummary(0.0, interval=1.0)
|
|
for began in (0.0, 0.25, 0.5, 0.75):
|
|
assert summary.frame_done(began, began + 0.05) is None
|
|
assert summary.frame_done(0.95, 1.0) == {"event": "render_stats", "fps": 5.0, "frame_ms": 50.0}
|
|
assert summary.frames == 0 and summary.started == 1.0 # the next window starts where this one ended
|
|
|
|
|
|
# ------------------------------------------------------------- session log writer
|
|
|
|
def _read_records(directory):
|
|
(path,) = directory.glob("session-*.jsonl")
|
|
return [json.loads(line) for line in path.read_text().splitlines()]
|
|
|
|
|
|
def test_session_log_writes_in_order_off_the_caller_thread(tmp_path):
|
|
from openpilot.starpilot.system.android_auto.supervisor import EventLog
|
|
log = EventLog(tmp_path)
|
|
log.open()
|
|
for index in range(50):
|
|
log("stats", index=index)
|
|
log.close()
|
|
records = _read_records(tmp_path)
|
|
assert [record["index"] for record in records] == list(range(50))
|
|
assert len(log.recent) == 40 and log.recent[-1]["index"] == 49
|
|
|
|
|
|
def test_session_log_never_blocks_on_a_stalled_disk(tmp_path, monkeypatch):
|
|
import threading
|
|
import time
|
|
from openpilot.starpilot.system.android_auto import supervisor
|
|
monkeypatch.setattr(supervisor, "LOG_QUEUE_MAX", 5)
|
|
release = threading.Event()
|
|
original = supervisor.EventLog._write
|
|
|
|
def stalled(handle, records, stopped):
|
|
release.wait(5) # the disk is stuck until the test lets it go
|
|
original(handle, records, stopped)
|
|
|
|
monkeypatch.setattr(supervisor.EventLog, "_write", staticmethod(stalled))
|
|
log = supervisor.EventLog(tmp_path)
|
|
log.open()
|
|
started = time.monotonic()
|
|
for index in range(20):
|
|
log("stats", index=index)
|
|
assert time.monotonic() - started < 0.5 # the caller never waited for the disk
|
|
release.set()
|
|
deadline = time.monotonic() + 2
|
|
while not log._queue.empty() and time.monotonic() < deadline:
|
|
time.sleep(0.01) # the disk catches up
|
|
log("after", index=20)
|
|
log.close()
|
|
records = _read_records(tmp_path)
|
|
dropped = [record for record in records if record["event"] == "log_dropped"]
|
|
assert dropped and dropped[0]["count"] == 15
|
|
assert [record["index"] for record in records if record["event"] != "log_dropped"] == [0, 1, 2, 3, 4, 20]
|
|
|
|
|
|
def test_session_log_without_a_file_keeps_the_recent_tail(tmp_path):
|
|
from openpilot.starpilot.system.android_auto.supervisor import EventLog
|
|
(tmp_path / "file").write_text("")
|
|
log = EventLog(tmp_path / "file" / "logs") # cannot be created: a file is in the way
|
|
log.open()
|
|
log("stage", stage="rfcomm")
|
|
log.close()
|
|
assert log.recent[-1]["event"] == "stage"
|
|
|
|
|
|
def test_log_reads_reject_replaced_symlinks_and_directories(tmp_path, monkeypatch):
|
|
logs = tmp_path / "logs"
|
|
logs.mkdir()
|
|
outside = tmp_path / "private"
|
|
outside.write_text("private material")
|
|
session = logs / "session-000001-test.jsonl"
|
|
session.symlink_to(outside)
|
|
assert compat_report.session_logs(logs) == []
|
|
with pytest.raises(OSError):
|
|
compat_report.read_log(session, compat_report.MAX_LOG_BYTES)
|
|
# A path substituted after listing must not bypass the opened-file checks.
|
|
monkeypatch.setattr(compat_report, "session_logs", lambda *_: [session])
|
|
with pytest.raises(OSError):
|
|
compat_report.bundle(logs)
|
|
session.unlink()
|
|
session.mkdir()
|
|
with pytest.raises(OSError):
|
|
compat_report.read_log(session, compat_report.MAX_LOG_BYTES)
|
|
|
|
|
|
def test_log_and_bundle_reads_enforce_size_budgets(tmp_path, monkeypatch):
|
|
session = tmp_path / "session-000001-test.jsonl"
|
|
session.write_bytes(b"x" * 9)
|
|
with pytest.raises(OSError):
|
|
compat_report.read_log(session, 8)
|
|
assert compat_report.read_log(session, 8, tail=True) == b"x" * 8
|
|
data = (json.dumps({"event": "session_stop"}) + "\n").encode() * 400
|
|
session.write_bytes(data)
|
|
(tmp_path / "session-000002-test.jsonl").write_bytes(data)
|
|
monkeypatch.setattr(compat_report, "MAX_BUNDLE_BYTES", len(data) + 4096)
|
|
with pytest.raises(OSError):
|
|
compat_report.bundle(tmp_path)
|
|
|
|
|
|
def test_session_log_full_queue_close_then_reopen_retires_old_writer(tmp_path, monkeypatch):
|
|
import threading
|
|
from openpilot.starpilot.system.android_auto import supervisor
|
|
monkeypatch.setattr(supervisor, "LOG_QUEUE_MAX", 2)
|
|
release = threading.Event()
|
|
entered = threading.Event()
|
|
original = supervisor.EventLog._write
|
|
handles = []
|
|
|
|
def stalled_once(handle, records, stopped):
|
|
handles.append(handle)
|
|
if len(handles) == 1:
|
|
entered.set()
|
|
release.wait(5)
|
|
original(handle, records, stopped)
|
|
|
|
monkeypatch.setattr(supervisor.EventLog, "_write", staticmethod(stalled_once))
|
|
log = supervisor.EventLog(tmp_path)
|
|
log.open()
|
|
old_writer = log._writer
|
|
assert entered.wait(1)
|
|
try:
|
|
log("first", index=0)
|
|
log("first", index=1)
|
|
assert log._queue.full()
|
|
log.close()
|
|
log.open()
|
|
log("second", index=2)
|
|
log.close()
|
|
finally:
|
|
release.set()
|
|
old_writer.join(timeout=2)
|
|
log.close()
|
|
assert not old_writer.is_alive()
|
|
assert all(handle.closed for handle in handles)
|
|
files = sorted(tmp_path.glob("session-*.jsonl"))
|
|
first = [json.loads(line) for line in files[0].read_text().splitlines()]
|
|
second = [json.loads(line) for line in files[1].read_text().splitlines()]
|
|
assert [item["event"] for item in first] == ["first", "first"]
|
|
assert [item["event"] for item in second] == ["second"]
|