From 75a29d3e7195887e26bd8a740dac2fbf6efd5c24 Mon Sep 17 00:00:00 2001 From: dirwin31 <83434411+dirwin31@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:28:15 -0800 Subject: [PATCH] Put the full report in the logs bundle; turn the Bluetooth-first refusal into a step The logs bundle, and so the Discord send, now carries report.json: the same tags and per-session detail a Bugsink report has (setup, offered modes, reach class, ladder, SDP, strategy) for the newest five sessions, sanitized and size-capped. It is built best-effort: if it cannot be, the bundle is unaffected. The adapter setup's 'Connect your paired car in Galaxy Bluetooth first' refusal (27 Bugsink reports) now shows a step with a link to Bluetooth, next to the usual question while setup is unfinished and alone once it is done. --- .../galaxy/tests/test_starport_guided.mjs | 15 +++++++++ .../galaxy/web/js/starport-guided.js | 16 +++++++--- .../system/starport/compat_report.py | 31 +++++++++++++++++++ .../tests/test_diagnostics_logging.py | 17 ++++++++++ 4 files changed, 75 insertions(+), 4 deletions(-) diff --git a/openpilot/starpilot/galaxy/tests/test_starport_guided.mjs b/openpilot/starpilot/galaxy/tests/test_starport_guided.mjs index d055fed73b..1c85cd4de6 100644 --- a/openpilot/starpilot/galaxy/tests/test_starport_guided.mjs +++ b/openpilot/starpilot/galaxy/tests/test_starport_guided.mjs @@ -132,3 +132,18 @@ assert.equal(guidedState({ setup: null, runtime: running("rfcomm") }).show, fals assert.equal(StarPortPage.computed.newSetup.call({ setup: null }), true) } console.log("StarPort guided setup: question, steps, candidates, live checklist, first-run notice and handshake list passed") + +// The adapter's "connect your car in Bluetooth first" refusal becomes a step with a link, not a dead end. +{ + const { BLUETOOTH_HINT } = await import("../web/js/starport-guided.js") + const refused = { running: false, state: "error", error: "Connect your paired car in Galaxy Bluetooth first to select it for this Android Auto adapter" } + const state = guidedState({ setup, runtime: refused, carType: "adapter", labels }) + assert.equal(state.phase, "setup") // the usual question and steps stay, with the action first + assert.deepEqual(state.hint, BLUETOOTH_HINT) + assert.equal(BLUETOOTH_HINT.href, "#/bluetooth") + const done = guidedState({ setup, runtime: refused, setupDone: true }) + assert.equal(done.phase, "bluetooth") // even a finished setup shows the thing to fix + assert.equal(guidedState({ setup, runtime: { running: false, state: "error", error: "something else" }, carType: "adapter", labels }).hint, null) + assert.match(StarPortGuidedSetup.template, /state\.hint\.href/) +} +console.log("StarPort guided setup: Bluetooth-first hint passed") diff --git a/openpilot/starpilot/galaxy/web/js/starport-guided.js b/openpilot/starpilot/galaxy/web/js/starport-guided.js index 70d1d0830c..798b7c2cca 100644 --- a/openpilot/starpilot/galaxy/web/js/starport-guided.js +++ b/openpilot/starpilot/galaxy/web/js/starport-guided.js @@ -3,6 +3,9 @@ // so it adds no new state of its own. Turned off, the classic screens come back ("Use classic setup"). export const FIRST_RUN_NOTICE = "The first connection can take a few minutes, and sometimes more than one drive, while we find what your car accepts. Keep the car on and its screen awake. Later connections are much faster because we remember what worked." +// The adapter flow refuses to continue until the car itself is connected in Galaxy Bluetooth (27 Bugsink reports, 7 devices). +export const BLUETOOTH_FIRST = /connect your paired car in galaxy bluetooth first/i +export const BLUETOOTH_HINT = { text: "Connect your car in Bluetooth first, then come back and tap Connect.", href: "#/bluetooth", label: "Open Bluetooth" } export const EXHAUSTED_NOTICE = "We tried every option we have this drive. We keep what works best and will try again the next time the car starts." const CHOICES = [ @@ -68,16 +71,20 @@ export function guidedState({ setup = null, runtime = null, selected = null, rec const connecting = !!runtime?.running && CONNECTING.has(state) const installed = !!setup?.identity?.installed const base = { show: false, phase: "live", title: "", detail: "", choices: [], carType, candidates: [], progress: [], handshake: [], - notice: "", exhausted: false } + notice: "", exhausted: false, hint: null } if (setup === null) return base + const hint = BLUETOOTH_FIRST.test(String(runtime?.error || "")) ? BLUETOOTH_HINT : null if (connecting) { const firstRun = !ladder?.learned && !(ladder?.successes > 0) && (runtime?.connection || "wireless") === "wireless" return { ...base, show: true, phase: "connecting", title: runtime?.label || "Connecting to your car", detail: runtime?.detail || "Keep your car’s screen on. We’ll move to the next step automatically.", progress: connectionStages(runtime), handshake: handshakeSteps(ladder), exhausted: !!ladder?.exhausted, - notice: ladder?.exhausted ? EXHAUSTED_NOTICE : firstRun ? FIRST_RUN_NOTICE : "" } + notice: ladder?.exhausted ? EXHAUSTED_NOTICE : firstRun ? FIRST_RUN_NOTICE : "", hint } + } + if (setupDone) { + // A finished setup shows nothing, except a refusal that is the one thing to act on. + return hint ? { ...base, show: true, phase: "bluetooth", title: "Your car needs to be connected in Bluetooth first", detail: hint.text, hint } : base } - if (setupDone) return base if (!installed) { return { ...base, show: true, phase: "package", title: "Start by adding your Android Auto package", detail: "Your car only talks to devices that present Google’s Android Auto certificate. Follow step 1 below, then come back here." } @@ -93,7 +100,7 @@ export function guidedState({ setup = null, runtime = null, selected = null, rec } const pairs = carType === "builtin" || carType === "adapter" return { ...base, show: true, phase: "setup", title: "How does your car connect to Android Auto?", - detail: INSTRUCTIONS[carType] || INSTRUCTIONS.builtin, choices, candidates: pairs && !pairingActive ? candidates : [] } + detail: INSTRUCTIONS[carType] || INSTRUCTIONS.builtin, choices, candidates: pairs && !pairingActive ? candidates : [], hint } } export const StarPortGuidedSetup = { @@ -142,6 +149,7 @@ export const StarPortGuidedSetup = { +
{{ state.hint.text }} {{ state.hint.label }}
{{ state.notice }}
diff --git a/openpilot/starpilot/system/starport/compat_report.py b/openpilot/starpilot/system/starport/compat_report.py index 64ede3ae94..f6692fc70f 100644 --- a/openpilot/starpilot/system/starport/compat_report.py +++ b/openpilot/starpilot/system/starport/compat_report.py @@ -293,6 +293,34 @@ def shareable_config(config: dict) -> dict: return shared +RECENT_DIAGNOSIS_SESSIONS = 5 +MAX_DIAGNOSIS_BYTES = 512 << 10 + + +def _diagnosis_report(logs: list[Path]) -> bytes: + """The same tags and per-session detail a Bugsink report carries (setup, offered modes, reach class, ladder, SDP...), + for the newest few sessions, sanitized. An addition to the bundle: if it cannot be built the bundle is unaffected.""" + try: + from openpilot.starpilot.system.starport import bugsink_report + records = [] + for path in logs[:RECENT_DIAGNOSIS_SESSIONS]: + try: + records.append(bugsink_report.session_record(path)) + except (OSError, ValueError, KeyError, TypeError): + continue + if not records: + return b"" + records.reverse() # oldest first, as the drive report expects + event = bugsink_report.drive_event(records) + report = {"tags": event["tags"], "message": event["message"], "sessions": [bugsink_report._public(record) for record in records]} + data = json.dumps(report, indent=1, default=str).encode() + if len(data) > MAX_DIAGNOSIS_BYTES: + data = json.dumps({key: report[key] for key in ("tags", "message")}, indent=1, default=str).encode() + return data + except Exception: + return b"" + + 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 @@ -321,6 +349,9 @@ def bundle(log_dir: Path | None = None, config_path: Path | None = None, *, 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()) + report = _diagnosis_report(logs) + if report: + add("report.json", report) 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: diff --git a/openpilot/starpilot/system/starport/tests/test_diagnostics_logging.py b/openpilot/starpilot/system/starport/tests/test_diagnostics_logging.py index 5bd9bbcb64..4852d9724a 100644 --- a/openpilot/starpilot/system/starport/tests/test_diagnostics_logging.py +++ b/openpilot/starpilot/system/starport/tests/test_diagnostics_logging.py @@ -231,6 +231,23 @@ def test_bundle_has_logs_reports_and_settings_without_addresses(tmp_path): 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) + # The bundle (and so the Discord send) carries what a Bugsink report carries: tags, and per-session detail. + report = json.loads(archive.read("report.json")) + assert report["tags"]["aa.report"] == "drive" and "aa.head_unit" in report["tags"] + assert report["sessions"][0]["offered_modes"] is not None and "setup" in report["sessions"][0] + assert "AA:BB:CC:DD:EE:FF" not in archive.read("report.json").decode() + + +def test_bundle_still_builds_when_the_report_cannot(tmp_path, monkeypatch): + from openpilot.starpilot.system.starport import bugsink_report + logs = tmp_path / "logs" + logs.mkdir() + write_log(logs, "session-000025-20260928-141351.jsonl", CIVIC_SESSION) + monkeypatch.setattr(bugsink_report, "session_record", lambda path: (_ for _ in ()).throw(RuntimeError("broken"))) + config = tmp_path / "config.json" + config.write_text("{}") + with zipfile.ZipFile(io.BytesIO(compat_report.bundle(logs, config))) as archive: + assert "REPORT.txt" in archive.namelist() and "report.json" not in archive.namelist() def test_report_tool_prints_a_log(tmp_path, monkeypatch, capsys):