mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-14 16:33:59 +08:00
StarPort: Clearer Steps to User
This commit is contained in:
@@ -2443,6 +2443,8 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
|
||||
command, arguments = 'start', {}
|
||||
elif action == 'reset_handshake' and set(payload) == {'action'}:
|
||||
command, arguments = 'reset_handshake', {}
|
||||
elif action == 'continue_ladder' and set(payload) == {'action'}:
|
||||
command, arguments = 'continue_ladder', {}
|
||||
elif action == 'auto_connect' and set(payload) == {'action', 'enabled'} and type(payload['enabled']) is bool:
|
||||
if not configuration_allowed():
|
||||
self.json(403, {'error': 'Park before changing automatic connection'})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { StarPortFeed, StarPortPage } from "../web/js/starport.js"
|
||||
import { guidedState, connectionStages, handshakeSteps, handshakeResettable, StarPortGuidedSetup, FIRST_RUN_NOTICE, EXHAUSTED_NOTICE } from "../web/js/starport-guided.js"
|
||||
import { guidedState, connectionStages, handshakeSteps, handshakeResettable, ladderRound, StarPortGuidedSetup, FIRST_RUN_NOTICE, EXHAUSTED_NOTICE, RESTART_TITLE } from "../web/js/starport-guided.js"
|
||||
|
||||
const setup = { identity: { installed: true } }
|
||||
const adapterModels = { aawireless: "AAWireless", default: "Motorola MA1", unofficial: "Unofficial dongle" }
|
||||
@@ -118,7 +118,7 @@ assert.equal(guidedState({ setup: null, runtime: running("rfcomm") }).show, fals
|
||||
|
||||
// The component emits the page's actions and never invents state.
|
||||
{
|
||||
assert.deepEqual(StarPortGuidedSetup.emits, ["choose-type", "choose-adapter", "choose-app", "use-car", "classic", "reset-handshake", "done"])
|
||||
assert.deepEqual(StarPortGuidedSetup.emits, ["choose-type", "choose-adapter", "choose-app", "use-car", "classic", "reset-handshake", "continue-ladder", "done"])
|
||||
assert.match(StarPortGuidedSetup.template, /v-if="state\.canResetHandshake"[^>]*@click="\$emit\('reset-handshake'\)"/)
|
||||
const template = StarPortGuidedSetup.template
|
||||
assert.match(template, /\$emit\('choose-type', choice\.key\)/)
|
||||
@@ -276,3 +276,43 @@ console.log("StarPort guided setup: steps, adapter and app choices, reopening an
|
||||
assert.equal(held.apps.options.find((option) => option.selected).key, "headunitpad")
|
||||
}
|
||||
console.log("StarPort guided setup: a choice keeps the card open passed")
|
||||
|
||||
// Every rung says what it does, how often it ran this drive and why it last ended; the live step shows under the current one.
|
||||
{
|
||||
const tried = ladder({ steps: [{ id: "standard", label: "Standard handshake", state: "current", description: "Answers normally.", tries: 3, reason: "The car never said hello" },
|
||||
{ id: "other_reply", label: "The other version reply", state: "waiting", description: "Other reply.", tries: 0, reason: "" }] })
|
||||
const steps = handshakeSteps(tried)
|
||||
assert.deepEqual(steps[0], { id: "standard", label: "Standard handshake", state: "current", description: "Answers normally.", tries: 3, reason: "The car never said hello" })
|
||||
assert.equal(steps[1].tries, 0)
|
||||
const state = guidedState({ setup, runtime: running("wifi_start", { setup_ladder: tried, handshake_step: "Asking for its Wi-Fi name and password" }) })
|
||||
assert.equal(state.liveStep, "Asking for its Wi-Fi name and password")
|
||||
assert.equal(state.round, "Round 1 of 3")
|
||||
assert.equal(ladderRound(ladder({ passes: 2 })), "Round 3 of 3")
|
||||
assert.equal(ladderRound(ladder({ learned: true })), "")
|
||||
assert.equal(ladderRound(ladder({ exhausted: true })), "")
|
||||
assert.equal(guidedState({ setup, runtime: running("waiting_for_usb", { connection: "wired", handshake_step: "x" }) }).liveStep, "")
|
||||
assert.match(StarPortGuidedSetup.template, /step\.description/)
|
||||
assert.match(StarPortGuidedSetup.template, /state\.liveStep/)
|
||||
}
|
||||
|
||||
// An unofficial dongle waiting for a restart gets the restart card with Continue; other receivers only get told about the hang-ups.
|
||||
{
|
||||
const restart = guidedState({ setup, carType: "adapter", runtime: running("dongle_restart", { hangups: 6, dongle_restart: { hangups: 6 } }) })
|
||||
assert.equal(restart.show, true)
|
||||
assert.equal(restart.phase, "restart")
|
||||
assert.equal(restart.title, RESTART_TITLE)
|
||||
assert.deepEqual(restart.restart, { hangups: 6 })
|
||||
assert.equal(restart.progress[0].state, "active")
|
||||
const builtin = guidedState({ setup, carType: "builtin", runtime: running("backoff", { last_stage: "rfcomm", hangups: 7 }) })
|
||||
assert.equal(builtin.restart, null)
|
||||
assert.match(builtin.notice, /The car has hung up 7 times/)
|
||||
assert.match(guidedState({ setup, carType: "adapter", runtime: running("backoff", { hangups: 6 }) }).notice, /unplug the adapter/)
|
||||
assert.equal(guidedState({ setup, carType: "builtin", runtime: running("backoff", { hangups: 5 }) }).notice, FIRST_RUN_NOTICE)
|
||||
assert.match(StarPortGuidedSetup.template, /\$emit\('continue-ladder'\)/)
|
||||
assert.match(StarPortPage.template, /@continue-ladder="continueLadder"/)
|
||||
const calls = []
|
||||
StarPortPage.methods.continueLadder.call({ control: (action) => calls.push(action) })
|
||||
assert.deepEqual(calls, ["continue_ladder"])
|
||||
}
|
||||
console.log("StarPort guided setup: rung detail, live step and the dongle restart card passed")
|
||||
|
||||
|
||||
@@ -1873,6 +1873,7 @@ progress { max-width: 100%; accent-color: var(--primary); }
|
||||
.gx-guided__choice:has(:checked) { background: var(--primary); color: var(--on-primary); }
|
||||
.gx-guided__choice:has(:checked):hover { background: var(--primary-hover); }
|
||||
.gx-guided__tile { flex: none; display: grid; place-items: center; width: 40px; height: 40px; border-radius: var(--radius-sm); background: var(--primary-container); color: var(--primary); font-size: var(--fs-lg); }
|
||||
.gx-guided__checklist > .gx-starport-row { flex-wrap: nowrap; }
|
||||
.gx-guided__mark { flex: none; display: grid; place-items: center; width: 28px; font-size: var(--fs-lg); color: var(--on-surface-variant); }
|
||||
.gx-guided__checklist > li.is-done .gx-guided__mark, .gx-guided__checklist > li.is-worked .gx-guided__mark { color: var(--success); }
|
||||
.gx-guided__checklist > li.is-active .gx-guided__mark, .gx-guided__checklist > li.is-current .gx-guided__mark, .gx-guided__checklist > li.is-retry .gx-guided__mark { color: var(--primary); }
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
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."
|
||||
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."
|
||||
export const RESTART_TITLE = "Your dongle keeps hanging up"
|
||||
export const RESTART_DETAIL = "Unplug it for 10 seconds, plug it back in and keep the car on. We carry on by ourselves when it comes back, or tap Continue."
|
||||
export const HANGUP_NOTICE_AT = 6
|
||||
|
||||
// Adapter setup may require connecting the companion car first.
|
||||
export const BLUETOOTH_FIRST = /connect your paired car in galaxy bluetooth first/i
|
||||
@@ -25,7 +28,7 @@ const CAR_STEP_LABEL = { builtin: "Car", adapter: "Adapter", usb: "Cable", app:
|
||||
|
||||
const STAGES = {
|
||||
wireless: [
|
||||
{ id: "bluetooth", label: "Reach the car over Bluetooth", states: ["connecting_bluetooth", "discovering", "rfcomm"] },
|
||||
{ id: "bluetooth", label: "Reach the car over Bluetooth", states: ["connecting_bluetooth", "discovering", "rfcomm", "dongle_restart"] },
|
||||
{ id: "wifi", label: "Join the car’s Wi-Fi", states: ["wifi_start", "wifi_info", "joining_wifi"] },
|
||||
{ id: "secure", label: "Secure the connection", states: ["connecting_tcp", "authenticating"] },
|
||||
{ id: "dashboard", label: "Bring up your dashboard", states: ["negotiating", "streaming", "suspended"] },
|
||||
@@ -43,7 +46,7 @@ const STAGES = {
|
||||
}
|
||||
|
||||
const CONNECTING = new Set(["connecting_bluetooth", "discovering", "rfcomm", "wifi_start", "wifi_info", "joining_wifi", "connecting_tcp",
|
||||
"authenticating", "negotiating", "waiting_for_usb", "usb_accessory", "waiting_for_ipad", "waiting_for_tablet", "backoff"])
|
||||
"authenticating", "negotiating", "waiting_for_usb", "usb_accessory", "waiting_for_ipad", "waiting_for_tablet", "backoff", "dongle_restart"])
|
||||
|
||||
export function connectionStages(runtime) {
|
||||
const kind = runtime?.connection === "wired" ? "wired" : ["headunitpad", "openheadunit"].includes(runtime?.connection) ? "app" : "wireless"
|
||||
@@ -58,11 +61,19 @@ export function connectionStages(runtime) {
|
||||
}))
|
||||
}
|
||||
|
||||
// Only worth showing once a handshake option has been tried and failed, or one is remembered for this car.
|
||||
// Worth showing once a handshake option has been tried this drive, has failed, or is remembered for this car.
|
||||
export function handshakeSteps(ladder) {
|
||||
const steps = Array.isArray(ladder?.steps) ? ladder.steps.filter((step) => step && typeof step.label === "string") : []
|
||||
const interesting = ladder?.learned || steps.some((step) => step.state === "failed" || step.state === "worked")
|
||||
return interesting ? steps.map((step) => ({ id: String(step.id), label: step.label, state: String(step.state) })) : []
|
||||
const interesting = ladder?.learned || steps.some((step) => step.state === "failed" || step.state === "worked" || step.tries > 0)
|
||||
return interesting ? steps.map((step) => ({ id: String(step.id), label: step.label, state: String(step.state),
|
||||
description: typeof step.description === "string" ? step.description : "", tries: Number.isInteger(step.tries) ? step.tries : 0,
|
||||
reason: typeof step.reason === "string" ? step.reason : "" })) : []
|
||||
}
|
||||
|
||||
// "Round 2 of 3" while the options are still being worked through; nothing once one is learned or all are used up.
|
||||
export function ladderRound(ladder) {
|
||||
if (!ladder || ladder.learned || ladder.exhausted || !Number.isInteger(ladder.max_passes)) return ""
|
||||
return `Round ${Math.min((Number(ladder.passes) || 0) + 1, ladder.max_passes)} of ${ladder.max_passes}`
|
||||
}
|
||||
|
||||
// Reset learned or failed handshakes without re-pairing.
|
||||
@@ -95,16 +106,23 @@ export function guidedState({ setup = null, runtime = null, selected = null, rec
|
||||
const installed = !!setup?.identity?.installed
|
||||
const steps = setupSteps({ installed, carDone: setupDone, live: state === "streaming", carType })
|
||||
const base = { show: false, phase: "live", title: "", detail: "", choices: [], carType, candidates: [], progress: [], handshake: [], canResetHandshake: false,
|
||||
notice: "", exhausted: false, hint: null, steps, adapter: null, apps: null, editing: false }
|
||||
notice: "", exhausted: false, hint: null, steps, adapter: null, apps: null, editing: false, liveStep: "", round: "", restart: 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,
|
||||
canResetHandshake: (runtime?.connection || "wireless") === "wireless" && handshakeResettable(ladder),
|
||||
notice: ladder?.exhausted ? EXHAUSTED_NOTICE : firstRun ? FIRST_RUN_NOTICE : "", hint }
|
||||
const wireless = (runtime?.connection || "wireless") === "wireless"
|
||||
const firstRun = !ladder?.learned && !(ladder?.successes > 0) && wireless
|
||||
const restarting = state === "dongle_restart"
|
||||
const hangups = Number(runtime?.hangups) || 0
|
||||
// Only an unofficial dongle waits for a restart; for every other receiver repeated hang-ups are said, not acted on.
|
||||
const hangupNotice = wireless && !restarting && hangups >= HANGUP_NOTICE_AT ?
|
||||
`${carType === "adapter" ? "The adapter" : "The car"} has hung up ${hangups} times before answering. If this keeps happening, ${carType === "adapter" ? "unplug the adapter for 10 seconds" : "turn the car off and on again"}.` : ""
|
||||
return { ...base, show: true, phase: restarting ? "restart" : "connecting", title: restarting ? RESTART_TITLE : runtime?.label || "Connecting to your car",
|
||||
detail: restarting ? RESTART_DETAIL : runtime?.detail || "Keep your car’s screen on. We’ll move to the next step automatically.",
|
||||
progress: connectionStages(runtime), handshake: wireless ? handshakeSteps(ladder) : [], exhausted: !!ladder?.exhausted,
|
||||
canResetHandshake: wireless && handshakeResettable(ladder), liveStep: wireless ? String(runtime?.handshake_step || "") : "",
|
||||
round: wireless ? ladderRound(ladder) : "", restart: restarting ? { hangups: Number(runtime?.dongle_restart?.hangups) || hangups } : null,
|
||||
notice: ladder?.exhausted ? EXHAUSTED_NOTICE : hangupNotice || (firstRun ? FIRST_RUN_NOTICE : ""), hint }
|
||||
}
|
||||
if (setupDone && !editing) {
|
||||
// A finished setup shows nothing, except a refusal that is the one thing to act on.
|
||||
@@ -134,7 +152,7 @@ export function guidedState({ setup = null, runtime = null, selected = null, rec
|
||||
export const StarPortGuidedSetup = {
|
||||
props: { state: { type: Object, required: true }, busy: { type: Boolean, default: false },
|
||||
choicesDisabled: { type: Boolean, default: false } },
|
||||
emits: ["choose-type", "choose-adapter", "choose-app", "use-car", "classic", "reset-handshake", "done"],
|
||||
emits: ["choose-type", "choose-adapter", "choose-app", "use-car", "classic", "reset-handshake", "continue-ladder", "done"],
|
||||
template: `
|
||||
<section class="gx-card gx-guided" :data-phase="state.phase" aria-label="Guided StarPort setup">
|
||||
<ol class="gx-guided__steps" aria-label="Setup progress">
|
||||
@@ -207,13 +225,18 @@ export const StarPortGuidedSetup = {
|
||||
<i v-else-if="step.state === 'retry'" class="bi bi-arrow-repeat"></i>
|
||||
<i v-else class="bi bi-circle"></i>
|
||||
</span>
|
||||
<span class="gx-starport-row__text"><strong>{{ step.label }}</strong><small v-if="step.state === 'retry'">Trying again</small></span>
|
||||
<span class="gx-starport-row__text"><strong>{{ step.label }}</strong><small v-if="step.state === 'retry'">Trying again</small><small v-else-if="step.state === 'active' && state.liveStep" aria-live="polite">{{ state.liveStep }}</small></span>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div v-if="state.restart" class="gx-guided__callout" role="alert">
|
||||
<span>It hung up {{ state.restart.hangups }} times before sending anything, so we stopped calling it.</span>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" :disabled="busy" @click="$emit('continue-ladder')">Continue</button>
|
||||
</div>
|
||||
|
||||
<details v-if="state.handshake.length" class="gx-starport-wiki" open>
|
||||
<summary>What we are trying</summary>
|
||||
<summary>What we are trying<template v-if="state.round"> · {{ state.round }}</template></summary>
|
||||
<ol class="gx-starport-list gx-guided__checklist" aria-label="Handshake options">
|
||||
<li v-for="step in state.handshake" :key="step.id" class="gx-starport-row" :class="'is-' + step.state">
|
||||
<span class="gx-guided__mark" aria-hidden="true">
|
||||
@@ -223,7 +246,10 @@ export const StarPortGuidedSetup = {
|
||||
<span v-else-if="step.state === 'current'" class="gx-spinner"></span>
|
||||
<i v-else class="bi bi-circle"></i>
|
||||
</span>
|
||||
<span class="gx-starport-row__text"><strong>{{ step.label }}</strong></span>
|
||||
<span class="gx-starport-row__text"><strong>{{ step.label }}</strong>
|
||||
<small v-if="step.description">{{ step.description }}</small>
|
||||
<small v-if="step.state === 'current' && state.liveStep">Now: {{ state.liveStep }}</small>
|
||||
<small v-if="step.tries || step.reason">{{ step.tries === 1 ? "1 try" : step.tries + " tries" }}<template v-if="step.reason"> · last: {{ step.reason }}</template></small></span>
|
||||
</li>
|
||||
</ol>
|
||||
<button v-if="state.canResetHandshake" type="button" class="gx-btn gx-btn--tonal" :disabled="busy" @click="$emit('reset-handshake')">
|
||||
|
||||
@@ -386,7 +386,7 @@ export class StarPortFeed {
|
||||
|
||||
async control(action, extra = {}) {
|
||||
if (!this.active || this.busy || !this.setup?.enabled || this.pairing?.active ||
|
||||
!["start", "stop", "select_receiver", "auto_connect", "connection", "car_type", "car_profile", "reset_handshake"].includes(action)) return false
|
||||
!["start", "stop", "select_receiver", "auto_connect", "connection", "car_type", "car_profile", "reset_handshake", "continue_ladder"].includes(action)) return false
|
||||
const result = await this.request("./api/starport/control", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action, ...extra })
|
||||
@@ -790,6 +790,7 @@ export const StarPortPage = {
|
||||
chooseApp(key) { this.showSteps = true; return this.saveApp(0, HEAD_UNIT_APPS[key]) },
|
||||
useClassic() { return this.feed.setNewSetup(false) },
|
||||
resetHandshake() { return this.control("reset_handshake") },
|
||||
continueLadder() { return this.control("continue_ladder") },
|
||||
useGuided() { return this.feed.setNewSetup(true) },
|
||||
saveCarType(index, value) {
|
||||
const labels = this.carTypeLabels
|
||||
@@ -969,7 +970,7 @@ export const StarPortPage = {
|
||||
</div>
|
||||
</section>
|
||||
<StarPortGuidedSetup v-if="setup && newSetup && guided.show" id="gx-starport-guided" :state="guided" :busy="busy" :choices-disabled="appDisabled"
|
||||
@choose-type="chooseType" @choose-adapter="chooseAdapter" @choose-app="chooseApp" @use-car="useCar" @classic="useClassic" @reset-handshake="resetHandshake" @done="showSteps = false">
|
||||
@choose-type="chooseType" @choose-adapter="chooseAdapter" @choose-app="chooseApp" @use-car="useCar" @classic="useClassic" @reset-handshake="resetHandshake" @continue-ladder="continueLadder" @done="showSteps = false">
|
||||
<template #car>
|
||||
<GxNotice v-if="wiredUnavailable" tone="warn">Wired AA is unavailable: {{ runtime.wired_blocked }}.</GxNotice>
|
||||
<template v-if="carType === 'usb'">
|
||||
|
||||
@@ -231,6 +231,10 @@ def session_ladder(events: list[dict]) -> list[dict]:
|
||||
tried[-1]["outcome"] = str(event.get("outcome", ""))[:16]
|
||||
if event.get("reason"):
|
||||
tried[-1]["reason"] = str(event["reason"])[:16]
|
||||
if event.get("why"):
|
||||
tried[-1]["why"] = scrub(event["why"], 80)
|
||||
elif name == "attempt_failed" and event.get("hung_up") and tried:
|
||||
tried[-1]["hung_up"] = True
|
||||
elif name == "ladder_not_ready" and tried:
|
||||
tried[-1]["not_ready"] = tried[-1].get("not_ready", 0) + 1
|
||||
elif name == "ladder_worked" and tried:
|
||||
@@ -303,6 +307,8 @@ def session_record(path: Path) -> dict:
|
||||
"error": scrub(last_failure.get("error", "")),
|
||||
"failures": len(failures),
|
||||
"drops": len(drops),
|
||||
"hangups": report["hangups"],
|
||||
"restarts": [{"hangups": restart.get("hangups"), "how": restart.get("how") or None} for restart in report["restarts"]],
|
||||
"ended_by_car": car_ended,
|
||||
"attempts": attempts,
|
||||
"timing_source": "monotonic" if events and all("mono_s" in e for e in events) else "wall clock (legacy log)",
|
||||
|
||||
@@ -21,7 +21,7 @@ import zipfile
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
from openpilot.starpilot.system.starport import identity as identity_store
|
||||
from openpilot.starpilot.system.starport import identity as identity_store, reachability
|
||||
from openpilot.starpilot.system.starport import system_snapshot
|
||||
from openpilot.starpilot.system.starport.session import describe_video_config
|
||||
|
||||
@@ -120,11 +120,13 @@ def summarize(events: list[dict]) -> dict:
|
||||
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": {}}
|
||||
"focus": {"granted": 0, "lost": 0}, "ignored": [], "stats": {}, "health": {},
|
||||
"ladder": {}, "hangups": 0, "restarts": []}
|
||||
ignored: dict[tuple, dict] = {}
|
||||
projected = False # the car acknowledged a frame; focus or the "streaming" stage alone do not show that
|
||||
failed_after = None
|
||||
car_off = False
|
||||
frames = 0 # handshake messages since the attempt began, for logs written before attempt_failed carried hung_up
|
||||
for record in events:
|
||||
name = record["event"]
|
||||
if name == "session_start":
|
||||
@@ -136,6 +138,22 @@ def summarize(events: list[dict]) -> dict:
|
||||
state = record.get("state", "")
|
||||
if state and (not report["stages"] or report["stages"][-1] != state):
|
||||
report["stages"].append(state)
|
||||
elif name == "attempt_start":
|
||||
frames = 0
|
||||
elif name == "bootstrap_rx":
|
||||
frames += 1
|
||||
elif name == "ladder_strategy" and record.get("rung"):
|
||||
report["ladder"].setdefault(record["rung"], {"tries": 0, "failed": 0, "why": ""})["tries"] += 1
|
||||
elif name == "ladder_failed" and record.get("rung"):
|
||||
rung = report["ladder"].setdefault(record["rung"], {"tries": 0, "failed": 0, "why": ""})
|
||||
rung["failed"] += 1
|
||||
rung["why"] = record.get("why") or record.get("reason", "")
|
||||
elif name == "ladder_worked" and record.get("rung"):
|
||||
report["ladder"].setdefault(record["rung"], {"tries": 0, "failed": 0, "why": ""})["why"] = "worked"
|
||||
elif name == "dongle_restart_requested":
|
||||
report["restarts"].append({"t": record.get("t", ""), "hangups": record.get("hangups"), "how": ""})
|
||||
elif name == "dongle_restart_done" and report["restarts"]:
|
||||
report["restarts"][-1]["how"] = record.get("how", "")
|
||||
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')}"
|
||||
@@ -195,6 +213,10 @@ def summarize(events: list[dict]) -> dict:
|
||||
elif name == "rfcomm_channel":
|
||||
report["bluetooth"]["rfcomm_channel"] = record.get("channel")
|
||||
elif name == "attempt_failed":
|
||||
hung_up = record.get("hung_up")
|
||||
if hung_up is None:
|
||||
hung_up = reachability.hung_up_early(record.get("stage", ""), record.get("kind", ""), record.get("error", ""), frames)
|
||||
report["hangups"] += bool(hung_up)
|
||||
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
|
||||
@@ -231,6 +253,22 @@ def summarize(events: list[dict]) -> dict:
|
||||
return report
|
||||
|
||||
|
||||
def stage_line(stages: list[str]) -> str:
|
||||
"""Stages in order, one lap per attempt, with identical laps in a row shown once with a count."""
|
||||
laps: list[list[str]] = []
|
||||
for stage in stages:
|
||||
if not laps or stage == "connecting_bluetooth":
|
||||
laps.append([])
|
||||
laps[-1].append(stage)
|
||||
parts: list[tuple[list[str], int]] = []
|
||||
for lap in laps:
|
||||
if parts and parts[-1][0] == lap:
|
||||
parts[-1] = (lap, parts[-1][1] + 1)
|
||||
else:
|
||||
parts.append((lap, 1))
|
||||
return " | ".join(" > ".join(lap) + (f" (x{count})" if count > 1 else "") for lap, count in parts)
|
||||
|
||||
|
||||
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 ''}",
|
||||
@@ -239,7 +277,14 @@ def render_text(report: dict, name: str = "") -> str:
|
||||
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"))
|
||||
lines.append("Stages: " + (stage_line(report["stages"]) or "none"))
|
||||
for rung, entry in (report.get("ladder") or {}).items():
|
||||
lines.append(f"Handshake {rung}: {entry['tries']} tries, {entry['failed']} counted failures" +
|
||||
(f" (last: {entry['why']})" if entry.get("why") else ""))
|
||||
if report.get("hangups"):
|
||||
lines.append(f"Hung up before any handshake message: {report['hangups']} attempts")
|
||||
for restart in report.get("restarts") or []:
|
||||
lines.append(f"Asked to restart the dongle at {restart['t'] or '?'} after {restart['hangups']} hang-ups: {restart['how'] or 'still waiting'}")
|
||||
for error in report["errors"][-5:]:
|
||||
lines.append(f"Error ({error['stage']}{', after car shutdown' if error.get('after_car_shutdown') else ''}): {error['error']}" +
|
||||
(f" [at {error['where']}]" if error.get("where") else ""))
|
||||
|
||||
@@ -30,7 +30,7 @@ from openpilot.starpilot.system.starport.supervisor import Supervisor
|
||||
PAIRING_COMMANDS = {"prepare_pairing", "pairing_status", "pairing_response", "cancel_pairing", "pair_device"}
|
||||
AUTHENTICATED_COMMANDS = {"start", "set_auto_connect", "select_receiver", "set_view", "set_connection", "set_car_type", "set_car_profile",
|
||||
"prepare_pairing", "pairing_status", "pairing_response", "cancel_pairing", "pair_device", "devices", "bluetooth_action",
|
||||
"phone_window", "bluetooth_power", "reset_handshake"}
|
||||
"phone_window", "bluetooth_power", "reset_handshake", "continue_ladder"}
|
||||
|
||||
|
||||
def _offroad() -> bool:
|
||||
@@ -126,6 +126,8 @@ def handle(supervisor: Supervisor, request: dict[str, Any], verifier: StarPortSo
|
||||
return {"cleared": supervisor.forget_receiver(str(request.get("address", "")))}
|
||||
elif command == "reset_handshake":
|
||||
return {"reset": supervisor.reset_handshake()}
|
||||
elif command == "continue_ladder":
|
||||
return {"continued": supervisor.continue_ladder()}
|
||||
elif command == "set_view":
|
||||
supervisor.set_view(str(request.get("view", "")))
|
||||
elif command == "set_connection":
|
||||
|
||||
@@ -32,6 +32,16 @@ def classify(stage: str, kind: str, message: str) -> str:
|
||||
return OTHER
|
||||
|
||||
|
||||
_HANG_UP = ("connection reset", "errno 104]", "connection refused", "errno 111]")
|
||||
_HANG_UP_STAGES = ("rfcomm", "wifi_start", "wifi_info")
|
||||
|
||||
|
||||
def hung_up_early(stage: str, kind: str, message: str, frames: int) -> bool:
|
||||
"""The receiver dropped or refused the link before sending any handshake message; a page timeout is not one."""
|
||||
text = f"{kind} {message}".lower()
|
||||
return frames == 0 and stage in _HANG_UP_STAGES and any(word in text for word in _HANG_UP)
|
||||
|
||||
|
||||
def retry_delay(reach: str, attempt: int, standard: float) -> float:
|
||||
"""The wait before the next attempt; never shorter than the standard backoff, and unchanged for other failures."""
|
||||
floor = {ASLEEP: ASLEEP_FLOOR, LOCAL: LOCAL_FLOOR}.get(reach)
|
||||
|
||||
@@ -32,6 +32,15 @@ LABELS = {
|
||||
"patient": "Waiting longer for the car",
|
||||
"experiments": "Adapter handshake variations",
|
||||
}
|
||||
DESCRIPTIONS = {
|
||||
"standard": "Answers the way StarPilot normally does.",
|
||||
"other_reply": "Answers with the other version reply some receivers expect.",
|
||||
"modern_dual": "Answers like a current Android phone and offers both 2.4 and 5 GHz Wi-Fi.",
|
||||
"modern_24ghz": "Answers like a current Android phone and asks for 2.4 GHz Wi-Fi only.",
|
||||
"phone_identity": "Answers like a real phone, with this comma's own name and Bluetooth address.",
|
||||
"patient": "Waits up to twice as long for the receiver to send its Wi-Fi details.",
|
||||
"experiments": "Tries the extra message variations some adapters need.",
|
||||
}
|
||||
MODERN_RUNGS = ("other_reply", "modern_dual", "modern_24ghz", "phone_identity") # only mean something to a car that speaks protocol 4 or newer
|
||||
|
||||
|
||||
@@ -131,6 +140,7 @@ class SetupLadder:
|
||||
self.path = path or STATE_PATH
|
||||
self.boot_id, self.clock = boot_id, clock
|
||||
self.cars: dict[str, dict] = {}
|
||||
self.live: dict[str, dict[str, dict]] = {} # tries and last reason per rung this drive; never saved
|
||||
self.load()
|
||||
|
||||
def load(self) -> None:
|
||||
@@ -163,6 +173,7 @@ class SetupLadder:
|
||||
"""A new ignition cycle: the passes are used up per drive, not per boot (the comma can stay powered between drives).
|
||||
|
||||
A dropped strategy is replaced by the last one that streamed; otherwise resumes at the first untried rung."""
|
||||
self.live.pop(address.upper(), None)
|
||||
entry = self.cars.get(address.upper())
|
||||
if not entry:
|
||||
return
|
||||
@@ -187,11 +198,23 @@ class SetupLadder:
|
||||
|
||||
def forget(self, address: str) -> bool:
|
||||
"""A car's pairing was deleted: drop what was learned about it."""
|
||||
self.live.pop(address.upper(), None)
|
||||
removed = self.cars.pop(address.upper(), None) is not None
|
||||
if removed:
|
||||
self.save()
|
||||
return removed
|
||||
|
||||
def _live(self, address: str, rung: str) -> dict:
|
||||
return self.live.setdefault(address.upper(), {}).setdefault(rung, {"tries": 0, "reason": ""})
|
||||
|
||||
def tried(self, address: str, rung: str) -> int:
|
||||
record = self._live(address, rung)
|
||||
record["tries"] += 1
|
||||
return record["tries"]
|
||||
|
||||
def note(self, address: str, rung: str, reason: str) -> None:
|
||||
self._live(address, rung)["reason"] = str(reason)[:160]
|
||||
|
||||
def exhausted(self, address: str) -> bool:
|
||||
entry = self.cars.get(address.upper())
|
||||
return bool(entry) and entry["boot"] == self.boot_id() and entry["passes"] >= MAX_PASSES and not entry["working"]
|
||||
@@ -276,12 +299,15 @@ class SetupLadder:
|
||||
working = _valid_strategy(entry["working"])
|
||||
current = working.rung if working else entry["rung"]
|
||||
passes = entry["passes"] if entry["boot"] == self.boot_id() else 0 # passes belong to one ignition cycle
|
||||
live = self.live.get(address.upper(), {}) if address else {}
|
||||
steps = []
|
||||
for rung in RUNGS:
|
||||
if (rung == "experiments" and not adapter) or (rung == "phone_identity" and not unofficial):
|
||||
continue
|
||||
state = entry["results"].get(rung) or ("current" if rung == current else "waiting")
|
||||
steps.append({"id": rung, "label": LABELS[rung], "state": state})
|
||||
record = live.get(rung, {})
|
||||
steps.append({"id": rung, "label": LABELS[rung], "state": state, "description": DESCRIPTIONS[rung],
|
||||
"tries": record.get("tries", 0), "reason": record.get("reason", "")})
|
||||
return {"steps": steps, "current": current, "learned": bool(working), "passes": passes, "max_passes": MAX_PASSES,
|
||||
"exhausted": passes >= MAX_PASSES and not working, "successes": entry["successes"],
|
||||
"t_ready_s": entry["t_ready"][-1] if entry["t_ready"] else None}
|
||||
|
||||
@@ -60,6 +60,9 @@ LOG_QUEUE_MAX = 1000 # session log records waiting for the writer; past
|
||||
ENCODER_RECOVERIES = 3 # hardware encoder reopens allowed per window before the session is torn down
|
||||
ENCODER_RECOVERY_WINDOW = 10.0
|
||||
CAR_LINK_HOLD = 10.0 # a hands-free connection from the car counts as "car present" this long
|
||||
HANGUP_PROMPT = 6 # early hang-ups in a row before an unofficial dongle is asked to restart
|
||||
DONGLE_RESTART_WAIT = 300.0 # longest wait for that restart
|
||||
RESTART_QUIET = 20.0 # hands-free silence that counts as the dongle having restarted
|
||||
HFP_FRESH = 3.0 # a hands-free link from the car this recent still means "the car is reaching out now"
|
||||
COMMA_PAGE_WINDOW = 6.0 # a hands-free link this soon after we paged the car is ours; later is the car's
|
||||
RFCOMM_BUSY_WAITS = (1.0, 2.0) # the kernel can still hold the last session's channel for a moment
|
||||
@@ -81,14 +84,17 @@ def encoder_preference(configured: str) -> str:
|
||||
return 'hardware' if COMMA_HARDWARE else configured
|
||||
|
||||
STATE_LABELS = {
|
||||
"idle": "off", "connecting_bluetooth": "connecting to car", "discovering": "finding android auto",
|
||||
"rfcomm": "starting wireless setup", "wifi_start": "waiting for car", "wifi_info": "getting car wi-fi",
|
||||
"joining_wifi": "joining car wi-fi", "connecting_tcp": "connecting", "authenticating": "authenticating",
|
||||
"negotiating": "negotiating video", "streaming": "projecting", "suspended": "car showing its own screen",
|
||||
"backoff": "retrying", "stopping": "stopping", "error": "error",
|
||||
"waiting_for_usb": "plug into the car's usb", "usb_accessory": "starting usb",
|
||||
"waiting_for_ipad": "open headunitpad on the ipad", "waiting_for_tablet": "open open headunit on the tablet",
|
||||
"idle": "Off", "connecting_bluetooth": "Connecting to car", "discovering": "Finding Android Auto",
|
||||
"rfcomm": "Starting wireless setup", "wifi_start": "Waiting for car", "wifi_info": "Getting car Wi-Fi",
|
||||
"joining_wifi": "Joining car Wi-Fi", "connecting_tcp": "Connecting", "authenticating": "Authenticating",
|
||||
"negotiating": "Negotiating video", "streaming": "Projecting", "suspended": "Car showing its own screen",
|
||||
"backoff": "Retrying", "stopping": "Stopping", "error": "Error",
|
||||
"waiting_for_usb": "Plug into the car's USB", "usb_accessory": "Starting USB",
|
||||
"waiting_for_ipad": "Open HeadunitPad on the iPad", "waiting_for_tablet": "Open Open Headunit on the tablet",
|
||||
"dongle_restart": "Waiting for the dongle to restart",
|
||||
}
|
||||
STAGE_STEPS = {"connecting_bluetooth": "Calling it over Bluetooth", "discovering": "Looking up its Android Auto service",
|
||||
"rfcomm": "Opening the Android Auto channel"}
|
||||
|
||||
|
||||
def _is_onroad() -> bool:
|
||||
@@ -438,6 +444,11 @@ class Supervisor:
|
||||
self._paged_at = -COMMA_PAGE_WINDOW # when this comma last asked BlueZ to connect to the car
|
||||
self._attempt_ended_at: float | None = None
|
||||
self._hfp_link = threading.Event() # set when the chosen car opens the hands-free link
|
||||
self._hangups = 0 # early hang-ups since the receiver last sent its Wi-Fi details
|
||||
self._attempt_frames = 0
|
||||
self._restart = threading.Event()
|
||||
self._restart_how = ""
|
||||
self._receiver_seen_at = -RESTART_QUIET # receiver only, so a companion car cannot fake a restart
|
||||
self._modern_reply: dict[str, bool] = {} # car address -> answer its v4+ version request like a current phone
|
||||
self._ladder = setup_ladder.SetupLadder(identity_store.CONFIG_PATH.with_name("setup_state.json")) # handshake to try per car
|
||||
self._strategy: setup_ladder.Strategy | None = None # the one this attempt runs
|
||||
@@ -449,7 +460,8 @@ class Supervisor:
|
||||
self.config = identity_store.load_config()
|
||||
self._status: dict = {"state": "idle", "detail": "", "error": "", "last_stage": "", "attempt": 0,
|
||||
"retry_in": 0.0, "mode": None, "stats": {}, "head_unit": {}, "identity": "", "running": False,
|
||||
"view": "", "encoder": "", "target_fps": 0}
|
||||
"view": "", "encoder": "", "target_fps": 0, "handshake_step": "", "hangups": 0,
|
||||
"dongle_restart": None}
|
||||
|
||||
# ------------------------------------------------------------------ status
|
||||
|
||||
@@ -460,6 +472,8 @@ class Supervisor:
|
||||
def _stage(self, state: str, detail: str = "") -> None:
|
||||
self._check_cancel()
|
||||
self._set(state=state, detail=detail, last_stage=state)
|
||||
if state in STAGE_STEPS:
|
||||
self._set(handshake_step=STAGE_STEPS[state])
|
||||
self.log("stage", state=state, detail=detail)
|
||||
|
||||
def _check_cancel(self) -> None:
|
||||
@@ -476,6 +490,7 @@ class Supervisor:
|
||||
status["companion_name"] = self.config.get("companion_name", "")
|
||||
status["pairing_note"] = self._pairing_note
|
||||
status["car_type"] = self.car_type()
|
||||
status["unofficial_dongle"] = self._unofficial_dongle()
|
||||
status.update(car_profiles.summary(self.config))
|
||||
status["adapter_choice_needed"] = self._adapter_choice_needed()
|
||||
status["configured_view"] = self.config["view"]
|
||||
@@ -635,6 +650,34 @@ class Supervisor:
|
||||
self._flush_reset_note()
|
||||
return reset
|
||||
|
||||
def continue_ladder(self) -> bool:
|
||||
"""Continue tapped: stop waiting for the dongle restart."""
|
||||
if self._status.get("state") != "dongle_restart" or self._restart.is_set():
|
||||
return False
|
||||
self.log("dongle_restart_continue")
|
||||
self._restart_how = "continue"
|
||||
self._restart.set()
|
||||
return True
|
||||
|
||||
def _await_dongle_restart(self) -> None:
|
||||
"""Ask an unofficial dongle that keeps hanging up for a power cycle; carry on when it returns, on Continue, or on timeout."""
|
||||
self._restart.clear()
|
||||
self._restart_how = "timeout"
|
||||
self.log("dongle_restart_requested", hangups=self._hangups, wait_s=DONGLE_RESTART_WAIT)
|
||||
self._set(state="dongle_restart", dongle_restart={"hangups": self._hangups, "wait_s": DONGLE_RESTART_WAIT}, retry_in=0.0, mode=None,
|
||||
handshake_step="Waiting for the dongle to restart")
|
||||
deadline = time.monotonic() + DONGLE_RESTART_WAIT
|
||||
try:
|
||||
while not self._restart.is_set() and (remaining := deadline - time.monotonic()) > 0:
|
||||
self._wait(min(0.2, remaining))
|
||||
except Cancelled:
|
||||
self._restart_how = "stopped"
|
||||
raise
|
||||
finally:
|
||||
self.log("dongle_restart_done", how=self._restart_how)
|
||||
self._hangups = 0
|
||||
self._set(dongle_restart=None, hangups=0, handshake_step="")
|
||||
|
||||
def _flush_reset_note(self) -> None:
|
||||
note, self._reset_note = self._reset_note, None
|
||||
if note:
|
||||
@@ -1183,6 +1226,13 @@ class Supervisor:
|
||||
now = time.monotonic()
|
||||
since_page = now - self._paged_at
|
||||
self.log("hfp_initiator", initiator="comma" if since_page < COMMA_PAGE_WINDOW else "car", since_page_s=round(since_page, 1))
|
||||
if address.upper() == self.config["receiver_address"].upper():
|
||||
quiet = now - self._receiver_seen_at
|
||||
if self._status.get("state") == "dongle_restart" and quiet >= RESTART_QUIET and not self._restart.is_set():
|
||||
self.log("dongle_restart_detected", quiet_s=round(quiet, 1))
|
||||
self._restart_how = "restarted"
|
||||
self._restart.set()
|
||||
self._receiver_seen_at = now
|
||||
self._car_seen_at = now
|
||||
self._hfp_link.set()
|
||||
|
||||
@@ -1353,6 +1403,8 @@ class Supervisor:
|
||||
wireless = connection == "wireless"
|
||||
receiver = {"wired": "usb", "headunitpad": "headunitpad", "openheadunit": "openheadunit"}.get(connection, self.config["receiver_name"])
|
||||
self._video_seen, self._strategy = False, None
|
||||
self._hangups = 0
|
||||
self._set(hangups=0, dongle_restart=None, handshake_step="")
|
||||
if trigger == "onroad" and wireless: # a new drive: the ladder's passes are per ignition, and the comma can stay on between drives
|
||||
self._ladder.begin_cycle(self.config["receiver_address"])
|
||||
from openpilot.starpilot.system.starport.bugsink_transport import device_metadata
|
||||
@@ -1374,6 +1426,8 @@ class Supervisor:
|
||||
self._run_started = started
|
||||
peer_stopped = False
|
||||
reach = reachability.OTHER # set by a failed attempt; a clean end or a car-ended session keeps the standard wait
|
||||
self._attempt_frames = 0
|
||||
self._set(handshake_step="")
|
||||
self.log("attempt_start")
|
||||
try:
|
||||
if car_first:
|
||||
@@ -1394,7 +1448,12 @@ class Supervisor:
|
||||
message = self._describe_error(stage, error)
|
||||
self._set(error=message)
|
||||
reach = reachability.classify(stage, type(error).__name__, f"{message} {error}")
|
||||
self.log("attempt_failed", stage=stage, error=message, kind=type(error).__name__, where=_where(error), reach=reach)
|
||||
hung_up = wireless and reachability.hung_up_early(stage, type(error).__name__, f"{message} {error}", self._attempt_frames)
|
||||
if hung_up:
|
||||
self._hangups += 1
|
||||
self._set(hangups=self._hangups)
|
||||
self.log("attempt_failed", stage=stage, error=message, kind=type(error).__name__, where=_where(error), reach=reach,
|
||||
frames=self._attempt_frames, hung_up=hung_up, hangups=self._hangups)
|
||||
if isinstance(error, SharedRadioStall):
|
||||
reconnect_notice.write(reconnect_notice.SHARED_RADIO_TEXT)
|
||||
if isinstance(error, StrayLinkError):
|
||||
@@ -1410,11 +1469,17 @@ class Supervisor:
|
||||
if self._stop.is_set():
|
||||
break
|
||||
attempt = 0 if time.monotonic() - started > STABLE_SESSION_SECONDS else attempt + 1
|
||||
if wireless and self._hangups >= HANGUP_PROMPT and self._unofficial_dongle():
|
||||
try:
|
||||
self._await_dongle_restart()
|
||||
except Cancelled:
|
||||
break
|
||||
continue
|
||||
delay = BACKOFF_SECONDS[min(attempt, len(BACKOFF_SECONDS) - 1)]
|
||||
delay = reachability.retry_delay(reach, attempt, delay)
|
||||
if peer_stopped:
|
||||
delay = max(delay, PEER_STOP_RETRY_SECONDS) # the car ended projection itself; do not bounce straight back
|
||||
self._set(state="backoff", attempt=attempt, retry_in=delay, mode=None)
|
||||
self._set(state="backoff", attempt=attempt, retry_in=delay, mode=None, handshake_step="")
|
||||
try:
|
||||
self._wait_backoff(delay, car_can_wake=wireless and not peer_stopped)
|
||||
except Cancelled:
|
||||
@@ -1434,7 +1499,7 @@ class Supervisor:
|
||||
self.log("session_stop")
|
||||
self.log.close()
|
||||
self.log.capture_system()
|
||||
self._set(state="idle", detail="", running=False, retry_in=0.0, mode=None)
|
||||
self._set(state="idle", detail="", running=False, retry_in=0.0, mode=None, handshake_step="")
|
||||
|
||||
@staticmethod
|
||||
def _describe_error(stage: str, error: Exception) -> str:
|
||||
@@ -1499,9 +1564,10 @@ class Supervisor:
|
||||
base_modern = self._modern_reply.get(address, config.get("modern_reply_cache", {}).get(address, False))
|
||||
strategy = self._strategy = self._ladder.strategy(address, base_modern)
|
||||
learned = self._ladder.snapshot(address, adapter, unofficial)["learned"]
|
||||
tries = self._ladder.tried(address, strategy.rung)
|
||||
self.log("ladder_strategy", rung=strategy.rung, label=strategy.label, learned=learned, modern_reply=strategy.modern_reply,
|
||||
channel_type=strategy.channel_type, patience=strategy.patience, experiments=strategy.experiments,
|
||||
phone_identity=strategy.phone_identity)
|
||||
phone_identity=strategy.phone_identity, tries=tries)
|
||||
try:
|
||||
self._stage("rfcomm", f"channel {channel}")
|
||||
rfcomm = self._connect_rfcomm(address, channel)
|
||||
@@ -1526,14 +1592,17 @@ class Supervisor:
|
||||
and error.stage in ("wifi_start", "wifi_info")
|
||||
and not (error.stage == "wifi_start" and boot.only_not_ready()) and not stopped)
|
||||
rejected = boot is not None and not stopped and boot.hung_up_on_reply(error)
|
||||
reason = "" if stopped else self._ladder_reason(error, boot, rejected)
|
||||
with self._lock:
|
||||
current = self._strategy is strategy
|
||||
if reason and current:
|
||||
self._ladder.note(address, strategy.rung, reason)
|
||||
outcome = self._ladder.failed(address, strategy, ladder_failure=ladder_failure and current, rejected=rejected and current,
|
||||
video_seen=self._video_seen, version_major=boot.version_major if boot is not None else 0,
|
||||
adapter=adapter, unofficial=unofficial)
|
||||
if outcome:
|
||||
self.log("ladder_failed", outcome=outcome, rung=strategy.rung, next=self._ladder.strategy(address, base_modern).rung,
|
||||
version_major=boot.version_major, reason="rejected_reply" if rejected else "timeout")
|
||||
version_major=boot.version_major, reason="rejected_reply" if rejected else "timeout", why=reason)
|
||||
elif boot is not None and isinstance(error, BootstrapTimeout) and error.stage == "wifi_start" and boot.only_not_ready():
|
||||
self.log("ladder_not_ready", rung=strategy.rung, refusals=len(boot.refusals))
|
||||
if source == "cache" and not self._stop.is_set() and drops_cached_channel(self._status["last_stage"], error):
|
||||
@@ -1761,10 +1830,65 @@ class Supervisor:
|
||||
|
||||
def _bootstrap_log(self, name: str, **values) -> None:
|
||||
self.log(name, **values)
|
||||
if name == "bootstrap_rx":
|
||||
self._attempt_frames += 1
|
||||
elif name == "bootstrap_credentials":
|
||||
self._hangups = 0
|
||||
self._set(hangups=0)
|
||||
if name == "bootstrap_tx" and values.get("message") == NAMES[2]:
|
||||
self._set(state="wifi_info", last_stage="wifi_info")
|
||||
elif name == "wifi_joining":
|
||||
self._set(state="joining_wifi", last_stage="joining_wifi", detail=str(values.get("ssid", "")))
|
||||
if step := self._handshake_step(name, values):
|
||||
self._set(handshake_step=step)
|
||||
|
||||
def _receiver_word(self) -> str:
|
||||
return "The adapter" if self.car_type() == "adapter" else "The car"
|
||||
|
||||
def _handshake_step(self, name: str, values: dict) -> str:
|
||||
"""A bootstrap event in words for the setup screen, or ""."""
|
||||
who = self._receiver_word()
|
||||
message = values.get("message")
|
||||
if name == "bootstrap_version":
|
||||
return f"{who} said hello (protocol {values.get('major')}.{values.get('minor')}); answering it"
|
||||
if name == "bootstrap_tx" and message == NAMES[5]:
|
||||
return f"Answered; waiting for {who.lower()}'s address"
|
||||
if name == "bootstrap_initial_kick":
|
||||
return f"{who} has not said hello; asking it to start"
|
||||
if name == "bootstrap_start_refused":
|
||||
return f"{who} says it is not ready yet"
|
||||
if name == "bootstrap_tx" and message == NAMES[2]:
|
||||
return "Asking for its Wi-Fi name and password"
|
||||
if name == "bootstrap_credentials":
|
||||
return f"Got the Wi-Fi details for {values.get('ssid') or 'its network'}"
|
||||
if name == "wifi_joining":
|
||||
return f"Joining {values.get('ssid') or 'its Wi-Fi'}"
|
||||
if name == "wifi_joined":
|
||||
return "Joined its Wi-Fi"
|
||||
return ""
|
||||
|
||||
def _ladder_reason(self, error: BaseException, boot, rejected: bool) -> str:
|
||||
"""Why an attempt with a rung ended, in words."""
|
||||
who = self._receiver_word()
|
||||
stage = getattr(error, "stage", self._status.get("last_stage", ""))
|
||||
if rejected:
|
||||
return f"{who} hung up right after our reply"
|
||||
if reachability.hung_up_early(stage, type(error).__name__, str(error), self._attempt_frames):
|
||||
return f"{who} hung up before sending anything"
|
||||
if isinstance(error, BootstrapTimeout):
|
||||
if stage == "wifi_info":
|
||||
return f"{who} never sent its Wi-Fi name and password"
|
||||
if boot is not None and boot.only_not_ready():
|
||||
return f"{who} kept saying it was not ready"
|
||||
if boot is not None and boot.version_major > 0:
|
||||
return f"{who} said hello but never sent its address"
|
||||
return f"{who} never said hello"
|
||||
reach = reachability.classify(stage, type(error).__name__, str(error))
|
||||
if reach == reachability.ASLEEP:
|
||||
return f"{who} did not answer over Bluetooth"
|
||||
if reach == reachability.NOT_READY and self._attempt_frames:
|
||||
return f"{who} hung up partway through the handshake"
|
||||
return (str(error) or type(error).__name__)[:160]
|
||||
|
||||
def _connect_tcp(self, result, lease) -> socket.socket:
|
||||
self._stage("connecting_tcp", f"{result.endpoint.ip}:{result.endpoint.port}")
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
"""A receiver that hangs up before saying anything is reported for every car; only an unofficial dongle is asked to restart."""
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from openpilot.starpilot.system.starport import compat_report, reachability as r, setup_ladder as sl
|
||||
from openpilot.starpilot.system.starport import supervisor as module
|
||||
from openpilot.starpilot.system.starport.tests.fake_head_unit import make_identity
|
||||
from openpilot.starpilot.system.starport.tests.test_starport import make_supervisor
|
||||
|
||||
RECEIVER = "AA:BB:CC:DD:EE:01"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stage,kind,message,frames,expected", [
|
||||
("rfcomm", "ConnectionResetError", "starting wireless setup: [Errno 104] Connection reset by peer", 0, True),
|
||||
("rfcomm", "ConnectionRefusedError", "starting wireless setup: [Errno 111] Connection refused", 0, True),
|
||||
("wifi_info", "ConnectionResetError", "getting car wi-fi: [Errno 104] Connection reset by peer", 0, True),
|
||||
("wifi_info", "ConnectionResetError", "getting car wi-fi: [Errno 104] Connection reset by peer", 2, False), # it had talked
|
||||
("rfcomm", "OSError", "starting wireless setup: [Errno 112] Host is down", 0, False), # off, not refusing
|
||||
("discovering", "RuntimeError", "org.bluez.Error.Failed: br-connection-page-timeout", 0, False),
|
||||
("connecting_tcp", "RuntimeError", "Car did not accept the projection connection: [Errno 111] Connection refused", 0, False),
|
||||
("rfcomm", "OSError", "[Errno 16] Device or resource busy", 0, False),
|
||||
])
|
||||
def test_only_a_link_dropped_before_any_message_is_a_hang_up(stage, kind, message, frames, expected):
|
||||
assert r.hung_up_early(stage, kind, message, frames) is expected
|
||||
|
||||
|
||||
def test_each_rung_says_what_it_does_how_often_it_ran_and_why_it_last_ended(tmp_path):
|
||||
ladder = sl.SetupLadder(tmp_path / "setup_state.json", boot_id=lambda: "boot", clock=lambda: 1.0)
|
||||
assert ladder.tried(RECEIVER, "standard") == 1 and ladder.tried(RECEIVER, "standard") == 2
|
||||
ladder.note(RECEIVER, "standard", "The adapter never sent its Wi-Fi name and password")
|
||||
step = ladder.snapshot(RECEIVER, adapter=True, unofficial=True)["steps"][0]
|
||||
assert step == {"id": "standard", "label": sl.LABELS["standard"], "state": "current", "description": sl.DESCRIPTIONS["standard"],
|
||||
"tries": 2, "reason": "The adapter never sent its Wi-Fi name and password"}
|
||||
assert set(sl.DESCRIPTIONS) == set(sl.RUNGS)
|
||||
assert not ladder.path.exists() # this drive's counts are never written
|
||||
ladder.begin_cycle(RECEIVER)
|
||||
assert ladder.snapshot(RECEIVER)["steps"][0]["tries"] == 0 # a new drive counts again
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def identity(tmp_path_factory):
|
||||
return make_identity(tmp_path_factory.mktemp("identity"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sup(identity, tmp_path, monkeypatch):
|
||||
supervisor, _ = make_supervisor(identity, tmp_path, monkeypatch, lambda *a, **k: None)
|
||||
monkeypatch.setattr(module, "DONGLE_RESTART_WAIT", 30.0)
|
||||
|
||||
def hang_up(lease):
|
||||
supervisor._stage("rfcomm", "channel 1")
|
||||
raise ConnectionResetError(104, "Connection reset by peer")
|
||||
monkeypatch.setattr(supervisor, "_attempt", hang_up)
|
||||
return supervisor
|
||||
|
||||
|
||||
def run(sup):
|
||||
thread = threading.Thread(target=sup._run, args=(1, "manual"), daemon=True)
|
||||
thread.start()
|
||||
return thread
|
||||
|
||||
|
||||
def wait_for(condition, timeout=10.0):
|
||||
deadline = time.monotonic() + timeout
|
||||
while not condition():
|
||||
assert time.monotonic() < deadline, "timed out"
|
||||
time.sleep(0.02)
|
||||
|
||||
|
||||
def stop(sup, thread):
|
||||
sup._stop.set()
|
||||
thread.join(5)
|
||||
assert not thread.is_alive()
|
||||
|
||||
|
||||
def events(sup, name):
|
||||
return [record for record in sup.log.recent if record["event"] == name]
|
||||
|
||||
|
||||
def test_an_unofficial_dongle_that_keeps_hanging_up_is_asked_to_restart_and_continue_carries_on(sup):
|
||||
sup.config.update(car_type="adapter", car_profile_override="unofficial")
|
||||
thread = run(sup)
|
||||
try:
|
||||
wait_for(lambda: sup.status()["state"] == "dongle_restart")
|
||||
status = sup.status()
|
||||
assert status["dongle_restart"]["hangups"] == module.HANGUP_PROMPT and status["unofficial_dongle"]
|
||||
assert events(sup, "attempt_failed")[-1]["hung_up"] is True
|
||||
assert sup.continue_ladder() is True and sup.continue_ladder() is False
|
||||
wait_for(lambda: events(sup, "dongle_restart_done"))
|
||||
assert events(sup, "dongle_restart_done")[0]["how"] == "continue"
|
||||
wait_for(lambda: sup.status()["state"] != "dongle_restart")
|
||||
finally:
|
||||
stop(sup, thread)
|
||||
|
||||
|
||||
def test_the_dongle_coming_back_after_going_quiet_ends_the_wait(sup):
|
||||
sup.config.update(car_type="adapter", car_profile_override="unofficial")
|
||||
thread = run(sup)
|
||||
try:
|
||||
wait_for(lambda: sup.status()["state"] == "dongle_restart")
|
||||
sup._receiver_seen_at = time.monotonic() - 5
|
||||
sup._hfp_connected(RECEIVER) # still cycling on its own: not a restart
|
||||
assert not sup._restart.is_set()
|
||||
sup._receiver_seen_at = time.monotonic() - module.RESTART_QUIET - 1
|
||||
sup._hfp_connected(RECEIVER)
|
||||
wait_for(lambda: events(sup, "dongle_restart_done"))
|
||||
assert events(sup, "dongle_restart_done")[0]["how"] == "restarted" and events(sup, "dongle_restart_detected")
|
||||
finally:
|
||||
stop(sup, thread)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("car_type,profile", [("builtin", ""), ("adapter", "aawireless"), ("adapter", "default")])
|
||||
def test_other_receivers_keep_retrying_as_before_and_only_the_count_is_shown(sup, car_type, profile):
|
||||
sup.config.update(car_type=car_type, car_profile_override=profile)
|
||||
thread = run(sup)
|
||||
try:
|
||||
wait_for(lambda: sup.status()["hangups"] >= module.HANGUP_PROMPT + 2)
|
||||
assert not events(sup, "dongle_restart_requested") and not sup.status()["unofficial_dongle"]
|
||||
assert sup.continue_ladder() is False
|
||||
finally:
|
||||
stop(sup, thread)
|
||||
|
||||
|
||||
def test_handing_over_wifi_details_clears_the_count_and_each_event_is_put_into_words(sup):
|
||||
sup._hangups = 4
|
||||
sup._bootstrap_log("bootstrap_rx", message="WifiVersionRequest", bytes=10)
|
||||
sup._bootstrap_log("bootstrap_version", major=4, minor=0)
|
||||
assert sup._attempt_frames == 1 and "said hello (protocol 4.0)" in sup.status()["handshake_step"]
|
||||
sup._bootstrap_log("bootstrap_credentials", ssid="Mavlou-1FF1", security="wpa2")
|
||||
assert sup._hangups == 0 and sup.status()["handshake_step"] == "Got the Wi-Fi details for Mavlou-1FF1"
|
||||
|
||||
|
||||
def test_the_report_counts_hang_ups_rungs_and_restarts_even_from_older_logs():
|
||||
def failed(error, **extra):
|
||||
return {"event": "attempt_failed", "stage": "rfcomm", "kind": "ConnectionResetError", "error": error, **extra}
|
||||
log = [{"event": "session_start", "t": "2026-11-07T03:26:50", "receiver": "Mavlou"},
|
||||
*[record for _ in range(3) for record in ({"event": "attempt_start"}, {"event": "stage", "state": "rfcomm"},
|
||||
{"event": "ladder_strategy", "rung": "modern_dual"},
|
||||
failed("starting wireless setup: [Errno 104] Connection reset by peer"))],
|
||||
{"event": "attempt_start"}, {"event": "bootstrap_rx", "message": "WifiVersionRequest"},
|
||||
{"event": "ladder_strategy", "rung": "modern_dual"},
|
||||
{"event": "ladder_failed", "rung": "modern_dual", "why": "The adapter said hello but never sent its address"},
|
||||
failed("getting car wi-fi: [Errno 104] Connection reset by peer"),
|
||||
{"event": "dongle_restart_requested", "t": "2026-11-07T03:28:13", "hangups": 6},
|
||||
{"event": "dongle_restart_done", "how": "restarted"}]
|
||||
report = compat_report.summarize(log)
|
||||
assert report["hangups"] == 3 # the one after a message is not a hang-up
|
||||
assert report["ladder"] == {"modern_dual": {"tries": 4, "failed": 1, "why": "The adapter said hello but never sent its address"}}
|
||||
text = compat_report.render_text(report)
|
||||
assert "Stages: rfcomm" in text and "Hung up before any handshake message: 3 attempts" in text
|
||||
assert "Handshake modern_dual: 4 tries, 1 counted failures (last: The adapter said hello but never sent its address)" in text
|
||||
assert "after 6 hang-ups: restarted" in text
|
||||
|
||||
|
||||
def test_the_stages_line_keeps_the_real_order_and_folds_identical_retries():
|
||||
stages = ["connecting_bluetooth", "discovering", "rfcomm", "streaming", *["connecting_bluetooth", "discovering", "rfcomm"] * 3,
|
||||
"connecting_bluetooth", "discovering"]
|
||||
assert compat_report.stage_line(stages).split(" | ") == ["connecting_bluetooth > discovering > rfcomm > streaming",
|
||||
"connecting_bluetooth > discovering > rfcomm (x3)", "connecting_bluetooth > discovering"]
|
||||
assert compat_report.stage_line(["waiting_for_usb", "usb_accessory"]) == "waiting_for_usb > usb_accessory"
|
||||
|
||||
|
||||
def test_a_stop_while_waiting_for_the_restart_is_logged(sup):
|
||||
sup.config.update(car_type="adapter", car_profile_override="unofficial")
|
||||
thread = run(sup)
|
||||
wait_for(lambda: sup.status()["state"] == "dongle_restart")
|
||||
stop(sup, thread)
|
||||
assert [record["how"] for record in events(sup, "dongle_restart_done")] == ["stopped"]
|
||||
|
||||
|
||||
def test_a_companion_car_opening_hands_free_is_not_the_dongle_restarting(sup):
|
||||
sup.config.update(car_type="adapter", car_profile_override="unofficial", companion_address="AA:BB:CC:DD:EE:02")
|
||||
thread = run(sup)
|
||||
try:
|
||||
wait_for(lambda: sup.status()["state"] == "dongle_restart")
|
||||
sup._receiver_seen_at = time.monotonic() - module.RESTART_QUIET - 1
|
||||
sup._hfp_connected("AA:BB:CC:DD:EE:02")
|
||||
assert not sup._restart.is_set()
|
||||
finally:
|
||||
stop(sup, thread)
|
||||
|
||||
|
||||
def test_only_galaxy_can_continue_the_ladder():
|
||||
from openpilot.starpilot.system.starport import daemon
|
||||
assert "continue_ladder" in daemon.AUTHENTICATED_COMMANDS
|
||||
|
||||
|
||||
def test_every_status_label_starts_with_a_capital_and_spells_names_properly():
|
||||
labels = module.STATE_LABELS.values()
|
||||
assert all(label[0].isupper() for label in labels)
|
||||
assert not any(word in label for label in labels for word in ("wi-fi", "android auto", "usb", "ipad", "headunitpad"))
|
||||
assert module.STATE_LABELS["wifi_info"] == "Getting car Wi-Fi" and module.STATE_LABELS["waiting_for_ipad"] == "Open HeadunitPad on the iPad"
|
||||
Reference in New Issue
Block a user