Files
StarPilot/openpilot/starpilot/system/android_auto/compat_report.py
T
Prabhaav Pillai 8df85fa083 Updated Android Auto page to clarify Wi-Fi usage and connection requirements.
Renamed button text in app.js for better user understanding.
Enhanced Galaxy page layout and messaging for remote access and installation instructions.
Improved hotspot settings interface with clearer instructions and error handling.
Streamlined local access messaging for better user guidance.
Adjusted navigation status handling to reflect accurate network states.
Updated tests to align with changes in network status reporting and command execution.
Refined system snapshot collection to ensure proper output handling and error reporting.
2026-10-10 01:29:42 -04:00

324 lines
16 KiB
Python

"""A short, shareable account of how one Android Auto session went with one car.
Reads a session log (``/data/android_auto/logs/session-*.jsonl``) and reports what a
compatibility report needs: which car and head unit, wired or wireless, how far the
connection got and why it stopped, the Wi-Fi security, TLS suite and video modes the
car offered, and the messages StarPilot does not act on yet. Used by
``openpilot/tools/android_auto/compat_report.py``.
The report carries no Wi-Fi name, key, address or vehicle identifier; the raw logs,
which the bundle also includes, already leave out keys and vehicle ids.
"""
from __future__ import annotations
import io
import json
import os
import stat
import re
import zipfile
from collections.abc import Callable
from pathlib import Path
from openpilot.starpilot.system.android_auto import identity as identity_store
from openpilot.starpilot.system.android_auto import system_snapshot
from openpilot.starpilot.system.android_auto.session import describe_video_config
SESSION_GLOB = "session-*.jsonl"
SESSION_NAME = re.compile(r"session-(?:\d{6}-)?[\w.-]+\.jsonl")
MAX_LOG_BYTES = 8 << 20
MAX_BUNDLE_BYTES = 64 << 20
MAX_SESSION_LOGS = 20
MAX_EVENTS = 200_000 # a day of 30 s stats is ~3000 lines; this only stops a runaway file
EXTRA_LOGS = ("render_profile.txt", "render_profile.1.txt", "car_ui.log")
EXTRA_LOG_BYTES = 1 << 20 # the tail of each extra log kept in a bundle
# Connection stages in the order a session passes them, for "how far did it get".
STAGE_ORDER = ("connecting_bluetooth", "discovering", "rfcomm", "wifi_start", "wifi_info", "joining_wifi", "connecting_tcp",
"waiting_for_usb", "usb_accessory", "authenticating", "negotiating", "streaming")
# ServiceDiscoveryResponse and its headunit_info, by field number (JSON turns the numbers into strings).
SDR_FIELDS = {"2": "car_make", "3": "car_model", "4": "car_year", "7": "head_unit_make", "8": "head_unit_model",
"9": "head_unit_software_build", "10": "head_unit_software_version", "14": "display_name"}
HEAD_UNIT_INFO_FIELDS = {"1": "car_make", "2": "car_model", "3": "car_year", "5": "head_unit_make", "6": "head_unit_model",
"7": "head_unit_software_build", "8": "head_unit_software_version"}
IGNORED_EVENTS = ("control_ignored", "channel_ignored", "video_ignored", "input_ignored", "unexpected_while_waiting",
"handshake_ignored", "bootstrap_ignored")
def read_log(path: Path, limit: int, *, tail: bool = False) -> bytes:
directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
try:
fd = os.open(path.name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory)
finally:
os.close(directory)
with os.fdopen(fd, "rb") as handle:
info = os.fstat(handle.fileno())
if not stat.S_ISREG(info.st_mode):
raise OSError("Not a regular Android Auto log")
if tail:
handle.seek(max(0, info.st_size - limit))
elif info.st_size > limit:
raise OSError("Android Auto log exceeds the size limit")
data = handle.read(limit + 1)
if len(data) > limit:
raise OSError("Android Auto log exceeds the size limit")
return data
def events_from_bytes(data: bytes) -> list[dict]:
events = []
for line in data.decode("utf-8", "replace").splitlines():
if len(events) >= MAX_EVENTS:
break
try:
record = json.loads(line)
except ValueError:
continue
if isinstance(record, dict) and isinstance(record.get("event"), str):
events.append(record)
return events
def load_events(path: Path) -> list[dict]:
try:
return events_from_bytes(read_log(path, MAX_LOG_BYTES))
except OSError:
return []
def _first_text(values) -> str:
if isinstance(values, list) and values and isinstance(values[0], str):
return values[0]
return ""
def _car_from_discovery(head_unit: dict) -> dict:
car = {label: _first_text(head_unit.get(number)) for number, label in SDR_FIELDS.items()}
info = head_unit.get("17")
if isinstance(info, list) and info and isinstance(info[0], dict):
for number, label in HEAD_UNIT_INFO_FIELDS.items():
car[label] = _first_text(info[0].get(number)) or car.get(label, "")
return {key: value for key, value in car.items() if value}
def _video_offer(channels: list | None) -> list[str]:
"""The logged video configurations (JSON turned their field numbers into strings), named as the session names them."""
offered = []
for channel in channels if isinstance(channels, list) else []:
if not isinstance(channel, dict):
continue
for config in channel.get("video_configs") or []:
if isinstance(config, dict):
fields = {int(number): values for number, values in config.items() if str(number).isdigit() and isinstance(values, list)}
offered.append(describe_video_config(fields, channel.get("display_type")))
return offered
def summarize(events: list[dict]) -> dict:
"""Everything a compatibility report needs from one session log, as plain JSON-able values."""
report: dict = {"started": events[0].get("t", "") if events else "", "events": len(events), "transport": "",
"trigger": "", "car": {}, "stages": [], "furthest_stage": "", "outcome": "no session",
"errors": [], "ended": [], "wifi": {}, "tls": {}, "video": {}, "usb": {}, "bluetooth": {},
"focus": {"granted": 0, "lost": 0}, "ignored": [], "stats": {}, "health": {}}
ignored: dict[tuple, dict] = {}
projected = False # the car acknowledged a frame; focus or the "streaming" stage alone do not show that
failed_after = None
for record in events:
name = record["event"]
if name == "session_start":
report["transport"] = "wired" if record.get("receiver") == "usb" else "wireless"
report["trigger"] = record.get("trigger", "")
if record.get("receiver") and record.get("receiver") != "usb":
report["car"].setdefault("bluetooth_name", record["receiver"])
elif name == "stage":
state = record.get("state", "")
if state and (not report["stages"] or report["stages"][-1] != state):
report["stages"].append(state)
elif name == "bootstrap_version" and isinstance(record.get("head_unit"), dict):
report["car"].update({key: value for key, value in record["head_unit"].items() if isinstance(value, str) and value})
report["wifi"]["version"] = f"{record.get('major')}.{record.get('minor')}"
elif name == "discovered":
report["car"].update(_car_from_discovery(record.get("head_unit") or {}))
report["video"]["offered"] = _video_offer(record.get("channels"))
services = sorted({service for channel in record.get("channels") or [] if isinstance(channel, dict)
for service in channel.get("services") or []})
report["video"]["car_services"] = services
elif name == "bootstrap_credentials":
report["wifi"].update(security=record.get("security", ""), ap_type=record.get("ap_type"))
elif name == "wifi_joined":
report["wifi"]["joined"] = True
elif name == "version":
report["tls"]["protocol"] = f"{record.get('major')}.{record.get('minor')} (replied {record.get('reply')})"
elif name == "tls_established":
report["tls"].update(version=record.get("version", ""), cipher=record.get("cipher", ""))
elif name == "tls_failed":
report["tls"]["failed"] = record.get("reason") or record.get("error", "")
elif name == "head_unit_verified":
report["tls"]["head_unit"] = record.get("subject", "")
elif name == "authentication_rejected":
report["tls"]["rejected_status"] = record.get("status")
elif name == "projection_ready":
mode = record.get("mode") or {}
margins = f"{mode.get('margin_width')}x{mode.get('margin_height')}"
report["video"]["chosen"] = f"{mode.get('width')}x{mode.get('height')} @ {mode.get('fps')} fps, margins {margins}"
elif name == "video_focus":
report["focus"]["granted" if record.get("focused") else "lost"] += 1
elif name == "video_acknowledged":
projected = True
failed_after = None
elif name == "usb_gadget_prepared":
report["usb"]["started_as"] = record.get("mode", "")
elif name == "usb_no_accessory_start":
report["usb"]["no_handshake_after_s"] = record.get("waited")
elif name == "usb_accessory_ready":
report["usb"]["method"] = record.get("method", "")
strings = record.get("strings") or {}
report["usb"]["accessory_strings"] = strings # AOA requires "Android" / "Android Auto"; description and version vary
elif name == "usb_state":
report["usb"].setdefault("states", [])
if not report["usb"]["states"] or report["usb"]["states"][-1] != record.get("state"):
report["usb"]["states"] = (report["usb"]["states"] + [record.get("state")])[-12:]
elif name == "hfp_wait":
report["bluetooth"]["hands_free_before_rfcomm"] = record.get("linked")
elif name == "hfp_connected":
report["bluetooth"]["hands_free"] = True
elif name == "rfcomm_channel":
report["bluetooth"]["rfcomm_channel"] = record.get("channel")
elif name == "attempt_failed":
error = {"stage": record.get("stage", ""), "error": record.get("error", "")}
if record.get("where"):
error["where"] = record["where"][-1] # the innermost frame: which call actually raised
if error not in report["errors"]:
report["errors"].append(error)
if projected:
failed_after = error["error"]
elif name == "session_ended" and record.get("reason"):
report["ended"].append(record["reason"])
elif name in ("stream_stall", "link_check_slow", "link_check_failed", "link_lost"):
entry = report["health"].setdefault(name, {"count": 0, "max_ms": 0})
entry["count"] += 1
entry["max_ms"] = max(entry["max_ms"], int(record.get("ms") or 0))
elif name == "stats":
report["stats"] = {key: value for key, value in record.items() if key not in ("t", "event")}
elif name in IGNORED_EVENTS:
key = (name, record.get("channel"), record.get("kind", record.get("message")) if name == "bootstrap_ignored" else record.get("kind"))
entry = ignored.setdefault(key, {"event": name, "channel": record.get("channel"), "kind": key[2], "count": 0})
entry["count"] = max(entry["count"] + 1, int(record.get("count") or 0))
if "message" in record and name != "bootstrap_ignored":
entry["example"] = record["message"]
report["ignored"] = sorted(ignored.values(), key=lambda entry: -entry["count"])[:20]
reached = [stage for stage in report["stages"] if stage in STAGE_ORDER]
report["furthest_stage"] = max(reached, key=STAGE_ORDER.index) if reached else ""
if projected:
report["outcome"] = f"projected, then failed: {failed_after}" if failed_after else "projected"
elif report["errors"]:
report["outcome"] = f"failed: {report['errors'][-1]['error']}"
elif events:
report["outcome"] = f"stopped at {report['furthest_stage'] or 'start'}"
return report
def render_text(report: dict, name: str = "") -> str:
"""The report as plain text, to paste into an issue or a message."""
lines = [f"StarPilot Android Auto compatibility report{f' ({name})' if name else ''}",
f"Started: {report['started'] or 'unknown'} Connection: {report['transport'] or 'unknown'} Trigger: {report['trigger'] or '-'}",
f"Outcome: {report['outcome']}"]
car = report["car"]
if car:
lines.append("Car: " + ", ".join(f"{key.replace('_', ' ')}: {value}" for key, value in car.items()))
lines.append("Stages: " + (" > ".join(report["stages"]) or "none"))
for error in report["errors"][-5:]:
lines.append(f"Error ({error['stage']}): {error['error']}" + (f" [at {error['where']}]" if error.get("where") else ""))
for reason in report["ended"][-3:]:
lines.append(f"Ended by car: {reason}")
for title, section in (("Wi-Fi", report["wifi"]), ("Bluetooth", report["bluetooth"]), ("TLS", report["tls"]),
("USB", report["usb"]), ("Video", report["video"])):
if section:
lines.append(f"{title}: " + "; ".join(f"{key.replace('_', ' ')}: {value}" for key, value in section.items()))
lines.append(f"Focus: granted {report['focus']['granted']}, lost {report['focus']['lost']}")
if report["health"]:
lines.append("Stalls: " + "; ".join(f"{key.replace('_', ' ')} x{value['count']} (max {value['max_ms']} ms)"
for key, value in report["health"].items()))
if report["stats"]:
lines.append("Last stats: " + json.dumps(report["stats"], default=str)[:400])
for entry in report["ignored"][:10]:
lines.append(f"Not handled: {entry['event']} channel {entry['channel']} kind {entry['kind']} x{entry['count']}"
+ (f" e.g. {json.dumps(entry['example'], default=str)[:200]}" if entry.get("example") is not None else ""))
return "\n".join(lines) + "\n"
def session_logs(log_dir: Path | None = None) -> list[Path]:
"""Session logs, newest first."""
directory = log_dir or identity_store.LOG_DIR
try:
logs = [path for path in directory.glob(SESSION_GLOB) if not path.is_symlink() and path.is_file() and SESSION_NAME.fullmatch(path.name)]
except OSError:
return []
return sorted(logs, key=identity_store.session_log_order, reverse=True)[:MAX_SESSION_LOGS]
def session_path(name: str, log_dir: Path | None = None) -> Path | None:
"""The log called ``name``, only if it is one of the session logs (no paths from outside)."""
return next((path for path in session_logs(log_dir) if path.name == name), None)
def _tail(path: Path, limit: int) -> bytes | None:
try:
return read_log(path, limit, tail=True)
except OSError:
return None
def shareable_config(config: dict) -> dict:
"""The settings without the Bluetooth addresses of the car and its companion device."""
shared = dict(config)
for key in ("receiver_address", "companion_address"):
if shared.get(key):
shared[key] = "redacted"
shared["rfcomm_cache"] = sorted((shared.get("rfcomm_cache") or {}).values()) # the channels, not whose they are
return shared
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
device (system journals, kernel log, build, load) under system/; never the identity or a full hardware address."""
directory = log_dir or identity_store.LOG_DIR
logs = session_logs(directory)
output = io.BytesIO()
total = 0
with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as archive:
def add(name: str, data: bytes):
nonlocal total
total += len(data)
if total > MAX_BUNDLE_BYTES:
raise OSError("Android Auto bundle exceeds the size limit")
archive.writestr(name, system_snapshot.redact(data))
summaries, starts = [], []
for path in logs:
try:
data = read_log(path, MAX_LOG_BYTES)
except FileNotFoundError:
continue
report = summarize(events_from_bytes(data))
starts.append(report.get("started") or "")
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())
add("REPORT.txt", ("\n".join(summaries) or "No Android Auto sessions have been logged yet.\n").encode())
config = identity_store.load_config(config_path)
add("config.json", json.dumps(shareable_config(config), indent=2, default=str).encode())
for name in EXTRA_LOGS:
data = _tail(directory / name, EXTRA_LOG_BYTES)
if data is not None:
add(f"logs/{name}", data)
if snapshot is not None:
# Optional context: whatever does not fit the size budget is left out rather than failing the download.
for name, data in snapshot(system_snapshot.window_start(starts)).items():
if total + len(data) > MAX_BUNDLE_BYTES:
continue
add(f"system/{name}", data)
return output.getvalue()