Skip to content

Add serial GNSS connection wizard to the Base Station configuration panel - #2959

Open
rafaellehmkuhl wants to merge 8 commits into
bluerobotics:masterfrom
rafaellehmkuhl:issue-1842-base-station-gnss-wizard
Open

Add serial GNSS connection wizard to the Base Station configuration panel#2959
rafaellehmkuhl wants to merge 8 commits into
bluerobotics:masterfrom
rafaellehmkuhl:issue-1842-base-station-gnss-wizard

Conversation

@rafaellehmkuhl

@rafaellehmkuhl rafaellehmkuhl commented Aug 19, 2026

Copy link
Copy Markdown
Member

Summary

The base station and the serial GNSS support landed separately, and the wiring between them was already there but invisible: useBaseStation has listed configured GNSS devices as position sources and fed their fixes into setPosition since it merged. To reach it you had to know, unprompted, to open Settings > Sources, guess which /dev/tty* is your receiver, guess its baud rate, save a device, and only then come back to the map. This branch is that missing step — a guided flow that starts where the operator already is, typing coordinates in by hand.

A "Use serial/USB device" button now sits directly under the latitude and longitude inputs in the base-station panel. It opens a dialog listing the connected serial devices; the operator picks the one they believe is their receiver and tests it. Nothing is written until they confirm.

  • The test is the existing baud probe. autodetectBaud already sweeps the common rates and only succeeds after three checksum-valid NMEA sentences, so it answers "is this a GNSS receiver" and "at what speed" in a single pass. No new probing logic. On failure the dialog says the device does not look like a receiver and returns them to the list to try another; on success it confirms it found one and reports the detected speed.
  • The operator sees real data before committing. A successful test starts a draft device, which the GNSS composable already connects in preview mode without publishing to the data lake, so the confirm step shows live fix quality, satellite count and coordinates. Backing out or closing discards the draft and its connection.
  • The name is theirs to pick. The field is pre-filled with Base Station, suffixed when that name is taken, since the name is what shows in the sources list and in the data-lake variable labels. Someone running two vehicles can make it Blue Boat Base Station before saving.
  • A port already in use is offered for reuse, not probed. Probing calls stopDevicesOnPort, so testing a port that a configured device already reads would disconnect that device and then leave a duplicate that can never share the port with it. deviceUsingPort detects this and the dialog offers the existing device instead, connecting it first when it is not already reading.
  • Confirming creates the device and points base-station tracking at it, with a snackbar. The position then follows the receiver on its own.

Two small helpers come first as their own commits: uniqueString, because the GNSS composable now needs the same append-a-suffix dedup for display names that it already did for device ids, and deviceUsingPort for the port-reuse case above. Both have tests.

Serial access is Electron-only, so in Lite the button renders disabled above a visible line explaining that it needs Standalone. The README already lists External Serial GNSS as desktop-only, so nothing changes there.

Screenshots

Captured against a receiver streaming NMEA, so the preview below shows a real fix rather than an empty state.

1. Pick the device. Nothing is written until you confirm, so a wrong guess costs a retry and no config.

Device selection

2. Test it. The existing baud sweep runs, and the dialog says up front how long that can take.

Probing the device

3. Confirm. The live fix is what tells you it really is your receiver, before you name it.

Confirming the receiver

The entry point, under the coordinate inputs in the base-station panel:

Before After

One open question: the review on #2815 asked for that dialog's status block to move into an ExpansiblePanel. Here the live fix is the evidence the operator uses to decide, so collapsing it would hide the thing they are meant to read. Left inline, happy to change it.

Test plan

  • With no GNSS device configured, open the base-station panel and confirm the "Use serial/USB device" button sits under the coordinate inputs.
  • Click it with a real NMEA receiver plugged in, select its port, and confirm the test reports a GNSS receiver and the detected baud rate.
  • Confirm the live preview on that step shows fix quality, satellites and coordinates updating.
  • Confirm the name field is pre-filled with Base Station, accept it, and confirm the base station starts following the receiver and the coordinate inputs go read-only.
  • Confirm the device now appears in Settings > Sources with its data-lake variables populated.
  • Repeat the flow and confirm the second device is offered as Base Station 2.
  • Change the pre-filled name before confirming and check the sources list and data-lake variable labels use it.
  • Test a port that is not a GNSS receiver (a MAVLink serial link, a USB-serial adapter with nothing on it) and confirm the dialog says so and lets you pick another without leaving.
  • Test a port already configured as another GNSS device and confirm the dialog offers to use that device instead of probing, and that a device that was disconnected gets connected before tracking switches to it.
  • Back out from the confirm step and close the dialog mid-flow; confirm no draft device is left behind in Settings > Sources and the preview connection is released.
  • Reload and confirm the base station reconnects to the receiver and resumes tracking.
  • In Lite (browser), confirm the button is disabled and the explanation is readable under it without hovering, and that nothing throws.
  • On the confirm step, unplug the receiver and confirm the status line stops saying "connected" instead of the readout silently freezing.
  • Cancel a test mid-sweep, then test the same port again, and confirm the second attempt still reports the receiver.

Checks

  • yarn lint clean.
  • yarn vitest run: 34 tests pass, including the two added here. cosmos.test.ts and connection.test.ts fail to collect on master as well, from the localStorage access at import time in settings-management.ts — unrelated to this branch, and the reason the new GNSS test mocks that module.
  • yarn typecheck still exits clean in under half a second on master too (languageId not found for App.vue), so it gave no signal here; the new component's API usage was checked by hand against the type definitions.

Closes #1842

@github-actions

Copy link
Copy Markdown

Automated PR Review — round 1

Warning

⚠️ IMPORTANT FIXES REQUIRED
10 open findings: 4 major, 4 minor, 2 nits.

Adds a button under the base-station latitude/longitude fields that opens a three-step dialog: it lists the computer's serial ports, probes the one the operator picks by sweeping the common speeds until valid satellite sentences come out of it, shows live position data from that receiver without saving anything, and on confirmation saves it as a positioning device and switches the base station over to following it. Two small helpers (unique-name suffixing, and finding the already-configured device on a port) land first with tests. The probe, the preview mode and the position-following watcher all already existed; this PR is the guided path into them.

What still needs attention

# Problem What it means Severity Status
1.1 Cancelling the test leaks the receiver If you close the dialog while it is still checking a device, the receiver stays held open and invisible, and nothing but restarting Cockpit gives it back. major
1.2 A failed preview looks like a slow one When the receiver cannot actually be opened, the dialog just says "Waiting..." forever, and saving anyway leaves a device that never connects while the app says the base station is now following it. major
2.1 Receiver identity is shared with the vehicle, unwarned The receiver you set up is saved onto the vehicle, so a colleague's computer will try to grab whatever is plugged into the same socket number on their machine at startup — possibly their autopilot cable. major
6.2 Browser users get no explanation In the browser version the button is greyed out and the text explaining that this needs the desktop app never appears. major
6.1 Dialog footer buttons are off-pattern The dialog's buttons look like solid blocks instead of the app's flat footer buttons, nothing marks which one commits, and cancelling is not recorded in the log. minor
7.1 Position readout written twice The same block that shows fix quality, satellites and coordinates now exists in two dialogs, so a fix to one will not reach the other. minor
7.2 Shared helper leaves a copy behind The de-duplication helper this PR extracts still has an identical hand-written twin in the points-of-interest code. minor
7.3 A shared function's trap is patched at the call site The next person calling this function hits the same silent no-op the comment here warns about. minor
11.1 Doc comment reverses its own order A reader trusting the comment gets the matching precedence backwards. nit
11.2 Inert CSS line One style declaration does nothing; the line below it is what has the effect. nit
Change map — what was established before judging

Claims (the PR body's, checked against the code)

  • "useBaseStation has listed configured GNSS devices as position sources and fed their fixes into setPosition since it merged"verified: src/composables/baseStation/useBaseStation.ts:140-143 builds gpsSourceOptions from gnss.devices, and :195-202 watches the tracked device's fix into applyTrackedPositionsetPosition. The diff adds no new plumbing there.
  • "autodetectBaud already sweeps the common rates and only succeeds after three checksum-valid NMEA sentences"verified: src/libs/sensors/gnss.ts:514-556, validSentenceThreshold = 3 at :157. Worst case is 6 rates × (1500 ms + 300 ms settle) ≈ 10.8 s, so the dialog's "up to 15 seconds" is a safe over-estimate.
  • "the GNSS composable already connects [a draft] in preview mode without publishing to the data lake"verified: useGnss.ts:78-86 passes !isDraft as publish, and gnss.ts:462 skips registerDeviceVariables when false.
  • "Backing out or closing discards the draft and its connection"contradicted for one path: backToSelection and close do (BaseStationGnssSetupDialog.vue:273-291, 376-379), but dismissing while the probe is still running does not, and there is no unmount teardown. Finding 1.1.
  • "Probing calls stopDevicesOnPort, so testing a port that a configured device already reads would disconnect that device"verified: gnss.ts:523 and :495-501. deviceUsingPort is a genuine guard against that, not a speculative one.
  • "in Lite the button renders disabled with a title explaining it needs Standalone" — the attribute is there (BaseStationConfigPanel.vue diff, :title on the new v-btn), but it does not reach the user. Finding 6.2.
  • "The README already lists External Serial GNSS as desktop-only"verified: README.md:109.
  • "Two small helpers … Both have tests"verified: src/tests/libs/utils.test.ts, src/tests/libs/sensors/gnss.test.ts.

No bug is being fixed, so there is no failure site to locate.

Entry points

Function Reached from Frequency
uniqueString (libs/utils.ts:492) generateDeviceId, planDeviceName per user action
generateDeviceId (useGnss.ts:31, rewritten) planDeviceIdplannedId computed in sources/GnssDeviceDialog.vue:192 (recomputes as the name is typed) and commitCreate per user action
planDeviceName (useGnss.ts:69, new) testSelectedPort after a successful probe per user action
deviceUsingPort (libs/sensors/gnss.ts:467, new) claimingDevice computed → re-evaluated on port selection and on any change to the synced device list per user action
openGnssSetup (BaseStationConfigPanel.vue:921) click on the new panel button per user action
testSelectedPort, confirmDevice, useExistingDevice, backToSelection, close, onRefreshPorts, selectPort, onNameEdited, trackDevice, discardDraft, resetToSelection (new dialog) footer/list click handlers per user action
onDialogUpdate (new dialog) InteractionDialog's internalShowDialog watcher (components/InteractionDialog.vue:242-249), i.e. Esc, backdrop click, or programmatic close per user action
modelValue watcher (new dialog) parent v-model flip per user action
previewItems / draftFix (new dialog) subscribeToDeviceStatelatestFixes[draftId], driven by handleDeviceBytes (gnss.ts:411-427) per incoming message (≤10 Hz, only while the confirm step is mounted)
portDescription (new dialog) render of the port list per user action

No changed function came out of the walk with no caller.

Invariants

  • A serial port takes one reader. Relied on by the whole flow. Covered: autodetectBaud stops readers on the port first (gnss.ts:523), and deviceUsingPort keeps the dialog from creating a second device for a port a configured one already holds. Not covered: the draft connection leaked by 1.1 holds a port that no UI surface lists and nothing can release. Partly covered: deviceUsingPort matches by USB model, so with two identical-model receivers it offers to reuse the wrong unit — the same ambiguity resolveDevicePort:580-585 already documents and handles by preferring the exact path, which the new helper does not do.
  • trackByGps only has an effect while the station is enabled (useBaseStation.ts:153). The dialog sets trackByGps = true without checking, which would strand tracking if the panel could be open with no position. It cannot: every entry point sets a position first (widgets/Map.vue:1767, views/MissionPlanningView.vue:2261) and the "Configure base station" menu entry only exists when config.enabled (widgets/Map.vue:1522). Invariant holds — no finding.
  • A device id from the vehicle-synced list may not exist on this machine. gpsSourceId is machine-local while the device list is vehicle-synced, and gpsSource (useBaseStation.ts:147-151) already falls back to browser geolocation for an unknown id, so trackDevice writing a fresh id is safe. Covered.
1. Correctness & Implementation Bugs — 2 findings

1.1 — Dismissing the dialog during the probe leaves a connected draft device and an open serial port behindmajor

Consequence: if the operator closes the dialog while it is still checking a device, the receiver is held open by an invisible connection that only restarting Cockpit releases.

src/components/BaseStationGnssSetupDialog.vue:333-374. InteractionDialog is not persistent, so during the 10-plus seconds of autodetectBaud the user can dismiss with Esc or a backdrop click. That runs onDialogUpdate(false)close()discardDraft(), which is a no-op because no draft exists yet, and emits update:modelValue: false. The awaited probe then resolves in a component nobody is looking at and runs lines 361-373 anyway: gnss.beginCreate() installs a draft in the shared singleton and gnss.connectDevice(draft.id) opens the port. Nothing afterwards can reach it — the draft is not in gnss.devices, so it appears in no list, and ConfigurationSourcesView.vue:151-154 calling beginCreate() again simply overwrites gnss.draft, orphaning the old draft's entry in the module-level runtimes map (gnss.ts:480) with its port still open.

The same hole exists for unmount: the dialog is a child of the panel, which is v-if="store.configPanelOpen" (BaseStationConfigPanel.vue:3), and store.remove() (useBaseStation.ts:119) and MissionPlanningView.vue:1156 both close that panel from outside the dialog. There is no onBeforeUnmount teardown, so the draft and its connection survive the component.

Fix: re-check props.modelValue after each await in testSelectedPort and discard the draft (or return before creating it) when the dialog is gone, and add an onBeforeUnmount(discardDraft). While you are there, the testing step's footer is a single disabled Testing... button, so Esc is the only way out of a step the user cannot abort — a real Cancel there needs the same guard.

1.2 — The preview connection has no failure path, and the confirm step shows no connection statemajor

Consequence: when the receiver cannot actually be opened, the dialog sits on "Waiting..." with no error, and confirming saves a device that never connects while a snackbar announces that the base station is now following it.

src/components/BaseStationGnssSetupDialog.vue:373 awaits gnss.connectDevice(draft.id) with no try/catch. startGnssDevice documents and throws three ways (src/libs/sensors/gnss.ts:454-488): not Electron, no matching port on this machine, and linkOpen returning false. The last two are reachable right after a successful probe — the port can be claimed in the interval, or resolveDevicePort can come back empty if the device is unplugged between the probe and the connect. The rejection escapes as an unhandled promise (the click handler is the outermost caller), and the confirm step has no status row of any kind: previewItems renders { label: 'Data', value: 'Waiting...' } (:258) both while connecting and after a hard failure.

The user's only move is "Use this device", which is not disabled. commitCreate then reads wasConnected from the status map (useGnss.ts:125), which is 'disconnected' on the throw path, and persists enabled: false (:139) — a device that initGnss will never auto-connect — while trackDevice (:315-324) still flips trackByGps on and reports "The base station position now follows …".

Fix: wrap the connect, put the error in probeError and return to the select step, as the probe failure at :344-348 already does. The sibling dialog shows a live status chip for exactly this (sources/GnssDeviceDialog.vue:73-97); at minimum, surface gnss.statuses[draftId] on the confirm step and gate "Use this device" on it, so committing an unreachable receiver is not the path of least resistance.

2. Persistence & User Data — inventory, 1 finding

Inventory

Key Backend What this PR does to it
cockpit-gnss-devices vehicle-synced (useBlueOsStorage, useGnss.ts:46) New writer. commitCreate appends a device carrying port (/dev/tty*), baud, enabled and usbMatch. Shape unchanged.
cockpit-base-station-gps-source-id machine-local (useStorage, useBaseStation.ts:36) New writer (trackDevice sets it to the new device id). Shape unchanged.
cockpit-base-station-track-by-gps machine-local (useStorage, useBaseStation.ts:35) New writer (set to true on confirm). Shape unchanged.

Nothing is reshaped, renamed or removed; no migration is added; every key keeps its cockpit- prefix. The two machine-local keys are the right backend — a positioning source and the decision to follow it describe this topside computer — and gpsSource already tolerates an id that does not resolve here (useBaseStation.ts:147-151). The remaining entry is the finding below.

2.1 — The new flow writes a machine-specific serial path into the vehicle-synced device list, without the warning its sibling showsmajor

Consequence: the receiver set up here is saved onto the vehicle, so another operator's Cockpit will try to open whatever is plugged into the same port path on their machine at startup — possibly the cable their autopilot is on.

cockpit-gnss-devices is vehicle-synced, and initGnss auto-connects every enabled entry at boot (src/libs/sensors/gnss.ts:596-606). resolveDevicePort prefers the USB model but falls back to the bare stored path when no model ids are present (:587). AGENTS.md:125 names this case exactly — machine-specific values "must never be auto-acted on after a sync, since auto-connecting to a synced /dev/ttyUSB0 can open the wrong device". The PR's own test plan contemplates a MAVLink serial link on a neighbouring port, which is what makes the consequence concrete.

The backend choice and the auto-connect are inherited, not introduced here, so the ask is scoped to what this creation path does differently from the one that already existed:

  • BaseStationGnssSetupDialog.vue:368 always writes usbMatch, even when the port reports nothing: { vendorId: undefined, productId: undefined, manufacturer: undefined }, which reaches the synced setting as usbMatch: {}. sources/GnssDeviceDialog.vue:248-250 deliberately writes undefined for that case instead. AGENTS.md:126 forbids writing undefined into a setting at all, so the right form is to set usbMatch only when there are ids.
  • The sibling dialog warns the operator when the receiver cannot be identified by model — "This device reports no USB model id, so auto-connect won't follow it to other computers" (sources/GnssDeviceDialog.vue:46-48). The guided flow, which is now the discoverable way in, drops it, so a non-portable configuration is saved silently. Carry that warning onto the confirm step.
  • SerialPortInfo already exposes serialNumber (src/types/serial.ts:30) and nothing uses it. AGENTS.md:125 asks for "a stable id (USB VID/PID, device serial)", and the per-unit serial is what would distinguish two identical receivers — the ambiguity resolveDevicePort:583-584 currently resolves by guessing. Adding it to the match written here is a small change with a real payoff.
6. UI / UX — 2 findings

6.1 — The setup dialog's footer diverges from the house footer on three countsminor

Consequence: the dialog's buttons look like solid blocks rather than the app's flat footer buttons, nothing marks which one commits, and cancelling out of the dialog leaves no trace in the log.

All three are the same surface — the #actions slot at BaseStationGnssSetupDialog.vue:192-208 — so they are grouped, but each fix is separate:

  • <v-btn text> is not the text variant. In Vuetify 3 (3.7.0 per package.json:100) text is a string prop carrying the button's label, so text with no value sets it to '' and leaves variant at its default elevated. All six footer buttons render as raised grey blocks. The tree writes this as variant="text" in 230 places against 4 bare text attributes, and all 4 are in sibling dialogs (sources/GnssDeviceDialog.vue:141,144,146, poi/PoiManager.vue:154) — so this is copied from the one place that has the same bug, not a house pattern.
  • Nothing distinguishes the committing action. In a hand-styled #actions footer the primary carries the bg-[#FFFFFF33] fill; here Cancel and Test device, and Back and Use this device, are byte-identical in styling.
  • The dismiss is unlogged. close() and backToSelection() call logUserAction only indirectly, via cancelCreate's "Cancelled GNSS device creation" (useGnss.ts:114), which fires only when a draft exists — so cancelling from the select step, the most common way out, records nothing. The open is logged (openGnssSetup), and AGENTS.md:217 asks for dialog open and close.

6.2 — In Lite, the explanation for the disabled button never reaches the usermajor

Consequence: browser users see a permanently greyed-out button in the base-station panel with no way to find out why.

The new button in BaseStationConfigPanel.vue carries :disabled="!gnss.isSupported" together with a native :title reading "Reading serial devices is only available in Cockpit Standalone". Vuetify renders a disabled v-btn with pointer-events: none on .v-btn--disabled, so the element never receives hover and the browser never shows that tooltip. Even where a native tooltip does render, a hover-only string is not the "information elements in the UI explaining that to the users" that AGENTS.md:118 requires alongside the README table.

The pattern is already established in this very file: BaseStationConfigPanel.vue:196-202 renders a <p class="px-1 pt-1 text-[10px] leading-tight opacity-70"> under the disabled OpenCellID option, explaining the limitation in place and telling the user what to do about it. Do the same under the new row, guarded by !gnss.isSupported. The copy is already right — "install Cockpit Standalone" rather than "upgrade" — it just needs to be visible.

7. Code Quality & Style — 3 findings

7.1 — The confirm step's fix readout duplicates the sibling dialog'sminor

Consequence: the block showing fix quality, satellites and coordinates now exists in two dialogs, so correcting one leaves the other wrong.

BaseStationGnssSetupDialog.vue:256-265 (previewItems) is a subset of sources/GnssDeviceDialog.vue:216-237 (statusItems): same fix.fixQualityLabel ?? fixQualityLabel(fix.fixQuality ?? 0), same satellitesUsed?.toString() ?? '-', same 7-digit coordinate formatting, rendered into the same grid grid-cols-2 … bg-[#FFFFFF11] cells. AGENTS.md:165 asks for one extraction once the same logic lives in two places, and this is pure GnssFix → label/value formatting with no Vue in it, so it belongs beside the other GNSS formatting helpers in src/libs/sensors/ (gnssStatusLabel and friends at gnss.ts:210-245) rather than in either component. One function returning the full list, with the dialog picking the four rows it wants, covers both.

7.2 — The extraction stops one file short of the duplicate it was extracted to removeminor

Consequence: the de-duplication helper this PR adds still has an identical hand-written twin, so the two can drift.

uniqueString (libs/utils.ts:492-497) is the exact loop that also sits in generatePointOfInterestId (src/composables/usePointsOfInterest.ts:30-36) — same base, same let suffix = 2, same while walk, same return. The commit that introduces the shared helper is where that third copy should have gone; the POI version becomes uniqueString(machinizeString(name) || 'poi', existingIds, '-'), mirroring what generateDeviceId now is.

7.3 — beginCreate's raw-object return is worked around at the call site instead of fixedminor

Consequence: the next caller that mutates what beginCreate returns gets silent no-ops, exactly as the comment here warns.

BaseStationGnssSetupDialog.vue:361-363 calls gnss.beginCreate(), throws the return value away, re-reads gnss.draft.value, and adds a comment explaining that the returned object is the unwrapped target. The comment is correct — beginCreate (useGnss.ts:95-102) returns the raw device while draft.value is the reactive proxy ref() wrapped it in, so mutating the return notifies nothing. But that makes the returned value a trap for every future caller, and AGENTS.md:63 asks for the fix at the shared function rather than at the call site. return draft.value as GnssDevice at useGnss.ts:101 removes both the workaround and the comment; the only other caller (ConfigurationSourcesView.vue:152) ignores the return.

11. Nitpicks / Optional — 2 findings

11.1 — deviceUsingPort's JSDoc reverses its own matching ordernit

libs/sensors/gnss.ts:457-472 says it follows "the same model-then-path matching resolveDevicePort uses", but the predicate returns on devicePort === port.path first and only then looks at the USB ids — the opposite precedence of resolveDevicePort:580-587, which prefers the model and falls back to the path. The boolean result is the same for one device; with two configured devices, one pinned by path and one matching by model, the two functions disagree about which owns the port. Either describe it as path-then-model, or align the order.

11.2 — justify-content: stretch on a flex row does nothingnit

BaseStationConfigPanel.vue (new .config-serial-gnss-row): .config-row is display: flex (:1203-1212), and stretch is not a valid flex justify-content value, so it computes to flex-start. The button's full width comes entirely from flex: 1 1 auto on the line below. Dropping the declaration leaves the rendering identical and the comment above it accurate.

Sections with nothing to report (6)

3. AGENTS.md Adherence — ✅ (scope checked on the one rewritten function, generateDeviceId, which is the extraction commit's own subject; no new dependencies; every added JSDoc block non-empty with typed @param/@returns; and each of uniqueString, planDeviceName and deviceUsingPort has a call site in this PR, so nothing is groundwork — the AGENTS.md:118 and :125-126 breaches are raised as 6.2 and 2.1)

4. Security — ✅ (no new dependencies, no network calls, no eval/Function/v-html, no encoded blobs or hidden Unicode; the only privileged calls added are autodetectBaud and connectDevice, both isElectron()-guarded at gnss.ts:519 and :457; nothing under .github/, scripts/ or src/electron/ is touched; and neither pr.json, pr.diff nor complexity-report.json contains text addressed to a reviewer)

5. Performance — ✅ (previewItems is the only added work on a message path — four O(1) formats per NMEA fix at ≤10 Hz, and only while the confirm step is mounted; deviceUsingPort is a linear scan of a list that holds a handful of devices, behind a click; the sole long-lived resource the diff registers is the draft preview connection, whose missing teardown is raised as 1.1; complexity report for this head measured 236 functions across 8 changed files with 0 triggers and no truncation)

8. Commit Hygiene — ✅ (four commits read helper → helper → component → wiring, each prefixed with the area it touches per the git log convention; no wip/fixup!/self-correcting or revert-and-redo commits; no issue or PR reference in any message, with Closes #1842 correctly confined to the PR body; the largest, 4f90305c, is 320 lines that are one new component file)

9. Tests — ✅ (nothing existing was removed or weakened; the two added tests cover real edge cases — suffix gaps not being filled, and ports without USB descriptors not matching each other on mutual undefined — and the settings-management mock is scoped to the new file with a comment saying why)

10. Documentation — ✅ (README.md:109 already lists External Serial GNSS as Standalone-only, so the Lite/Standalone table owed no change; the three added public functions carry typed JSDoc, and deviceUsingPort's explains why a port takes a single reader)

Generated by Claude. This is advisory; a human reviewer must still approve.

@rafaellehmkuhl
rafaellehmkuhl force-pushed the issue-1842-base-station-gnss-wizard branch from a6bd889 to e318a58 Compare August 19, 2026 21:53
@rafaellehmkuhl rafaellehmkuhl changed the title Set the base station position from a serial GNSS receiver Add serial GNSS connection wizard to the Base Station configuration panel Aug 19, 2026
@rafaellehmkuhl
rafaellehmkuhl force-pushed the issue-1842-base-station-gnss-wizard branch from e318a58 to f1815f8 Compare August 19, 2026 22:06
@rafaellehmkuhl

Copy link
Copy Markdown
Member Author
Review follow-up — round 1

Note on the base: the review ran against a6bd8895, but the branch head when this round started was e318a583 — a UI pass on the dialog (Vuetify list/typography, close button, phone-screen grid) that also happened to land two thirds of 6.1. This round is rebased on top of that, so those parts are not mine.

Done

  • src/components/BaseStationGnssSetupDialog.vue (1.1 — dismissing during the probe leaves a connected draft behind): testSelectedPort re-checks props.modelValue after the probe and after the preview connect, so a dismissed dialog returns before beginCreate rather than installing a draft nothing can reach, and discards it if the dismiss lands during the connect. onBeforeUnmount(discardDraft) covers the panel being closed from outside the dialog. The probe itself has no abort, so a cancelled sweep still releases the port when it finishes rather than immediately.
  • src/components/BaseStationGnssSetupDialog.vue (1.2 — no failure path on the preview connect): the connect is wrapped, and a throw puts the reason in probeError and returns to the select step, as the probe failure already did. The step now advances to confirm only after the port is open, so Waiting... can no longer be a hard failure, and there is no path to committing a device that never connected.
  • src/components/BaseStationGnssSetupDialog.vue (2.1 — machine-specific data in the vehicle-synced list): usbMatch is written only when the port reports both vendor and product ids, so usbMatch: {} never reaches the setting. The sibling's "This device reports no USB model id…" warning now shows on the confirm step.
  • src/components/BaseStationGnssSetupDialog.vue (6.1 — unlogged dismiss): close and backToSelection log the interaction. The variant="text" footer and the primary fill came with the UI pass noted above.
  • src/components/BaseStationConfigPanel.vue (6.2 — Lite explanation never reaches the user): a paragraph under the row when !gnss.isSupported, in the same style as the OpenCellID one at :213. The disabled-state :title that could never be hovered is gone.
  • src/libs/sensors/gnss.ts, src/types/gnss.ts (7.1 — duplicated fix readout): gnssFixItems returns the full labelled list as GnssFixItem[]; sources/GnssDeviceDialog.vue renders all of it, the setup dialog picks four rows by key.
  • src/composables/usePointsOfInterest.ts (7.2 — copy left behind): generatePointOfInterestId is now uniqueString(machinizeString(name) || 'poi', existingIds, '-'), folded into the commit that adds the helper.
  • src/composables/useGnss.ts (7.3 — raw-object return): beginCreate returns draft.value, and the call site's workaround and comment are gone.
  • src/libs/sensors/gnss.ts (11.1 — JSDoc reverses its own order): took the second option and aligned the order instead of rewording, since parity with resolveDevicePort is the property worth having. Covered by a new assertion in src/tests/libs/sensors/gnss.test.ts for the two-device case the finding describes.
  • src/components/BaseStationConfigPanel.vue (11.2 — inert declaration): the whole .config-serial-gnss-row rule and its class are gone; flex: 1 1 auto on the button was already doing the work.

Won't change (with reasoning)

  • 2.1, third bullet — add serialNumber to the stored match: storing it only pays off once resolveDevicePort matches on it, and that is inherited behaviour this PR does not touch — changing it would change port resolution for every already-configured device. Writing the field now would ship data nothing reads, which AGENTS.md rules out. Worth its own PR, together with the two-identical-receivers ambiguity at gnss.ts:583-584.

Deferred

  • 6.1, first bullet, beyond this dialog — the same bare text prop is at sources/GnssDeviceDialog.vue:141,144,146 and poi/PoiManager.vue:154. Fixing them is three words but it visibly restyles two unrelated dialogs' footers, so it does not belong in this PR.

@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

/review

@github-actions

Copy link
Copy Markdown

Automated PR Review — round 2

Warning

⚠️ IMPORTANT FIXES REQUIRED
6 open: 1 major, 3 minor (1 disputed), 2 nits. 8 closed since round 1.

Adds a button under the base-station latitude/longitude fields that opens a three-step dialog: it lists the computer's serial ports, probes the one the operator picks by sweeping the common speeds until valid satellite sentences come out of it, previews live position data from that receiver without saving anything, and on confirmation saves it as a positioning device and switches the base station over to following it. Two small helpers (unique-name suffixing, and finding the already-configured device on a port) land first with tests, and the fix-readout formatting the two GNSS dialogs shared is now one function. The probe, the preview mode and the position-following watcher all already existed; this PR is the guided path into them.

What still needs attention

# Problem What it means Severity Status
1.3 Cancelling a test poisons the next one If you back out while a device is being checked and then check the same device again, the app tells you it is not a GNSS receiver when it is. major
1.2 Confirm step never shows the connection If the receiver drops out while you are looking at the preview, the reading freezes with nothing saying so, and saving anyway leaves a device that will not connect while the app announces the base station is following it. minor :large_yellow_circle:
1.4 A last-instant cancel can still hold the receiver Closing the dialog in the split second the receiver is being opened leaves it held by an invisible connection that only restarting Cockpit releases. minor
2.1 Receivers are still identified by model, not by unit Two identical receivers cannot be told apart, so the vehicle-shared configuration can point at the wrong one of the pair. minor 💬
11.3 Off-palette panel tint Five panels inside the new dialog use a tint 1 step off the one the rest of the app uses. nit
11.4 PR description no longer matches the code The description and test plan still tell a tester to look for a tooltip that the code has replaced with visible text. nit

🙋 Decisions for a human

2.1 — The new flow identifies the receiver by USB model only, not by unit
Author's argument: storing the port's serial number would only pay off once the port-resolution code matches on it, and that code is inherited behaviour this PR does not touch; writing the field now ships data nothing reads, which AGENTS.md forbids, so it belongs in its own PR together with the two-identical-receivers ambiguity.

  • Accept the argument and leave the code as it is
  • Ask for the change anyway

Ticking a box records the decision; the finding itself closes only on /resolve 2.1 <reason>.

Since round 1 — 8 closed, 4 new, comparing a6bd889f1815f8

incremental.diff was not usable this round. The branch was rebased (the four commits all carry new shas, and complexity-report.json gives the merge base as ce3a8d44, newer than round 1's), so the compare from a6bd889 reproduces the entire PR — all ten files, +544/-46 — rather than the increment. Every status below was therefore judged from pr.diff against the base checkout, not from the increment.

resolutions.json is empty: no /resolve has been issued on this PR, so nothing was closed here without a corresponding code change.

Findings that changed status

  • 1.1 — Dismissing during the probe leaves a connected draft and an open port — Addressed. All three asks landed in src/components/BaseStationGnssSetupDialog.vue: props.modelValue is re-checked after the probe (:282-284, before beginCreate, so no draft is created for a dialog nobody is looking at) and again after the connect (:317-320); onBeforeUnmount(discardDraft) (:218) covers the panel being closed from outside; and the testing step now has a real Cancel (:125-128). What the guards do not cover is raised fresh as 1.3 and 1.4.
  • :large_yellow_circle: 1.2 — No failure path on the preview connect, no connection state on confirm — Partially addressed, and re-graded minor because the half that landed is the half that was reachable. Kept open below.
  • 💬 2.1 — Machine-specific data in the vehicle-synced list — Disputed, and re-graded minor for what remains. Two of the three asks landed in code: usbMatch is written only when the port reports both ids (:301-305), so usbMatch: {} no longer reaches the setting, and the sibling's "reports no USB model id" warning now shows on the confirm step (:76-81, gated on draftHasUsbModel at :184). The third is argued rather than changed, which is what keeps it open — see Decisions for a human.
  • 6.1 — Setup dialog footer — Addressed on all three counts: the dismisses are variant="text" (:11, :60, :127, :130), the committing actions carry the bg-[#FFFFFF33] fill (:112, :118, :132), and both close (:211-215) and backToSelection (:326-330) log. The sibling dialogs' bare text props that the finding cited as evidence are still there (sources/GnssDeviceDialog.vue:141,144,146), and the author is right that they were never this finding's ask.
  • 6.2 — Lite explanation never reaches the user — Addressed. src/components/BaseStationConfigPanel.vue:69-72 renders the explanation as a visible paragraph under the row when !gnss.isSupported, in the same px-1 pt-1 text-[10px] leading-tight opacity-70 form as the OpenCellID one at :198, and it says "Install Cockpit Standalone" rather than "upgrade". The title attribute on the button survives but now describes what the button does rather than carrying the Lite explanation, so nothing user-facing depends on hovering a disabled control.
  • 7.1 — Duplicated fix readout — Addressed. gnssFixItems (src/libs/sensors/gnss.ts:257-280) returns the whole labelled list beside the other GNSS formatting helpers; sources/GnssDeviceDialog.vue:210 renders all of it, and the setup dialog picks four rows by key (:186-193). The GnssFixItem.key field (src/types/gnss.ts:59-68) is what makes the pick stable, and the sibling's :key="item.label" was updated with it.
  • 7.2 — Copy left in usePointsOfInterest — Addressed. generatePointOfInterestId is now one call to uniqueString (src/composables/usePointsOfInterest.ts:30-31), folded into the commit that adds the helper.
  • 7.3 — beginCreate's raw-object return — Addressed at the shared function: useGnss.ts:103-104 returns draft.value, and the call site takes the return directly (:290) with the workaround comment gone.
  • 11.1 — JSDoc reversed its own order — Addressed by aligning the code instead of the prose: deviceUsingPort (src/libs/sensors/gnss.ts:636-640) now tries the USB model first and falls back to the path, matching resolveDevicePort, with a new assertion for the two-device case (src/tests/libs/sensors/gnss.test.ts:36-40).
  • 11.2 — Inert justify-content: stretch — Addressed. The rule and its class are gone; only .config-serial-gnss-btn with flex: 1 1 auto remains.

New this round: 1.3 and 1.4 (both from re-reading the dismiss paths the round-1 guards created), 11.3 and 11.4.

Discussion since round 1

One substantive comment, from rafaellehmkuhl (the follow-up summary at #issuecomment-5348585064). Every "Done" item was checked against the diff and every one holds, as recorded above. Two parts of it are worth repeating here because they are accurate and this review builds on them: "The probe itself has no abort, so a cancelled sweep still releases the port when it finishes rather than immediately" — that is the gap 1.3 turns into a user-visible consequence; and the note that a separate UI pass, rebased under this branch, is where the footer variants and the phone-screen grid came from. The bare /review comment is a command and was ignored as content.

Change map — what was established before judging

Claims (the PR body's, checked against the code)

  • "useBaseStation has listed configured GNSS devices as position sources and fed their fixes into setPosition since it merged"verified: src/composables/baseStation/useBaseStation.ts:140-143 builds gpsSourceOptions from gnss.devices, and :195-200 watches the tracked device's fix into applyTrackedPositionsetPosition. The diff adds no plumbing there.
  • "autodetectBaud already sweeps the common rates and only succeeds after three checksum-valid NMEA sentences"verified: src/libs/sensors/gnss.ts:514-556 in the base checkout, validSentenceThreshold at :157. Worst case is 6 rates × (1500 ms + 300 ms settle) ≈ 10.8 s, so the dialog's "up to 15 seconds" is a safe over-estimate.
  • "the GNSS composable already connects [a draft] in preview mode without publishing to the data lake"verified: useGnss.ts:78-86 passes !isDraft as publish, and gnss.ts:462 skips registerDeviceVariables when false.
  • "Backing out or closing discards the draft and its connection"verified for the paths round 1 contradicted: close (BaseStationGnssSetupDialog.vue:211-215), backToSelection (:326-330), the two props.modelValue re-checks (:282-284, :317-320) and onBeforeUnmount (:218). Two windows remain uncovered, raised as 1.3 and 1.4.
  • "Probing calls stopDevicesOnPort, so testing a port that a configured device already reads would disconnect that device"verified: gnss.ts:523 and :495-501. Nothing restarts a device stopped that way, which is what makes deviceUsingPort a load-bearing guard rather than a convenience: the dialog never offers Test for a port a configured device holds (:112-118).
  • "in Lite the button renders disabled with a title explaining it needs Standalone"contradicted, and now stale rather than wrong: the explanation is a visible paragraph (BaseStationConfigPanel.vue:69-72), not a title. Finding 11.4.
  • "The README already lists External Serial GNSS as desktop-only"verified: README.md:109.
  • "Two small helpers … Both have tests"verified: src/tests/libs/utils.test.ts:21-32, src/tests/libs/sensors/gnss.test.ts.

No bug is being fixed, so there is no failure site to locate.

Entry points

Function Reached from Frequency
uniqueString (libs/utils.ts:295) generateDeviceId, planDeviceName, generatePointOfInterestId per user action
generateDeviceId (useGnss.ts:31, rewritten) planDeviceIdplannedId computed in sources/GnssDeviceDialog.vue:192 (recomputes as the name is typed) and commitCreate per user action
planDeviceName (useGnss.ts:69, new) testSelectedPort after a successful probe per user action
beginCreate (useGnss.ts:95, changed return) dialog testSelectedPort; ConfigurationSourcesView.vue:152 (ignores the return) per user action
generatePointOfInterestId (usePointsOfInterest.ts:30, rewritten) POI creation in the map/mission pipeline per user action
gnssFixItems / formatFixNumber (libs/sensors/gnss.ts:257, :248, new) statusItems (sources/GnssDeviceDialog.vue:210) and previewItems (new dialog :186) per incoming message (≤10 Hz, only while one of the two dialogs is mounted)
deviceUsingPort (libs/sensors/gnss.ts:636, new) claimingDevice computed → re-evaluated on port selection and on any change to the synced device list per user action
openGnssSetup (BaseStationConfigPanel.vue:925) click on the new panel button per user action
testSelectedPort, confirmDevice, useExistingDevice, backToSelection, close, onRefreshPorts, selectPort, onNameEdited, trackDevice, discardDraft, resetToSelection (new dialog) footer/list click handlers per user action
discardDraft (new dialog :201) additionally onBeforeUnmount (:218) one-shot per panel close
onDialogUpdate (new dialog :220) InteractionDialog's internalShowDialog watcher (components/InteractionDialog.vue:242-249), i.e. Esc, backdrop click, or programmatic close per user action
modelValue watcher (new dialog :225) parent v-model flip per user action
previewItems / draftFix (new dialog :186, :183) subscribeToDeviceStatelatestFixes[draftId], driven by handleDeviceBytes (gnss.ts:411-427) per incoming message (≤10 Hz, only while the confirm step is mounted)
portDescription (new dialog :195) render of the port list per user action

No changed function came out of the walk with no caller.

Invariants

  • A serial port takes one reader. Covered: autodetectBaud stops readers on the port first (gnss.ts:523); deviceUsingPort keeps the dialog from creating a second device for a port a configured one already holds; the draft leak round 1 found is closed by the two props.modelValue re-checks and the unmount teardown. Not covered: two overlapping probes of the same port, which the electron link service keys by URI including the baud (src/electron/services/link/index.ts:57-60) and therefore does not de-duplicate — finding 1.3; and the window inside startGnssDevice before runtimes.set (gnss.ts:465-480), where a concurrent cancelCreate cannot see the reader that is about to exist — finding 1.4.
  • The GNSS draft is a process-wide singleton. discardDraft (:201-203) cancels whatever is in gnss.draft, not specifically the draft this dialog created, so a foreign draft would be destroyed by this dialog closing. Safe as written: BaseStationConfigPanel is mounted exactly once (App.vue:103) and only leaves the tree through direct interaction (BaseStationConfigPanel.vue:1156, useBaseStation.ts:119, MissionPlanningView.vue:1156), and the only other producer of a draft cancels its own on close (sources/GnssDeviceDialog.vue:203). No finding, but the assumption is worth a line in the code if a second host is ever added.
  • trackByGps only has an effect while the station is enabled (useBaseStation.ts:153). The dialog sets it without checking, which would strand tracking if the panel could be open with no position. It cannot: every entry point sets a position first (widgets/Map.vue:1768, views/MissionPlanningView.vue:2262) and the menu entry only exists when config.enabled. Holds.
  • A device id from the vehicle-synced list may not exist on this machine. gpsSourceId is machine-local while the device list is vehicle-synced, and gpsSource (useBaseStation.ts:147-150) already falls back to browser geolocation for an unknown id, so trackDevice writing a fresh id is safe. Covered.
1. Correctness & Implementation Bugs — 3 findings (1 carried)

1.3 — Cancelling a test leaves the probe running, and a second test of the same port then reports no receivermajor

Consequence: if you back out of the check — deliberately, or by clicking the background — and then check the same device again, the dialog tells you it is not a GNSS receiver when it is.

The testing step's new Cancel (src/components/BaseStationGnssSetupDialog.vue:125-128) and the Esc/backdrop dismiss both run close(), which returns the user to the panel while autodetectBaud keeps sweeping — the author says as much in the round-1 follow-up. The sweep runs for up to ~10.8 s more, opening and closing the port at one baud after another (src/libs/sensors/gnss.ts:526-553 in the base checkout). Nothing records that a probe is in flight, so the dialog will happily start a second one on the same port, and the two collide:

  • The electron link service keys open links by the full URI, baud included (src/electron/services/link/index.ts:57-60), so two probes at different rates are two SerialLinks on one device. node-serialport locks the device by default, so the second linkOpen throws, is caught into return false (:103-106), and autodetectBaud treats it as "nothing here" and moves to the next rate (:542-544).
  • When the two do land on the same rate the URIs match, linkOpen returns the already-open link (:59), the second probe's handler replaces the first in pathHandlers (gnss.ts:532), and the first probe's linkClose then kills the stream mid-window (:548-549).

Either way the second probe counts too few valid sentences and returns null, and the dialog says the port "does not look like a GNSS receiver, as no valid positioning data came out of it" (:287-288) about a working receiver. The dismiss is also silent about it: the port stays busy for ten seconds after a dialog the user believes they closed.

The lack of an abort is inherited, but this PR is what makes a probe outlive the surface that started it, so the fix belongs at the chokepoint rather than in the dialog: keep the set of ports being probed in src/libs/sensors/gnss.ts and have autodetectBaud refuse (or await) a port already in it, clearing it in a finally. That covers the sources dialog's Autodetect button too, which can collide the same way. A cancellation token checked between rates — cheap, since the loop already awaits twice per rate — would additionally make Cancel release the port at once instead of ten seconds later.

1.4 — Dismissing during the preview connect orphans the reader the guard was meant to releaseminor

Consequence: closing the dialog in the fraction of a second while the receiver is being opened leaves it held by a connection nothing lists and nothing can close, until Cockpit is restarted.

This is not 1.1 again — 1.1's asks all landed — but the post-connect guard has a hole. testSelectedPort awaits gnss.connectDevice(draft.id) (src/components/BaseStationGnssSetupDialog.vue:308) and then discards on !props.modelValue (:317-320) by calling discardDraft(), which is a no-op unless gnss.draft is still set (:201-203). A dismiss during that await already ran close()discardDraft()cancelCreate(), which sets draft.value = null (useGnss.ts:118). So by the time the guard runs there is nothing left for it to discard, and meanwhile:

cancelCreate's stopGnssDevice (useGnss.ts:105) found no runtime, because startGnssDevice only registers one at gnss.ts:480, after the resolveDevicePort port listing and before linkOpen. The in-flight start then completes: it sets the runtime, opens the port, installs the path handler and starts a setInterval watchdog (gnss.ts:480-492) for a draft id that no longer exists anywhere. The port and the interval both survive until the app restarts — the same end state as 1.1, reached through a window of one port listing plus one open rather than ten seconds of probing.

Fix: capture the id and release it unconditionally rather than through the draft. const draftId = draft.id before the connect, then on the !props.modelValue branch await stopGnssDevice(draftId) (via gnss.disconnectDevice(draftId), which falls through to it when the draft is gone). Making cancelCreate await an in-flight start would fix it at the composable instead, and would also cover the same race from the sources dialog.

1.2 — The confirm step still shows no connection state (carried from round 1, partially addressed)minor

Consequence: if the receiver drops out while the operator is reading the preview, the numbers freeze with nothing saying the link is gone, and saving anyway leaves a device that will not reconnect while a snackbar announces the base station is following it.

What the finding asked for was two things. The first landed: the connect is wrapped (src/components/BaseStationGnssSetupDialog.vue:308-315), a throw puts the reason in probeError and returns to the select step exactly as the probe failure does, and the step advances to confirm only after the port is open (:322-323) — so the three ways startGnssDevice throws (src/libs/sensors/gnss.ts:456-488) can no longer masquerade as a slow preview, and there is no path to committing a device that never opened. That is why this is now minor rather than major.

The second did not: the confirm step still has no status row of any kind, and "Use this device" is gated only on the name being non-empty (:132). previewItems renders Waiting... while no fix has arrived (:188) and, once one has, keeps rendering the last one — deviceFixes holds it (gnss.ts:423) and the watchdog only moves the status to no-data (:402-408), which nothing on this step displays. So a receiver unplugged between the preview opening and the click shows a frozen, plausible-looking position. Committing then reads wasConnected from that same status (useGnss.ts:125), which is no-data, i.e. truthy, so the device is saved enabled: true, the reconnect fails into a console.error (:148) the operator never sees, and trackDevice still reports "The base station position now follows …".

Fix as before: surface gnss.statuses[draftId] on the confirm step — the sibling already has the chip for it at sources/GnssDeviceDialog.vue:73-97 — and disable "Use this device" while it is disconnected.

2. Persistence & User Data — inventory, 1 finding (carried, disputed)

Inventory

Key Backend What this PR does to it
cockpit-gnss-devices vehicle-synced (useBlueOsStorage, useGnss.ts:46) New writer. commitCreate appends a device carrying port (/dev/tty*), baud, enabled and, now only when the port reports both USB ids, usbMatch. Shape unchanged.
cockpit-base-station-gps-source-id machine-local (useStorage, useBaseStation.ts:36) New writer (trackDevice sets it to the new device id). Shape unchanged.
cockpit-base-station-track-by-gps machine-local (useStorage, useBaseStation.ts:35) New writer (set to true on confirm). Shape unchanged.

Nothing is reshaped, renamed or removed; no migration is added; every key keeps its cockpit- prefix. The two machine-local keys are the right backend — a positioning source and the decision to follow it describe this topside computer — and gpsSource already tolerates an id that does not resolve here (useBaseStation.ts:147-150). Both writes go through reactive() (useBaseStation.ts:216-219), so assigning to baseStation.gpsSourceId writes through the underlying ref rather than replacing it. The usbMatch: {} that round 1 flagged is gone: BaseStationGnssSetupDialog.vue:301-305 writes the object only when port.vendorId && port.productId, which is stricter than the sibling at sources/GnssDeviceDialog.vue:248-250, and the operator is now warned on the confirm step when the receiver cannot be identified by model (:76-81).

2.1 — The new flow identifies the receiver by USB model only, not by unit (carried from round 1, disputed; re-graded to minor for what remains)minor

Consequence: two receivers of the same model cannot be told apart, so the configuration shared with the vehicle can point at the wrong one of the pair.

The two parts of this finding that could reach a user are fixed, which is why the severity drops: a device with no USB ids no longer writes a bare usbMatch into the vehicle-synced list, and a device that cannot be identified by model now says so before it is saved. What is left is the third bullet. SerialPortInfo already exposes serialNumber (src/types/serial.ts:30) and the electron side already returns it (src/electron/services/link/index.ts:48); nothing reads it. AGENTS.md:125 asks for "a stable id (USB VID/PID, device serial)", and the per-unit serial is the only thing that separates two identical receivers — the ambiguity resolveDevicePort currently resolves by preferring the last-used path and otherwise guessing (src/libs/sensors/gnss.ts:583-584).

The author declines, arguing that the field would be written and never read until the resolution code matches on it, and that changing that code would change port resolution for every already-configured device — so it belongs in its own PR. The argument is sound on its own terms, and it cites a rule this review also applies (added code needs a call site in the PR that adds it). It is a judgement call about scope rather than about the code, so it goes to a human; see Decisions for a human above.

11. Nitpicks / Optional — 2 findings

11.3 — The dialog's nested panels use a tint that is not the house onenit

src/components/BaseStationGnssSetupDialog.vue uses bg-[#FFFFFF14] for its seven nested surfaces (:22, :32, :42, :51, :68, :76, :87). The tree's nested-glass tint is #FFFFFF11 — 46 uses across 25 files, including the readout cells in the sibling this dialog was modelled on (sources/GnssDeviceDialog.vue:105) — against two uses of #FFFFFF14 in one unrelated file. The difference is invisible; the inconsistency is not, once someone greps for the token.

11.4 — The PR description and test plan describe the Lite behaviour the code no longer hasnit

The body still says "in Lite the button renders disabled with a title explaining it needs Standalone", and the test plan asks the tester to "confirm the button is disabled with the explanatory tooltip". The fix for 6.2 replaced that tooltip with a visible paragraph (src/components/BaseStationConfigPanel.vue:69-72), which is the better behaviour and the one that should be checked — a tester following the plan as written would look for something that is deliberately not there. The title still on the button describes what the button does, so it is not the string the plan means.

Sections with nothing to report (8)

3. AGENTS.md Adherence — ✅ (no new dependencies and package.json untouched; every added JSDoc block has a non-empty summary with typed @param/@returns; each of uniqueString, planDeviceName, gnssFixItems and deviceUsingPort has a call site in this PR, so nothing is groundwork — the one field the finding wanted added with no reader is 2.1, argued the other way; the only edits outside the feature are the two the review asked for, the uniqueString and gnssFixItems extractions and their call sites)

4. Security — ✅ (no new dependencies, no network calls, no eval/Function/v-html, no encoded blobs or hidden Unicode; the privileged calls added are autodetectBaud and connectDevice, both isElectron()-guarded at gnss.ts:519 and :457, and the Lite path now stops at a disabled button with a visible explanation; nothing under .github/, scripts/ or src/electron/ is touched; and none of pr.json, pr.diff, complexity-report.json or new-comments.json — the last of which any GitHub user can write into — contains text addressed to a reviewer)

5. Performance — ✅ (gnssFixItems is the only added work on a message path: ten O(1) formats per fix at ≤10 Hz, and the setup dialog's four-row pick over them, only while a GNSS dialog is mounted — the sibling built the same ten inline before; deviceUsingPort is two linear scans of a handful of devices behind a click; the one long-lived resource the diff registers is the draft preview connection, whose teardown is now on four paths, with the surviving hole raised as 1.4 rather than here; the complexity report for this head measured 293 functions across 10 changed files with 0 triggers and no truncation)

6. UI / UX — ✅ (round 1's two findings are both addressed; re-checked this round: footer is dismiss-left/primary-right on every step with the dismisses variant="text" and the commits carrying bg-[#FFFFFF33], the panel button carries the #FFFFFF22 panel-action fill, the close X copies the sibling's markup and adds the aria-label it lacks, the -mt-7 content offset matches sources/GnssDeviceDialog.vue:15 rather than being invented here, the readout collapses to one column via interfaceStore.isOnPhoneScreen, no overlay-teleporting control was added so theme="dark" is not owed, every label is sentence case, both terminal actions raise a snackbar, and all nine interactions log in past tense — the off-palette nested tint is 11.3)

7. Code Quality & Style — ✅ (all three round-1 findings addressed; the extraction landed in src/libs/sensors/gnss.ts beside gnssStatusLabel rather than in either component, which is where the finding pointed and inside that module's stated purpose; no comment was deleted or reworded over unchanged code; no stray any, func-style and explicit-function-return-type satisfied on every added arrow, and the longest added line is inside .vue where max-len is off)

8. Commit Hygiene — ✅ (four commits, all rewritten this round with round 1's fixes folded into their targets rather than appended as review commits, which is what AGENTS.md:191 and :196 ask for; they read helper → helper → component → wiring, each prefixed with the area it touches; no wip/fixup!/self-correcting or revert-and-redo commits; no issue or PR reference in any message, with Closes #1842 correctly confined to the PR body; the largest, bd2254934, is one new component file)

9. Tests — ✅ (nothing existing was removed or weakened; deviceUsingPort's test gained the model-over-path assertion that pins the ordering 11.1 was about, alongside the ports-without-descriptors case; uniqueString's three cases are unchanged; the settings-management mock stays scoped to the new file with the comment saying why)

10. Documentation — ✅ (README.md:109 already lists External Serial GNSS as Standalone-only, so the Lite/Standalone table owed no change and the UI element AGENTS.md:118 asks for alongside it now exists; the four added public functions and the new GnssFixItem interface all carry typed JSDoc, and deviceUsingPort's explains why a port takes a single reader; the only stale prose is the PR body itself, 11.4)

Generated by Claude. This is advisory; a human reviewer must still approve.

@rafaellehmkuhl
rafaellehmkuhl force-pushed the issue-1842-base-station-gnss-wizard branch from f1815f8 to 0249e5c Compare August 19, 2026 22:49
@rafaellehmkuhl

Copy link
Copy Markdown
Member Author
Review follow-up — round 2

Done

  • src/libs/sensors/gnss.ts (1.3 — cancelling a test poisons the next one): in-flight probes are now kept per port, and a second autodetectBaud on a port already being probed is handed the running sweep's promise instead of opening a rival link. So the retest after a cancel gets that sweep's answer — which is the correct one — rather than a starved null. Fixed at the chokepoint, so the sources dialog's Autodetect is covered too. Own commit, since autodetectBaud predates this PR. Test: two concurrent probes of one port produce exactly one linkOpen.
  • src/libs/sensors/gnss.ts (1.4 — a last-instant cancel can still hold the receiver): startGnssDevice now registers its in-flight promise and stopGnssDevice awaits it before looking for a runtime, so a stop can no longer land in the window where the reader does not exist yet and return believing there was nothing to release. Own commit. Test: a stop issued while the start is parked in the port listing closes the port once the start completes.
  • src/components/BaseStationGnssSetupDialog.vue (1.2 — confirm step never shows the connection): the confirm step carries the status icon and label, from the same gnssStatus* helpers as the sibling's chip, and "Use this device" is disabled while the status is disconnected.
  • src/components/BaseStationGnssSetupDialog.vue (11.3 — off-palette panel tint): all seven surfaces are bg-[#FFFFFF11]. The two uses in map/MapOverlaysDialog.vue are not mine to restyle here.
  • PR description (11.4 — description no longer matches the code): the Lite paragraph and the test-plan line now describe the visible explanation rather than a tooltip, plus two new plan items for the confirm-step status and for the cancel-then-retest path.

Done differently

  • src/libs/sensors/gnss.ts (1.4 — where the fix goes): took the composable-or-lib alternative rather than capturing the id in the dialog. The dialog fix would have left the identical race in sources/GnssDeviceDialog.vue, and the window is opened by startGnssDevice publishing its runtime late, so the guard belongs there. It also covers the initGnss auto-connect racing a manual disconnect.

Won't change (with reasoning)

  • 1.3, the cancellation token — left out deliberately, not forgotten. Sharing the sweep is what removes the wrong verdict; an abort would work against it, since the retest now wants the running sweep's result and a cancelled one would have nothing to hand it. The port still stays busy for the rest of the sweep after a cancel, which is the inherited behaviour and now costs the user nothing.
  • 1.2, disabling on no-data — the button is gated on disconnected only, as the finding asked. no-data is shown ("connected (no data)") but not blocking: a receiver that is streaming while still hunting for a fix is a legitimate thing to save, and refusing it would strand the operator with no way forward.
  • 2.1 — unchanged, and the reasoning is the round-1 one. Recorded as a human decision, so nothing new from me here.

@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

/review

@github-actions

Copy link
Copy Markdown

Automated PR Review — round 3

Note

📝 MINOR SUGGESTIONS
3 open: 3 minor (1 disputed), 2 of them raised this round. 5 closed since round 2, 13 in total.

Adds a button under the base-station latitude/longitude fields that opens a three-step dialog: it lists the computer's serial ports, probes the one the operator picks by sweeping the common speeds until valid satellite sentences come out of it, previews live position data from that receiver without saving anything, and on confirmation saves it as a positioning device and switches the base station over to following it. Two small helpers (unique-name suffixing, and finding the already-configured device on a port) land first with tests, and the fix readout the two GNSS dialogs shared is now one function. This round adds two fixes to the serial layer underneath: a second check of a port already being checked now waits for the one in flight instead of fighting it, and closing a device now waits for a connection that is still being opened instead of walking past it.

What still needs attention

# Problem What it means Severity Status
1.5 Cancel-then-retest now starts the flow twice The exact recovery the new fix enables leaves a second, invisible connection attempt running for the rest of the session, with a timer nothing ever stops. minor
1.6 You can still save a receiver that has gone silent Unplug the receiver on the last screen and the app lets you save it anyway, then announces the base station is following a device that never reconnects. minor :large_yellow_circle:
2.1 Receivers are still identified by model, not by unit Two identical receivers cannot be told apart, so the vehicle-shared configuration can point at the wrong one of the pair. minor 💬

🙋 Decisions for a human

2.1 — The new flow identifies the receiver by USB model only, not by unit
Author's argument: storing the port's serial number would only pay off once the port-resolution code matches on it, and that code is inherited behaviour this PR does not touch; writing the field now ships data nothing reads, which AGENTS.md forbids, so it belongs in its own PR together with the two-identical-receivers ambiguity. Unchanged this round, and the author says so explicitly.

  • Accept the argument and leave the code as it is
  • Ask for the change anyway

Ticking a box records the decision; the finding itself closes only on /resolve 2.1 <reason>.

Since round 2 — 5 closed, 2 new, comparing f1815f80249e5c

incremental.diff was not usable this round either, and for a narrower reason than last time. The branch was rewritten from its third commit onward: 2a284cd9b and aa25a104d are shared with f1815f8, so the merge base of the two heads is aa25a104d and the compare reproduces everything from the dialog commit forward — seven files, including BaseStationGnssSetupDialog.vue as a 372-line addition — rather than the two fix commits that are the actual increment. complexity-report.json still gives the merge base against master as ce3a8d4485, unchanged from round 2, which is consistent with only the branch tip having moved. Every status below was judged from pr.diff against the base checkout.

resolutions.json is empty: no /resolve has been issued on this PR, so nothing was closed here without a corresponding code change, and there are no unrecognised ids to report back.

Findings that changed status

  • 1.3 — Cancelling a test leaves the probe running, and a second test of the same port then reports no receiver — Addressed, at the chokepoint the finding named. activeProbes (src/libs/sensors/gnss.ts:556) keys an in-flight sweep by port; autodetectBaud (:609-622) is now a thin wrapper that hands back the running promise when there is one, and clears the entry in a .finally guarded against a newer probe having replaced it (:617-619). The sweep itself moved unchanged into the private probePort (:558). Because the wrapper sits under autodetectBaud rather than in the dialog, the sources dialog's Autodetect (src/composables/useGnss.ts:173) is covered too, which is the half the finding said a dialog-side fix would miss. The regression test at src/tests/libs/sensors/gnss.test.ts:57-68 asserts one linkOpen for two concurrent probes of one port. The cancellation token was the optional half of the ask and the author declines it with a reason that holds: a retest now wants the running sweep's answer, so aborting it would defeat the fix. What the shared sweep does create on that same path is raised fresh as 1.5.
  • 1.4 — Dismissing during the preview connect orphans the reader the guard was meant to release — Addressed, and at the deeper of the two sites the finding offered. startGnssDevice (src/libs/sensors/gnss.ts:531-545) now publishes its in-flight promise into pendingStarts before awaiting it, and stopGnssDevice awaits that promise before looking for a runtime (:472-474), so a stop can no longer land in the resolveDevicePort-to-linkOpen window and return believing there was nothing to release. Checked for the deadlock this shape invites: startGnssDevice calls stopGnssDevice at :536 before registering at :539, so a start never awaits itself, and the registration happens in the same synchronous turn as the call, so no user event can slip between them. The rejection is swallowed with .catch(() => undefined) so a failed start cannot leave a stop hanging. Test at src/tests/libs/sensors/gnss.test.ts:73-96. The author is right that this also covers sources/GnssDeviceDialog.vue and the initGnss auto-connect racing a manual disconnect.
  • 1.2 — The confirm step still shows no connection state — Addressed on both asks. The status row is at src/components/BaseStationGnssSetupDialog.vue:83-86, driven by draftStatus/statusLabel/statusColor/statusIcon (:205-208) off the same gnssStatus* helpers as the sibling's chip, with a comment at :203-204 saying why the readout alone is not enough; and "Use this device" is gated on the status at :140. Both parts of what round 2 asked for are in the code. What the gate does not catch is that disconnected is not the state an unplugged receiver reaches — that was an imprecision in round 2's own prescription, so it is raised as 1.6 rather than held against this fix.
  • 11.3 — The dialog's nested panels use a tint that is not the house one — Addressed. All seven surfaces are bg-[#FFFFFF11] (:22, :32, :42, :51, :68, :76, :92); no #FFFFFF14 remains anywhere in pr.diff. The author is right that the two uses in map/MapOverlaysDialog.vue were never this finding's ask.
  • 11.4 — The PR description and test plan describe the Lite behaviour the code no longer has — Addressed. The body now says the button "renders disabled above a visible line explaining that it needs Standalone", and the test-plan line asks the tester to confirm "the explanation is readable under it without hovering", which is what src/components/BaseStationConfigPanel.vue:70-73 renders. Two plan items were added for the confirm-step status and the cancel-then-retest path.

Still open, unchanged: 2.1, disputed since round 1 and re-argued by neither side this round. New this round: 1.5 and 1.6, both from re-reading the two fixes above against the flow they enable.

Discussion since round 2

One substantive comment, from rafaellehmkuhl (the round-2 follow-up at #issuecomment-5348945706). Every "Done" item was checked against the diff and every one holds, as recorded above; the two "Won't change" items were checked against the code rather than taken at their word:

  • On 1.3's cancellation token, the reasoning that "the retest now wants the running sweep's result" is correct — an abort would strand the joining caller — and the finding is closed on the half that landed.
  • On 1.2, the reasoning that "a receiver that is streaming while still hunting for a fix is a legitimate thing to save" does not match what no-data means here. NmeaAggregator.ingest (src/libs/sensors/nmea.ts:118-145) returns true for any recognised sentence whatever the fix says, so a receiver streaming without a fix keeps refreshing lastValidLineAt and stays connected. no-data is reached only when nothing parseable has arrived for five to ten seconds (src/libs/sensors/gnss.ts:437-444) — the receiver is gone, not hunting. That is why 1.6 exists; it is a correction to the mechanism, not a re-litigation of the decision, and the fix it asks for is one line.

The bare /review comment is a command and was ignored as content. Nothing in pr.json, pr.diff, incremental.diff, new-comments.json or complexity-report.json contains text addressed to a reviewer.

Change map — what was established before judging

Claims (the PR body's, checked against the code; the body was rewritten this round)

  • "in Lite the button renders disabled above a visible line explaining that it needs Standalone"verified, and no longer the stale claim it was in round 2: src/components/BaseStationConfigPanel.vue:57-69 is the disabled button, :70-73 the visible paragraph.
  • "in-flight probes are kept per port … the retest after a cancel gets that sweep's answer"verified: src/libs/sensors/gnss.ts:556, :609-622. The join is by port path only, so two probes of the same port at different baud candidates can no longer exist.
  • "startGnssDevice now registers its in-flight promise and stopGnssDevice awaits it"verified: :464, :472-474, :531-545. Traced for self-deadlock and for the registration gap; neither is reachable (see 1.4 above).
  • "the confirm step carries the status icon and label … 'Use this device' is disabled while the status is disconnected"verified: src/components/BaseStationGnssSetupDialog.vue:83-86 and :140. The second half is inert in practice, which is 1.6.
  • "useBaseStation has listed configured GNSS devices as position sources … since it merged"verified: src/composables/baseStation/useBaseStation.ts:140-143 and :195-200. The diff adds no plumbing there.
  • "autodetectBaud already sweeps the common rates and only succeeds after three checksum-valid NMEA sentences"verified: src/libs/sensors/gnss.ts:558-595, validSentenceThreshold at :158. Worst case six rates × (1500 ms + 300 ms) ≈ 10.8 s, so the dialog's "up to 15 seconds" is a safe over-estimate.
  • "the GNSS composable already connects [a draft] in preview mode without publishing to the data lake"verified: src/composables/useGnss.ts:86 passes !isDraft as publish, and gnss.ts:487 skips registerDeviceVariables when false.
  • "Backing out or closing discards the draft and its connection"verified for every path round 1 and round 2 contradicted: close (:235-239), backToSelection (:350-354), the two props.modelValue re-checks (:306-308, :341-344), onBeforeUnmount (:242), and now the composable-level release for the connect window. The one window left is a second draft rather than an unreleased one — 1.5.
  • "Probing calls stopDevicesOnPort, so testing a port that a configured device already reads would disconnect that device"verified: gnss.ts:562 and :547-554, which is what makes deviceUsingPort (:666-670) load-bearing rather than a convenience.
  • "Two small helpers … Both have tests"verified: src/tests/libs/utils.test.ts:21-32, src/tests/libs/sensors/gnss.test.ts:29-55.
  • "yarn typecheck still exits clean in under half a second on master too … so it gave no signal here" — not verifiable from here and not treated as evidence either way; the component's API usage was read against the type definitions by hand for this review too.

Failure site — this round the PR does fix two bugs, and both fixes are at the code that misbehaves rather than at a call site. The wrong verdict after a cancel came from autodetectBaud in the base checkout (src/libs/sensors/gnss.ts:514-556) keeping no record of an in-flight sweep, and is fixed there. The held port came from startGnssDevice publishing its runtime only at base :480, after the port listing and before linkOpen, so a concurrent stopGnssDevice (base :434-443) saw nothing; it is fixed at both of those functions. Neither fix is a dialog-side patch over a shared defect, which is what round 2 asked for.

Entry points

Function Reached from Frequency
uniqueString (libs/utils.ts:295) generateDeviceId, planDeviceName, generatePointOfInterestId per user action
generateDeviceId (useGnss.ts:31, rewritten) planDeviceIdplannedId in sources/GnssDeviceDialog.vue:192, and commitCreate per user action
planDeviceName (useGnss.ts:69, new) testSelectedPort after a successful probe per user action
beginCreate (useGnss.ts:97, changed return) dialog testSelectedPort:321; ConfigurationSourcesView.vue:152 per user action
generatePointOfInterestId (usePointsOfInterest.ts:30, rewritten) POI creation in the map/mission pipeline per user action
gnssFixItems / formatFixNumber (libs/sensors/gnss.ts:257, :248, new) statusItems (sources/GnssDeviceDialog.vue:210) and previewItems (new dialog :212) per incoming message (≤10 Hz, only while a GNSS dialog is mounted)
deviceUsingPort (libs/sensors/gnss.ts:666, new) claimingDevice computed → port selection and any change to the synced device list per user action
autodetectBaud (libs/sensors/gnss.ts:609, now a wrapper) dialog testSelectedPort:299; useGnss.autodetect:173 ← sources dialog Autodetect per user action
probePort (libs/sensors/gnss.ts:558, extracted) autodetectBaud only per user action
startGnssDevice (libs/sensors/gnss.ts:531, now a wrapper) useGnss.connectDevice:86; initGnss auto-connect at boot per user action, plus one-shot at boot
openGnssDevice (libs/sensors/gnss.ts:486, extracted) startGnssDevice only per user action
stopGnssDevice (libs/sensors/gnss.ts:471, changed) startGnssDevice, stopDevicesOnPort, useGnss.clearDeviceRuntimeState (cancel/commit/remove), useGnss.disconnectDevice per user action
draftStatus / statusLabel / statusColor / statusIcon (new dialog :205-208) subscribeToDeviceStatestatuses[draftId], driven by handleDeviceBytes (gnss.ts:446-462) and the watchdog (:437-444) per incoming message and per 5 s tick, only while the confirm step is mounted
previewItems / draftFix (new dialog :212, :200) same subscription, via latestFixes[draftId] per incoming message (≤10 Hz)
openGnssSetup (BaseStationConfigPanel.vue:923) click on the new panel button per user action
testSelectedPort, confirmDevice, useExistingDevice, backToSelection, close, onRefreshPorts, selectPort, onNameEdited, trackDevice, discardDraft, resetToSelection, portDescription (new dialog) footer/list click handlers and render per user action
discardDraft (new dialog :225) additionally onBeforeUnmount (:242) one-shot per panel close
onDialogUpdate (new dialog :244) InteractionDialog's internalShowDialog watcher (components/InteractionDialog.vue:242-249) — Esc, backdrop, programmatic close per user action
modelValue watcher (new dialog :249) parent v-model flip per user action

No changed function came out of the walk with no caller.

Invariants

  • A serial port takes one reader. Now enforced at the two chokepoints rather than at the surfaces: activeProbes (gnss.ts:556) collapses concurrent probes of one port into one sweep, and pendingStarts (:464, :472-474) closes the start-versus-stop window. Enumerated the callers that could still violate it: stopDevicesOnPort (:547-554) iterates runtimes only, so a device whose start has not yet published its runtime is invisible to it — but every such start is now in pendingStarts and the per-device stop awaits it, so the sequence is serialised. The one violator left is not a second reader but a second draft: testSelectedPort can be entered twice in one component instance and both entries now succeed, which is 1.5.
  • The GNSS draft is a process-wide singleton. beginCreate (useGnss.ts:97-105) overwrites draft.value unconditionally and discardDraft (new dialog :225-227) cancels whatever is in it, not specifically the draft this dialog created. Round 2 found this safe because only one host can produce a draft at a time; that is no longer quite true, because one host can now produce two — see 1.5.
  • A joined probe runs with the first caller's parameters. autodetectBaud (:609-622) hands back the running promise without comparing candidates or perBaudMs. Checked every in-tree caller: the dialog (:299) and useGnss.autodetect (useGnss.ts:173) both take the defaults, and only the test varies them, so nothing depends on it today. Worth a line in the JSDoc, which currently documents the join but not the parameter shadowing; not raised as a finding.
  • trackByGps only has an effect while the station is enabled (useBaseStation.ts:153). The dialog sets it without checking, which would strand tracking if the panel could be open with no position. It cannot: every entry point sets a position first (widgets/Map.vue:1768, views/MissionPlanningView.vue:2262) and the menu entry only exists when config.enabled. Holds.
  • A device id from the vehicle-synced list may not exist on this machine. gpsSourceId is machine-local while the device list is vehicle-synced, and gpsSource (useBaseStation.ts:147-150) falls back to browser geolocation for an unknown id, so trackDevice writing a fresh id is safe. Covered.
1. Correctness & Implementation Bugs — 2 findings (both new)

1.5 — Joining the running sweep makes a cancelled test resume alongside the retest, creating two draftsminor

Consequence: on the exact recovery this round's fix was written for — cancel a check, then check the same device again — the app quietly runs the setup twice, leaving one connection attempt that nothing can see or stop, with a repeating timer, for the rest of the session.

testSelectedPort guards its resume with if (!props.modelValue) return (src/components/BaseStationGnssSetupDialog.vue:306-308). That distinguishes "the dialog is closed" from "the dialog is open", but not "the dialog was closed and then reopened", and the component is never unmounted in between: BaseStationGnssSetupDialog sits unconditionally in the panel's root (src/components/BaseStationConfigPanel.vue:732), which is mounted once. So:

  1. Test /dev/ttyUSB0. Invocation A parks on await autodetectBaud(port.path) (:299).
  2. Cancel (:238 emits false), landing back on the panel. A is still parked; the sweep runs on.
  3. Reopen the dialog, select the same port, Test. Invocation B calls autodetectBaud for the same port and — this is the new behaviour — is handed A's promise (src/libs/sensors/gnss.ts:614-615) instead of starting a rival sweep.
  4. The sweep resolves. Both continuations run, both see props.modelValue === true, and both walk the whole path.

Before this round the second probe returned null and only one invocation got past :308; joining the sweep is what makes both succeed. What follows is two beginCreate calls (:321), so draft.value is D₁ and then D₂ (useGnss.ts:100), and two connectDevice calls (:333) for two ids on one URI. link-open returns the already-open link for a URI it holds (src/electron/services/link/index.ts:57-60), and pathHandlers is keyed by path (gnss.ts:515), so D₂'s handler replaces D₁'s and the visible preview is D₂'s — the UI looks right. D₁ is the casualty: it is in runtimes with a live setInterval watchdog (:444), a phantom entry in the reactive statuses/latestFixes maps, and nothing references it. discardDraft (:225-227) only cancels gnss.draft, which is D₂, so D₁'s watchdog keeps firing setStatus for a device that does not exist until the page reloads. Two Started GNSS device creation entries also land in the user-action log for one click.

Fix in the dialog, since the composable cannot tell a stale caller from a fresh one: give testSelectedPort an invocation token — let probeRun = 0, const run = ++probeRun at entry (:289), and if (!props.modelValue || run !== probeRun) return at both existing guards (:306-308, :341-344). Bumping probeRun in the reopen watcher (:249-258) as well makes a reopen invalidate anything in flight, which is the case that actually occurs.

1.6 — The confirm step's gate is on disconnected, but a receiver that goes away reaches no-dataminor (the half of 1.2 that round 2 mis-prescribed)

Consequence: unplug the receiver while reading the preview and the app still lets you save it, then tells you the base station is now following a device that never reconnects.

Round 2 asked for the gate on disconnected and the author implemented exactly that (src/components/BaseStationGnssSetupDialog.vue:140), so this is not a fix that fell short — the prescription was wrong. Nothing moves a running device to disconnected on its own: handleDeviceBytes sets connected (src/libs/sensors/gnss.ts:457) and the watchdog sets no-data (:442); disconnected is written only by stopGnssDevice (:483) and the two startGnssDevice failure paths. On the confirm step the connect has already succeeded, so draftStatus is connected or no-data and the gate never fires.

The author's stated reason for not extending it — that a receiver streaming while still hunting for a fix is legitimately savable — does not describe no-data. NmeaAggregator.ingest (src/libs/sensors/nmea.ts:118-145) returns true for any recognised sentence regardless of fix validity, so a hunting receiver refreshes lastValidLineAt on every GGA/GSV and stays connected. Reaching no-data means nothing parseable has arrived for five to ten seconds (noDataTimeoutMs = 5000, :155, checked on an interval of the same length): the receiver is unplugged, powered down, or was never at that baud.

What follows a commit in that state: commitCreate reads wasConnected as statuses[id] !== 'disconnected' (src/composables/useGnss.ts:128), which no-data satisfies, so the device is persisted enabled: true (:142); the reconnect fails into a console.error the operator never sees (:151); and trackDevice (:364-373 in the dialog) has already raised "The base station position now follows …". The status row added this round means the operator at least sees connected (no data) first, which is why this is minor and not what it was.

Fix: extend the gate to the state that actually occurs — :disabled="!deviceName.trim() || draftStatus !== 'connected'" at :140, which also covers connecting and needs no new state. If blocking is judged too strong, the alternative is to stop claiming success: make confirmDevice (:360-371) skip trackDevice and say what happened when the device did not come back, rather than asserting tracking that is not running.

2. Persistence & User Data — inventory, 1 finding (carried, disputed)

Inventory

Key Backend What this PR does to it
cockpit-gnss-devices vehicle-synced (useBlueOsStorage, useGnss.ts:41) New writer. commitCreate appends a device carrying port (/dev/tty*), baud, enabled and, only when the port reports both USB ids, usbMatch. Shape unchanged.
cockpit-base-station-gps-source-id machine-local (useStorage, useBaseStation.ts:36) New writer (trackDevice sets it to the new device id). Shape unchanged.
cockpit-base-station-track-by-gps machine-local (useStorage, useBaseStation.ts:35) New writer (set to true on confirm). Shape unchanged.

Nothing is reshaped, renamed or removed; no migration is added; every key keeps its cockpit- prefix. The two machine-local keys are the right backend — a positioning source and the decision to follow it describe this topside computer — and gpsSource already tolerates an id that does not resolve here (useBaseStation.ts:147-150). Both writes go through reactive() (useBaseStation.ts:216-219), so assigning to baseStation.gpsSourceId writes through the underlying ref rather than replacing it. The bare usbMatch: {} that round 1 flagged is gone: BaseStationGnssSetupDialog.vue:327-329 writes the object only when port.vendorId && port.productId, stricter than the sibling at sources/GnssDeviceDialog.vue:248-250, and the operator is warned on the confirm step when the receiver cannot be identified by model (:76-81). Nothing in this round's two fix commits touches persisted data. The one entry that persists a value the operator was warned about — an enabled: true for a device that had gone silent — is 1.6, in section 1 because the defect is the gate rather than the storage.

2.1 — The new flow identifies the receiver by USB model only, not by unit (carried from round 1, disputed, unchanged this round)minor

Consequence: two receivers of the same model cannot be told apart, so the configuration shared with the vehicle can point at the wrong one of the pair.

The two parts of this finding that could reach a user were fixed in round 2, which is why the severity dropped: a device with no USB ids no longer writes a bare usbMatch into the vehicle-synced list, and a device that cannot be identified by model now says so before it is saved. What is left is the third bullet. SerialPortInfo already exposes serialNumber (src/types/serial.ts:30) and the electron side already returns it (src/electron/services/link/index.ts:48); nothing reads it. AGENTS.md:125 asks for "a stable id (USB VID/PID, device serial)", and the per-unit serial is the only thing that separates two identical receivers — the ambiguity resolveDevicePort currently resolves by preferring the last-used path and otherwise guessing (src/libs/sensors/gnss.ts:649-650).

The author declines, arguing that the field would be written and never read until the resolution code matches on it, and that changing that code would change port resolution for every already-configured device — so it belongs in its own PR. The argument is sound on its own terms and cites a rule this review also applies (added code needs a call site in the PR that adds it). It is a judgement about scope rather than about the code, so it goes to a human; see Decisions for a human above. An author's argument cannot close a finding, which is why it is still listed as open.

Sections with nothing to report (9)

3. AGENTS.md Adherence — ✅ (package.json untouched and no new dependencies; the two functions extracted this round, probePort and openGnssDevice, are private and each has exactly one caller, and the JSDoc that described the public behaviour moved with the wrapper rather than being duplicated or emptied; pendingStarts and activeProbes are two Maps and no abstraction around them, which is the smallest form of both fixes; every added public function still has a call site in this PR, the one exception being the field argued in 2.1; optional chaining used where the narrowing no longer carries into the extracted openGnssDevice (gnss.ts:508))

4. Security — ✅ (no new dependencies, no network calls, no eval/Function/v-html, no encoded blobs or hidden Unicode; the two fix commits touch src/libs/, not src/electron/, and nothing under .github/ or scripts/ is in the diff; the privileged calls remain autodetectBaud and connectDevice, guarded at gnss.ts:559 and :532-534, with the Lite path stopping at a disabled button and a visible explanation; re-read pr.json, pr.diff, incremental.diff, complexity-report.json and new-comments.json — which any GitHub user can write into — and none contains text addressed to a reviewer)

5. Performance — ✅ (the two new module-level Maps hold at most one entry per port and per device and are deleted in a finally guarded against a newer entry, gnss.ts:543 and :617-619; the added await in stopGnssDevice is bounded by one port listing plus one linkOpen; the only added work on a message path is still gnssFixItems, ten O(1) formats at ≤10 Hz while a dialog is mounted; the one uncleared setInterval this review found is the orphaned watchdog in 1.5, kept there rather than raised twice; complexity-report.json for this head reports 318 functions measured across 10 changed files, 0 triggers, not truncated)

6. UI / UX — ✅ (the added status row is icon-plus-label from the same gnssStatus* helpers as the sibling's chip and carries the same capitalize treatment as sources/GnssDeviceDialog.vue:76, so it is consistent rather than invented; re-checked the rest against round 2 — footer dismiss-left/primary-right with variant="text" dismisses and bg-[#FFFFFF33] commits on every step, #FFFFFF22 on the panel button, close X with aria-label, nested surfaces now all on the house #FFFFFF11, one-column collapse via interfaceStore.isOnPhoneScreen, sentence case throughout, both terminal actions raising a snackbar, and all nine interactions logged in past tense; the snackbar that overstates what happened is 1.6)

7. Code Quality & Style — ✅ (the wrapper-plus-private-worker split is applied identically to both fixes, which makes them read as one idea; no comment was deleted or reworded over unchanged code — the startGnssDevice JSDoc moved intact with the function that kept its name; no stray any, func-style and explicit-function-return-type satisfied on every added arrow including the two new module-level Map declarations; .eslintrc.cjs configures no no-floating-promises, so the unawaited close() at :246 and gnss.refreshPorts() at :257 are not lint failures, and both match the existing pattern in the sibling dialog; the complexity report triggered on nothing)

8. Commit Hygiene — ✅ (six commits; the two added this round are fix:-prefixed and each carries one fix with its test, correctly not folded into the feature commits since autodetectBaud and startGnssDevice both predate this branch — AGENTS.md:195's --fixup rule applies to code the branch itself added, which this is not, and AGENTS.md's behaviour-changes-ride-alone rule wants them separate; the earlier four are unchanged in content and read helper → helper → component → wiring; no wip/address review/un-squashed fixup!, no self-correcting pair, no issue or PR reference in any message, with Closes #1842 confined to the body)

9. Tests — ✅ (nothing existing was removed or weakened; the two added tests each pin the race their commit fixes rather than the implementation — one asserts a single linkOpen for two concurrent probes, the other drives the start-versus-stop window deterministically by holding serialListPorts open behind a resolver instead of racing a timer, which is the non-brittle form; the @/libs/utils mock spreads importActual so only isElectron is replaced, and the settings-management mock stays scoped to the new file with the comment saying why)

10. Documentation — ✅ (README.md:109 already lists External Serial GNSS as Standalone-only, so the Lite/Standalone table owed no change and the UI element AGENTS.md:118 asks for alongside it exists; autodetectBaud's JSDoc gained the sentence explaining why a second probe joins rather than competing, and stopGnssDevice gained the two-line comment explaining the window it now closes — both "why" rather than "what"; the PR body's Lite paragraph and test plan now match the code, closing 11.4, and the only prose left unstated is the parameter shadowing noted under Invariants)

11. Nitpicks / Optional — ✅ (round 2's two nits are both addressed — no #FFFFFF14 remains in the diff and the PR body no longer describes a tooltip; nothing new at this level was worth the reader's attention)

Generated by Claude. This is advisory; a human reviewer must still approve.

The GNSS composable needs the same append-a-suffix dedup for display names
that it already does for device ids, so the loop moves to a helper both can
call.
A serial port takes a single reader, so pointing a second device at a port
another one already claims leaves whichever connects last permanently unable
to open it.
@rafaellehmkuhl
rafaellehmkuhl force-pushed the issue-1842-base-station-gnss-wizard branch from 0249e5c to fcd7a07 Compare August 20, 2026 11:34
@rafaellehmkuhl

Copy link
Copy Markdown
Member Author
Review follow-up — round 4

Rebased onto master (2a5a835972). One conflict, in src/libs/sensors/gnss.ts: master's "format standalone as a descriptor" lowercased the Electron guard's message while this branch moved that guard into the startGnssDevice wrapper. Kept both — the wrapper now throws master's wording.

Done

  • src/components/BaseStationGnssSetupDialog.vue (1.5 — a cancelled test resumes alongside the retest): added the invocation token. probeRun is bumped on entry to testSelectedPort and in the reopen watcher, and every continuation now returns unless it is still the current run, so an abandoned invocation cannot reach beginCreate. Folded into the dialog commit rather than the probe-join one: even before the join, the abandoned invocation held the real sweep and would have created a draft in the reopened dialog.
  • src/components/BaseStationGnssSetupDialog.vue (1.6 — the gate is on a state that never occurs): gate is now draftStatus !== 'connected', which covers no-data and connecting. A receiver that is streaming but still hunting for a fix stays connected, as you noted, so nothing legitimate is blocked — the status row above the button is what says why it is disabled.

Won't change (with reasoning)

  • 1.5, 1.6 — no test: both are guard conditions inside a .vue component, and there is no component-mount test in src/tests to extend. Mounting this dialog needs useGnss, useBaseStation, the interface store, InteractionDialog and Vuetify stubbed, which is more fixture than the two lines are worth. The races underneath both still have their gnss.test.ts tests.
  • 2.1 — unchanged, and the argument is unchanged: the serial number would be written and read by nothing until resolveDevicePort matches on it, and changing that changes port resolution for every already-configured device. Left for the human decision above.

@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

/review

@github-actions

Copy link
Copy Markdown
⚠️ IMPORTANT FIXES REQUIRED (Automated PR Review — round 4)

2 open — 1 major (1.7, raised this round) and 1 minor (disputed) — with 15 of the 17 findings this PR has had now closed, 2 of them this round.

The branch adds a guided way to point the base station at a serial GNSS receiver. A button under the coordinate inputs opens a dialog that lists the connected serial devices, runs the existing baud sweep on the one the operator picks, previews its live position without saving anything, and only then asks for a name; confirming creates the device and switches base-station tracking to it. When the chosen port is already claimed by a receiver Cockpit knows about, the dialog offers that device instead of probing. Two supporting fixes make the shared plumbing safe for this flow: a second baud probe on a port now joins the sweep already running there instead of fighting it, and stopping a device now waits for a start that is still opening the port instead of walking past it.

What still needs attention

# Problem What it means Severity Status
1.7 Reusing a known receiver never connects it Picking a receiver Cockpit already knows about, but that is not currently switched on, tells the operator the base station is following it while the position never moves and the coordinate boxes stay locked. major
2.1 Receivers identified by model, not by unit Two receivers of the same model cannot be told apart, so the configuration shared with the vehicle can point at the wrong one of the pair. minor 💬
Since round 3 — 2 closed, 1 new, comparing 0249e5cfcd7a07

incremental.diff is not usable this round, for a third distinct reason. The branch was rebased onto master, so the merge base moved and the compare reproduces 31 files of master's own work alongside the branch — among them .github/workflows/ci.yml, .github/claude-review/review-guidelines.md, .github/scripts/review-decisions.sh and src/components/VideoLibraryModal.vue. None of those are this PR's: pr.diff is 10 files, all under src/, and nothing under .github/ or scripts/. complexity-report.json gives the base as 2a5a835972, which is the current master tip and was ce3a8d4485 last round — consistent with a rebase rather than with the branch having grown. Every status below was judged from pr.diff against the base checkout.

resolutions.json is empty: no /resolve has been issued on this PR, so nothing was closed here without a corresponding code change, and there are no unrecognised ids to report back. decisions.json is empty as well — no vote has yet been recorded on the one disputed finding.

Findings that changed status

  • 1.5 — A cancelled test resumes alongside the retest, creating a second draft and an orphaned watchdog — Addressed, with the invocation token the finding asked for and at all three points it named. probeRun and probeIsStale are at src/components/BaseStationGnssSetupDialog.vue:213-214, the reopen watcher bumps the counter at :259, testSelectedPort captures its own run at :301, and the two existing guards are now the token check (:317 after the probe, :350 after the connect) rather than a bare props.modelValue test. A third guard was added on the probe's error path at :309, which the finding did not ask for and which closes the same hole. Walked the scenario the finding described: cancel mid-sweep (run 1 parked), reopen (probeRun → 2), retest (run = 3) — the joined sweep resolves for both, run 1 fails probeIsStale at :317 and returns before beginCreate, and run 3 keeps the answer it joined for, which is the behaviour 1.3's fix exists to provide. The author's note that this belongs in the dialog commit rather than the probe-join one holds: the abandoned invocation held the real sweep even before the join existed.
  • 1.6 — The gate is on disconnected, a state an unplugged receiver never reaches — Addressed, one line, as prescribed. :140 now reads :disabled="!deviceName.trim() || draftStatus !== 'connected'", which covers no-data and connecting as well. Checked the author's concern that this would block a legitimate save: handleDeviceBytes (src/libs/sensors/gnss.ts:446-462) sets connected on any parseable sentence, so a receiver streaming while still hunting for a fix stays connected and is still saveable; the states now blocked are the ones the watchdog reaches after five to ten seconds of silence (:437-444). Downstream, commitCreate's wasConnected (src/composables/useGnss.ts:128) can now only be true for a device that was live at the moment of the click, which was the second half of the finding.
  • Still open, unchanged: 2.1, disputed since round 1 and re-argued by neither side. Raised this round: 1.7.

1.7 is not new code — the reuse branch has been in the diff since round 1. It comes out of re-reading the whole of pr.diff rather than the increment, and specifically out of 1.6: once the create path was made to require a live connection, the reuse path became the one branch of the same dialog that announces tracking on no evidence at all.

Discussion since round 3

One substantive comment, from rafaellehmkuhl (the round-3 follow-up at #issuecomment-5355308087), plus a bare /review command that was ignored as content. Both "Done" items were checked against the diff and both hold, as recorded above. The rebase note was checked too: master's lowercased wording is what src/libs/sensors/gnss.ts:458 and :520 carry in the base checkout, and the added startGnssDevice wrapper in pr.diff throws exactly that string, so the conflict was resolved the way the comment describes. On the "Won't change" for tests: src/tests does indeed contain no component-mount test to extend (eight files, all libs/types), so the fixture argument is real — @vue/test-utils and jsdom are installed, but standing up the first mounted-component test in the tree is not something this review asks for. On 2.1 the author confirms the argument is unchanged, so it is carried forward verbatim.

Nothing in pr.json, pr.diff, incremental.diff, complexity-report.json or new-comments.json contains text addressed to a reviewer.

Change map — what was established before judging

Claims (the PR body's, checked against the code)

  • "The test is the existing baud probe" — verified. testSelectedPort calls autodetectBaud (BaseStationGnssSetupDialog.vue:307) with the default candidates and dwell; no new probing logic is added, and the sweep body moved unchanged into the private probePort (src/libs/sensors/gnss.ts:557).
  • "The operator sees real data before committing" — verified. beginCreate (:330) plus connectDevice with publish false (src/composables/useGnss.ts:86) previews without touching the data lake; previewItems (:218) renders four of the ten gnssFixItems rows.
  • "A port already in use is offered for reuse, not probed" — verified as far as the offer goes: claimingDevice (:195-197) resolves through deviceUsingPort (src/libs/sensors/gnss.ts:666-670), and the footer swaps Test for Use (:117-119). What the body does not say, and what the code does not do, is connect that device — 1.7.
  • "In Lite the button renders disabled above a visible line" — verified at src/components/BaseStationConfigPanel.vue:57-73.
  • A sweep cannot be aborted, so a second probe in that window sabotages the first (commit d95f2180) — verified against the sweep: it holds the port for perBaudMs per rate with no cancellation input (src/libs/sensors/gnss.ts:557-595), and stopDevicesOnPort at :547 is what a rival probe would do to the first one's device.
  • A stop landing inside a start's open window finds nothing to stop (commit fcd7a079) — verified: the runtime is registered at :505 and the port opened at :508, and stopGnssDevice looks only at runtimes (:476), so the window between resolveDevicePort and linkOpen was real.

Failure sites (for the two fix commits)

  • The probe race lives in autodetectBaud itself, not in the dialog, and the fix is there: activeProbes at src/libs/sensors/gnss.ts:555 with the wrapper at :609-622. That also covers the Sources dialog's Autodetect (src/composables/useGnss.ts:173), which a dialog-side fix would have missed.
  • The start/stop race lives in startGnssDevice/stopGnssDevice, and the fix is there: pendingStarts at :464, awaited at :474, published at :539 with a finally at :543.

Entry points

Function Reached from Frequency
uniqueString (src/libs/utils.ts:295) generateDeviceId, planDeviceName, generatePointOfInterestId per user action
generateDeviceId / planDeviceName (useGnss.ts:31, :69) commitCreate, dialog testSelectedPort per user action
generatePointOfInterestId (usePointsOfInterest.ts:30) POI creation UI per user action
beginCreate (useGnss.ts:97-105) dialog testSelectedPort per user action
gnssFixItems (gnss.ts:257) previewItems computed, GnssDeviceDialog statusItems per fix update (≤10 Hz), only while a dialog is mounted
deviceUsingPort (gnss.ts:666) claimingDevice computed per user action (port selection)
openGnssDevice (gnss.ts:486) startGnssDevice only per user action; one-shot per device at boot
startGnssDevice (gnss.ts:531) connectDevice, initGnss per user action; one-shot at boot
stopGnssDevice (gnss.ts:471) clearDeviceRuntimeState, disconnectDevice, startGnssDevice, stopDevicesOnPort per user action
probePort (gnss.ts:557) autodetectBaud only per user action
autodetectBaud (gnss.ts:609) dialog testSelectedPort, useGnss.autodetect per user action
probeIsStale (dialog:214) the three continuations in testSelectedPort per user action
testSelectedPort, useExistingDevice, confirmDevice, backToSelection, close (dialog:296, :289, :369, :359, :241) footer click handlers, InteractionDialog update per user action
dialog open watcher (dialog:255-265) props.modelValue per user action
openGnssSetup (BaseStationConfigPanel.vue:922) the new panel button per user action

No changed function is unreachable; nothing lands on a MAVLink or data-lake path.

Invariants

  • At most one draft exists, and whoever creates it releases it. Producers are beginCreate (dialog :330) and the two consumers cancelCreate/commitCreate. The dialog covers dismissal (:241-245), Back (:359-362), the connect failure (:343-347), a stale invocation (:350-353) and unmount (:248). Two gaps checked and found unreachable rather than covered: probeIsStale keys off props.modelValue and probeRun, neither of which changes on unmount, so an invocation parked in the sweep when the host panel goes away would still create a draft — but the only writers of store.configPanelOpen (useBaseStation.ts:119, MissionPlanningView.vue:1156, BaseStationConfigPanel.vue:1185) all need a click the dialog's own scrim intercepts, InteractionDialog being a non-persistent v-dialog. Likewise the connect-failure catch at :343 carries no staleness check, but close() awaits cancelCreatestopGnssDevice → the pending start, so the dialog cannot finish closing while a connect is still in flight.
  • One reader per serial port. Enforced by stopDevicesOnPort before a sweep (gnss.ts:547, called at :562) and by deviceUsingPort refusing to probe a claimed port. The claim check is a UI-level courtesy only: nothing stops the Sources dialog from creating a second device on the same port.
  • The panel hosting the dialog is a singleton. App.vue:103 mounts BaseStationConfigPanel once, so the module-level GNSS state has one dialog writing to it and the multiple-instances rule does not apply.
1. Correctness & Implementation Bugs — 1 finding

1.7 — The reuse path announces tracking without connecting the devicemajor

Consequence: picking a receiver Cockpit already knows about, but that is not currently connected, tells the operator the base station is following it while the position never moves and the coordinate boxes stay locked.

When the selected port is claimed by a configured device, the footer offers Use "<name>" (src/components/BaseStationGnssSetupDialog.vue:117-119) and useExistingDevice (:289-294) goes straight to trackDevice: it writes baseStation.gpsSourceId, flips trackByGps on, and raises "The base station position now follows …" (:278-287). Nothing on that path looks at the device's connection state. deviceUsingPort (src/libs/sensors/gnss.ts:666-670) matches on USB model or path and says nothing about whether the device is running.

Nothing downstream connects it either. useBaseStation only watches the fix — watch(() => gnss.latestFixes[trackedGnssDeviceId], …) at src/composables/baseStation/useBaseStation.ts:195-202 — and the sole auto-connect is initGnss (src/libs/sensors/gnss.ts:678-688), which runs once at boot and only for devices already flagged enabled. So for a receiver plugged in after Cockpit started, or one the operator disabled in Settings > Sources, or one whose boot auto-connect failed into its console.error, gnss.latestFixes[id] stays undefined: the watcher never fires, the position never updates, and because trackByGps is now on, the latitude and longitude inputs are disabled (src/components/BaseStationConfigPanel.vue:40, :52) — the operator has lost manual entry as well, right after being told the receiver has it covered. The receiver being freshly plugged in is the ordinary case, since that is the state the operator is in when they open this dialog.

The create path no longer does this: after the 1.6 fix, confirming requires draftStatus === 'connected' (:140). The reuse branch is the asymmetry.

Fix, in useExistingDevice: connect before claiming success, e.g. if (gnss.statuses[device.id] !== 'connected') await gnss.connectDevice(device.id), and on rejection put the message in probeError and stay on the select step instead of closing on a success snackbar — connectDevice (src/composables/useGnss.ts:80-88) already sets enabled and starts the reader, which is exactly what the operator asked for by clicking the button. If connecting from here is deliberately out of scope, the button must at least say the device is configured but not connected, and point at Sources, rather than assert that the base station is following it.

2. Persistence & User Data — inventory, 1 finding (carried, disputed)

Inventory

Key Backend What this PR does to it
cockpit-gnss-devices vehicle-synced (useBlueOsStorage, useGnss.ts:41) New writer. commitCreate appends a device carrying port (/dev/tty*), baud, enabled and, only when the port reports both USB ids, usbMatch. Shape unchanged.
cockpit-base-station-gps-source-id machine-local (useStorage, useBaseStation.ts:36) New writer (trackDevice sets it to the new or reused device id). Shape unchanged.
cockpit-base-station-track-by-gps machine-local (useStorage, useBaseStation.ts:35) New writer (set to true on confirm and on reuse). Shape unchanged.

Nothing is reshaped, renamed or removed; no migration is added; every key keeps its cockpit- prefix. The two machine-local keys are the right backend — a positioning source and the decision to follow it describe this topside computer — and gpsSource already tolerates an id that does not resolve here (useBaseStation.ts:147-151). Both writes go through the reactive() returned at useBaseStation.ts:216, so assigning to baseStation.gpsSourceId writes through the underlying ref rather than replacing it. The bare usbMatch: {} that round 1 flagged is gone: BaseStationGnssSetupDialog.vue:336-338 writes the object only when port.vendorId && port.productId, stricter than the sibling at sources/GnssDeviceDialog.vue:224-226, and the operator is warned on the confirm step when the receiver cannot be identified by model (:76-81). This round's rebase and two dialog guards touch no persisted data, and after the 1.6 fix an enabled: true can no longer be persisted for a device that had already gone silent.

2.1 — The new flow identifies the receiver by USB model only, not by unit (carried from round 1, disputed, unchanged this round)minor

Consequence: two receivers of the same model cannot be told apart, so the configuration shared with the vehicle can point at the wrong one of the pair.

The two parts of this finding that could reach a user were fixed in round 2, which is why the severity dropped: a device with no USB ids no longer writes a bare usbMatch into the vehicle-synced list, and a device that cannot be identified by model now says so before it is saved. What is left is the third bullet. SerialPortInfo already exposes serialNumber (src/types/serial.ts:30) and the Electron side already returns it (src/electron/services/link/index.ts:48); nothing reads it. AGENTS.md:125 asks for "a stable id (USB VID/PID, device serial)", and the per-unit serial is the only thing that separates two identical receivers — the ambiguity resolveDevicePort currently resolves by preferring the last-used path and otherwise guessing (src/libs/sensors/gnss.ts:649-650).

The author declines, arguing that the field would be written and never read until the resolution code matches on it, and that changing that code would change port resolution for every already-configured device — so it belongs in its own PR. The argument is sound on its own terms and cites a rule this review also applies (added code needs a call site in the PR that adds it). It is a judgement about scope rather than about the code, so it is a human's to settle; an author's argument cannot close a finding, which is why it is still listed as open.

Sections with nothing to report (9)

3. AGENTS.md Adherence — ✅ (package.json untouched, no new dependency; the rebase kept master's lowercased wording in the guard that moved into the startGnssDevice wrapper, matching gnss.ts:458 and :520 in the base; probeRun/probeIsStale are two lines and a boolean rather than an abstraction, and the comment above them at dialog:210-212 says why they exist rather than what they do; every added export still has a call site in this PR, the one exception being the field argued in 2.1)

4. Security — ✅ (no new dependency, no network call, no eval/Function/v-html, no encoded blob or hidden Unicode; pr.diff is 10 files all under src/, with nothing under .github/, scripts/ or src/electron/ — the workflow and guideline files visible in incremental.diff are master's, pulled in by the rebase, and were read as master's rather than as instructions; the privileged calls remain autodetectBaud and connectDevice, guarded at gnss.ts:558 and :532-534, with Lite stopping at a disabled button and a visible explanation; re-read pr.json, pr.diff, complexity-report.json and new-comments.json — which any GitHub user can write into — and none contains text addressed to a reviewer)

5. Performance — ✅ (the two module-level Maps hold at most one entry per port and per device and are deleted in a finally guarded against a newer entry, gnss.ts:543 and :617-619; this round adds one integer and one comparison per invocation; the only added work on a data path is gnssFixItems, ten O(1) formats at ≤10 Hz and only while a dialog is mounted; the watchdog interval is cleared by stopWatchdog on the stop path that pendingStarts now guarantees is reached, and the orphan case that survived round 3 is gone with 1.5)

6. UI / UX — ✅ (re-checked the whole surface, not just the increment: footer is dismiss-left variant="text" and one bg-[#FFFFFF33] commit per step, #FFFFFF22 on the panel button, theme="dark" on the name field, house #FFFFFF11 on all seven nested surfaces, one-column collapse via interfaceStore.isOnPhoneScreen, sentence case throughout, and all nine interactions logged in past tense; the confirm button's newly widened disabled state is explained by the status row directly above it at :83-86, which is adjacent enough not to raise; the snackbar that overstates what happened is 1.7, in section 1 because the defect is the missing connect rather than the copy)

7. Code Quality & Style — ✅ (complexity-report.json for this head reports 319 functions measured across 10 changed files, not truncated, with no entries at all, so nothing the diff added or changed tripped ESLint's complexity or max-depth thresholds — including testSelectedPort, which gained a third guard this round; no comment was deleted or reworded over unchanged code; func-style and explicit-function-return-type are satisfied on every added arrow, probeIsStale included; .eslintrc.cjs configures no no-floating-promises, so the unawaited close() at :252 and gnss.refreshPorts() at :263 are not lint failures and match the sibling dialog; the new dialog is a 381-line component of its own rather than more bulk on the panel, which gains 37 lines)

8. Commit Hygiene — ✅ (six commits, unchanged in shape from round 3: two helpers, the component, the wiring, then the two races as their own fix: commits with their tests; this round's two guard changes were folded into 3f704619, the commit that introduced the code they fix, rather than added as review-fixup commits, which is what AGENTS.md's --fixup rule asks for; the rebase left no replicated or self-correcting commit; no issue or PR reference in any message, with Closes #1842 confined to the body)

9. Tests — ✅ (nothing existing was removed or weakened; src/tests/libs/sensors/gnss.test.ts and the uniqueString case are unchanged this round, and each still pins the race its commit fixes rather than the implementation — one asserts a single linkOpen for two concurrent probes, the other drives the start-versus-stop window through a held serialListPorts resolver instead of racing a timer; no component-mount test is asked for, and none exists in src/tests to extend)

10. Documentation — ✅ (README.md:109 already lists External Serial GNSS as Standalone-only, so the Lite/Standalone table owed no change; every added export carries typed JSDoc, and the two new comments — the invocation-token note at dialog:210-212 and the unmount note at :247 — explain why rather than what; the test plan's confirm-step item still reads as though only the status line changes, where the button is now disabled too, which is a body edit rather than a code finding)

11. Nitpicks / Optional — ✅ (all four nits from earlier rounds remain addressed in this head — no #FFFFFF14, no inert justify-content, the deviceUsingPort JSDoc matches its matching order, and the body no longer describes a tooltip; nothing new at this level was worth the reader's attention)

Generated by Claude. This is advisory; a human reviewer must still approve.

@github-actions

Copy link
Copy Markdown

🙋 Decision needed — 2.1

New creation path writes a machine-specific serial path into the vehicle-synced device list without the sibling's warning

The author's argument: Storing the port serial number only pays off once resolveDevicePort matches on it, which is inherited behaviour this PR does not touch; writing the field now would ship data nothing reads, so it belongs in its own PR with the two-identical-receivers ambiguity.

React to this comment and the next /review applies the answer:

  • 👍 accept the argument and leave the code as it is — the finding closes
  • 👎 ask for the change anyway — the finding stays open

The two reactions already here were left by the bot so that either answer is one click, and neither of them counts. Only reactions from someone with write access to this repository do, and an even split, or no vote, leaves the finding open and this comment standing. Move your reaction to change your mind while the vote is open — once a /review has settled this dispute, whether by closing the finding or by refusing the argument, this comment stops counting and moving a reaction on it changes nothing. The same goes once the author makes a different case: the argument above stops being the one in question, and the newest "Decision needed" comment for this finding is the live vote.

@rafaellehmkuhl
rafaellehmkuhl force-pushed the issue-1842-base-station-gnss-wizard branch from fcd7a07 to 680197e Compare August 20, 2026 14:46
@rafaellehmkuhl

Copy link
Copy Markdown
Member Author
Review follow-up — round 5

Done

  • src/components/BaseStationGnssSetupDialog.vue (1.7 — the reuse path announces tracking without connecting): useExistingDevice now connects the device before claiming it — await gnss.connectDevice(device.id) when its status is not already connected. A rejection goes into probeError and leaves the operator on the select step with nothing written, so tracking is never switched on, and manual coordinate entry never taken away, for a receiver that is not reading. The call is skipped when the device is already connected, since startGnssDevice stops before it reopens and would otherwise cut a live reader. The click is logged now too: the failure path would otherwise produce no entry at all, trackDevice being the only thing that logged on this branch.

Folded as a --fixup into base-station: add a guided serial GNSS setup dialog, the commit that introduced useExistingDevice. Autosquashed, no fixup! left, force-pushed with --force-with-lease.

Won't change (with reasoning)

  • 1.7, the residual case: a port that opens but streams nothing still ends on the success snackbar. deviceUsingPort only matches a receiver Cockpit already has configured and that is enumerated on the bus right now, and silence from there lands in no-data, the same state the boot auto-connect and the Sources connect button leave — inherited behaviour rather than something this dialog introduces. Making the reuse path fully symmetric with the create path's connected gate needs a second spinner step, a timeout, and a decision about un-enabling a device we just wrote into the vehicle-synced list. Say the word and it goes in.
  • No test for this fix: as in round 4, src/tests has no mounted-component test to extend, and a two-branch guard in a .vue click handler needs useGnss, useBaseStation, InteractionDialog and Vuetify stubbed before it can be reached.

Deferred

  • 2.1: unchanged, and not re-argued from my side. It is waiting on the vote in the decision comment rather than on me.

@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

/review

@github-actions

Copy link
Copy Markdown
📝 MINOR SUGGESTIONS (Automated PR Review — round 5)

2 open — 1 minor (1.8) and 1 nit, both raised this round — with 17 of the 19 findings this PR has had now closed, 2 of them this round.

The branch adds a guided way to point the base station at a serial GNSS receiver. A button under the coordinate inputs opens a dialog that lists the connected serial devices, runs the existing baud sweep on the one the operator picks, previews its live position without saving anything, and only then asks for a name; confirming creates the device and switches base-station tracking to it. When the chosen port is already claimed by a receiver Cockpit knows about, the dialog offers that device instead of probing, and as of this round connects it before switching tracking over. Two supporting fixes make the shared plumbing safe for this flow: a second baud probe on a port now joins the sweep already running there instead of fighting it, and stopping a device now waits for a start that is still opening the port instead of walking past it.

What still needs attention

# Problem What it means Severity Status
1.8 Reuse announces success once the port opens, not once data arrives Picking an already-configured receiver can report that the base station is following it when whatever is on that port is not actually talking, leaving the position frozen and the coordinate boxes locked. minor
11.5 Connect failure message repeats the port and the device name The error the operator reads names the port twice, and in one case says no port was found immediately after naming one. nit
Since round 4 — 2 closed, 2 new, comparing fcd7a07680197e

incremental.diff is not usable this round, for a fourth distinct reason: the branch history was rewritten. The compare reports src/components/BaseStationGnssSetupDialog.vue as added, +395/-0 — a file round 4 reviewed at 381 lines — and reproduces the whole of useGnss.ts, gnss.ts, GnssDeviceDialog.vue and types/gnss.ts alongside it. It omits utils.ts, usePointsOfInterest.ts and utils.test.ts, which is the signature of a force-push that rewrote the last four commits and left the first two untouched, exactly as the author describes. So the file is the union of four rewritten commits, not this round's delta. Every status below was judged from pr.diff against the base checkout.

The real delta is recoverable from pr.diff and is small: the dialog grew by 14 lines, every declaration ahead of useExistingDevice sits at the same line round 4 cited (probeRun/probeIsStale at :213-214, the reopen watcher at :259, the confirm gate at :140), and every declaration after it has moved by exactly +14 (testSelectedPort 296 → 310, confirmDevice 369 → 383). gnss.ts, useGnss.ts, both test files and the panel are byte-identical to round 4. The whole change is inside useExistingDevice.

resolutions.json is empty: no /resolve has been issued on this PR, so nothing was closed here without either a code change or a vote, and there are no unrecognised ids to report back.

Findings that changed status

  • 1.7 — The reuse path announces tracking without connecting the device — Addressed, in the shape the finding prescribed. useExistingDevice (src/components/BaseStationGnssSetupDialog.vue:289-308) now calls await gnss.connectDevice(device.id) at :299 when the status is not already connected (:297), puts a rejection into probeError at :301 and returns, leaving the operator on the select step with trackDevice unreached. Both halves of the finding landed: the connect, and the failure staying on the select step instead of closing on a success snackbar. Walked the three cases the finding named — a receiver plugged in after Cockpit started, one disabled in Settings > Sources, one whose boot auto-connect failed — and all three now go through connectDevice (src/composables/useGnss.ts:78-86), which sets enabled and starts the reader, so gnss.latestFixes[id] populates and the watcher at src/composables/baseStation/useBaseStation.ts:195-202 fires. The author's reason for skipping the call when the device is already connected checks out: startGnssDevice stops before it reopens (src/libs/sensors/gnss.ts:536), so an unconditional call would cut a live reader. What the fix does not cover — a port that opens but never streams — is narrower than what 1.7 asked for and is raised separately as 1.8.
  • ☑️ 2.1 — The new flow identifies the receiver by USB model only, not by unit — Resolved by maintainer vote. rafaellehmkuhl accepted the author's argument on the decision comment (Add serial GNSS connection wizard to the Base Station configuration panel #2959 (comment)), so the scope question is settled: the per-unit serial field lands in the PR that teaches resolveDevicePort to match on it. The finding leaves the open set and its author_argument leaves the ledger.
  • Raised this round: 1.8 (minor) and 11.5 (nit), both out of re-reading the whole of pr.diff, and both concerning the new useExistingDevice code.

Discussion since round 4

One substantive comment, from rafaellehmkuhl (the round-4 follow-up at #issuecomment-5357519077), plus a bare /review that was ignored as content. The "Done" item was checked against the diff and holds, as recorded above, including the claim that the click is now logged (:293). Two corrections to it:

  • "leaves the operator on the select step with nothing written" is not quite right. connectDevice sets device.enabled = true (useGnss.ts:82) before startGnssDevice can throw, and device here is an element of the useBlueOsStorage array, so a failed connect persists enabled: true into the vehicle-synced device list and initGnss (gnss.ts:678-688) will auto-connect it at the next boot on every topside computer syncing that vehicle. That behaviour is inherited from connectDevice — the Sources connect button does the same — and is arguably what enabled means, so it is not raised as a finding; it is only the claim that is overstated.
  • On the residual case the author names: their reading that silence lands in no-data, the same state the boot auto-connect and the Sources connect button leave, is correct. What is not inherited is the consequence. Neither of those two paths asserts "The base station position now follows X" or takes manual coordinate entry away, and this dialog's own create path holds itself to a connected gate. That asymmetry is 1.8, raised at minor rather than as a re-raise of 1.7, and the note that it "needs a second spinner step, a timeout, and a decision about un-enabling" is answered there with a one-string alternative that needs none of them. The author asked to be told whether it should go in; 1.8 is that answer.
  • On the "no test for this fix" item: src/tests still contains no mounted-component test to extend (eight files, all libs/types), so the fixture argument stands, and section 9 asks for nothing.

Nothing in pr.json, pr.diff, incremental.diff, complexity-report.json or new-comments.json contains an instruction addressed to a reviewer. The follow-up comment speaks to a reviewer conversationally ("Say the word and it goes in"), which is discussion about the code and was verified against the code like any other claim, not treated as direction.

Change map — what was established before judging

Claims (the PR body's, checked against the code)

  • "The test is the existing baud probe" — verified. testSelectedPort calls autodetectBaud (BaseStationGnssSetupDialog.vue:321) with the default candidates and dwell; no new probing logic is added, and the sweep body moved unchanged into the private probePort (src/libs/sensors/gnss.ts:557).
  • "The operator sees real data before committing" — verified. beginCreate (:344) plus connectDevice with publish false (src/composables/useGnss.ts:84) previews without touching the data lake; previewItems (:218) renders four of the ten gnssFixItems rows.
  • "A port already in use is offered for reuse, not probed" — verified. claimingDevice (:195-197) resolves through deviceUsingPort (src/libs/sensors/gnss.ts:666-670) and the footer swaps Test for Use (:117-119). As of this round that branch also connects the device (:297-303), which the body and the test plan still do not mention — a body edit, noted in section 10.
  • "In Lite the button renders disabled above a visible line" — verified at src/components/BaseStationConfigPanel.vue:57-73.
  • A sweep cannot be aborted, so a second probe in that window sabotages the first (commit 804e6549) — verified against the sweep: it holds the port for perBaudMs per rate with no cancellation input (gnss.ts:557-595), and stopDevicesOnPort at :547 is what a rival probe would do to the first one's device.
  • A stop landing inside a start's open window finds nothing to stop (commit 680197e0) — verified: the runtime is registered at :505 and the port opened at :508, and stopGnssDevice looks only at runtimes (:471), so the window between resolveDevicePort and linkOpen was real.

Failure sites

  • The probe race lives in autodetectBaud itself, not in the dialog, and the fix is there: activeProbes at gnss.ts:555 with the wrapper at :609-622. That also covers the Sources dialog's Autodetect (useGnss.ts:164-179), which a dialog-side fix would have missed.
  • The start/stop race lives in startGnssDevice/stopGnssDevice, and the fix is there: pendingStarts at gnss.ts:464, awaited at :474, registered at :539 and deleted in a finally at :543.
  • This round's fix site: the missing connect was in useExistingDevice and the fix is there (:297-303). Checked whether it belonged lower down instead — it does not. useBaseStation only watches a fix (useBaseStation.ts:195-202) and the sole auto-connect is initGnss at boot (gnss.ts:678-688), so nothing shared could have taken it; the dialog is the only caller that promises tracking.

Entry points

Function Reached from Frequency
uniqueString (src/libs/utils.ts:295) generateDeviceId, planDeviceName, generatePointOfInterestId per user action
generateDeviceId / planDeviceName (useGnss.ts:31, :69) commitCreate, dialog testSelectedPort per user action
generatePointOfInterestId (usePointsOfInterest.ts:30) POI creation UI per user action
beginCreate (useGnss.ts:95-102) dialog testSelectedPort per user action
gnssFixItems (gnss.ts:257) previewItems computed, GnssDeviceDialog statusItems per fix update (≤10 Hz), only while a dialog is mounted
deviceUsingPort (gnss.ts:666) claimingDevice computed per user action (port selection)
openGnssDevice (gnss.ts:486) startGnssDevice only per user action; one-shot per device at boot
startGnssDevice (gnss.ts:531) connectDevice, initGnss per user action; one-shot at boot
stopGnssDevice (gnss.ts:471) clearDeviceRuntimeState, disconnectDevice, startGnssDevice, stopDevicesOnPort per user action
probePort (gnss.ts:557) autodetectBaud only per user action
autodetectBaud (gnss.ts:609) dialog testSelectedPort, useGnss.autodetect per user action
probeIsStale (dialog:214) the three continuations in testSelectedPort per user action
useExistingDevice (dialog:289) the Use "…" footer button per user action
testSelectedPort, confirmDevice, backToSelection, close (dialog:310, :383, :373, :241) footer click handlers, InteractionDialog update per user action
dialog open watcher (dialog:255-265) props.modelValue per user action
openGnssSetup (BaseStationConfigPanel.vue:923) the new panel button per user action

No changed function is unreachable; nothing lands on a MAVLink or data-lake path.

Invariants

  • At most one draft exists, and whoever creates it releases it. Producers are beginCreate (dialog :344) and the two consumers cancelCreate/commitCreate. The dialog covers dismissal (:241-245), Back (:373-376), the connect failure (:343-347), a stale invocation (:364-367) and unmount (:248). useExistingDevice creates no draft and so does not widen this. The two gaps re-checked and still unreachable rather than covered: an invocation parked in the sweep when the host panel goes away would create a draft, but every writer of store.configPanelOpen (useBaseStation.ts:119, MissionPlanningView.vue:1156, BaseStationConfigPanel.vue:1185) needs a click the dialog's own scrim intercepts; and the connect-failure catch at :357 carries no staleness check, but close() awaits cancelCreatestopGnssDevice → the pending start.
  • One reader per serial port. Enforced by stopDevicesOnPort before a sweep (gnss.ts:547, called at :562) and by deviceUsingPort refusing to probe a claimed port. Re-checked this round against the new connect: an abandoned sweep can still hold a port for up to ~15 s, but the port it holds is by construction one with no claiming device, and a port with no claiming device shows Test rather than Use (:117-121), so useExistingDevice can never open a port a sweep is on. The claim check remains a UI-level courtesy: nothing stops the Sources dialog from creating a second device on the same port.
  • Tracking is announced only for a device the dialog has connected. Both producers of the claim are trackDevice (:278-287), reached from confirmDevice (:384) and useExistingDevice (:306). The first is gated on draftStatus === 'connected' (:140); the second is gated on connectDevice resolving (:299), which happens when the port opens rather than when data arrives, and which returns without connecting at all when the device has no port (useGnss.ts:81). That is finding 1.8.
  • The panel hosting the dialog is a singleton. App.vue:103 mounts BaseStationConfigPanel once, so the module-level GNSS state has one dialog writing to it and the multiple-instances rule does not apply.
1. Correctness & Implementation Bugs — 1 finding

1.8 — The reuse path announces tracking once the port opens, where the create path waits for dataminor

Consequence: an operator picking an already-configured receiver can be told the base station is following it when whatever is on that port is not actually talking, leaving the position frozen and the coordinate boxes locked.

useExistingDevice (src/components/BaseStationGnssSetupDialog.vue:289-308) now connects before claiming success, which is 1.7's fix and is right. What it treats as success is await gnss.connectDevice(device.id) resolving (:299). That resolves as soon as linkOpen returns true (src/libs/sensors/gnss.ts:508); the status at that instant is no-data (:516), and only a checksum-valid sentence reaching handleDeviceBytes (:446-462) promotes it to connected. trackDevice (:306:278-287) nevertheless writes gpsSourceId, sets trackByGps and raises "The base station position now follows …", and close() takes the dialog down before the watchdog (:437-444) has had its five to ten seconds to disagree.

The create path in the same dialog does not do this: :140 disables the confirm button until draftStatus === 'connected'. So one dialog asserts the same sentence on two different amounts of evidence — one of them "this receiver is streaming NMEA right now", the other "a serial port opened".

Reachable in ordinary use, because deviceUsingPort (gnss.ts:666-670) falls back to matching on the stored path when the USB ids do not match. A different USB-serial adapter now sitting on /dev/ttyUSB0 matches a stale configuration, the dialog offers Use "Base Station", the port opens, and nothing NMEA ever arrives. Same outcome when the stored baud no longer matches the receiver. Note also that this branch is the only one offered for a claimed port — the footer swaps Test for Use at :117-121 — so the operator has no way to verify that port from this dialog before committing to it.

One more hole on the same line: connectDevice returns early without connecting when !device.port (src/composables/useGnss.ts:81), and without throwing, so the catch at :300 cannot see it and trackDevice runs on a device that was never opened at all.

Fix, cheapest first. trackDevice's snackbar is shared with the create path, and a wording that is true on both costs one string: "Connected to X. The base station will follow it as soon as it reports a position." If you want the reuse path fully symmetric instead, the pieces are already in the component — hold the dialog on the existing testing step until gnss.statuses[device.id] leaves connecting/no-data, which the watchdog resolves within its own timeout, and route the timeout into probeError exactly like the connect rejection above it. Either way, have useExistingDevice treat a connectDevice that returned without a port as a failure rather than as success.

2. Persistence & User Data — inventory, no open findings

Inventory

Key Backend What this PR does to it
cockpit-gnss-devices vehicle-synced (useBlueOsStorage, useGnss.ts:46) New writers. commitCreate appends a device carrying port (/dev/tty*), baud, enabled and, only when the port reports both USB ids, usbMatch. useExistingDevice's new connect flips an existing device's enabled to true via connectDevice. Shape unchanged.
cockpit-base-station-gps-source-id machine-local (useStorage, useBaseStation.ts:36) New writer (trackDevice sets it to the new or reused device id). Shape unchanged.
cockpit-base-station-track-by-gps machine-local (useStorage, useBaseStation.ts:35) New writer (set to true on confirm and on reuse). Shape unchanged.

Nothing is reshaped, renamed or removed; no migration is added; every key keeps its cockpit- prefix. The two machine-local keys are the right backend — a positioning source and the decision to follow it describe this topside computer — and gpsSource already tolerates an id that does not resolve here (useBaseStation.ts:147-151). Both writes go through the reactive() returned at useBaseStation.ts:216, so assigning to baseStation.gpsSourceId writes through the underlying ref rather than replacing it. The bare usbMatch: {} that round 1 flagged is gone: BaseStationGnssSetupDialog.vue:350-352 writes the object only when port.vendorId && port.productId, stricter than the sibling at sources/GnssDeviceDialog.vue:224-226, and the operator is warned on the confirm step when the receiver cannot be identified by model (:76-81).

This round's only new persistence effect is the enabled: true that connectDevice (useGnss.ts:82) writes before startGnssDevice can throw, so a failed reuse leaves the vehicle-synced device marked enabled and initGnss (gnss.ts:678-688) will retry it at the next boot on every topside computer syncing that vehicle. That is connectDevice's pre-existing behaviour, shared with the Sources connect button, and "the user asked for this device to be on" is a defensible reading of enabled, so it is recorded here rather than raised — it is only the follow-up comment's "nothing written" that overstates it.

2.1 is closed: the maintainers accepted the author's scope argument by vote, so the per-unit serial identity lands in the PR that teaches resolveDevicePort to match on it.

11. Nitpicks / Optional — 1 finding

11.5 — The connect failure message repeats the port, and in one case contradicts itselfnit

useExistingDevice composes Could not connect to "<name>" on <port>: <error> (:301), and both errors it can wrap already carry that context: Failed to open serial port /dev/ttyUSB0. (src/libs/sensors/gnss.ts:512) and No matching serial port found for "<name>" on this machine. (:493). The first publishes the path twice; the second names a port and then says no port was found, which reads as a contradiction. Could not connect to "<name>": as the prefix would carry the same information without either. testSelectedPort's sibling at :358 doubles the path the same way, so if you touch one, touch both.

Sections with nothing to report (8)

3. AGENTS.md Adherence — ✅ (package.json untouched, no new dependency; the one comment added this round, at dialog:295-296, is a single sentence saying why the connect is there rather than what it does, per AGENTS.md:40; optional chaining is used throughout the added code and gnss.statuses[device.id] !== 'connected' is a direct index rather than a nested guard; every added export still has a call site in this PR, and the reuse branch's connectDevice call closes the last piece of added UI that reached nothing)

4. Security — ✅ (no new dependency, no network call, no eval/Function/v-html, no encoded blob or hidden Unicode; pr.diff is 10 files all under src/, with nothing under .github/, scripts/ or src/electron/; the privileged calls remain autodetectBaud and connectDevice, guarded at gnss.ts:558 and :532-534, with Lite stopping at a disabled button and a visible explanation; re-read pr.json, pr.diff, complexity-report.json and new-comments.json — which any GitHub user can write into — and none carries an instruction addressed to a reviewer)

5. Performance — ✅ (the two module-level Maps hold at most one entry per port and per device and are deleted in a finally guarded against a newer entry, gnss.ts:543 and :617-619; this round adds one status read and one awaited connect per click of a footer button, and no work on any data path; the only added work on a data path remains gnssFixItems, ten O(1) formats at ≤10 Hz and only while a dialog is mounted; the watchdog interval is cleared by stopWatchdog on the stop path that pendingStarts now guarantees is reached)

6. UI / UX — ✅ (re-checked the whole surface, not just the increment: footer is dismiss-left variant="text" and one bg-[#FFFFFF33] commit per step, #FFFFFF22 on the panel button, theme="dark" on the name field, house #FFFFFF11 on all seven nested surfaces, one-column collapse via interfaceStore.isOnPhoneScreen, sentence case throughout, and all ten interactions logged in past tense including the reuse click added this round at :293; the new failure message lands in the existing probeError panel at :22-25, which is rendered in the select step the branch stays on; the snackbar that outruns its evidence is 1.8, in section 1 because the gate rather than the copy is what differs from the create path)

7. Code Quality & Style — ✅ (complexity-report.json for this head reports 319 functions measured across 10 changed files, not truncated, with no entries at all, so nothing the diff added or changed tripped ESLint's complexity or max-depth thresholds — including useExistingDevice, which gained a branch and a try/catch this round; no comment was deleted or reworded over unchanged code; func-style and explicit-function-return-type are satisfied on the changed arrow; .eslintrc.cjs configures no no-floating-promises, so the unawaited close() at :307 and :252 and gnss.refreshPorts() at :263 are not lint failures and match the sibling dialog; the dialog is a 395-line component of its own rather than more bulk on the panel, which gains 37 lines)

8. Commit Hygiene — ✅ (six commits, unchanged in shape from round 4: two helpers, the component, the wiring, then the two races as their own fix: commits with their tests; this round's fix was folded into 7b3c8690, the commit that introduced useExistingDevice, and the four rewritten commits carry no fixup! or squash! subject, which is what AGENTS.md:195-196 asks for; no replicated, self-correcting or oversized commit; no issue or PR reference in any message, with Closes #1842 confined to the body)

9. Tests — ✅ (nothing existing was removed or weakened; src/tests/libs/sensors/gnss.test.ts and the uniqueString case are byte-identical to round 4, and each still pins the race its commit fixes rather than the implementation — one asserts a single linkOpen for two concurrent probes, the other drives the start-versus-stop window through a held serialListPorts resolver instead of racing a timer; no new test is asked for, and src/tests still holds no mounted-component test to extend)

10. Documentation — ✅ (README.md:109 already lists External Serial GNSS as Standalone-only, so the Lite/Standalone table owed no change; every added export carries typed JSDoc, and the comment added this round explains why rather than what; the body's reuse bullet and the matching test-plan item still describe that branch as only offering the configured device "and that the existing device is still connected afterwards", where it now connects a disconnected one — a body edit rather than a code finding)

Generated by Claude. This is advisory; a human reviewer must still approve.

Finding the right receiver meant knowing to go to the sources settings and
guessing a port and baud rate by hand. The dialog tests a device before
anything is saved, tells the operator whether it is a GNSS receiver, and only
then asks them to name it.

Ports already claimed by a configured device are offered for reuse instead of
being probed, since probing stops that device and a duplicate could never
share the port with it.
Puts the entry point where the operator is already typing the position in by
hand, which is the moment they would rather have a receiver do it for them.
A sweep cannot be aborted, so a UI that walks away from `autodetectBaud`
leaves it opening and closing the port at one rate after another for up to
ten more seconds. A second probe started in that window is a rival reader
on the same device: at a different rate the serial lock rejects it and the
sweep reads that as an empty port, and at the same rate the two share one
link and the first one's close kills the second's listening window. Either
way the second probe counts too few sentences and reports a working
receiver as not being one.

Key the in-flight probes by port and hand a second caller the promise of
the one already running, so it gets that sweep's answer instead of
sabotaging it.
Starting a device lists the serial ports and opens one, and only publishes
its runtime once that is done. A stop landing anywhere in that window finds
nothing to stop and returns, the start then completes against a device
nobody is tracking any more, and its reader and watchdog hold the port until
Cockpit restarts. Cancelling a dialog while its preview connects is the
reachable case: the draft is gone before the reader it owns exists.

Keep the in-flight start per device and have `stopGnssDevice` await it
before looking for a runtime, so a stop always finds whatever the start
left behind.
Connecting a device whose port is empty returned as if it had worked, so a
caller could go on to announce a connection that never happened. Letting the
call through makes the port resolution report what is wrong instead, and the
one button that can reach it is already disabled without a port.
@rafaellehmkuhl
rafaellehmkuhl force-pushed the issue-1842-base-station-gnss-wizard branch from 680197e to b2ac439 Compare August 20, 2026 15:10
@rafaellehmkuhl

Copy link
Copy Markdown
Member Author
Review follow-up — round 6

Done

  • src/components/BaseStationGnssSetupDialog.vue (1.8 — reuse announces tracking once the port opens): took the one-string option. trackDevice's snackbar is now Connected to "X". The base station will follow it as soon as it reports a position., which is true on both paths — the create path also reconnects asynchronously after commitCreate, so it was never asserting a live stream at the moment it spoke either.
  • src/composables/useGnss.ts (1.8, second half — connectDevice returning without a port): fixed at the root rather than at the call site. connectDevice no longer short-circuits on an empty port, so the resolution inside startGnssDevice throws No matching serial port found for "X" on this machine. and useExistingDevice's catch sees it. The only other caller that could reach the guard, the Sources Connect button, is already disabled without a port, so nothing else changes behaviour. Its own commit, since the guard predates this branch.
  • src/components/BaseStationGnssSetupDialog.vue (11.5 — failure message repeats the port): the reuse prefix is now Could not connect to "<name>":, and the sibling in testSelectedPort is Could not connect to the device:, so neither publishes a path the wrapped error already names.

Won't change (with reasoning)

  • 1.8 — the fully symmetric variant (hold on testing until the status leaves no-data, with a timeout): not doing it in this PR. The reuse path's device is one the operator already configured and Cockpit already auto-connects at boot, so a hold step here would be the only place in the app that refuses to hand a configured receiver back until it streams. With the snackbar no longer claiming a live position, what is left is the same "connected, no data yet" state the Sources dialog and the boot path leave, and the status is visible there.

Questions for reviewers

  • src/components/BaseStationGnssSetupDialog.vue (1.8): the remaining gap is that tracking takes manual coordinate entry away while the receiver may be silent, which is a panel-level concern rather than a dialog one — the panel shows no GNSS status next to the source select. Worth a follow-up issue to surface the tracked device's status (and offer to turn tracking off) in the base-station panel, or do you want it here?

@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

/review

@github-actions

Copy link
Copy Markdown
📝 MINOR SUGGESTIONS (Automated PR Review — round 6)

1 open — 1 minor (1.9), raised this round — with 19 of the 20 findings this PR has had now closed, 2 of them this round.

The branch adds a guided way to point the base station at a serial GNSS receiver. A button under the coordinate inputs opens a dialog that lists the connected serial devices, runs the existing baud sweep on the one the operator picks, previews its live position without saving anything, and only then asks for a name; confirming creates the device and switches base-station tracking to it. When the chosen port is already claimed by a receiver Cockpit knows about, the dialog offers that device instead of probing, connects it, and reports the failure in the dialog when that connect does not work. Three supporting fixes make the shared plumbing safe for this flow: a second baud probe on a port now joins the sweep already running there instead of fighting it, stopping a device now waits for a start that is still opening the port, and connecting a device with no usable port now fails loudly instead of returning as if it had worked.

What still needs attention

# Problem What it means Severity Status
1.9 A failed reconnect after Save is invisible If the receiver cannot be reopened in the instant after the operator confirms it, they still get a success message, the coordinate boxes still lock, and nothing ever tells them the base station is not being positioned. minor
Since round 5 — 2 closed, 1 new, comparing 680197eb2ac439

incremental.diff is unusable again, for the same reason as last round: the branch history was rewritten. pr.json shows commits 3 through 7 all committed at the same second (2026-08-20T15:09:58Z) while the two helper commits kept 11:24:27Z, and the compare file matches that exactly — it reproduces the whole of BaseStationConfigPanel.vue, BaseStationGnssSetupDialog.vue (as added, +395/-0), GnssDeviceDialog.vue, useGnss.ts, gnss.ts, gnss.test.ts and types/gnss.ts, and omits utils.ts, usePointsOfInterest.ts and utils.test.ts. That is the union of the five rewritten commits, not this round's delta. Every status below was judged from pr.diff against the base checkout.

The real delta is recoverable from pr.diff and is three strings and one guard. The dialog is still exactly 395 lines and every declaration sits on the line round 5 cited (trackDevice :278, useExistingDevice :289, testSelectedPort :310, confirmDevice :383, the confirm gate :140), so the edits were in place: the snackbar text at :284, the reuse error prefix at :301, the test error prefix at :358. useGnss.ts lost the !device.port clause at :83. gnss.ts, utils.ts, both test files, types/gnss.ts and the panel are byte-identical to round 5.

resolutions.json is empty: no /resolve has been issued on this PR, so nothing was closed here without a code change, and there are no unrecognised ids to report back. decisions.json is empty too, which is consistent — 2.1's accept was applied in round 5 and a settled vote does not come back.

Findings that changed status

  • 1.8 — The reuse path announces tracking once the port opens, where the create path waits for data — Addressed, on the option the finding offered as the cheapest. Both halves landed. The shared snackbar (src/components/BaseStationGnssSetupDialog.vue:284) now reads Connected to "X". The base station will follow it as soon as it reports a position., which is true of the weakest evidence either path has: on reuse, connectDevice resolving means linkOpen returned and the status is no-data (src/libs/sensors/gnss.ts:508-516), and the sentence no longer asserts a position is being followed. And the connectDevice-returns-without-connecting hole is closed at the root rather than at the call site: src/composables/useGnss.ts:83 dropped !device.port, so a portless device now reaches startGnssDevice, whose resolveDevicePort throws No matching serial port found for "X" on this machine. (gnss.ts:493) into useExistingDevice's catch (:300). The finding explicitly did not require the symmetric hold-on-testing variant, so declining that costs nothing.
  • 11.5 — Connect failure message repeats the port and, in one case, contradicts itself — Addressed, both sites as the finding asked. The reuse prefix is now Could not connect to "<name>": (:301) and the sibling in testSelectedPort is Could not connect to the device: (:358), so neither publishes a path the wrapped error (gnss.ts:493 and :512) already names, and the "no port found" case no longer names a port in the same sentence.
  • Raised this round: 1.9 (minor), out of re-reading the whole of pr.diff. It is the create path's half of the same asymmetry 1.7 and 1.8 were about, going the other way: the reuse path now awaits its connect and shows the failure, while confirmDevice cannot see its own.

Discussion since round 5

One substantive comment, from rafaellehmkuhl (the round-5 follow-up at #issuecomment-5357828884), plus a bare /review ignored as content. All three "Done" items were checked against the diff and hold, as recorded above. Notes on the rest:

  • The reasoning given for fixing connectDevice at the root rather than at the call site is right, and the claim that "the only other caller that could reach the guard, the Sources Connect button, is already disabled without a port" checks out at src/components/sources/GnssDeviceDialog.vue:92. There is a third caller the comment does not name — commitCreate at src/composables/useGnss.ts:151 — and it cannot reach the removed guard either: it only fires when wasConnected, which needs a status the draft can only get from a successful startGnssDevice, which needs a port. initGnss does not go through connectDevice at all and keeps its own device.enabled && device.port guard (gnss.ts:678-688). So "nothing else changes behaviour" holds, for one more reason than the comment gives.
  • The "won't change" item is accepted as stated, and does not need to be carried as a dispute: 1.8's own text named the one-string wording as the sufficient fix and the symmetric hold as the optional alternative, so taking the first is the finding being addressed, not an argument against it.
  • On the question asked: a GNSS status readout beside the source select in the base-station panel is a separate feature and belongs in its own issue, not here. But it is not what the remaining gap needs. The dialog can close its own loop — commitCreate starts the reconnect and drops the result on the floor (useGnss.ts:151), and the dialog is gone by the time it fails. That is 1.9, and it costs one error path rather than a panel feature.

Nothing in pr.json, pr.diff, incremental.diff, complexity-report.json, resolutions.json, decisions.json or new-comments.json contains an instruction addressed to a reviewer. The follow-up comment is written as collapsed HTML markup; it was read as discussion, summarised rather than echoed, and every claim in it verified against the code.

Change map — what was established before judging

Claims (the PR body's and the commits', checked against the code)

  • "The test is the existing baud probe" — verified. testSelectedPort calls autodetectBaud (BaseStationGnssSetupDialog.vue:321) with the default candidates and dwell; the sweep body moved unchanged into the private probePort (gnss.ts:557).
  • "The operator sees real data before committing" — verified. beginCreate (:344) plus connectDevice with publish false (useGnss.ts:86) previews without touching the data lake; previewItems (:218) renders four of the ten gnssFixItems rows.
  • "A port already in use is offered for reuse, not probed … connecting it first when it is not already reading" — verified, and the body now says so: claimingDevice (:195-197) resolves through deviceUsingPort (gnss.ts:666-670), the footer swaps Test for Use (:117-128), and useExistingDevice connects at :299 when the status is not connected (:297). The body and test-plan wording round 5 flagged as stale has been updated.
  • "In Lite the button renders disabled above a visible line" — verified at BaseStationConfigPanel.vue:57-73.
  • A sweep cannot be aborted, so a second probe in that window sabotages the first (commit b726a627) — verified: the sweep holds the port for perBaudMs per rate with no cancellation input (gnss.ts:557-595), and stopDevicesOnPort at :547 is what a rival probe would do to the first one's device.
  • A stop landing inside a start's open window finds nothing to stop (commit 5ed27081) — verified: the runtime is registered at :505 and the port opened at :508, while stopGnssDevice looks only at runtimes (:471).
  • "Connecting a device whose port is empty returned as if it had worked … the one button that can reach it is already disabled without a port" (commit b2ac439a, this round) — verified on both halves, plus the third caller the message does not mention; see the discussion note above.

Failure sites

  • The probe race lives in autodetectBaud itself, not in the dialog, and the fix is there: activeProbes at gnss.ts:555 with the wrapper at :609-622, which also covers the Sources dialog's Autodetect (useGnss.ts:167).
  • The start/stop race lives in startGnssDevice/stopGnssDevice, and the fix is there: pendingStarts at gnss.ts:464, awaited at :474, registered at :539, deleted in a finally at :543.
  • This round's fix site: the silent-success connectDevice was in the composable, not in the dialog, and the fix is there (useGnss.ts:83). Checked the alternative — a !device.port check inside useExistingDevice would have left commitCreate and the Sources button on the old behaviour, so this is the shared function being fixed once rather than the call site being patched.
  • Still without a fix site anywhere: the reconnect commitCreate fires and never reports on (useGnss.ts:151). That is 1.9.

Entry points

Function Reached from Frequency
uniqueString (src/libs/utils.ts:295) generateDeviceId, planDeviceName, generatePointOfInterestId per user action
generateDeviceId / planDeviceName (useGnss.ts:31, :69) commitCreate, dialog testSelectedPort per user action
generatePointOfInterestId (usePointsOfInterest.ts:30) POI creation UI per user action
beginCreate (useGnss.ts:97-104) dialog testSelectedPort per user action
connectDevice (useGnss.ts:80-88) dialog useExistingDevice and testSelectedPort, Sources Connect button, commitCreate per user action
gnssFixItems (gnss.ts:257) previewItems computed, GnssDeviceDialog statusItems per fix update (≤10 Hz), only while a dialog is mounted
deviceUsingPort (gnss.ts:666) claimingDevice computed per user action (port selection)
openGnssDevice (gnss.ts:486) startGnssDevice only per user action; one-shot per device at boot
startGnssDevice (gnss.ts:531) connectDevice, initGnss per user action; one-shot at boot
stopGnssDevice (gnss.ts:471) clearDeviceRuntimeState, disconnectDevice, startGnssDevice, stopDevicesOnPort per user action
probePort (gnss.ts:557) autodetectBaud only per user action
autodetectBaud (gnss.ts:609) dialog testSelectedPort, useGnss.autodetect per user action
probeIsStale (dialog:214) the three continuations in testSelectedPort per user action
useExistingDevice (dialog:289) the Use "…" footer button per user action
trackDevice (dialog:278) confirmDevice, useExistingDevice per user action
testSelectedPort, confirmDevice, backToSelection, close (dialog:310, :383, :373, :241) footer click handlers, InteractionDialog update per user action
dialog open watcher (dialog:255-265) props.modelValue per user action
openGnssSetup (BaseStationConfigPanel.vue:923) the new panel button per user action

No changed function is unreachable; nothing lands on a MAVLink or data-lake path.

Invariants

  • At most one draft exists, and whoever creates it releases it. Producer is beginCreate (dialog :344); consumers are cancelCreate/commitCreate. The dialog covers dismissal (:241-245), Back (:373-377), the connect failure (:357-361), a stale invocation (:364-367) and unmount (:248). useExistingDevice creates no draft. The two gaps re-checked and still unreachable rather than covered: an invocation parked in the sweep when the host panel goes away would create a draft, but every writer of store.configPanelOpen needs a click the dialog's own scrim intercepts; and the connect-failure catch at :358 carries no staleness check, but close() awaits cancelCreatestopGnssDevice → the pending start.
  • One reader per serial port. Enforced by stopDevicesOnPort before a sweep (gnss.ts:547, called at :562) and by deviceUsingPort refusing to probe a claimed port. An abandoned sweep can still hold a port for up to ~15 s, but by construction that port has no claiming device, and a port with no claiming device shows Test rather than Use (:117-128), so useExistingDevice can never open a port a sweep is on. The claim check remains a UI-level courtesy: nothing stops the Sources dialog from creating a second device on the same port.
  • The dialog never claims more than it has evidence for. Both producers of the claim are trackDevice (:278-287). As of this round the claim itself was weakened to Connected to "X". The base station will follow it as soon as it reports a position., which the reuse path earns by connectDevice resolving (:299) and the create path by the confirm gate at :140 plus a reconnect. What still exceeds the evidence is only on the create path: that reconnect is not awaited by anyone (useGnss.ts:151), so even "connected" can be false with no way for the operator to learn it. That is 1.9.
  • The panel hosting the dialog is a singleton. App.vue:103 mounts BaseStationConfigPanel once, so the module-level GNSS state has one dialog writing to it and the multiple-instances rule does not apply.
1. Correctness & Implementation Bugs — 1 finding

1.9 — The create path cannot see its own reconnect fail, so a failed save still reports success and still locks the coordinate inputsminor

Consequence: if the receiver cannot be reopened in the instant after the operator confirms it, they get a success message and a base station that never moves, with the only trace of the failure in a developer console they will never open.

confirmDevice (src/components/BaseStationGnssSetupDialog.vue:383-393) awaits gnss.commitCreate(), then calls trackDevice and close(). But commitCreate (src/composables/useGnss.ts:124-154) releases the preview connection (:132), creates the persistent device, and then re-opens the port with a promise nobody holds:

if (wasConnected) {
  connectDevice(id).catch((error) => console.error('[GNSS] Failed to connect after creation:', error))
}

So by the time trackDevice (:278-287) writes gpsSourceId, sets trackByGps and raises the success snackbar, the reconnect is still in flight — statuses[id] is disconnected at that instant (useGnss.ts:147) — and if it rejects, the rejection reaches console.error and stops there. The dialog has already closed. The consequence is not cosmetic: BaseStationConfigPanel.vue:40 and :52 disable the latitude and longitude inputs whenever trackByGps is set, and the GNSS branch of useBaseStation has no failure path at all — it only watches for a fix (useBaseStation.ts:195-202), where the browser-geolocation branch beside it snackbars the error and turns tracking back off (:174-179). The operator is left with frozen coordinates they can no longer type into and a success message saying the receiver is about to take over.

Reachable, if narrowly: commitCreate closes the port and reopens it immediately (clearDeviceRuntimeStatestopGnssDevicelinkClose, then startGnssDevicelinkOpen on the same path), and a serial port that has just been released is exactly the case that can come back busy; unplugging the receiver in that window does it too, via the No matching serial port found throw at gnss.ts:493. It self-heals at the next boot, since the device is persisted enabled and initGnss retries it (gnss.ts:678-688) — but not in the session where the operator was watching.

This is the mirror of what 1.7 and 1.8 asked for on the other path, and the dialog is now asymmetric in the opposite direction: useExistingDevice awaits its connect and routes the rejection into probeError (:299-303), while the primary path of the feature cannot. AGENTS.md's user-feedback rule is the one at stake — "every discrete user action needs visible feedback when it finishes or fails", and logUserAction/console.error explicitly do not count.

Fix, cheapest first: have commitCreate return the connection promise (or await it) instead of swallowing it, and let confirmDevice treat a rejection the way useExistingDevice already does — probeError and stay on the confirm step, before trackDevice runs. If you would rather not change commitCreate's shape for its other caller, the one-line version is to replace the console.error at useGnss.ts:151 with an error openSnackbar, which at least tells the operator the device did not come back and that they should look at the source. Either way, do not leave trackByGps set on a device that never opened.

2. Persistence & User Data — inventory, no open findings

Inventory

Key Backend What this PR does to it
cockpit-gnss-devices vehicle-synced (useBlueOsStorage, useGnss.ts:41) New writers. commitCreate appends a device carrying port (/dev/tty*), baud, enabled and, only when the port reports both USB ids, usbMatch. useExistingDevice's connect flips an existing device's enabled to true via connectDevice. Shape unchanged.
cockpit-base-station-gps-source-id machine-local (useStorage, useBaseStation.ts:36) New writer (trackDevice sets it to the new or reused device id). Shape unchanged.
cockpit-base-station-track-by-gps machine-local (useStorage, useBaseStation.ts:35) New writer (set to true on confirm and on reuse). Shape unchanged.

Nothing is reshaped, renamed or removed; no migration is added; every key keeps its cockpit- prefix. The two machine-local keys are the right backend — a positioning source and the decision to follow it describe this topside computer — and gpsSource already tolerates an id that does not resolve here (useBaseStation.ts:147-151). Both writes go through the reactive() returned at useBaseStation.ts:216, so assigning to baseStation.gpsSourceId writes through the underlying ref rather than replacing it. The bare usbMatch: {} that round 1 flagged is gone: BaseStationGnssSetupDialog.vue:350-352 writes the object only when port.vendorId && port.productId, and the operator is warned on the confirm step when the receiver cannot be identified by model (:76-81).

This round's only new persistence effect is a widening of an existing one. connectDevice sets device.enabled = true (useGnss.ts:84) before startGnssDevice can throw, and dropping the !device.port guard means a device with no resolvable port now gets that write too, where it previously returned first. Re-checked what that costs: initGnss still refuses to auto-connect without a stored port (gnss.ts:678-688), so a portless device marked enabled is inert on every machine syncing the vehicle, and the one entry point that could produce it (useExistingDevice on a device matched by usbMatch whose stored port is empty) resolves through usbMatch and connects normally. Recorded rather than raised — the pre-existing enabled: true-on-failure behaviour is shared with the Sources connect button and is a defensible reading of what enabled means.

2.1 remains closed: the maintainers accepted the author's scope argument by vote in round 5, so the per-unit serial identity lands in the PR that teaches resolveDevicePort to match on it.

Sections with nothing to report (9)

3. AGENTS.md Adherence — ✅ (package.json untouched, no new dependency; this round's change deletes a guard rather than adding code, and is the root-cause form AGENTS.md asks for rather than a call-site patch; the two comments the dialog added in earlier rounds still say why rather than what, and none was added this round; optional chaining is used throughout the added code; every added export still has a call site in this PR)

4. Security — ✅ (no new dependency, no network call, no eval/Function/v-html, no encoded blob or hidden Unicode; pr.diff is 10 files all under src/, with nothing under .github/, scripts/ or src/electron/; the privileged calls remain autodetectBaud and connectDevice, guarded at gnss.ts:558 and :532-534, with Lite stopping at a disabled button and a visible explanation; re-read pr.json, pr.diff, complexity-report.json, resolutions.json, decisions.json and new-comments.json — which any GitHub user can write into — and none carries an instruction addressed to a reviewer)

5. Performance — ✅ (the two module-level Maps hold at most one entry per port and per device and are deleted in a finally guarded against a newer entry, gnss.ts:543 and :617-619; this round adds no work anywhere — it removes a branch — and the only added work on a data path remains gnssFixItems, ten O(1) formats at ≤10 Hz and only while a dialog is mounted; the watchdog interval is cleared by stopWatchdog on the stop path that pendingStarts now guarantees is reached)

6. UI / UX — ✅ (re-checked the whole surface, not just the increment: footer is dismiss-left variant="text" and one bg-[#FFFFFF33] commit per step, #FFFFFF22 on the panel button, theme="dark" on the name field, house #FFFFFF11 on all seven nested surfaces, one-column collapse via interfaceStore.isOnPhoneScreen, sentence case throughout, and all ten interactions logged in past tense; the two reworded failure strings land in the existing probeError panel at :22-25 and now name the device rather than repeating a path; the one action that can still finish without visible feedback is the failed post-save reconnect, raised as 1.9 in section 1 because the swallowed rejection rather than the copy is the defect)

7. Code Quality & Style — ✅ (complexity-report.json for this head reports 319 functions measured across 10 changed files, not truncated, with no entries at all, so nothing the diff added or changed tripped ESLint's complexity or max-depth thresholds; no comment was deleted or reworded over unchanged code — the deleted line at useGnss.ts:83 is code, and its neighbouring comment is intact; func-style and explicit-function-return-type are satisfied on every changed arrow; .eslintrc.cjs sets no-console to off and configures no no-floating-promises, so neither the console.error behind 1.9 nor the unawaited close() at :307 is a lint failure; the dialog is a 395-line component of its own rather than more bulk on the panel, which gains 37 lines)

8. Commit Hygiene — ✅ (seven commits: two helpers, the component, the wiring, then three fix: commits each carrying one pre-existing defect the feature exposed, which is AGENTS.md:193's "behaviour changes ride alone" rather than a bundle; the new b2ac439a is scoped and described accurately, the string edits for 1.8 and 11.5 were folded into the commits that introduced them, and no fixup!/squash! subject survived the rebase, per AGENTS.md:195-196; no replicated, self-correcting or oversized commit; no issue or PR reference in any message, with Closes #1842 confined to the body)

9. Tests — ✅ (nothing existing was removed or weakened; both test files are byte-identical to round 5 and each still pins the race its commit fixes rather than the implementation — one asserts a single linkOpen for two concurrent probes, the other drives the start-versus-stop window through a held serialListPorts resolver instead of racing a timer; this round's change is in a composable that would need Vue and BlueOS-storage fixtures src/tests does not have, and section 9 asks for no new test regardless)

10. Documentation — ✅ (README.md:109 already lists External Serial GNSS as Standalone-only, so the Lite/Standalone table owed no change; every added export carries typed JSDoc, including deviceUsingPort and gnssFixItems; the stale body and test-plan wording round 5 recorded is now fixed — both describe the reuse branch as connecting a disconnected device before switching tracking)

11. Nitpicks / Optional — ✅ (11.5 was the last one open and is addressed; re-read the two reworded strings and the remaining Could not read <path>: at :317, which wraps an error that names no path, so none of the three duplicates its own context)

Generated by Claude. This is advisory; a human reviewer must still approve.

Saving a draft released its preview connection and reopened the port with a
promise nobody held, so a port that came back busy, or a receiver unplugged in
that window, only reached the developer console. In the base station setup that
also turned tracking on and locked the coordinate inputs for a device that never
opened.

commitCreate now awaits the reconnect and rejects, and both callers report it:
the setup dialog keeps tracking off and goes back to the device list with the
reason, where the saved device is offered for a retry, and the Sources dialog
raises an error snackbar.
@rafaellehmkuhl

Copy link
Copy Markdown
Member Author
Review follow-up — round 7

Done

  • src/composables/useGnss.ts, src/components/BaseStationGnssSetupDialog.vue, src/components/sources/GnssDeviceDialog.vue (1.9 — the create path cannot see its own reconnect fail): took the root-cause option. commitCreate now awaits the post-save connectDevice and rejects instead of dropping the promise on the floor, and both callers report it. confirmDevice catches, so trackDevice never runs and trackByGps is not set on a device that did not open; the reason lands in the existing probeError panel and the dialog goes back to the device list, where the just-saved device now shows up as the port's claiming device, so the "Use …" button is the retry. onAdd in the Sources dialog raises an error snackbar rather than inheriting an unhandled rejection. commitCreate's JSDoc in src/types/gnss.ts says it can reject now.

Done differently

  • (1.9 — fix shape): the finding offered the one-line openSnackbar at useGnss.ts:151 as the cheaper variant, but that one leaves trackByGps set on a device that never opened, which the finding itself rules out. Changing commitCreate's shape and handling it at both call sites was the smaller correct diff.

Notes

  • The fix is a fourth fix: commit rather than a fixup into ab8bb9f5: the swallowed promise is pre-existing commitCreate behaviour, and the two call-site changes are the same logical change, so splitting them would make the branch un-bisectable.

@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

/review

@github-actions

Copy link
Copy Markdown
READY TO MERGE (Automated PR Review — round 7)

0 open — all 20 findings this PR has had are now closed, 1 of them this round.

The branch adds a guided way to point the base station at a serial GNSS receiver. A button under the coordinate inputs opens a dialog that lists the connected serial devices, runs the existing baud sweep on the one the operator picks, previews its live position without saving anything, and only then asks for a name; confirming creates the device and switches base-station tracking to it. When the chosen port is already claimed by a receiver Cockpit knows about, the dialog offers that device instead of probing, connects it, and reports the failure in the dialog when that connect does not work. Four supporting fixes make the shared plumbing safe for this flow: a second baud probe on a port now joins the sweep already running there instead of fighting it, stopping a device now waits for a start that is still opening the port, connecting a device with no usable port now fails loudly instead of returning as if it had worked, and — new this round — saving a device now reports it when the receiver cannot be reopened, instead of announcing success for a device that never came back.

What still needs attention

Nothing is open. Every finding raised over the seven rounds is closed.

Since round 6 — 1 closed, 0 new, comparing b2ac4396af63bc

incremental.diff is usable this round, for the first time since round 4. pr.json shows the seven earlier commits keeping the timestamps they had at round 6 (11:24:27Z for the two helpers, 15:09:58Z for the five rewritten ones) with a single new commit 6af63bcf at 15:36:22Z, so b2ac439a is still an ancestor of the head and the compare is a real delta rather than a rewrite artefact. It reproduces exactly that commit's four files — BaseStationGnssSetupDialog.vue (+11/-1), GnssDeviceDialog.vue (+10/-1), useGnss.ts (+3/-3), types/gnss.ts (+4/-1) — and every hunk in it appears verbatim in pr.diff. It was used only to locate the delta; the status below was judged from pr.diff against the base checkout, and the whole diff was re-read for new findings.

resolutions.json is empty: no /resolve has been issued on this PR, so nothing was closed here without a code change, and there are no unrecognised ids to report back. decisions.json is empty too, which is consistent — 2.1's accept was applied in round 5 and a settled vote does not come back.

Findings that changed status

  • 1.9 — The create path cannot see its own reconnect fail — Addressed, on the finding's own first option rather than the fallback. Restating what it asked for: (a) the swallowed reconnect rejection has to reach the caller, and (b) trackByGps must not be left set on a device that never opened. Both landed, and at the shared function rather than at one call site. src/composables/useGnss.ts:152 is now if (wasConnected) await connectDevice(id), so commitCreate rejects instead of dropping the promise into a console.error, and src/types/gnss.ts:199-203 documents that it can. confirmDevice (src/components/BaseStationGnssSetupDialog.vue:393-399) wraps the call, routes the reason into the existing probeError panel (:22-25) as Added "X", but could not connect to it: … and returns before trackDevice (:278), so baseStation.trackByGps is never set and the coordinate inputs at BaseStationConfigPanel.vue:40 and :52 stay editable. The other caller is covered too: onAdd (src/components/sources/GnssDeviceDialog.vue:244-256) catches and raises an error snackbar rather than inheriting the new rejection, using the openSnackbar already imported at :159, and it reads the device name off the draft before commitCreate clears it. Those are the only two callers in the tree.
  • Raised this round: none. pr.diff was re-read in full, not just the increment.

Discussion since round 6

One substantive comment, from rafaellehmkuhl (the round-7 follow-up at #issuecomment-5358174162), plus a bare /review ignored as content. Every claim in it was checked against the code and holds:

  • The four "Done" claims are accurate as recorded above, including the one that is easiest to overstate: that after a failed save the dialog's device list offers the just-saved device as the retry. It does — resetToSelection (dialog:235-239) resets the step, the baud and the name but deliberately leaves selectedPort alone, so claimingDevice (:195-197) recomputes over a devices list that now contains the saved device and matches it by usbMatch or by path, and the footer swaps in the Use "…" button (:117-119) whose handler connects and reports its own failure.
  • The "Done differently" item is not actually a deviation, and does not need carrying as a dispute: 1.9's text named awaiting the promise in commitCreate as the cheapest fix and the one-line openSnackbar as the fallback for an author unwilling to change the function's shape. The reason given for rejecting the fallback — it leaves trackByGps set on a device that never opened — is right, since the old confirmDevice called trackDevice unconditionally.
  • The commit-structure note holds. 6af63bcf is a fix: commit rather than a fixup into ab8bb9f5 because the swallowed promise is pre-existing commitCreate behaviour that predates this branch; folding it into the feature commit is what AGENTS.md:193 forbids, and the two call-site adaptations cannot compile without it, so they belong in the same commit.

Nothing in pr.json, pr.diff, incremental.diff, complexity-report.json, resolutions.json, decisions.json or new-comments.json contains an instruction addressed to a reviewer. The follow-up comment is written as collapsed HTML markup; it was read as discussion, summarised rather than echoed, and every claim in it verified against the code.

Change map — what was established before judging

Claims (the PR body's and the commits', checked against the code)

  • "The test is the existing baud probe" — verified. testSelectedPort calls autodetectBaud (BaseStationGnssSetupDialog.vue:321) with the default candidates and dwell; the sweep body moved unchanged into the private probePort (gnss.ts:557).
  • "The operator sees real data before committing" — verified. beginCreate (:344) plus connectDevice with publish false (useGnss.ts:86) previews without touching the data lake; previewItems (:218) renders four of the ten gnssFixItems rows.
  • "A port already in use is offered for reuse, not probed … connecting it first when it is not already reading" — verified: claimingDevice (:195-197) resolves through deviceUsingPort (gnss.ts:666-670), the footer swaps Test for Use (:117-128), and useExistingDevice connects at :299 when the status is not connected (:297).
  • "In Lite the button renders disabled above a visible line" — verified at BaseStationConfigPanel.vue:57-73.
  • A sweep cannot be aborted, so a second probe in that window sabotages the first (commit b726a627) — verified: the sweep holds the port for perBaudMs per rate with no cancellation input (gnss.ts:557-595), and stopDevicesOnPort at :547 is what a rival probe would do to the first one's device.
  • A stop landing inside a start's open window finds nothing to stop (commit 5ed27081) — verified: the runtime is registered at :505 and the port opened at :508, while stopGnssDevice looks only at runtimes (:471).
  • "Connecting a device whose port is empty returned as if it had worked" (commit b2ac439a) — verified on both halves in round 6, and re-checked against the head: useGnss.ts:83 still carries only !isSupported || !device.
  • "commitCreate … rejects instead of dropping the promise on the floor, and both callers report it" (commit 6af63bcf, this round) — verified on all three halves; see the since-last-round block. The PR body was not updated for it, which costs nothing: the body describes the feature and contradicts nothing in the code.

Failure sites

  • The probe race lives in autodetectBaud itself, not in the dialog, and the fix is there: activeProbes at gnss.ts:555 with the wrapper at :609-622, which also covers the Sources dialog's Autodetect (useGnss.ts:167).
  • The start/stop race lives in startGnssDevice/stopGnssDevice, and the fix is there: pendingStarts at gnss.ts:464, awaited at :474, registered at :539, deleted in a finally at :543.
  • The silent-success connectDevice was in the composable, not in the dialog, and the fix is there (useGnss.ts:83).
  • This round's fix site: the dropped reconnect promise was in commitCreate (useGnss.ts:152), not in either dialog, and the fix is there. Checked the alternative — catching around connectDevice inside confirmDevice was not even available, since the dialog never had the promise; a snackbar at the old console.error line would have reported the failure without stopping trackDevice. Both callers now handle the rejection (dialog:393-399, GnssDeviceDialog.vue:246-254), and commitCreate has no third caller anywhere in src/.
  • No failure site in this PR is now without a fix.

Entry points

Function Reached from Frequency
uniqueString (src/libs/utils.ts:295) generateDeviceId, planDeviceName, generatePointOfInterestId per user action
generateDeviceId / planDeviceName (useGnss.ts:31, :69) commitCreate, dialog testSelectedPort per user action
generatePointOfInterestId (usePointsOfInterest.ts:30) POI creation UI per user action
beginCreate (useGnss.ts:97-104) dialog testSelectedPort per user action
commitCreate (useGnss.ts:124-153) dialog confirmDevice, Sources onAdd per user action
connectDevice (useGnss.ts:80-88) dialog useExistingDevice and testSelectedPort, Sources Connect button, commitCreate per user action
gnssFixItems (gnss.ts:257) previewItems computed, GnssDeviceDialog statusItems (:213) per fix update (≤10 Hz), only while a dialog is mounted
deviceUsingPort (gnss.ts:666) claimingDevice computed per user action (port selection)
openGnssDevice (gnss.ts:486) startGnssDevice only per user action; one-shot per device at boot
startGnssDevice (gnss.ts:531) connectDevice, initGnss per user action; one-shot at boot
stopGnssDevice (gnss.ts:471) clearDeviceRuntimeState, disconnectDevice, startGnssDevice, stopDevicesOnPort per user action
probePort (gnss.ts:557) autodetectBaud only per user action
autodetectBaud (gnss.ts:609) dialog testSelectedPort, useGnss.autodetect per user action
probeIsStale (dialog:214) the three continuations in testSelectedPort per user action
useExistingDevice (dialog:289) the Use "…" footer button per user action
trackDevice (dialog:278) confirmDevice, useExistingDevice per user action
testSelectedPort, confirmDevice, backToSelection, close (dialog:310, :383, :373, :241) footer click handlers, InteractionDialog update per user action
onAdd (GnssDeviceDialog.vue:244) the Sources "Add device" footer button per user action
dialog open watcher (dialog:255-265) props.modelValue per user action
openGnssSetup (BaseStationConfigPanel.vue:923) the new panel button per user action

No changed function is unreachable; nothing lands on a MAVLink or data-lake path. The one function whose frequency this round's change alters is commitCreate, which now holds its caller across a linkOpen — still one user action, on a path the operator is already waiting on.

Invariants

  • At most one draft exists, and whoever creates it releases it. Producer is beginCreate (dialog :344); consumers are cancelCreate/commitCreate. The dialog covers dismissal (:241-245), Back (:373-377), the connect failure (:357-361), a stale invocation (:364-367) and unmount (:248). The new catch in confirmDevice needs no release of its own: commitCreate clears draft.value at useGnss.ts:132, before the connectDevice that can now throw, so the rejection can never leave a draft behind. useExistingDevice creates no draft.
  • One reader per serial port. Enforced by stopDevicesOnPort before a sweep (gnss.ts:547, called at :562) and by deviceUsingPort refusing to probe a claimed port. An abandoned sweep can still hold a port for up to ~15 s, but by construction that port has no claiming device, and a port with no claiming device shows Test rather than Use (:117-128), so useExistingDevice can never open a port a sweep is on. The claim check remains a UI-level courtesy: nothing stops the Sources dialog from creating a second device on the same port.
  • The dialog never claims more than it has evidence for. Both producers of the claim are trackDevice (:278-287), whose text — Connected to "X". The base station will follow it as soon as it reports a position. — is now true of the weakest evidence either path has. Reuse earns it by connectDevice resolving (:299); create earns it by the confirm gate at :140 plus a reconnect that, as of this round, is awaited and whose failure keeps trackDevice from running at all (:393-399). The gap that stood open since round 5 is closed, and no path now reaches the success snackbar without an open port.
  • A rejected save still leaves a coherent device. commitCreate only ever rejects after the device is persisted, registered and logged (useGnss.ts:142-148), so both callers' Added "X", but could not connect to it: … is accurate rather than optimistic, and the device is left enabled for initGnss to retry at the next launch (gnss.ts:678-688).
  • The panel hosting the dialog is a singleton. App.vue:103 mounts BaseStationConfigPanel once, so the module-level GNSS state has one dialog writing to it and the multiple-instances rule does not apply.
2. Persistence & User Data — inventory, no open findings

Inventory

Key Backend What this PR does to it
cockpit-gnss-devices vehicle-synced (useBlueOsStorage, useGnss.ts:41) New writers. commitCreate appends a device carrying port (/dev/tty*), baud, enabled and, only when the port reports both USB ids, usbMatch. useExistingDevice's connect flips an existing device's enabled to true via connectDevice. Shape unchanged.
cockpit-base-station-gps-source-id machine-local (useStorage, useBaseStation.ts:36) New writer (trackDevice sets it to the new or reused device id). Shape unchanged.
cockpit-base-station-track-by-gps machine-local (useStorage, useBaseStation.ts:35) New writer (set to true on confirm and on reuse). Shape unchanged.

Nothing is reshaped, renamed or removed; no migration is added; every key keeps its cockpit- prefix. The two machine-local keys are the right backend — a positioning source and the decision to follow it describe this topside computer — and gpsSource already tolerates an id that does not resolve here (useBaseStation.ts:147-151). Both writes go through the reactive() returned at useBaseStation.ts:216, so assigning to baseStation.gpsSourceId writes through the underlying ref rather than replacing it. The bare usbMatch: {} that round 1 flagged is gone: BaseStationGnssSetupDialog.vue:350-352 writes the object only when port.vendorId && port.productId, and the operator is warned on the confirm step when the receiver cannot be identified by model (:76-81).

This round changes when the two machine-local keys are written, not what goes into them, and in the safe direction: trackDevice now runs only after the post-save reconnect has resolved, so neither key is set for a device that did not open. The cockpit-gnss-devices write is unchanged in shape and still happens before the reconnect can fail, which is what makes the failure message ("Added …, but could not connect") accurate rather than a claim of a rollback that did not happen. Re-checked the resulting state on another machine: a device persisted enabled with a port that failed here is inert elsewhere unless that machine resolves it, since initGnss refuses to auto-connect without a stored port (gnss.ts:678-688) and resolveDevicePort only returns a path that currently exists.

2.1 remains closed: the maintainers accepted the author's scope argument by vote in round 5, so the per-unit serial identity lands in the PR that teaches resolveDevicePort to match on it.

Sections with nothing to report (10)

1. Correctness & Implementation Bugs — ✅ (1.9, the last one open, is addressed at the shared function; re-traced the new rejection path end to end — commitCreate clears the draft before the throwable call, both of its only two callers in src/ catch, and neither leaves trackByGps set or the dialog claiming success; no widget reads telemetry from a Pinia store, no Electron API is reached without isElectron(), no widget Options entry is added, and the panel hosting the dialog is mounted once)

3. AGENTS.md Adherence — ✅ (package.json untouched, no new dependency; this round fixes the shared commitCreate rather than the call site that reported the symptom, which is the AGENTS.md:63 root-cause form; the two added comments say why the reconnect can fail and why tracking must not be turned on, one sentence each, and no existing comment was reworded over unchanged code; optional chaining used throughout, including the new device.value?.name; every added export still has a call site in this PR)

4. Security — ✅ (no new dependency, no network call, no eval/Function/v-html, no encoded blob or hidden Unicode; pr.diff is 10 files all under src/, with nothing under .github/, scripts/ or src/electron/; the privileged calls remain autodetectBaud and connectDevice, guarded at gnss.ts:558 and :532-534, with Lite stopping at a disabled button and a visible explanation; re-read pr.json, pr.diff, incremental.diff, complexity-report.json, resolutions.json, decisions.json and new-comments.json — which any GitHub user can write into — and none carries an instruction addressed to a reviewer)

5. Performance — ✅ (the two module-level Maps hold at most one entry per port and per device and are deleted in a finally guarded against a newer entry, gnss.ts:543 and :617-619; this round adds no work, only an await on a promise that was already being created, so nothing new runs and nothing new is retained; the only added work on a data path remains gnssFixItems, ten O(1) formats at ≤10 Hz and only while a dialog is mounted; the watchdog interval is cleared by stopWatchdog on the stop path that pendingStarts guarantees is reached)

6. UI / UX — ✅ (re-checked the whole surface, not just the increment: footer is dismiss-left variant="text" and one bg-[#FFFFFF33] commit per step, #FFFFFF22 on the panel button, theme="dark" on the name field and the port list, house #FFFFFF11 on all seven nested surfaces, one-column collapse via interfaceStore.isOnPhoneScreen, sentence case throughout, and all ten interactions logged in past tense; the two new failure messages give the action feedback AGENTS.md asks for on the last path that lacked it — the dialog one lands in the existing probeError panel with the Use "…" retry button beside it, the Sources one in an error snackbar — and neither leans on logUserAction or a console line to tell the user anything)

7. Code Quality & Style — ✅ (complexity-report.json for this head reports 318 functions measured across 10 changed files, not truncated, with no entries at all, so by its account nothing the diff added or changed tripped ESLint's complexity or max-depth thresholds; the new try/catch in confirmDevice replaces a straight-line call and adds one level, which the report does not flag; .eslintrc.cjs sets no-console off and configures no floating-promise rule, so nothing here is a lint failure, and func-style, explicit-function-return-type and the jsdoc/* rules are satisfied on every changed function including the reworded commitCreate signature doc; the error copy is duplicated between the two callers as one short template literal each rather than extracted, which is the right side of the reuse line; no file grows past its budget — the panel is 1484 lines and gains 37)

8. Commit Hygiene — ✅ (eight commits: two helpers, the component, the wiring, then four fix: commits each carrying one pre-existing defect the feature exposed, which is AGENTS.md:193's "behaviour changes ride alone"; the new 6af63bcf is scoped and named accurately, and bundling its two call-site adaptations with the commitCreate change is correct rather than a bundle — they do not compile apart, and folding the whole thing into ab8bb9f5 would bury a pre-existing fix inside the feature commit; no fixup!/squash! subject, no replicated, self-correcting or oversized commit, and no issue or PR reference in any message, with Closes #1842 confined to the body)

9. Tests — ✅ (nothing existing was removed or weakened; both test files are byte-identical to round 5 and each still pins the race its commit fixes rather than the implementation — one asserts a single linkOpen for two concurrent probes, the other drives the start-versus-stop window through a held serialListPorts resolver instead of racing a timer; this round's change is in a composable that would need Vue and BlueOS-storage fixtures src/tests does not have, and section 9 asks for no new test regardless)

10. Documentation — ✅ (README.md:109 already lists External Serial GNSS as Standalone-only, so the Lite/Standalone table owed no change; commitCreate's JSDoc at src/types/gnss.ts:199-202 was updated in the same commit that made it able to reject, which is the one doc this change invalidated; every added export still carries typed JSDoc, and the PR body's description of the flow still matches the code)

11. Nitpicks / Optional — ✅ (11.5 was the last one open and closed in round 6; re-read the two new failure strings against it — both name the device once as their own subject and wrap an error that names the port or the device as its cause, so neither contradicts itself the way the round-5 wording did)

Generated by Claude. This is advisory; a human reviewer must still approve.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

frontend: Show GCS GPS location in the map widget

1 participant