diff --git a/openpilot/starpilot/galaxy/server.py b/openpilot/starpilot/galaxy/server.py index 00fc7b7e58..27a6176c65 100644 --- a/openpilot/starpilot/galaxy/server.py +++ b/openpilot/starpilot/galaxy/server.py @@ -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'}) diff --git a/openpilot/starpilot/galaxy/tests/test_starport_guided.mjs b/openpilot/starpilot/galaxy/tests/test_starport_guided.mjs index c73db0a9e7..da9f1323d6 100644 --- a/openpilot/starpilot/galaxy/tests/test_starport_guided.mjs +++ b/openpilot/starpilot/galaxy/tests/test_starport_guided.mjs @@ -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") + diff --git a/openpilot/starpilot/galaxy/web/css/galaxy.css b/openpilot/starpilot/galaxy/web/css/galaxy.css index 36d4355464..c4fe4bdb59 100644 --- a/openpilot/starpilot/galaxy/web/css/galaxy.css +++ b/openpilot/starpilot/galaxy/web/css/galaxy.css @@ -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); } diff --git a/openpilot/starpilot/galaxy/web/js/starport-guided.js b/openpilot/starpilot/galaxy/web/js/starport-guided.js index 4ad690560e..560866fd05 100644 --- a/openpilot/starpilot/galaxy/web/js/starport-guided.js +++ b/openpilot/starpilot/galaxy/web/js/starport-guided.js @@ -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: `
    @@ -207,13 +225,18 @@ export const StarPortGuidedSetup = { - {{ step.label }}Trying again + {{ step.label }}Trying again{{ state.liveStep }}
+ +
- What we are trying + What we are trying
  1. - {{ step.label }} + {{ step.label }} + {{ step.description }} + Now: {{ state.liveStep }} + {{ step.tries === 1 ? "1 try" : step.tries + " tries" }}
+ @choose-type="chooseType" @choose-adapter="chooseAdapter" @choose-app="chooseApp" @use-car="useCar" @classic="useClassic" @reset-handshake="resetHandshake" @continue-ladder="continueLadder" @done="showSteps = false">