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.
This commit is contained in:
dirwin31
2026-11-01 14:28:15 -08:00
committed by Danny
parent e5a981b5ad
commit 75a29d3e71
4 changed files with 75 additions and 4 deletions
@@ -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")
@@ -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 = {
</li>
</ol>
</details>
<p v-if="state.hint" class="gx-note" role="status">{{ state.hint.text }} <a class="gx-btn gx-btn--tonal" :href="state.hint.href">{{ state.hint.label }}</a></p>
<p v-if="state.notice" class="gx-note" role="status">{{ state.notice }}</p>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="busy" @click="$emit('classic')">Use classic setup</button>
</div>
@@ -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:
@@ -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):