From 97e09e0fd987e054d5f30d0be72976c134adcdcf Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Sat, 17 Oct 2026 12:45:18 -0400 Subject: [PATCH 1/3] =?UTF-8?q?fix(nodes):=20steady=20roster=20polling=20?= =?UTF-8?q?=E2=80=94=20no=20lost=20replies,=20no=20false=20not-responding,?= =?UTF-8?q?=20no=20orphan=20bridge=20(#150)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of #132 (fix-track chunk 3). Owners: `main/nodes/node-hub.ts`, `main/nodes/node-status-wire.ts` (`NodeRoster`), `main/gateway/gateway-health.ts`, `main/gateway/gateway-connection.ts`, `main/gateway/serial-bridge.ts`. No firmware change. ## Before → after | Item | Before | After | |---|---|---| | **M2** roster requests | Every request (5 s poll, reconnect seed, Refresh) cleared the reply burst, so a request landing mid-reply marked the boards whose rows were already in as offline: false offline/online rows, extra last-seen saves, possible false PDU outages | A request never discards a reply that is arriving. PanelBridge starts each reply with its own node-0 row (never sent as a delta) and ends it with `_NODE_STATUS_END`, in the order asked. From a node-0 row to the next END a reply is arriving, and a request keeps its rows. Only a request with no reply arriving isolates the next burst from earlier deltas, as before | | **G1** not-responding deadline | Poll interval = deadline = 5 s. One lost reply let the 200 ms tick reach the deadline just before the next poll was answered: a "Gateway not responding" flash, then "responding again" | A request unanswered for 1 s is asked again, every second, until answered. The 5 s poll and the 5 s deadline are unchanged. "Not responding" now takes five lost replies in a row | | **M4** stopped serial bridge | After Disconnect or Retry, a released bridge's callbacks still drove the link, and could reopen the port and hold it | `GatewayConnection` ignores every callback from a bridge that isn't current. `SerialBridge` re-checks `stopped` after each await, and releases a port that finished opening after the stop | | **M7** Retry and last seen | error → Retry reset the roster without saving last seen | Retry runs the `closing` riders first, as Disconnect, unplug and quit do (B13) | | **M3** v2 bridge END | The END still completed the roster, so every board read offline while on the bus, and Hide/Forget were allowed | After a v2 row the roster ignores END: boards read *unknown*, and Hide/Forget stay refused. The once-per-link warning (B29) is unchanged; it now reads the roster's v2 flag, so there is one detector | | **Refresh** (user 2026-10-17) | Connection › Connected nodes had a Refresh button | Removed, along with everything only it used: the `nodes:refresh` intent (contract, preload, `IPC_ORDER`, handler, harness, API guard), the store intent `refreshNodes`, and `canRefresh` on `device:status` (`DeviceLinkFacts`, `DEVICE_IDLE`, `linkFacts`, the `GatewayConnectionDeps.canRefresh` hook, `NodeHub.canRefresh()`). The empty-roster hint no longer says "or press Refresh". `canRequestRoster` stays because it is the poll's own guard | ## Poll and deadline design (G1, designed with M2) - **Visible cadence stays 5 s.** The 5 s poll timer is unchanged. - **Re-ask:** `GatewayWatch` owns the rule beside the deadline, so the coupled constants live in one file. `GATEWAY_REASK_MS` = 1000 ms is the roster reply window, because PanelBridge answers in milliseconds. `reaskDue(now)` is true once the oldest request is still unanswered and the latest ask was ≥ 1 s ago. NodeHub checks it on the link's 200 ms tick and asks again. The rule is armed only after the Gateway has answered since the port opened, same as "not responding": a Gateway built without node status keeps only the plain 5 s poll. There is no re-ask during a calibration pause. - **Deadline unchanged:** `GATEWAY_NO_ANSWER_MS` = 5000 ms, counted from the oldest unanswered request. A later ask doesn't restart it, so the alert text "no answer for 5 s" stays literally true. - **Why it can't flash on one loss:** inside the 5 s window there are asks at t = 0, 1, 2, 3 and 4 s. One lost reply is re-asked at about 1.0–1.2 s and answered long before 5 s. Up to four consecutive losses never trip the alert. A unit test pins `floor((5000 − 1) / 1000) ≥ 4`. - **Re-asks can't cut a reply short:** a re-ask goes out only when nothing at all has arrived since the ask. Any line of a reply counts as an answer, so a re-ask never lands inside a reply. M2's rule covers the 5 s poll, which can land inside one. - **Real-outage bound:** "not responding" is raised 5.0–5.2 s after the first unanswered request (deadline + one 200 ms tick). That is at most **10.2 s** after the Gateway goes silent, because the next poll can be up to 5 s away. This is the same bound as before. Recovery is faster: re-asks continue every second, so "responding again" follows within about 1 s of the Gateway's return. - **Cost:** about 10 bytes per second on the serial link, and only while the Gateway is not answering. ## Changed assertions (on purpose) | Test | Change | Why | |---|---|---| | `renderer/test/t5-connection-nodes-card` "Refresh asks main for the roster while the link is up" | Replaced by "has no Refresh button: the roster poll and every reconnect ask for it (#132)" | The button is removed | | `t5-connection-nodes-card` "Refresh is disabled and the rows give way to a hint while the link is down" | Renamed "the rows give way to a hint while the link is down"; the `Refresh … toBeDisabled()` line dropped. Hint and row assertions are unchanged | The button is removed | | `main/app/pushed-rules.seam` "device:status: the link's action, and Refresh only while a roster request can go" | The `canRefresh` fields dropped; asserts that no push carries `canRefresh`. The `action` assertions are unchanged | `canRefresh` is removed | | `main/gateway/link-state` "every push carries it, and Refresh's availability as the node layer hands it in" | Asserts `action` and that `canRefresh` is absent | `linkFacts` lost its `canRefresh` parameter | | `main/nodes/node-status-wire` describe "canRequestRoster (R3.4a: Connection's Refresh)" | Describe name only → "(the poll, its re-ask and the seed on connect)". Assertions unchanged | Wording | Everything else keeps its assertions. That includes the GatewayWatch G1 mechanics test (`gateway-health` "counts from the oldest unanswered request — a later poll does not restart it"). The design keeps those mechanics and adds the re-ask beside them, so the test needed no change. Every existing roster test, including "marks nodes absent from a completed burst offline" (delta isolation), passes unchanged. ## New tests, each shown able to fail To prove each one can fail, I swapped in the base commit's production files and ran the new tests against them: 21 failed. I also broke the fix on purpose, which the table marks *mutated*. | Item | Test | Fails on | |---|---|---| | M2 | `node-status-wire` "a reply arriving across a new request is kept", "replies queued behind each other: a request in the second one keeps it whole" | base; *mutated* (`beginBurst` always clears) | | M2 | `node-status-wire` "a board missing from a whole reply is still offline by it", "with no reply arriving, a request still isolates … earlier deltas" | Guards that pruning still works (pass on both) | | G1 | `gateway-health` "the re-ask" ×4 (timing, room for four re-asks, never unarmed or paused, one lost reply never raises) | base | | G1 | `roster-poll.seam` "the poll's reply is lost: it is asked again within a second, and nothing flashes" | base (it flashes); *mutated* (no re-ask) | | G1 | `roster-poll.seam` "a real outage still raises it: within 5.2 s of the first unanswered request" (and ≤ 10.2 s after silence) | base; *mutated* | | M4 | `app-services` "after Disconnect then Connect / after an error then Retry, the released bridge no longer drives the link" | base (the orphan's late open makes the link `connected`) | | M4 | `serial-bridge` "stopped while looking for the board", "stopped while the port is opening: the open … is released", "… a failed open schedules no retry" | base | | M7 | `roster-poll.seam` "the boards on the bus were last seen at the Retry" | base | | M3 | `node-status-wire` v2 END ×2; `roster-poll.seam` "your boards stay unknown, not offline, so Hide and Forget stay refused" | base | | Refresh | `roster-poll.seam` "no nodes:refresh intent, and the 5 s poll writes the roster request"; T5 "has no Refresh button" | base | | **Soak** | `roster-poll.seam` "30 simulated minutes of lost and late replies": a simulated PanelBridge (4 boards plus its node-0 row). Replies are lost 25% of the time, at most 2 in a row. Delays are 2–50 ms (60%), 0.8–1.6 s (25%) or 2.5–4.5 s (15%), always within 4.5 s of the first unanswered request. Lines trickle 0–300 ms apart, in FIFO order, so requests land inside replies. Asserts: no `nodes:model` push ever shows a board not online; no node offline/online event; no extra last-seen save; no not-responding push or event; and more than 360 asks over the run | base; *mutated* M2 (false offline); *mutated* G1 (flash). Also run with 8 other seeds: all pass | **Loss threshold**, as the soak defines it: below it, the Gateway leaves no request unanswered for 5 s. In practice that is at most 4 lost asks in a row. ## API guard diff `src/shared/app/ipc.compat.test-d.ts`: ```diff @@ -86,6 +86,12 @@ // `CockpitSnapshot.panels` holds only boards in your cockpit, and `BoardPanel.placed` (always true // for those) was removed. // +// Fix track #132 (remove Refresh, user 2026-10-17): Connection › Connected nodes lost its Refresh +// button — the 5 s roster poll, its re-ask and every reconnect already ask for the roster. The +// intent `nodes:refresh` (CTRL.nodesRefresh, `refreshNodes`) was removed from the frozen copy +// below, and `DeviceStatusPush` lost `canRefresh` (its only reader was that button; a type change +// only, no channel renamed). +// // Type-only: never imported, never run, not matched by vitest (`*.test.ts`) or the build. import type { Mutate, StoreApi } from 'zustand/vanilla' import type {} from 'zustand/middleware' // registers the subscribeWithSelector store mutator @@ -227,7 +233,6 @@ interface OldCTRL { readonly captureToggle: 'capture:toggle' readonly replayOpen: 'replay:open' readonly hidAvailability: 'hid:availability' - readonly nodesRefresh: 'nodes:refresh' readonly nodesGet: 'nodes:get' readonly nodeMappingSet: 'node-mapping:set' readonly nodeMappingAdopt: 'node-mapping:adopt' @@ -288,7 +293,6 @@ interface OldSkyhawkApi { toggleCapture(): Promise openReplay(): Promise getHidAvailability(): Promise - refreshNodes(): Promise getNodes(): Promise setNodeMapping(nodeId: number, patch: MappingPatch): Promise adoptNode(nodeId: number, spec: AdoptSpec): Promise ``` ## Checks 1360 tests pass (1339 before). `lint` 0/0, `lint:deps` clean, `typecheck` (3 projects) clean. ## Not settled - **A single lost line inside a reply** still gives a one-poll false offline: a row is lost, then the END reconciles. The END carries PanelBridge's row count (it includes node 0), which could guard against this, but that is a new rule beyond these decisions and isn't done here. - **#132's Refresh row** also says "Update the design board's Connection board too". That is the design artifact, not this repo, so it isn't in this PR. --- src/main/app/app-services.test.ts | 34 +++ src/main/app/app-services.ts | 6 +- src/main/app/ipc.ts | 1 - src/main/app/pushed-rules.seam.test.ts | 15 +- src/main/app/roster-poll.seam.test.ts | 266 ++++++++++++++++++ src/main/gateway/gateway-connection.ts | 43 +-- src/main/gateway/gateway-health.test.ts | 52 ++++ src/main/gateway/gateway-health.ts | 31 +- src/main/gateway/link-state.test.ts | 9 +- src/main/gateway/link-state.ts | 11 +- src/main/gateway/serial-bridge.test.ts | 51 ++++ src/main/gateway/serial-bridge.ts | 21 +- src/main/nodes/ipc.ts | 3 +- src/main/nodes/node-hub.ts | 64 +++-- src/main/nodes/node-status-wire.test.ts | 104 ++++++- src/main/nodes/node-status-wire.ts | 42 ++- src/main/test-harness/services.ts | 1 - src/preload/index.ts | 1 - src/renderer/src/gateway/store.ts | 4 - src/renderer/src/nodes/NodesCard.tsx | 8 +- src/renderer/src/nodes/store.ts | 5 - src/renderer/test/skyhawk-mock.ts | 14 +- .../test/t5-connection-nodes-card.test.tsx | 14 +- src/shared/app/ipc.compat.test-d.ts | 8 +- src/shared/app/ipc.ts | 1 - src/shared/gateway/ipc.ts | 8 +- src/shared/nodes/ipc.ts | 1 - 27 files changed, 682 insertions(+), 136 deletions(-) create mode 100644 src/main/app/roster-poll.seam.test.ts diff --git a/src/main/app/app-services.test.ts b/src/main/app/app-services.test.ts index ed427e0..426b8db 100644 --- a/src/main/app/app-services.test.ts +++ b/src/main/app/app-services.test.ts @@ -476,6 +476,40 @@ describe('device link is independent of the relay (#69)', () => { session.shutdown() }) + it.each([ + [ + 'Disconnect then Connect', + (s: InstanceType) => { + s.connection.disconnect() + s.connection.connect() + } + ], + [ + 'an error then Retry', + (s: InstanceType, orphan: FakeSerial) => { + orphan.errorCb(new Error('Access denied')) + s.connection.connect() + } + ] + ])('after %s, the released bridge no longer drives the link (M4)', (_, release) => { + const { session, serial: orphan, of } = launched() + release(session, orphan) + const current = FakeSerial.last! + expect(current, 'a fresh bridge').not.toBe(orphan) + const pushed = of('device:status').length + // The released bridge had an open in flight: it completes, then the port errors and closes. + orphan.openCb('/dev/fake') + orphan.dataCb(ascii('_NODE_STATUS_END\n')) + orphan.errorCb(new Error('Access denied')) + orphan.closeCb() + expect(session.status().device.state, 'still looking with the fresh bridge').toBe('scanning') + expect(of('device:status'), 'no status from the orphan').toHaveLength(pushed) + expect(orphan.written, 'no roster request through the orphan').toEqual([]) + current.openCb('/dev/fake') // the current bridge still drives it + expect(session.status().device.state).toBe('connected') + session.shutdown() + }) + it('ignores connect while the link is already open or looking', () => { const { session, serial } = launched() session.connection.connect() diff --git a/src/main/app/app-services.ts b/src/main/app/app-services.ts index 129f1fc..4f633a5 100644 --- a/src/main/app/app-services.ts +++ b/src/main/app/app-services.ts @@ -74,8 +74,7 @@ export class AppServices { log: debugLog, frames: this.calibration, health: () => this.nodes.gatewayHealth(), - serialMonitor: () => this.serialMonitor.shown, - canRefresh: () => this.nodes.canRefresh() + serialMonitor: () => this.serialMonitor.shown }) this.nodes = new NodeHub(emit, new NodeRoster(), (events) => this.journal.report(events), { mappingFile, @@ -106,7 +105,8 @@ export class AppServices { // `connected`: roster request → `opened`: calibration read, HID reopen // port closes `closing`: nodes' last seen → `closed`: calibration → reconnect/disconnect // teardown `release`: calibration → port stop → `released`: the Gateway's health - // 200 ms tick `flush`: HID → (link up) `tick`: node pushes, Gateway health + // 200 ms tick `flush`: HID → (link up) `tick`: node pushes, Gateway health, re-ask + // Retry `closing`: nodes' last seen → teardown → a fresh port (M7) // shutdown `closing`: nodes → timers cleared → teardown → `stop`: HID // The DCS side keeps its order from before the split too (R2.3a). None of its constructors has // a side effect. diff --git a/src/main/app/ipc.ts b/src/main/app/ipc.ts index f97b7db..bc484aa 100644 --- a/src/main/app/ipc.ts +++ b/src/main/app/ipc.ts @@ -30,7 +30,6 @@ const IPC_ORDER = [ 'device:connect', 'device:disconnect', 'hid:availability', - 'nodes:refresh', 'nodes:get', 'node-mapping:set', 'node-mapping:adopt', diff --git a/src/main/app/pushed-rules.seam.test.ts b/src/main/app/pushed-rules.seam.test.ts index 03ceeb9..fc92f86 100644 --- a/src/main/app/pushed-rules.seam.test.ts +++ b/src/main/app/pushed-rules.seam.test.ts @@ -94,32 +94,29 @@ describe('relay:facts: what the relay’s surfaces show', () => { }) describe('R3.4a: the last renderer decisions, pushed by their owners', () => { - it('device:status: the link’s action, and Refresh only while a roster request can go', () => { + it('device:status: the link’s action, and no Refresh fact (#132: the button is gone)', () => { const app = makeServices(monitor) expect(app.lastPush('device:status')).toMatchObject({ state: 'scanning', - action: { offer: 'disconnect', intent: 'disconnect', disabled: true }, - canRefresh: false + action: { offer: 'disconnect', intent: 'disconnect', disabled: true } }) app.serial.open() expect(app.lastPush('device:status')).toMatchObject({ state: 'connected', - action: { offer: 'disconnect', intent: 'disconnect' }, - canRefresh: true + action: { offer: 'disconnect', intent: 'disconnect' } }) app.serial.close() app.serial.fail('EIO') expect(app.lastPush('device:status')).toMatchObject({ state: 'error', - action: { offer: 'retry', intent: 'connect' }, - canRefresh: false + action: { offer: 'retry', intent: 'connect' } }) app.invoke(CTRL.deviceDisconnect) expect(app.lastPush('device:status')).toMatchObject({ state: 'disconnected', - action: { offer: 'connect', intent: 'connect' }, - canRefresh: false + action: { offer: 'connect', intent: 'connect' } }) + for (const push of app.pushesOf('device:status')) expect(push).not.toHaveProperty('canRefresh') }) it('relay:facts: Record hidden and Capture shown in Replay, together; Settings’ row always', () => { diff --git a/src/main/app/roster-poll.seam.test.ts b/src/main/app/roster-poll.seam.test.ts new file mode 100644 index 0000000..be6c51f --- /dev/null +++ b/src/main/app/roster-poll.seam.test.ts @@ -0,0 +1,266 @@ +// SESSION-seam tests for the roster poll and the Gateway's "not responding" deadline (#132: M2, +// G1, M3, M7 and the removed Refresh), through the services harness: a simulated PanelBridge +// answers the roster requests the app writes, and the test reads only pushes, intents and files. +import { describe, expect, it } from 'vitest' +import { CTRL } from '@shared/app' +import { eventText } from '@shared/format' +import type { NodeMappingFile } from '@shared/nodes' +import { statusLine } from '../../test-fixtures/nodes' +import { makeServices, type Services } from '../test-harness/services' + +// After the harness, so main is loaded against its fakes. +const { nodeRosterRequest } = await import('../nodes/node-status-wire') +const REQUEST = Buffer.from(nodeRosterRequest()).toString('latin1') +/** Boards 1 and 2 are in your cockpit. */ +const COCKPIT = { + 'node-mapping.json': { version: 4, nodes: { 1: { added: true }, 2: { added: true } } } +} +/** Boards 1–4 are in your cockpit: the soak's bus. */ +const SOAK_COCKPIT = { + 'node-mapping.json': { + version: 4, + nodes: { 1: { added: true }, 2: { added: true }, 3: { added: true }, 4: { added: true } } + } +} +const STEP_MS = 50 + +const iso = (ms: number) => new Date(ms).toISOString() +const lastSeen = (app: Services, id: number) => + app.files.json('node-mapping.json')?.nodes[id]?.lastSeen + +/** A seeded PRNG (mulberry32): the same soak every run. */ +function random(seed: number): () => number { + let a = seed >>> 0 + return () => { + a = (a + 0x6d2b79f5) >>> 0 + let t = a + t = Math.imul(t ^ (t >>> 15), t | 1) + t ^= t + Math.imul(t ^ (t >>> 7), t | 61) + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +interface BridgeModel { + /** The reply to the n-th roster request (0 = the seed on connect) is lost. */ + lost?: (n: number, now: number) => boolean + /** How long the n-th reply takes to start arriving. */ + delay?: (n: number) => number + /** The gap between one reply line and the next. */ + gap?: () => number + /** + * The longest the bridge leaves a request without any line back, while it answers at all: a + * late reply is brought forward to this. Unset: no limit. + */ + answerWithinMs?: number +} + +/** + * PanelBridge as the wire shows it: every roster request it reads is answered with one unbroken + * burst — its own node-0 row, a row per board, `_NODE_STATUS_END ` — in the order asked, + * each line arriving on its own, so a request can land in the middle of a reply. + */ +function bridge(app: Services, boards: number[], model: BridgeModel = {}) { + const PANEL = 0x01 + const PDU = 0x02 + const kind = (id: number) => (id >= 3 && id <= 5 ? PDU : PANEL) // NODE_IDS.md: 3–5 are PDUs + let asked = 0 + let lastLineAt = 0 + /** The first request since the last line sent: what the app's not-responding clock counts from. */ + let waitingSince: number | undefined + const lines: { at: number; text: string }[] = [] + const burst = [ + statusLine(0, true, 3), + ...boards.map((id) => statusLine(id, true, kind(id))), + `_NODE_STATUS_END ${boards.length + 1}\n` + ] + return { + /** Requests read so far. */ + get asked() { + return asked + }, + /** Read what the app wrote, and send every reply line that is due. */ + step(): void { + const now = app.clock.now() + const n = app.serial.writtenText().split(REQUEST).length - 1 + app.serial.clearWritten() + for (let i = 0; i < n; i++, asked++) { + waitingSince ??= now + if (model.lost?.(asked, now)) continue + let at = now + (model.delay?.(asked) ?? 5) + if (model.answerWithinMs !== undefined) { + at = Math.min(at, waitingSince + model.answerWithinMs) + } + at = Math.max(at, lastLineAt) // one serial line: replies arrive in the order asked + for (const text of burst) { + lines.push({ at, text }) + lastLineAt = at + at += model.gap?.() ?? 0 + } + } + while (lines.length && lines[0].at <= now) { + app.serial.receive(lines.shift()!.text) + waitingSince = undefined + } + } + } +} + +/** Run the app and the bridge together for `ms`, in STEP_MS steps; `each` sees every step. */ +function run(app: Services, b: ReturnType, ms: number, each?: () => void): void { + for (let t = 0; t < ms; t += STEP_MS) { + app.clock.advance(STEP_MS) + b.step() + each?.() + } +} + +const notResponding = (app: Services) => + app.lastPush('device:status')?.gateway?.kind === 'not-responding' + +describe('G1 one lost roster reply never reads as "Gateway not responding"', () => { + it('the poll’s reply is lost: it is asked again within a second, and nothing flashes', () => { + const app = makeServices({ paths: { files: COCKPIT } }) + // Request 0 is the seed on connect, 1 the first 5 s poll: that reply is lost. + const b = bridge(app, [1, 2], { lost: (n) => n === 1 }) + app.serial.open() + let lostAt: number | undefined + let reaskAt: number | undefined + run(app, b, 30_000, () => { + if (b.asked === 2 && lostAt === undefined) lostAt = app.clock.now() + if (b.asked === 3 && reaskAt === undefined) reaskAt = app.clock.now() + }) + expect(app.pushesOf('device:status').some((s) => s.gateway?.kind === 'not-responding')).toBe( + false + ) + expect( + app.invoke(CTRL.eventsGet).filter((e) => /not responding|responding again/.test(eventText(e))) + ).toEqual([]) + // The re-ask follows the lost poll by a second (one 200 ms tick of slack), not the next poll. + expect(reaskAt! - lostAt!).toBeLessThanOrEqual(1000 + 200 + STEP_MS) + }) + + it('a real outage still raises it: within 5.2 s of the first unanswered request', () => { + const app = makeServices({ paths: { files: COCKPIT } }) + const outage: { from?: number } = {} + const b = bridge(app, [1, 2], { + lost: (_n, now) => outage.from !== undefined && now >= outage.from + }) + app.serial.open() + run(app, b, 12_000) + const silentFrom = app.clock.now() + outage.from = silentFrom // the Gateway stops answering, its port still open + const askedBefore = b.asked + let firstUnanswered: number | undefined + let raisedAt: number | undefined + run(app, b, 20_000, () => { + if (b.asked > askedBefore) firstUnanswered ??= app.clock.now() + if (notResponding(app)) raisedAt ??= app.clock.now() + }) + expect(raisedAt, 'raised').toBeDefined() + // The bound: the next poll (≤ 5 s) + the 5 s deadline + one 200 ms tick. + expect(raisedAt! - silentFrom).toBeLessThanOrEqual(5000 + 5000 + 200 + STEP_MS) + expect(raisedAt! - firstUnanswered!).toBeGreaterThanOrEqual(5000 - STEP_MS) + expect(raisedAt! - firstUnanswered!).toBeLessThanOrEqual(5000 + 200 + STEP_MS) + expect(b.asked - askedBefore, 'asked again every second meanwhile').toBeGreaterThanOrEqual(5) + }) +}) + +describe('soak: 30 simulated minutes of lost and late replies (M2, G1, the #290 gate)', () => { + it('no board ever reads offline, nothing is re-saved, and "not responding" never flashes', () => { + const app = makeServices({ paths: { files: SOAK_COCKPIT } }) + const rnd = random(290) + // Below the loss threshold: a reply is lost a quarter of the time but never more than two in + // a row, and the bridge never leaves a request unanswered for 4.5 s — so the Gateway is never + // silent for the 5 s deadline. Some replies come seconds late and trickle in, so later + // requests (the 5 s poll, re-asks) land while one is still arriving: what M2 is about. + let lostInRow = 0 + const b = bridge(app, [1, 2, 3, 4], { + lost: () => { + const lost = lostInRow < 2 && rnd() < 0.25 + lostInRow = lost ? lostInRow + 1 : 0 + return lost + }, + delay: () => { + const r = rnd() + return r < 0.6 ? 2 + rnd() * 48 : r < 0.85 ? 800 + rnd() * 800 : 2500 + rnd() * 2000 + }, + gap: () => (rnd() < 0.85 ? rnd() * 40 : 100 + rnd() * 200), + answerWithinMs: 4500 + }) + app.serial.open() + // Until the first answer arms the re-ask, only the 5 s poll asks: two lost in a row, then a + // late answer, is 14.5 s. Every board is online by then. + run(app, b, 16_000) + const seen = () => [1, 2, 3, 4].map((id) => lastSeen(app, id)) + const onlineAt = seen() + expect(onlineAt.every(Boolean), 'all came online').toBe(true) + + let offline: string | undefined + let flashed = false + run(app, b, 30 * 60_000, () => { + flashed ||= notResponding(app) + const m = app.lastPush('nodes:model') + for (const n of m?.nodes ?? []) { + if (n.availability !== 'online') offline ??= `${n.nodeId} at ${app.clock.now()}` + } + }) + + expect(offline, 'a false offline').toBeUndefined() + expect(flashed, 'a "not responding" flash').toBe(false) + expect(b.asked, 'the poll and its re-asks kept asking').toBeGreaterThan(360) + expect(seen(), 'no extra saves').toEqual(onlineAt) + const events = app.invoke(CTRL.eventsGet) + expect( + events.filter((e) => e.source === 'node' && e.nodeId !== undefined).map(eventText), + 'no board went offline or came back' + ).toEqual([]) + expect(events.map(eventText).filter((r) => /responding/.test(r))).toEqual([]) + }) +}) + +describe('M3 a v2 Gateway’s roster END completes nothing', () => { + it('your boards stay unknown, not offline, so Hide and Forget stay refused', () => { + const app = makeServices({ paths: { files: COCKPIT } }) + const v2 = '_NODE_STATUS 01010000000000000080000000\n' // 26 hex: no nodeKind + app.serial.open() + app.serial.receive(v2 + '_NODE_STATUS_END 1\n') + app.clock.advance(200) + const model = app.invoke(CTRL.nodesGet).model + expect(model.rosterComplete).toBe(false) + expect(model.nodes.map((n) => [n.nodeId, n.availability])).toEqual([ + [1, 'unknown'], + [2, 'unknown'] + ]) + expect(app.invoke(CTRL.nodeMappingHide, 2, true).ok).toBe(false) + expect(app.invoke(CTRL.nodeMappingForget, 2).ok).toBe(false) + }) +}) + +describe('M7 Retry after an error saves last seen, as Disconnect, unplug and quit do (B13)', () => { + it('the boards on the bus were last seen at the Retry', () => { + const app = makeServices({ paths: { files: COCKPIT } }) + app.serial.open() + app.serial.receive(statusLine(0, true, 3) + statusLine(1) + '_NODE_STATUS_END 2\n') + app.clock.advance(200) // the link flush: it came online + const cameOnline = app.clock.now() + expect(lastSeen(app, 1)).toBe(iso(cameOnline)) + app.clock.advance(3000) + app.serial.fail('EIO') // the port fails while open + expect(app.lastPush('device:status')?.state).toBe('error') + app.clock.advance(1000) + const retryAt = app.clock.now() + app.invoke(CTRL.deviceConnect) // Retry + expect(lastSeen(app, 1)).toBe(iso(retryAt)) + }) +}) + +describe('Refresh is gone; the poll still asks (#132)', () => { + it('no nodes:refresh intent, and the 5 s poll writes the roster request', () => { + expect(Object.values(CTRL)).not.toContain('nodes:refresh') + const app = makeServices() + app.serial.open() + app.serial.clearWritten() + app.clock.advance(5000) + expect(app.serial.writtenText()).toContain(REQUEST) + }) +}) diff --git a/src/main/gateway/gateway-connection.ts b/src/main/gateway/gateway-connection.ts index 829b57e..0a22de6 100644 --- a/src/main/gateway/gateway-connection.ts +++ b/src/main/gateway/gateway-connection.ts @@ -56,7 +56,10 @@ export interface LinkRider { timers?(every: LinkTimer): void /** start(): after the timers, before the first connect. */ start?(): void - /** shutdown(), disconnect(), and the port closing under the link: before anything else. */ + /** + * shutdown(), disconnect(), Retry (connect() after an error), and the port closing under the + * link: before anything else. + */ closing?(): void /** connect(): a new port, before any byte from it can arrive. */ attach?(): void @@ -86,17 +89,14 @@ export interface LinkRider { /** * What GatewayConnection needs from app (P6: it never asks electron): the debug log * (SerialBridge), the calibration frame filter, the Gateway's health as the node layer follows - * it (NodeHub drives GatewayWatch, UD-C), which the connected status carries, whether the - * page's serial monitor is on (SerialTrafficMonitor), which every status push carries (R3.3), and - * whether a roster request can go out (NodeHub's guard), which every status push carries too as - * Refresh's availability (R3.4a). + * it (NodeHub drives GatewayWatch, UD-C), which the connected status carries, and whether the + * page's serial monitor is on (SerialTrafficMonitor), which every status push carries (R3.3). */ export interface GatewayConnectionDeps { log: Log frames: CalFrameFilter health: () => GatewayStatus | undefined serialMonitor?: () => boolean - canRefresh?: () => boolean } /** The link's push tick: node status, node model and the HID snapshot. */ @@ -206,19 +206,32 @@ export class GatewayConnection { */ connect(): void { if (this.serial && this.status.state !== 'error') return + // The Retry: the boards on the bus were last seen now, as on Disconnect (M7, #93). + if (this.serial) for (const r of this.riders) r.closing?.() this.teardown() // Always retries: "not found" keeps looking whatever the setting, because someone asked for // this link. What auto-connect governs is reopening after a drop, handled in onClose. const s = new SerialBridge(true, this.deps.log) this.serial = s for (const r of this.riders) r.attach?.() // calibration: a CalLink for this port - s.onData((chunk) => this.onSerialData(chunk)) + // Only the current bridge drives the link (M4): one released by Disconnect or Retry may still + // call back (an open it had in flight), and must not reopen the link or hold its state. + const current = (): boolean => this.serial === s + s.onData((chunk) => { + if (current()) this.onSerialData(chunk) + }) s.onMonitor((dir, chunk) => { + if (!current()) return for (const cb of this.trafficCbs) cb(dir, chunk) }) - s.onOpen((path) => this.onOpen(path)) - s.onClose(() => this.onClose()) + s.onOpen((path) => { + if (current()) this.onOpen(path) + }) + s.onClose(() => { + if (current()) this.onClose() + }) s.onError((err) => { + if (!current()) return if (err instanceof PortNotFoundError) { // The normal state of an unplugged board. Still looking; not an error. this.setStatus({ state: 'no-device' }) @@ -415,14 +428,10 @@ export class GatewayConnection { private withFacts(status: DeviceStatus): DeviceStatusPush { return { ...shownStatus(status), - ...linkFacts( - status, - { - linkDownAt: this.linkDown.value, - serialMonitor: this.deps.serialMonitor?.() ?? false - }, - this.deps.canRefresh?.() ?? false - ) + ...linkFacts(status, { + linkDownAt: this.linkDown.value, + serialMonitor: this.deps.serialMonitor?.() ?? false + }) } } diff --git a/src/main/gateway/gateway-health.test.ts b/src/main/gateway/gateway-health.test.ts index 231bceb..585048e 100644 --- a/src/main/gateway/gateway-health.test.ts +++ b/src/main/gateway/gateway-health.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { GATEWAY_NO_ANSWER_MS, + GATEWAY_REASK_MS, GatewayWatch, gatewayEvents, gatewayStatus, @@ -226,6 +227,57 @@ describe('GatewayWatch — not responding', () => { }) }) +describe('GatewayWatch — the re-ask (G1)', () => { + const armed = (): GatewayWatch => { + const w = new GatewayWatch() + w.answered('_NODE_STATUS_END', 0) + return w + } + + it('asks again once a request has gone unanswered for GATEWAY_REASK_MS, and after each re-ask', () => { + const w = armed() + w.asked(1000) + expect(w.reaskDue(1000 + GATEWAY_REASK_MS - 1)).toBe(false) + expect(w.reaskDue(1000 + GATEWAY_REASK_MS)).toBe(true) + w.asked(2000) // the re-ask + expect(w.reaskDue(2500)).toBe(false) + expect(w.reaskDue(3000)).toBe(true) + w.answered('_NODE_STATUS 00…', 3100) + expect(w.reaskDue(10_000), 'answered: nothing to ask again').toBe(false) + }) + + it('leaves room for four re-asks inside the not-responding window', () => { + // Five asks unanswered in a row before "not responding": one lost reply can never trip it. + expect(Math.floor((GATEWAY_NO_ANSWER_MS - 1) / GATEWAY_REASK_MS)).toBeGreaterThanOrEqual(4) + }) + + it('never for a Gateway that has not answered since the port opened, nor through a pause', () => { + const fresh = new GatewayWatch() + fresh.asked(0) + expect(fresh.reaskDue(60_000), 'left to the regular poll').toBe(false) + const w = armed() + w.asked(0) + w.tick(500, true) // a calibration exchange holds the link + expect(w.reaskDue(5000)).toBe(false) + }) + + it('one lost reply, re-asked and answered, never reads as "not responding"', () => { + const w = armed() + w.asked(5000) // the poll: its reply is lost + let t = 5000 + const out: LogEventInput[] = [] + for (; t < 5000 + GATEWAY_NO_ANSWER_MS * 2; t += 200) { + out.push(...w.tick(t)) + if (w.reaskDue(t)) { + w.asked(t) // the re-ask: answered at once + out.push(...w.answered('_NODE_STATUS_END 1', t + 5)) + } + } + expect(out).toEqual([]) + expect(w.status()).toBeUndefined() + }) +}) + describe('GatewayWatch — its own row', () => { it('pushes only when the status changes (not uptime or rxCount)', () => { const w = new GatewayWatch() diff --git a/src/main/gateway/gateway-health.ts b/src/main/gateway/gateway-health.ts index 35e8c47..953be95 100644 --- a/src/main/gateway/gateway-health.ts +++ b/src/main/gateway/gateway-health.ts @@ -8,6 +8,8 @@ // popover, Overview alert, Cockpit Attention, event log — only reads it. Every alert is a flag the // Gateway raised itself; the one client-side rule is "not responding": once it has answered since // the port opened, a roster request unanswered for GATEWAY_NO_ANSWER_MS while the port is open. +// The re-ask that goes with it lives here too (G1): an unanswered request is asked again every +// GATEWAY_REASK_MS, so one lost reply is answered long before the deadline and never trips it. import type { LogEventInput } from '@shared/events' import { NODE_FAULT_CODES, type NodeFaultName } from '@shared/firmware' import { title } from '@shared/format' @@ -19,6 +21,14 @@ import { isLinkUp } from './link-state' /** How long a roster request may go unanswered, with the port open, before the Gateway is "not responding". */ export const GATEWAY_NO_ANSWER_MS = 5000 +/** + * How long a roster request may go unanswered before it is asked again (G1), and again after each + * re-ask. PanelBridge answers in milliseconds (the roster reply window is 1 s), so a request still + * unanswered after this was lost. Inside the not-responding window that is four re-asks after the + * first request: "not responding" takes five lost replies in a row, never one. + */ +export const GATEWAY_REASK_MS = 1000 + const SECS = GATEWAY_NO_ANSWER_MS / 1000 type Flag = (typeof STATUS_FLAGS)[number] @@ -202,11 +212,13 @@ export function gatewayEvents( /** * Follows the Gateway for one open port: its own row, and whether it still answers roster * requests. Driven by the device link (asked / answered / report / tick); hands back log rows for - * what changed, and says when the status worth pushing changed. + * what changed, says when the status worth pushing changed, and when to ask again (reaskDue). */ export class GatewayWatch { /** The oldest roster request still unanswered. */ private askedAt?: number + /** The latest roster request: a re-ask is timed from it. */ + private lastAskAt?: number private row?: NodeStatus private noAnswer = false /** @@ -220,6 +232,7 @@ export class GatewayWatch { /** A port open or close: a different board, or the same one after an outage. */ reset(): void { this.askedAt = undefined + this.lastAskAt = undefined this.row = undefined this.noAnswer = false this.answeredOnce = false @@ -230,6 +243,22 @@ export class GatewayWatch { /** A roster request went out. A later one doesn't restart the clock. */ asked(now: number): void { this.askedAt ??= now + this.lastAskAt = now + } + + /** + * Ask again now (G1): a request has gone unanswered for GATEWAY_REASK_MS since the latest ask. + * Only once it has answered since the port opened, as "not responding" is: a Gateway built + * without node status is left to the regular poll. Nothing is pending through a calibration + * pause (tick), so there is no re-ask then either. + */ + reaskDue(now: number): boolean { + return ( + this.answeredOnce && + this.askedAt !== undefined && + this.lastAskAt !== undefined && + now - this.lastAskAt >= GATEWAY_REASK_MS + ) } /** Any `_NODE_STATUS` / `_NODE_STATUS_END` line: it answers. */ diff --git a/src/main/gateway/link-state.test.ts b/src/main/gateway/link-state.test.ts index 3b6af8a..1babc05 100644 --- a/src/main/gateway/link-state.test.ts +++ b/src/main/gateway/link-state.test.ts @@ -86,12 +86,11 @@ describe('the link’s action (R3.4a: the renderer’s deviceView table before)' }) }) - it('every push carries it, and Refresh’s availability as the node layer hands it in', () => { + it('every push carries it, and nothing for Refresh (#132: the button is gone)', () => { const up = { state: 'connected' as const } - expect(linkFacts(up, { serialMonitor: false }, true)).toMatchObject({ - action: { offer: 'disconnect', intent: 'disconnect' }, - canRefresh: true + expect(linkFacts(up, { serialMonitor: false })).toMatchObject({ + action: { offer: 'disconnect', intent: 'disconnect' } }) - expect(linkFacts(up, { serialMonitor: false }).canRefresh, 'no node layer').toBe(false) + expect(linkFacts(up, { serialMonitor: false })).not.toHaveProperty('canRefresh') }) }) diff --git a/src/main/gateway/link-state.ts b/src/main/gateway/link-state.ts index a70915b..8a14e35 100644 --- a/src/main/gateway/link-state.ts +++ b/src/main/gateway/link-state.ts @@ -105,14 +105,10 @@ export class LinkDownClock { } } -/** - * What `device:status` carries besides the status itself. `canRefresh` is NodeHub's (its roster - * request guard), handed in: none without a node layer. - */ +/** What `device:status` carries besides the status itself. */ export function linkFacts( status: DeviceStatus, - page: { linkDownAt?: number; serialMonitor: boolean }, - canRefresh = false + page: { linkDownAt?: number; serialMonitor: boolean } ): DeviceLinkFacts { return { linkUp: isLinkUp(status.state), @@ -120,7 +116,6 @@ export function linkFacts( busy: isBusy(status.detail), ...(page.linkDownAt !== undefined ? { linkDownAt: page.linkDownAt } : {}), serialMonitor: page.serialMonitor, - action: linkAction(status.state), - canRefresh + action: linkAction(status.state) } } diff --git a/src/main/gateway/serial-bridge.test.ts b/src/main/gateway/serial-bridge.test.ts index ffc1e88..d713c3d 100644 --- a/src/main/gateway/serial-bridge.test.ts +++ b/src/main/gateway/serial-bridge.test.ts @@ -147,3 +147,54 @@ describe('SerialBridge port lifetime', () => { }) }) }) + +describe('SerialBridge stopped mid-attempt (M4)', () => { + beforeEach(() => { + FakePort.built = [] + FakePort.present = true + }) + + it('stopped while looking for the board: builds no port and reports nothing', async () => { + const s = new SerialBridge(true) + const calls: string[] = [] + s.onOpen(() => calls.push('open')) + s.onError((e) => calls.push(`error ${e.message}`)) + s.start() // the port enumeration is in flight + s.stop() + await flush() + expect(FakePort.built, 'no port built after the stop').toEqual([]) + expect(calls).toEqual([]) + }) + + it('stopped while the port is opening: the open that completes is released, not reported', async () => { + const s = new SerialBridge(true) + const opened: string[] = [] + s.onOpen((p) => opened.push(p)) + s.start() + await flush() + const port = FakePort.built[0]! + s.stop() // Disconnect or Retry, with the open still in flight + port.settle() // the driver completes the open after the stop + expect(opened, 'the link is never told').toEqual([]) + expect(port.isOpen, 'and the port is closed again, not held until quit').toBe(false) + await flush() + expect(port.destroyed).toBe(true) + }) + + it('stopped while the port is opening: a failed open schedules no retry', async () => { + vi.useFakeTimers() + try { + const s = new SerialBridge(true) + const errors: string[] = [] + s.onError((e) => errors.push(e.message)) + s.start() + await vi.advanceTimersByTimeAsync(0) + s.stop() + FakePort.built[0]!.settle(new Error('access denied')) + expect(errors).toEqual([]) + expect(vi.getTimerCount(), 'no reconnect pending').toBe(0) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/gateway/serial-bridge.ts b/src/main/gateway/serial-bridge.ts index b7a072f..4871b53 100644 --- a/src/main/gateway/serial-bridge.ts +++ b/src/main/gateway/serial-bridge.ts @@ -136,7 +136,12 @@ export class SerialBridge implements SerialLink { private disposePort(): Promise { const port = this.port this.port = undefined - if (!port) return Promise.resolve() + return port ? this.release(port) : Promise.resolve() + } + + /** Release one port instance: its listeners unbound, closed if open, then destroyed. */ + private release(port: SerialPort): Promise { + if (this.port === port) this.port = undefined port.removeAllListeners() // Re-armed immediately: a late error from the dying handle would otherwise reach a stream with // no 'error' listener, which Node escalates to an uncaught exception. @@ -170,9 +175,15 @@ export class SerialBridge implements SerialLink { } } + /** + * One attempt to find and open the board. `stopped` is checked again after every await (M4): a + * bridge stopped while its attempt was in flight goes no further — it opens nothing, reports + * nothing, and releases a port that finished opening after the stop. + */ private async openOnce(): Promise { try { const path = await findSimGatewayPort() + if (this.stopped) return if (!path) { if (!this.notFoundLogged) this.log('serial.notFound', { vid: VID_HEX, pid: PID_HEX }) this.notFoundLogged = true @@ -188,6 +199,7 @@ export class SerialBridge implements SerialLink { // blocking its own reopen. Measured against real hardware: 24 of 30 reopens failed with // "Access denied" without this await, 0 of 30 with it. await this.disposePort() + if (this.stopped) return const port = new SerialPort({ path, baudRate: BAUD, autoOpen: false }) this.port = port port.on('data', (d: Buffer) => { @@ -206,7 +218,11 @@ export class SerialBridge implements SerialLink { // Awaited so `opening` covers the open itself, not merely the call that starts it. await new Promise((resolve) => { port.open((err) => { - if (err) { + if (this.stopped) { + // Stopped mid-open: stop() released `this.port`, but this handle may have opened + // after it. Release it here so the port is not held until quit. + if (!err) void this.release(port) + } else if (err) { this.log('serial.openError', err.message) this.errorCb(err) this.retry() @@ -217,6 +233,7 @@ export class SerialBridge implements SerialLink { }) }) } catch (err) { + if (this.stopped) return this.errorCb(err as Error) this.retry() } diff --git a/src/main/nodes/ipc.ts b/src/main/nodes/ipc.ts index c64430e..4580cbd 100644 --- a/src/main/nodes/ipc.ts +++ b/src/main/nodes/ipc.ts @@ -1,4 +1,4 @@ -// nodes' intents: the roster, the node data and the node mapping. app/ipc.ts registers them. +// nodes' intents: the node data and the node mapping. app/ipc.ts registers them. import type { IpcRegistrar } from '@shared/app' import { NODES_CTRL, @@ -15,7 +15,6 @@ export interface NodesIpcDeps { export function registerNodesIpc(ipc: IpcRegistrar, deps: NodesIpcDeps): void { const { nodes } = deps - ipc.handle(NODES_CTRL.nodesRefresh, () => nodes.requestNodes()) ipc.handle(NODES_CTRL.nodesGet, () => nodes.snapshot()) ipc.handle(NODES_CTRL.nodeMappingSet, (_e, id: number, patch: MappingPatch) => nodes.setMapping(id, patch) diff --git a/src/main/nodes/node-hub.ts b/src/main/nodes/node-hub.ts index c596bd1..f989ed1 100644 --- a/src/main/nodes/node-hub.ts +++ b/src/main/nodes/node-hub.ts @@ -1,9 +1,10 @@ -// Node data layer in main: the node roster and its 5 s poll, the node lines the Gateway's line -// tap hands it (`_NODE_STATUS`, `_NODE_TELEM`), the Gateway's own health (it drives GatewayWatch, -// UD-C), the node mapping (through MappingStore), and the combined node picture it pushes. It -// rides on the Gateway connection (its `rider`, a gateway LinkRider) and writes the roster request -// through the connection's write (R2.2b). It also hosts the Cockpit snapshot (R3.2): built from -// each node push and the link, and pushed with them on `cockpit:snapshot`. +// Node data layer in main: the node roster and its 5 s poll (and the re-ask GatewayWatch times), +// the node lines the Gateway's line tap hands it (`_NODE_STATUS`, `_NODE_TELEM`), the Gateway's +// own health (it drives GatewayWatch, UD-C), the node mapping (through MappingStore), and the +// combined node picture it pushes. It rides on the Gateway connection (its `rider`, a gateway +// LinkRider) and writes the roster request through the connection's write (R2.2b). It also hosts +// the Cockpit snapshot (R3.2): built from each node push and the link, and pushed with them on +// `cockpit:snapshot`. import type { AdoptRequest, AdoptSpec, @@ -61,7 +62,6 @@ import { BRIDGE_NODE_ID, canRequestRoster, cleanNodeLine, - isLegacyNodeStatus, NODE_END_MSG, NODE_MSG, nodeRosterRequest, @@ -117,8 +117,11 @@ export interface NodeHubDeps { /** Shortest gap between takes of the PDU pages. ~3 PDUs send ~3 frames/s between them. */ const NODE_TELEM_PUSH_MS = 1000 -/** The roster poll. */ -const NODES_REFRESH_MS = 5000 +/** + * The roster poll. Also the health channel: PanelBridge answers from its cache and does not forward + * HEALTH, so an unanswered request is asked again sooner (GatewayWatch.reaskDue, G1). + */ +const NODES_POLL_MS = 5000 type Unsubscribe = () => void @@ -216,7 +219,7 @@ export class NodeHub implements NodeLineSink { /** NodeHub's part of the Gateway connection's moments. AppServices rides it after HID. */ readonly rider: LinkRider = { // Poll the node roster so silent deaths get reconciled — relaying or not. - timers: (every) => every(NODES_REFRESH_MS, () => this.requestNodes()), + timers: (every) => every(NODES_POLL_MS, () => this.requestNodes()), // Shutdown, Disconnect, unplug: the boards on the bus were last seen now (#93). closing: () => this.linkClosed(), released: () => this.gateway.reset(), @@ -231,10 +234,13 @@ export class NodeHub implements NodeLineSink { this.gateway.takeChanged() // just sent, on the connected status this.requestNodes() // seed the roster as soon as the device is up }, - // The link's push tick, while it is up: node pushes, then the Gateway's health. + // The link's push tick, while it is up: node pushes, then the Gateway's health, then a re-ask + // of a roster request gone unanswered (G1: one lost reply never reads as "not responding"). tick: () => { + const now = Date.now() this.flush(this.roster.takeDirty()) - this.onEvents(this.gateway.tick(Date.now(), this.link.exportGated)) + this.onEvents(this.gateway.tick(now, this.link.exportGated)) + if (this.gateway.reaskDue(now)) this.requestNodes() if (this.gateway.takeChanged()) { this.link.pushGatewayHealth(this.gateway.status()) this.cockpit.linkChanged() @@ -266,16 +272,15 @@ export class NodeHub implements NodeLineSink { return this.gateway.status() } - /** A roster request can go out now (canRequestRoster): Connection's Refresh (R3.4a). */ - canRefresh(): boolean { - return canRequestRoster(this.link.attached, this.link.deviceStatus().state) - } - - /** Ask PanelBridge for the full node roster (inject the request export to the serial). */ + /** + * Ask PanelBridge for the full node roster (inject the request export to the serial): the poll, + * its re-ask and the seed on connect. A reply still arriving is kept (NodeRoster.beginBurst, M2). + */ requestNodes(): void { - if (!this.canRefresh()) return - this.roster.beginBurst() // isolate the reply burst so prior deltas don't block pruning - this.gateway.asked(Date.now()) + if (!canRequestRoster(this.link.attached, this.link.deviceStatus().state)) return + const now = Date.now() + this.roster.beginBurst(now) + this.gateway.asked(now) this.link.write(Buffer.from(nodeRosterRequest())) } @@ -287,18 +292,19 @@ export class NodeHub implements NodeLineSink { /** One line from the Gateway's line tap. True for a node line: tapped here, not a command. */ nodeLine(identifier: string, arg: string, line: string, t: number): boolean { - if (identifier === NODE_MSG && !this.legacyNodesWarned && isLegacyNodeStatus(arg.trim())) { - // Every v2 row would be dropped as malformed, leaving the Cockpit silently empty. - this.legacyNodesWarned = true - this.raise( - 'NODES', - 'The Gateway sends node status v2; this client needs v3 — update the Gateway firmware (OpenSkyhawk#344)' - ) - } // Node-status messages are tapped to the roster, not logged as panel commands. if (identifier === NODE_MSG || identifier === NODE_END_MSG) { this.log('node', `${identifier} ${arg}`.trim()) this.roster.applyMessage(identifier, arg, t) + if (!this.legacyNodesWarned && this.roster.legacyBridge()) { + // Every v2 row is dropped as malformed (and its END ignored, M3): say why the Cockpit is + // empty, once. + this.legacyNodesWarned = true + this.raise( + 'NODES', + 'The Gateway sends node status v2; this client needs v3 — update the Gateway firmware (OpenSkyhawk#344)' + ) + } this.statusLine(identifier, line, t) this.gatewayLine(identifier, arg, line, t) return true diff --git a/src/main/nodes/node-status-wire.test.ts b/src/main/nodes/node-status-wire.test.ts index da8a156..da061d5 100644 --- a/src/main/nodes/node-status-wire.test.ts +++ b/src/main/nodes/node-status-wire.test.ts @@ -12,7 +12,7 @@ import { } from './node-status-wire' import { nodeFaultTag } from './faults' import type { NodeStatus } from '@shared/nodes' -import { nodeStatus } from '../../test-fixtures/nodes' +import { nodeStatus, statusRow } from '../../test-fixtures/nodes' const node = (over: Partial): NodeStatus => nodeStatus(1, { uptimeSec: 0, rxCount: 0, ...over }) @@ -104,7 +104,7 @@ describe('nodeRosterRequest', () => { }) }) -describe('canRequestRoster (R3.4a: Connection’s Refresh)', () => { +describe('canRequestRoster (the poll, its re-ask and the seed on connect)', () => { it('only with a port held and the link up', () => { expect(canRequestRoster(true, 'connected')).toBe(true) expect(canRequestRoster(false, 'connected'), 'released at shutdown').toBe(false) @@ -191,6 +191,106 @@ describe('NodeRoster', () => { }) }) +describe('NodeRoster — a new request never cuts a reply short (M2)', () => { + const row = (id: number) => statusRow(id, true, id === 0 ? 3 : 1) + /** One v3 reply's lines: the bridge's node-0 row first, then a row per board. */ + const reply = (r: NodeRoster, ids: number[], t: number) => { + for (const id of [0, ...ids]) r.applyMessage(NODE_MSG, row(id), t) + } + const states = (r: NodeRoster) => + r + .snapshot() + .filter((n) => n.nodeId !== 0) + .map((n) => [n.nodeId, n.present]) + + it('a reply arriving across a new request is kept: no board reads offline', () => { + const r = new NodeRoster() + r.beginBurst(0) // the first poll's roster: boards 1, 2 and 3 + reply(r, [1, 2, 3], 10) + r.applyMessage(NODE_END_MSG, '4', 10) + r.beginBurst(5000) // the next poll + reply(r, [1], 5010) + r.beginBurst(5011) // a request lands while that reply is still arriving + r.applyMessage(NODE_MSG, row(2), 5012) + r.applyMessage(NODE_MSG, row(3), 5013) + r.applyMessage(NODE_END_MSG, '4', 5014) + expect(states(r)).toEqual([ + [1, true], + [2, true], + [3, true] + ]) + for (const id of [1, 2, 3]) expect(r.seenInfo().get(id)?.offlineAt, `${id}`).toBeUndefined() + }) + + it('replies queued behind each other: a request in the second one keeps it whole', () => { + const r = new NodeRoster() + r.beginBurst(0) + r.beginBurst(1000) // asked again before the first reply came + reply(r, [1, 2], 1500) // the first reply, whole + r.applyMessage(NODE_END_MSG, '3', 1500) + reply(r, [1], 1510) // the second reply starts + r.beginBurst(1520) // and the next request lands inside it + r.applyMessage(NODE_MSG, row(2), 1530) + r.applyMessage(NODE_END_MSG, '3', 1530) + expect(states(r)).toEqual([ + [1, true], + [2, true] + ]) + expect(r.seenInfo().get(1)?.offlineAt).toBeUndefined() + }) + + it('a board missing from a whole reply is still offline by it', () => { + const r = new NodeRoster() + r.beginBurst(0) + reply(r, [1, 2], 1) + r.applyMessage(NODE_END_MSG, '3', 1) + r.beginBurst(5000) + r.beginBurst(5001) // a second request changes nothing about what the reply says + reply(r, [1], 5002) + r.applyMessage(NODE_END_MSG, '2', 5002) + expect(states(r)).toEqual([ + [1, true], + [2, false] + ]) + expect(r.seenInfo().get(2)).toMatchObject({ offlineAt: 5002, offlineVia: 'roster' }) + }) + + it('with no reply arriving, a request still isolates the next reply from earlier deltas', () => { + const r = new NodeRoster() + r.applyMessage(NODE_MSG, row(1), 0) // a live delta: board 1 joined + r.beginBurst(5000) + reply(r, [3], 5001) // the reply no longer lists it + r.applyMessage(NODE_END_MSG, '2', 5001) + expect(states(r)).toEqual([ + [1, false], + [3, true] + ]) + }) +}) + +describe('NodeRoster — a v2 bridge’s END completes nothing (M3)', () => { + const V2 = '01010000000000000080000000' // 26 hex: a v2 row, dropped + + it('with a v2 bridge the roster never completes, so no board reads offline', () => { + const r = new NodeRoster() + r.beginBurst(0) + r.applyMessage(NODE_MSG, V2, 1) + r.applyMessage(NODE_END_MSG, '1', 1) + expect(r.legacyBridge()).toBe(true) + expect(r.rosterCompleteAt(), 'unknown, not a complete empty roster').toBeUndefined() + expect(r.snapshot()).toEqual([]) + }) + + it('a reset (a port open) forgets it: a v3 bridge on the new link completes as before', () => { + const r = new NodeRoster() + r.applyMessage(NODE_MSG, V2, 1) + r.reset() + expect(r.legacyBridge()).toBe(false) + r.applyMessage(NODE_END_MSG, '0', 2) + expect(r.rosterCompleteAt()).toBe(2) + }) +}) + describe('nodeFaultTag', () => { it('uses the enriched abbr', () => { const n = node({ diff --git a/src/main/nodes/node-status-wire.ts b/src/main/nodes/node-status-wire.ts index a6f1775..3cb7304 100644 --- a/src/main/nodes/node-status-wire.ts +++ b/src/main/nodes/node-status-wire.ts @@ -104,8 +104,8 @@ export function nodeRosterRequest(): Uint8Array { } /** - * A roster request can go out — the poll, the seed on connect, and Refresh: a port is held and the - * link is up. NodeHub's guard; `device:status` carries it as Refresh's availability (R3.4a). + * A roster request can go out — the poll, its re-ask, and the seed on connect: a port is held and + * the link is up. NodeHub's guard. */ export function canRequestRoster(attached: boolean, state: DeviceState): boolean { return attached && isLinkUp(state) @@ -122,13 +122,26 @@ export const ROSTER_REPLY_MS = 1000 * burst ends with `_NODE_STATUS_END`, after which nodes absent from the burst are * marked offline (present=false) — they STAY in the roster (shown red) rather than * being removed, so a panel that drops off is still visible. + * + * A request never cuts a reply short (M2). PanelBridge answers every request with one unbroken + * burst that starts with its own node-0 row (v3; that row is never a delta) and ends with + * `_NODE_STATUS_END`, in the order asked. So from a node-0 row to the next END a reply is + * arriving: a request then keeps the rows already in, and the END reconciles all of them. Only a + * request with no reply arriving isolates the next burst from prior deltas. */ export class NodeRoster { private nodes = new Map() private seen = new Map() private burst: NodeStatus[] = [] - /** When the pending roster request went out; cleared by its `_NODE_STATUS_END`. */ + /** When the latest roster request went out; cleared by an `_NODE_STATUS_END`. */ private burstAt?: number + /** A reply is arriving: its node-0 row is in, its `_NODE_STATUS_END` not yet (M2). */ + private replying = false + /** + * A v2 (pre-#344) bridge was heard on this link (M3). Its rows are dropped, so its END would + * reconcile against nothing and mark every board offline while it is on the bus: ignored. + */ + private legacy = false private completeAt?: number private dirty = false @@ -137,13 +150,18 @@ export class NodeRoster { this.seen.clear() this.burst = [] this.burstAt = undefined + this.replying = false + this.legacy = false this.completeAt = undefined this.dirty = true } - /** Call when a roster request is sent, so the reply burst is isolated from prior deltas. */ + /** + * Call when a roster request is sent. With no reply arriving, the reply burst is isolated from + * prior deltas; while one is arriving its rows are kept (M2): a request never discards them. + */ beginBurst(now = Date.now()): void { - this.burst = [] + if (!this.replying) this.burst = [] this.burstAt = now } @@ -162,8 +180,12 @@ export class NodeRoster { applyMessage(name: string, arg: string, now = Date.now()): void { if (name === NODE_MSG) { const ns = parseNodeStatus(arg.trim()) - if (!ns) return + if (!ns) { + if (isLegacyNodeStatus(arg.trim())) this.legacy = true + return + } this.burst.push(ns) + if (ns.nodeId === BRIDGE_NODE_ID) this.replying = true // a reply starts with node 0 const wasPresent = this.nodes.get(ns.nodeId)?.present ?? true this.nodes.set(ns.nodeId, ns) // keep absent nodes too; present flag carries state const seen = this.seen.get(ns.nodeId) ?? { firstSeenAt: now } @@ -177,6 +199,8 @@ export class NodeRoster { } this.dirty = true } else if (name === NODE_END_MSG) { + // A v2 bridge's END completes nothing: its rows never reached the roster (M3). + if (this.legacy) return // Nodes not in this burst went silent → mark offline, but keep them visible. const inBurst = new Set(this.burst.map((n) => n.nodeId)) for (const [id, node] of this.nodes) { @@ -188,6 +212,7 @@ export class NodeRoster { } this.burst = [] this.burstAt = undefined + this.replying = false this.completeAt ??= now this.dirty = true } @@ -202,6 +227,11 @@ export class NodeRoster { return this.seen } + /** A v2 (pre-#344) bridge was heard since reset: its rows are dropped, its END ignored (M3). */ + legacyBridge(): boolean { + return this.legacy + } + /** When the first complete roster (first `_NODE_STATUS_END`) since reset arrived. */ rosterCompleteAt(): number | undefined { return this.completeAt diff --git a/src/main/test-harness/services.ts b/src/main/test-harness/services.ts index 714e5dd..4881368 100644 --- a/src/main/test-harness/services.ts +++ b/src/main/test-harness/services.ts @@ -226,7 +226,6 @@ function launch(opts: ServicesOptions) { }, [CTRL.deviceConnect]: () => services.connection.connect(), [CTRL.deviceDisconnect]: () => services.connection.disconnect(), - [CTRL.nodesRefresh]: () => services.nodes.requestNodes(), [CTRL.nodesGet]: () => services.nodes.snapshot(), [CTRL.nodeMappingSet]: (id: number, patch: MappingPatch) => services.nodes.setMapping(id, patch), diff --git a/src/preload/index.ts b/src/preload/index.ts index ea9e094..15223e4 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -81,7 +81,6 @@ const api: SkyhawkApi = { toggleCapture: () => ipcRenderer.invoke(CTRL.captureToggle) as Promise, openReplay: () => ipcRenderer.invoke(CTRL.replayOpen) as Promise, getHidAvailability: () => ipcRenderer.invoke(CTRL.hidAvailability) as Promise, - refreshNodes: () => ipcRenderer.invoke(CTRL.nodesRefresh) as Promise, getNodes: () => ipcRenderer.invoke(CTRL.nodesGet) as Promise, setNodeMapping: (nodeId, patch) => ipcRenderer.invoke(CTRL.nodeMappingSet, nodeId, patch) as Promise, diff --git a/src/renderer/src/gateway/store.ts b/src/renderer/src/gateway/store.ts index cd72616..0bb54fd 100644 --- a/src/renderer/src/gateway/store.ts +++ b/src/renderer/src/gateway/store.ts @@ -33,8 +33,6 @@ export interface GatewayState { deviceBusy: boolean /** The device card's and the popover's button, as main offers it (`device:status`, R3.4a). */ deviceAction: LinkAction - /** A roster request can go out: Connection's Refresh (`device:status`, R3.4a). */ - canRefresh: boolean /** * When the device link last went down after being up — "last known" time on the Cockpit. Main * stamps it (`device:status`, R3.3). @@ -76,7 +74,6 @@ function linkState( | 'boardPresent' | 'deviceBusy' | 'deviceAction' - | 'canRefresh' | 'linkDownAt' | 'serialMonitor' > { @@ -89,7 +86,6 @@ function linkState( boardPresent: d.boardPresent, deviceBusy: d.busy, deviceAction: d.action, - canRefresh: d.canRefresh, linkDownAt: d.linkDownAt, serialMonitor: d.serialMonitor } diff --git a/src/renderer/src/nodes/NodesCard.tsx b/src/renderer/src/nodes/NodesCard.tsx index f6d5090..0743de6 100644 --- a/src/renderer/src/nodes/NodesCard.tsx +++ b/src/renderer/src/nodes/NodesCard.tsx @@ -12,26 +12,20 @@ export function NodesCard({ autoConnect }: { autoConnect: boolean }) { // Counts and rows as main summed them up (cockpit:snapshot, R3.2b). const sum = useNodesStore((x) => x.cockpit.nodesCard) const deviceState = useGatewayStore((x) => x.deviceState) - const refreshNodes = useNodesStore((x) => x.refreshNodes) const openInCockpit = useNav((x) => x.openInCockpit) const set = useNav((x) => x.set) // Node status rides on the device link, which is up in every source mode (#69). const linkUp = useGatewayStore((x) => x.linkUp) - // Whether a roster request can go out, as NodeHub decides it (`device:status`, R3.4a). - const canRefresh = useGatewayStore((x) => x.canRefresh) return (
Connected nodes -
{!linkUp ? (
{deviceView(deviceState, { autoConnect }).nodesHint}
) : sum.known === 0 ? ( -
No nodes reported. Power on a PanelGroup, or press Refresh.
+
No nodes reported. Power on a PanelGroup.
) : ( <>
diff --git a/src/renderer/src/nodes/store.ts b/src/renderer/src/nodes/store.ts index 379bf85..6cd4ed4 100644 --- a/src/renderer/src/nodes/store.ts +++ b/src/renderer/src/nodes/store.ts @@ -20,7 +20,6 @@ export interface NodesState { */ cockpit: CockpitSnapshot - refreshNodes: () => void /** `nodes:get`: the Settings sections' pull when they mount. */ pullNodes: Ask<'getNodes'> getNodeRegistry: Ask<'getNodeRegistry'> @@ -46,10 +45,6 @@ export function bindNodes(skyhawk: () => SkyhawkApi | undefined): void { export const useNodesStore = create(() => ({ cockpit: COCKPIT_IDLE, - refreshNodes: () => { - void api()?.refreshNodes() - }, - pullNodes: () => api()?.getNodes(), getNodeRegistry: () => api()?.getNodeRegistry(), setNodeMapping: (nodeId, patch) => api()?.setNodeMapping(nodeId, patch), diff --git a/src/renderer/test/skyhawk-mock.ts b/src/renderer/test/skyhawk-mock.ts index 8a63745..bb11e15 100644 --- a/src/renderer/test/skyhawk-mock.ts +++ b/src/renderer/test/skyhawk-mock.ts @@ -98,7 +98,6 @@ import { CalibrationWorkflowHost } from '../../main/hid/calibration/workflow-hos import { CockpitSnapshotHost } from '../../main/nodes/cockpit-host' import { isLinkUp, LinkDownClock, linkFacts } from '../../main/gateway/link-state' import { shownStatus } from '../../main/gateway/gateway-health' -import { canRequestRoster } from '../../main/nodes/node-status-wire' import { captureView, relayFacts, replayView } from '../../main/dcs/relay-rules' import { savedEdit } from '../../main/config/settings-form' import { resolvePsu } from '../../main/nodes/psu-limits' @@ -350,7 +349,6 @@ export function installSkyhawkMock(): SkyhawkMock { pushFacts() } ), - refreshNodes: vi.fn(() => resolve(undefined)), setNodeMapping: vi.fn(() => resolve(mapped())), adoptNode: vi.fn(() => resolve(mapped())), forgetNode: vi.fn(() => resolve(mapped())), @@ -451,23 +449,13 @@ export function installSkyhawkMock(): SkyhawkMock { /** The Cockpit's "last known" time and the serial monitor's flag: the page's, kept in main. */ const linkDown = new LinkDownClock() let monitorShown = false - /** - * NodeHub's roster guard, for Refresh (R3.4a). A port is held from connect() until it is - * released — every state but `disconnected` (GatewayConnection.attached). - */ - const canRefresh = (): boolean => - canRequestRoster(device().state !== 'disconnected', device().state) /** * The status with what main concludes from it, as the last push carried it: the Gateway's * health only while the link is up (shownStatus, R3.4a). */ const devicePush = (): DeviceStatusPush => ({ ...shownStatus(device()), - ...linkFacts( - device(), - { linkDownAt: linkDown.value, serialMonitor: monitorShown }, - canRefresh() - ) + ...linkFacts(device(), { linkDownAt: linkDown.value, serialMonitor: monitorShown }) }) /** One `device:status` push, judged as GatewayConnection judges each. */ const statusPush = (): DeviceStatusPush => { diff --git a/src/renderer/test/t5-connection-nodes-card.test.tsx b/src/renderer/test/t5-connection-nodes-card.test.tsx index 9f9af99..80b7c5b 100644 --- a/src/renderer/test/t5-connection-nodes-card.test.tsx +++ b/src/renderer/test/t5-connection-nodes-card.test.tsx @@ -97,21 +97,17 @@ describe('T5 Connection › nodes summary card (B63, B14, B37, X2)', () => { expect(screen.getByText('+1 more · 1 no status yet · healthy nodes hidden')).toBeInTheDocument() }) - it('Refresh asks main for the roster while the link is up', async () => { - const { sky, user } = await renderScenario(, { - device: connected(), - model: scene() - }) - await user.click(screen.getByRole('button', { name: 'Refresh' })) - expect(sky.refreshNodes).toHaveBeenCalledTimes(1) + it('has no Refresh button: the roster poll and every reconnect ask for it (#132)', async () => { + await renderScenario(, { device: connected(), model: scene() }) + expect(screen.getByText('Connected nodes')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Refresh' })).toBeNull() }) - it('Refresh is disabled and the rows give way to a hint while the link is down', async () => { + it('the rows give way to a hint while the link is down', async () => { await renderScenario(, { device: link('disconnected'), model: scene() }) - expect(screen.getByRole('button', { name: 'Refresh' })).toBeDisabled() expect( screen.getByText('Device link released — press Connect to see node status.') ).toBeInTheDocument() diff --git a/src/shared/app/ipc.compat.test-d.ts b/src/shared/app/ipc.compat.test-d.ts index 604945f..cfc64c9 100644 --- a/src/shared/app/ipc.compat.test-d.ts +++ b/src/shared/app/ipc.compat.test-d.ts @@ -86,6 +86,12 @@ // `CockpitSnapshot.panels` holds only boards in your cockpit, and `BoardPanel.placed` (always true // for those) was removed. // +// Fix track #132 (remove Refresh, user 2026-10-17): Connection › Connected nodes lost its Refresh +// button — the 5 s roster poll, its re-ask and every reconnect already ask for the roster. The +// intent `nodes:refresh` (CTRL.nodesRefresh, `refreshNodes`) was removed from the frozen copy +// below, and `DeviceStatusPush` lost `canRefresh` (its only reader was that button; a type change +// only, no channel renamed). +// // Type-only: never imported, never run, not matched by vitest (`*.test.ts`) or the build. import type { Mutate, StoreApi } from 'zustand/vanilla' import type {} from 'zustand/middleware' // registers the subscribeWithSelector store mutator @@ -227,7 +233,6 @@ interface OldCTRL { readonly captureToggle: 'capture:toggle' readonly replayOpen: 'replay:open' readonly hidAvailability: 'hid:availability' - readonly nodesRefresh: 'nodes:refresh' readonly nodesGet: 'nodes:get' readonly nodeMappingSet: 'node-mapping:set' readonly nodeMappingAdopt: 'node-mapping:adopt' @@ -288,7 +293,6 @@ interface OldSkyhawkApi { toggleCapture(): Promise openReplay(): Promise getHidAvailability(): Promise - refreshNodes(): Promise getNodes(): Promise setNodeMapping(nodeId: number, patch: MappingPatch): Promise adoptNode(nodeId: number, spec: AdoptSpec): Promise diff --git a/src/shared/app/ipc.ts b/src/shared/app/ipc.ts index 3e94349..ee7b40e 100644 --- a/src/shared/app/ipc.ts +++ b/src/shared/app/ipc.ts @@ -256,7 +256,6 @@ export interface SkyhawkApi { toggleCapture(): Promise openReplay(): Promise getHidAvailability(): Promise - refreshNodes(): Promise // ── node data layer (#61) ───────────────────────────────────────────────── // Every mapping change persists to node-mapping.json and also pushes `nodes:model`. diff --git a/src/shared/gateway/ipc.ts b/src/shared/gateway/ipc.ts index 81df94d..f1c2e88 100644 --- a/src/shared/gateway/ipc.ts +++ b/src/shared/gateway/ipc.ts @@ -99,11 +99,6 @@ export interface DeviceLinkFacts { serialMonitor: boolean /** The device card's and the Device popover's button (R3.4a). */ action: LinkAction - /** - * A roster request can go out, so Connection's Refresh is available: NodeHub's own guard on it - * (main/nodes/node-status-wire.ts canRequestRoster, R3.4a). - */ - canRefresh: boolean } /** `device:status` as main pushes it: the link's status and what it concludes from it. */ @@ -119,8 +114,7 @@ export const DEVICE_IDLE: DeviceStatusPush = { boardPresent: false, busy: false, serialMonitor: false, - action: { offer: 'disconnect', intent: 'disconnect' }, - canRefresh: false + action: { offer: 'disconnect', intent: 'disconnect' } } /** One raw serial chunk for the live serial monitor. */ diff --git a/src/shared/nodes/ipc.ts b/src/shared/nodes/ipc.ts index 17d332e..3eff230 100644 --- a/src/shared/nodes/ipc.ts +++ b/src/shared/nodes/ipc.ts @@ -893,7 +893,6 @@ export const NODES_PUSH = { /** Intent channel names (renderer -> main, invoke/response). */ export const NODES_CTRL = { - nodesRefresh: 'nodes:refresh', nodesGet: 'nodes:get', nodeMappingSet: 'node-mapping:set', nodeMappingAdopt: 'node-mapping:adopt', From b1526fb9ec7cc2ff71b52ff6a3d016bcd0a3288b Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Sat, 17 Oct 2026 12:51:45 -0400 Subject: [PATCH 2/3] fix(hid): calibrate badge follows the link, one NACK name table, reload keeps the app's state (#149) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three items from the fix track's chunk 3: N6, X11 and N1. Each one changes behaviour on purpose, as decided on 2026-10-16. One commit per item, plus a small follow-up refactor. N7 (re-reading the HID controls on each connect) was dropped. A mismatched reflash isn't a realistic case, and the HID controls come from `HIDControls.h`, built into the client, so a re-read could never change anything in the app. ## Before → after | Item | Before | After | |---|---|---| | **N6** calibrate badge | The per-axis CAL/RAW badge opened calibration with the link down, while the Calibrate button was greyed out. | One rule in main (`tabState`): calibration needs the serial link. It drives `calibrate.enabled` and the new `tab.badgeOpens`. With the link down a badge still shows its state, greyed out like the button. | | **X11** NACK names | Two functions. `nackRefusalName` named only the 5 reachable reasons. `nackReasonName` named every reason in the table. | One `nackName()` built from `CAL_NACK_REASONS`. Both callers use it: CalLink's refusal message and the failure's `reasonName`. | | **N1** reload | On `relay:status`, main cleared its page-held copies, so a reload lost state main still had. | Main keeps that state. The bundle carries it, and the Cockpit snapshot keeps the session's peak W. | ## NACK name differences The 5 reachable reasons are unchanged: `BAD_INDEX`, `BAD_ORDER`, `NO_SESSION`, `NO_STORAGE`, `BAD_DEADZONE`. | Reason | Refusal message (`device refused (…)`), before → after | `reasonName`, before → after | |---|---|---| | 0x1–0x3 (`BAD_CRC`, `BAD_LENGTH`, `BAD_TYPE`) | `reason 0x1` → `BAD_CRC` (and so on) | unchanged (name) | | unknown byte, e.g. 0x9 | unchanged (`reason 0x9`) | `0x9` → `reason 0x9` | Neither difference is visible in normal use. The framing layer stops 0x1–0x3 before a NACK can be sent. `reasonName` is not displayed anywhere; the dialog shows `message`. ## Rehydrated values (N1) | Value | Where it comes back | |---|---| | Recording on/off (and the last stop's event count) | `relay:status.relayFacts.recording` | | Replay file | `relay:status.relayFacts.replay` | | Serial-monitor flag | `relay:status.device.serialMonitor` | | Aircraft name | `relay:status.aircraft` (new field: the last `aircraft:changed`) | | Telemetry gauges | `relay:status.telemetry` (new field: the last `telemetry:tick`, relaying or not) | | Peak W | the `cockpit:snapshot` pushed on load; `CockpitSnapshotHost.rendererLoaded` keeps the session peak | | Link-down time | `relay:status.device.linkDownAt` | These still start over on a reload: - the open tab and open dialogs (view state); - the calibration view (R3.1, `cal:attach`), so a calibration dialog never resumes. Removed, since nothing calls them now: `DcsRelay.rendererLoaded`, `GatewayConnection.rendererLoaded`, `SerialTrafficMonitor.rendererLoaded` and `LinkDownClock.pageLoaded`. Follow-up refactor: `SerialTrafficMonitor` kept a second copy of its on/off (`shownOn`) only so a reload could clear it. With that reset gone the two never differ, so `device:status` now reads the monitor's own flag. ## API guard (`ipc.compat.test-d.ts`) Updated on purpose, with no channel, intent or method added, removed or renamed: - `RelayStatus` gained `aircraft` and `telemetry`; - `CalibrationTabState` gained `badgeOpens` (type only). The header note sits after the Refresh-removal note from #150. ## Changed assertions | Test | Was | Now | Why | |---|---|---|---| | `main/app/pushed-rules.seam.test.ts` "the serial monitor's flag … a reloaded page **starts over** (N1)" → "… **keeps it** (N1)" | `serialMonitor: false`, `linkDownAt` undefined after `relay:status` | `serialMonitor: true`, `linkDownAt` = the drop time | N1 | | `main/nodes/cockpit-host.test.ts` "a renderer reload **starts the peak over** …" → "… **keeps the session peak** (N1)" | peak 12 W after reload | 24 W | N1 | | `main/gateway/link-state.test.ts` "a reloaded page has none until the link next drops (N1)" | — | **removed** | It tested `LinkDownClock.pageLoaded`, which N1 removes | Test infrastructure changed, with no assertion changes: - **`skyhawk-mock.ts`:** `getStatus` no longer resets the page-held copies and returns `aircraft` and `telemetry`. - **`ipc-adapter.test.ts`:** the fake main's clock starts from the link state main had already pushed (it used to get this from `pageLoaded`). Its bundle has default `aircraft` and `telemetry`. - **`render.ts`:** new `reloadRenderer(ui, page)` helper. It unmounts the page, unsubscribes, resets the stores, then subscribes and pulls again while main keeps running. - **`test-harness/services.ts`:** `capture:toggle` and `replay:open` are now registered through `registerDcsIpc`, over fake dialogs. Every other test keeps its assertions. ## New tests (each run against a reverted fix to check that it fails) - **N6:** - `workflow-host.test.ts` (`tabState`, and the host across a drop). - `t25` "with the link down a badge shows its state but does not open calibration". - Fail when `badgeOpens` follows anything but the link, or when the renderer stops gating the badge. - **X11:** `codec.test.ts` names every `CAL_NACK_REASONS` entry, plus 0x0, 0x9 and 0xff. - **N1:** - `main/app/reload.seam.test.ts` covers all seven values through the services harness. Seven separate one-at-a-time mutations, each putting back one old reset, all fail it. - `t38-reload-rehydrate.test.tsx` covers each value through `reloadRenderer`, plus a check that the tab still resets. Each of the old resets, put back in the mock or the store, fails its case. - `ipc-adapter.test.ts` adds a case using the existing `freshStore` reload pattern. ## Checks - Tests: 1372 pass (1360 on `fix/chunk-3` with #150; this PR adds 13 and removes 1). - Lint: 0 errors, 0 warnings. - `lint:deps`: clean. - Typecheck: clean on all 3 projects. Part of #132 --- ARCHITECTURE.md | 5 +- src/main/app/app-services.ts | 17 ++- src/main/app/ipc.ts | 2 +- src/main/app/pushed-rules.seam.test.ts | 7 +- src/main/app/reload.seam.test.ts | 116 ++++++++++++++++++ src/main/dcs/dcs-relay.ts | 53 +++++--- src/main/dcs/sim-telemetry-decoder.ts | 21 +++- src/main/gateway/gateway-connection.ts | 10 +- src/main/gateway/link-state.test.ts | 19 --- src/main/gateway/link-state.ts | 13 +- src/main/gateway/serial-traffic-monitor.ts | 25 ++-- src/main/hid/calibration/cal-link.ts | 4 +- .../hid/calibration/calibration-view-model.ts | 6 +- src/main/hid/calibration/codec.test.ts | 23 +++- src/main/hid/calibration/codec.ts | 27 +--- .../calibration/hid-calibration-service.ts | 4 +- .../hid/calibration/workflow-host.test.ts | 31 +++++ src/main/nodes/cockpit-host.test.ts | 4 +- src/main/nodes/cockpit-host.ts | 10 +- src/main/test-harness/services.ts | 47 ++++++- src/renderer/src/app/ipc-adapter.test.ts | 33 ++++- src/renderer/src/dcs/store.ts | 7 +- src/renderer/src/hid/HidPanel.tsx | 2 +- src/renderer/test/render.ts | 32 ++++- src/renderer/test/skyhawk-mock.ts | 36 +++--- src/renderer/test/t25-hid-tab.test.tsx | 21 ++++ .../test/t38-reload-rehydrate.test.tsx | 111 +++++++++++++++++ src/shared/app/ipc.compat.test-d.ts | 9 ++ src/shared/app/ipc.ts | 18 ++- src/shared/dcs/ipc.ts | 6 +- src/shared/gateway/ipc.ts | 6 +- src/shared/hid/calibration/ipc.ts | 6 + 32 files changed, 568 insertions(+), 163 deletions(-) create mode 100644 src/main/app/reload.seam.test.ts create mode 100644 src/renderer/test/t38-reload-rehydrate.test.tsx diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d4e5fce..30d2781 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -143,7 +143,10 @@ main service ──emit──▶ push:batch ──▶ preload ──▶ ipc-adap When the renderer loads (or reloads in dev while main keeps running) the adapter also pulls the current picture: `relay:status`, `nodes:get`, `config:get`, `config:backups-get`, `events:get`, -`hid:availability`. +`hid:availability`. A reload loses nothing main holds: the `relay:status` bundle hands back the +recording, the loaded capture, the serial monitor's flag, the aircraft, the gauges and the link's +last-known time, and the Cockpit snapshot keeps the session's peak W. Only view state starts over: +the tab, open dialogs, and a calibration dialog, which never resumes across a reload. ## `push:batch`: one frame per main turn diff --git a/src/main/app/app-services.ts b/src/main/app/app-services.ts index 4f633a5..b35c781 100644 --- a/src/main/app/app-services.ts +++ b/src/main/app/app-services.ts @@ -195,26 +195,25 @@ export class AppServices { } /** - * A renderer (re)loaded (`relay:status`, its first pull): what the page held for itself starts - * over, as its store did (N1) — the link's "last known" time and the serial monitor's flag. + * A renderer (re)loaded (`relay:status`, its first pull): it has the HID report and its live + * axes now, as it has its catalogue from a pull. What main holds for the screen — the link's + * "last known" time, the serial monitor's flag, the recording, the capture, the aircraft and + * the gauges — is kept, and the bundle hands it over (N1). */ rendererLoaded(): void { - this.connection.rendererLoaded() - this.serialMonitor.rendererLoaded() - // …and it has the HID report and its live axes now, as it had its catalogue from a pull. this.hid.rendererLoaded() - // …and the recording and the capture it shows. - this.relay.rendererLoaded() } - /** Snapshot so a (re)loaded renderer can rehydrate the relay and link state. */ + /** Snapshot so a (re)loaded renderer can rehydrate what main holds for the screen (N1). */ status(): RelayStatus { const device = this.connection.statusPush() - const { running, relay } = this.relay.status() + const { running, relay, aircraft, telemetry } = this.relay.status() return { running, relay, relayFacts: this.relay.facts(), + aircraft, + telemetry, device, cal: this.calibration.workflow.state() } diff --git a/src/main/app/ipc.ts b/src/main/app/ipc.ts index bc484aa..8ebf617 100644 --- a/src/main/app/ipc.ts +++ b/src/main/app/ipc.ts @@ -115,7 +115,7 @@ export function registerIpc(ipcMain: IpcRegistrar, deps: IpcDeps): void { services.calibration.workflow.rendererLoaded() // …and the Cockpit snapshot starts over from what main holds, as the old page's store did (R3.2). services.nodes.rendererLoaded() - // …and what the old page's store held for itself goes with it (R3.3, N1). + // …and the HID report goes out; what main holds for the screen is kept for the bundle (N1). services.rendererLoaded() return services.status() }) diff --git a/src/main/app/pushed-rules.seam.test.ts b/src/main/app/pushed-rules.seam.test.ts index fc92f86..bd642da 100644 --- a/src/main/app/pushed-rules.seam.test.ts +++ b/src/main/app/pushed-rules.seam.test.ts @@ -37,17 +37,18 @@ describe('device:status carries the link’s facts', () => { }) }) - it('the serial monitor’s flag comes back on device:status; a reloaded page starts over (N1)', () => { + it('the serial monitor’s flag comes back on device:status; a reloaded page keeps it (N1)', () => { const app = makeServices(monitor) app.serial.open() const n = app.pushesOf('device:status').length app.invoke(CTRL.serialMonitor, true) expect(app.pushesOf('device:status')).toHaveLength(n + 1) expect(app.lastPush('device:status')).toMatchObject({ state: 'connected', serialMonitor: true }) + const at = app.clock.now() app.serial.close() const reloaded = app.invoke(CTRL.relayStatus) - expect(reloaded.device).toMatchObject({ serialMonitor: false }) - expect(reloaded.device.linkDownAt, 'no last-known time for a reloaded page').toBeUndefined() + expect(reloaded.device).toMatchObject({ serialMonitor: true }) + expect(reloaded.device.linkDownAt, 'the last-known time, as main holds it').toBe(at) }) }) diff --git a/src/main/app/reload.seam.test.ts b/src/main/app/reload.seam.test.ts new file mode 100644 index 0000000..f21242f --- /dev/null +++ b/src/main/app/reload.seam.test.ts @@ -0,0 +1,116 @@ +// N1 SESSION-seam test: a renderer that (re)loads is handed what main still holds for the screen — +// the recording, the loaded capture, the serial monitor's flag, the aircraft, the gauges, the +// session's peak W and the link's last-known time — in the rehydrate bundle (`relay:status`) and +// the pushes its load sends. Before, main cleared its page-held copies on `relay:status`. +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { CTRL } from '@shared/app' +import { AIRCRAFT_NONE, TELEMETRY_IDLE } from '@shared/dcs' +import { A4EC_OUTPUTS_BY_ADDRESS } from '@shared/firmware' +import { serializeCapture } from '../dcs/capture-file' +import { makeServices } from '../test-harness/services' +import { statusLine } from '../../test-fixtures/nodes' + +/** The A-4E-C address carrying RPM, a gauge the telemetry tick reads. */ +const [RPM_ADDRESS] = [...A4EC_OUTPUTS_BY_ADDRESS].find(([, o]) => o.some((x) => x.id === 'RPM'))! + +/** One DCS-BIOS export frame: sync, then 16-bit writes from `address` on. */ +function exportFrame(address: number, words: number[]): Buffer { + const head = [0x55, 0x55, 0x55, 0x55, address & 0xff, address >> 8, words.length * 2, 0] + return Buffer.from([...head, ...words.flatMap((w) => [w & 0xff, w >> 8])]) +} + +/** `_ACFT_NAME` (address 0, two characters per word), as DCS writes it. */ +function aircraftFrame(name: string): Buffer { + const bytes = Buffer.from(name.padEnd(24, '\0'), 'latin1') + const words = Array.from( + { length: bytes.length / 2 }, + (_, i) => bytes[2 * i]! | (bytes[2 * i + 1]! << 8) + ) + return exportFrame(0, words) +} + +/** PDU_Left (node 3), 12 V rail page: 12.00 V average, 11.80 V minimum, `mA` average. */ +const railPage = (mA: number) => + `_NODE_TELEM 030100E02E182E${(mA & 0xff).toString(16).padStart(2, '0')}${(mA >> 8) + .toString(16) + .padStart(2, '0')}\n` + +const monitor = { + paths: { + files: { + 'config.json': { sourceMode: 'monitor' }, + 'flight.skyhawk-capture': serializeCapture([ + { t: 0, dir: 'in', hex: '55555555' }, + { t: 1500, dir: 'in', hex: '55555555' } + ]) + } + } +} + +describe('a renderer reload keeps what main holds for the screen (N1)', () => { + it('the recording, the capture, the monitor flag, the aircraft, the gauges, peak W, last known', async () => { + const app = makeServices(monitor) + app.serial.open() + + // The Serial tab's monitor on; the Cockpit's PDU drawing 8.4 W, then 1.2 W. + app.invoke(CTRL.serialMonitor, true) + app.serial.receive(statusLine(3, true, 2) + '_NODE_STATUS_END\n') + app.invoke(CTRL.nodeMappingAdopt, 3, { + name: 'PDU_Left', + console: 'Left', + pduId: null, + kind: 'pdu' + }) + app.serial.receive(railPage(700)) + app.clock.advance(1000) + app.serial.receive(railPage(100)) + app.clock.advance(1000) + const peak = app.lastPush('cockpit:snapshot')!.band.peakW + expect(peak).toBeCloseTo(8.4) + + // Relaying: DCS names the aircraft and moves RPM; a recording runs; a capture is loaded. + app.invoke(CTRL.relayStart) + app.transport.export(aircraftFrame('A-4E-C')) + app.transport.export(exportFrame(RPM_ADDRESS, [0x8000])) + app.clock.advance(200) + const aircraft = app.lastPush('aircraft:changed')! + const gauges = app.lastPush('telemetry:tick')! + expect(aircraft).toMatchObject({ name: 'A-4E-C', supported: true }) + expect(gauges).not.toEqual(TELEMETRY_IDLE) + app.dialogs.saveCapture('session.skyhawk-capture') + await app.invoke(CTRL.captureToggle) + app.dialogs.openCapture('flight.skyhawk-capture') + await app.invoke(CTRL.replayOpen) + const facts = app.lastPush('relay:facts')! + expect(facts.recording).toEqual({ on: true }) + expect(facts.replay).toEqual({ + file: join(app.files.dir, 'flight.skyhawk-capture'), + events: 2, + durationMs: 1500 + }) + + // Stopped, and the Gateway unplugged: the gauges and the aircraft stay as last shown. + app.invoke(CTRL.relayStop) + const at = app.clock.now() + app.serial.close() + + // The window reloads. + const before = app.pushes.length + const bundle = app.invoke(CTRL.relayStatus) + expect(bundle.relayFacts.recording, 'the recording').toEqual({ on: true }) + expect(bundle.relayFacts.replay, 'the loaded capture').toEqual(facts.replay) + expect(bundle.device.serialMonitor, 'the monitor flag').toBe(true) + expect(bundle.aircraft, 'the aircraft').toEqual(aircraft) + expect(bundle.aircraft).not.toEqual(AIRCRAFT_NONE) + expect(bundle.telemetry, 'the gauges').toEqual(gauges) + expect(bundle.device.linkDownAt, 'the last-known time').toBe(at) + const reloaded = app.pushes.slice(before) + expect(reloaded.map((p) => p.channel)).toContain('cockpit:snapshot') + expect(app.lastPush('cockpit:snapshot')?.band.peakW, 'peak W').toBe(peak) + expect( + reloaded.some((p) => p.channel === 'relay:facts' || p.channel === 'device:status'), + 'nothing main holds changed, so nothing of it is pushed again' + ).toBe(false) + }) +}) diff --git a/src/main/dcs/dcs-relay.ts b/src/main/dcs/dcs-relay.ts index 79d8ee6..cd4e312 100644 --- a/src/main/dcs/dcs-relay.ts +++ b/src/main/dcs/dcs-relay.ts @@ -6,7 +6,17 @@ // The device link is not started or stopped here. It is up whenever the board is plugged in // (gateway/gateway-connection.ts); a source mode only chooses where the stream comes from and // whether it reaches the device. -import type { RecordState, RelayFacts, RelayResult, RelayState, ReplayLoad } from '@shared/dcs' +import { + AIRCRAFT_NONE, + TELEMETRY_IDLE, + type AircraftStatus, + type RecordState, + type RelayFacts, + type RelayResult, + type RelayState, + type ReplayLoad, + type TelemetryReadout +} from '@shared/dcs' import type { Emit, RelayStatus } from '@shared/app' import type { LogEventInput } from '@shared/events' import type { DeviceStatus } from '@shared/gateway' @@ -76,9 +86,15 @@ export class DcsRelay { private running = false private relay: RelayState = { running: false } private timers: ReturnType[] = [] - /** The recording and the capture as this page set them (N1: a reloaded page starts over). */ + /** The recording and the capture, as their intents last answered (a reload keeps them, N1). */ private recordingView: RelayFacts['recording'] = { on: false } private replayLoaded?: RelayFacts['replay'] + /** + * The gauges and the aircraft as the telemetry tick last pushed them: what the screen shows, + * stopped or not, so a renderer that (re)loads is handed them (N1). + */ + private telemetry: TelemetryReadout[] = TELEMETRY_IDLE + private aircraft: AircraftStatus = AIRCRAFT_NONE /** The `relay:facts` last pushed, to push only a change. */ private factsKey?: string private readonly device: RelayDevice @@ -170,7 +186,7 @@ export class DcsRelay { } this.timers = [ - setInterval(() => flushTelemetry(this.decoder, this.emit), TELEMETRY_MS), + setInterval(() => this.flushTelemetry(), TELEMETRY_MS), setInterval(() => this.emit('stats:tick', this.stats.snapshot()), STATS_MS) ] return { ok: true } @@ -210,9 +226,17 @@ export class DcsRelay { return this.replay.info() } - /** Whether it is relaying, and the relay's state, for the renderer's rehydrate bundle. */ - status(): Pick { - return { running: this.running, relay: this.relay } + /** + * Whether it is relaying, the relay's state, and the gauges and aircraft last pushed, for the + * renderer's rehydrate bundle. + */ + status(): Pick { + return { + running: this.running, + relay: this.relay, + aircraft: this.aircraft, + telemetry: this.telemetry + } } // ── what the relay's surfaces show (relay:facts, R3.3) ───────────────────── @@ -247,16 +271,6 @@ export class DcsRelay { this.pushFacts() } - /** - * A renderer (re)loaded (`relay:status`): the recording and the capture it shows start over, as - * its store did, whatever main is doing (N1). The bundle carries the facts. - */ - rendererLoaded(): void { - this.recordingView = { on: false } - this.replayLoaded = undefined - this.factsKey = JSON.stringify(this.facts()) - } - private pushFacts(): void { const facts = this.facts() const key = JSON.stringify(facts) @@ -267,6 +281,13 @@ export class DcsRelay { // ── pipeline ─────────────────────────────────────────────────────────────── + /** The telemetry tick (sim-telemetry-decoder.ts), what it pushed remembered for a reload. */ + private flushTelemetry(): void { + const { telemetry, aircraft } = flushTelemetry(this.decoder, this.emit) + this.telemetry = telemetry + if (aircraft) this.aircraft = aircraft + } + /** One chunk of the DCS-BIOS export, from DCS or a capture. */ private onExport(chunk: Buffer): void { this.stats.addIn(chunk.length) diff --git a/src/main/dcs/sim-telemetry-decoder.ts b/src/main/dcs/sim-telemetry-decoder.ts index 221b84f..0417d38 100644 --- a/src/main/dcs/sim-telemetry-decoder.ts +++ b/src/main/dcs/sim-telemetry-decoder.ts @@ -211,9 +211,20 @@ interface TelemetryEmit { (channel: 'aircraft:changed', payload: AircraftStatus): void } -/** The telemetry tick: the five readouts, then the aircraft if it changed since the last tick. */ -export function flushTelemetry(decoder: Decoder, emit: TelemetryEmit): void { - emit('telemetry:tick', decoder.telemetrySnapshot()) - const ac = decoder.aircraft() - if (ac) emit('aircraft:changed', ac) +/** What one telemetry tick pushed: the readouts, and the aircraft if it changed. */ +export interface TelemetryFlush { + telemetry: TelemetryReadout[] + aircraft: AircraftStatus | null +} + +/** + * The telemetry tick: the five readouts, then the aircraft if it changed since the last tick. + * Answers what it pushed, which DcsRelay keeps for a renderer that (re)loads (N1). + */ +export function flushTelemetry(decoder: Decoder, emit: TelemetryEmit): TelemetryFlush { + const telemetry = decoder.telemetrySnapshot() + emit('telemetry:tick', telemetry) + const aircraft = decoder.aircraft() + if (aircraft) emit('aircraft:changed', aircraft) + return { telemetry, aircraft } } diff --git a/src/main/gateway/gateway-connection.ts b/src/main/gateway/gateway-connection.ts index 0a22de6..497f042 100644 --- a/src/main/gateway/gateway-connection.ts +++ b/src/main/gateway/gateway-connection.ts @@ -112,7 +112,7 @@ function subscribe(set: Set, cb: T): Unsubscribe { export class GatewayConnection { private serial?: SerialBridge private status: DeviceStatus = { state: 'disconnected' } - /** The Cockpit's "last known" time, as the page's store kept it (R3.3). */ + /** The Cockpit's "last known" time (R3.3); a reloaded renderer is handed it too (N1). */ private readonly linkDown = new LinkDownClock() private autoConnect = true private started = false @@ -309,14 +309,6 @@ export class GatewayConnection { return this.withFacts(this.status) } - /** - * A renderer (re)loaded (`relay:status`): the page's own link facts start over, as its store - * did — no "last known" time until the link next drops (N1). - */ - rendererLoaded(): void { - this.linkDown.pageLoaded(this.status.state) - } - /** Something `device:status` carries changed without the link changing (the serial monitor). */ pushStatus(): void { this.emitStatus(this.status) diff --git a/src/main/gateway/link-state.test.ts b/src/main/gateway/link-state.test.ts index 1babc05..8b9e0a3 100644 --- a/src/main/gateway/link-state.test.ts +++ b/src/main/gateway/link-state.test.ts @@ -53,25 +53,6 @@ describe('the Cockpit’s "last known" time (store.ts before R3.3)', () => { expect(clock.pushed('disconnected')).toBe(4000) expect(clock.pushed('disconnected')).toBe(4000) }) - - it('a reloaded page has none until the link next drops (N1)', () => { - let now = 1000 - const clock = new LinkDownClock(() => now) - clock.pushed('connected') - clock.pushed('reconnecting') - expect(clock.value).toBe(1000) - clock.pageLoaded('reconnecting') - expect(clock.value).toBeUndefined() - now = 2000 - expect(clock.pushed('no-device'), 'down → down is no drop').toBeUndefined() - expect(clock.pushed('connected')).toBeUndefined() - expect(clock.pushed('reconnecting')).toBe(2000) - // Reloaded while up: the next drop stamps. - clock.pushed('connected') - clock.pageLoaded('connected') - now = 3000 - expect(clock.pushed('error')).toBe(3000) - }) }) describe('the link’s action (R3.4a: the renderer’s deviceView table before)', () => { diff --git a/src/main/gateway/link-state.ts b/src/main/gateway/link-state.ts index 8a14e35..5092af3 100644 --- a/src/main/gateway/link-state.ts +++ b/src/main/gateway/link-state.ts @@ -72,8 +72,8 @@ export function linkAction(state: DeviceState): LinkAction { /** * "When the link last went down after being up", as the renderer's store kept it for the * Cockpit's "last known" time, judged per `device:status` push: stamped on an up → down change, - * cleared while up, otherwise left as it was. The page's own: a renderer that (re)loads starts - * with none until the next drop (N1), so `pageLoaded` forgets it. + * cleared while up, otherwise left as it was. A renderer that (re)loads is handed it in the + * rehydrate bundle (N1). */ export class LinkDownClock { private wasUp = false @@ -94,15 +94,6 @@ export class LinkDownClock { get value(): number | undefined { return this.at } - - /** - * A renderer (re)loaded: its store started with no time, and took the link's state from the - * rehydrate bundle (`relay:status`), which is where its next push is judged from. - */ - pageLoaded(state: DeviceState): void { - this.at = undefined - this.wasUp = isLinkUp(state) - } } /** What `device:status` carries besides the status itself. */ diff --git a/src/main/gateway/serial-traffic-monitor.ts b/src/main/gateway/serial-traffic-monitor.ts index c969c14..f7662b1 100644 --- a/src/main/gateway/serial-traffic-monitor.ts +++ b/src/main/gateway/serial-traffic-monitor.ts @@ -9,33 +9,27 @@ const SERIAL_FLUSH_MS = 50 const MAX_SERIAL_BUF = 4000 export class SerialTrafficMonitor implements LinkRider { - private serialMonitor = false - private serialBuf: SerialFrame[] = [] /** - * The Serial tab's toggle as the page last set it, which `device:status` carries (R3.3). The - * page's own: a renderer that (re)loads shows the monitor off while main keeps it running — and - * its traffic coming — until the toggle is pressed (N1), as when the page held the flag. + * The monitor is on: the Serial tab's toggle as it was last set. `device:status` carries it + * (R3.3), and so does the rehydrate bundle, so a renderer that (re)loads shows the monitor as + * main runs it (N1). */ - private shownOn = false + private serialMonitor = false + private serialBuf: SerialFrame[] = [] private changed?: () => void constructor(private readonly emit: Emit) {} - /** The page's monitor flag (`device:status`). */ + /** The monitor's flag, as `device:status` carries it. */ get shown(): boolean { - return this.shownOn + return this.serialMonitor } - /** Told each time the page's flag is set: GatewayConnection pushes it (AppServices). */ + /** Told each time the flag is set: GatewayConnection pushes it (AppServices). */ onShownChange(cb: () => void): void { this.changed = cb } - /** A renderer (re)loaded: its flag starts off; the monitor itself keeps running (N1). */ - rendererLoaded(): void { - this.shownOn = false - } - /** The `serial:traffic` flush, on the link's timer list. */ timers(every: LinkTimer): void { every(SERIAL_FLUSH_MS, () => this.flushSerial()) @@ -45,8 +39,7 @@ export class SerialTrafficMonitor implements LinkRider { setSerialMonitor(on: boolean): void { this.serialMonitor = on if (!on) this.serialBuf = [] - // The page set its toggle: what it shows comes back on `device:status` (R3.3). - this.shownOn = on + // What the toggle shows comes back on `device:status` (R3.3). this.changed?.() } diff --git a/src/main/hid/calibration/cal-link.ts b/src/main/hid/calibration/cal-link.ts index 3062ad4..c47c9b0 100644 --- a/src/main/hid/calibration/cal-link.ts +++ b/src/main/hid/calibration/cal-link.ts @@ -21,7 +21,7 @@ import { decodeSessionAck, encodeCommit, encodeReset, - nackRefusalName, + nackName, type CalData, type CalFrame, type HelloAck, @@ -49,7 +49,7 @@ export class CalNackError extends Error { /** Axis index where one applies, else CAL_AXIS_NONE. */ readonly detail: number ) { - super(`calibration: device refused (${nackRefusalName(reason)})`) + super(`calibration: device refused (${nackName(reason)})`) this.name = 'CalNackError' } } diff --git a/src/main/hid/calibration/calibration-view-model.ts b/src/main/hid/calibration/calibration-view-model.ts index 8e02ad2..5d51b4a 100644 --- a/src/main/hid/calibration/calibration-view-model.ts +++ b/src/main/hid/calibration/calibration-view-model.ts @@ -171,16 +171,20 @@ export function tabState( snapshot?.axes.forEach((a, i) => { if (a?.present) badges[i] = a.calibrated ? 'cal' : 'raw' }) + // One link rule for every way into the dialog (N6): calibration needs the serial link, so the + // Calibrate button and the per-axis badges open it only while the link is up. + const opens = linkUp return { snapshot, badges, + badgeOpens: opens, uncalibrated, offer, showOffer: !!offer && linkUp && present.length > 0, offerAxis: offer?.axes[0], calibrate: { shown: present.length > 0 || !linkUp, - enabled: linkUp, + enabled: opens, axis: present.find((a) => !a.calibrated)?.idx ?? present[0]?.idx } } diff --git a/src/main/hid/calibration/codec.test.ts b/src/main/hid/calibration/codec.test.ts index 5a3ac65..aed97e7 100644 --- a/src/main/hid/calibration/codec.test.ts +++ b/src/main/hid/calibration/codec.test.ts @@ -17,8 +17,10 @@ import { encodeCommit, encodeHello, encodeReset, - encodeSessionOpen + encodeSessionOpen, + nackName } from './codec' +import { CAL_NACK_REASONS } from '@shared/firmware' const hex = (s: string) => Uint8Array.from(s.match(/../g)!.map((b) => parseInt(b, 16))) const cat = (...parts: Uint8Array[]) => { @@ -295,6 +297,25 @@ describe('decoders', () => { expect(decodeNack(hex('050503'))).toEqual({ type: CAL_TYPE.COMMIT, reason: 0x05, detail: 3 }) }) + it('names every NACK reason the firmware table lists, and an unknown one by its byte (X11)', () => { + // One name for the refusal message and the failure's reasonName, from the synced table: the + // five reachable reasons and the three the framing layer stops alike. + const named = Object.keys(CAL_NACK_REASONS).map((k) => [Number(k), nackName(Number(k))]) + expect(named).toEqual([ + [0x1, 'BAD_CRC'], + [0x2, 'BAD_LENGTH'], + [0x3, 'BAD_TYPE'], + [0x4, 'BAD_INDEX'], + [0x5, 'BAD_ORDER'], + [0x6, 'NO_SESSION'], + [0x7, 'NO_STORAGE'], + [0x8, 'BAD_DEADZONE'] + ]) + expect(nackName(0x9)).toBe('reason 0x9') + expect(nackName(0x0)).toBe('reason 0x0') + expect(nackName(0xff)).toBe('reason 0xff') + }) + it('decodes RAW, keeping raw and cal distinct — and both unsigned', () => { // One convention across the whole channel. If a conversion is ever added for `cal` alone, // this fails: the offset applied to some values and not others is the silent-failure case, diff --git a/src/main/hid/calibration/codec.ts b/src/main/hid/calibration/codec.ts index 2af888c..15bf336 100644 --- a/src/main/hid/calibration/codec.ts +++ b/src/main/hid/calibration/codec.ts @@ -26,8 +26,7 @@ import { CAL_RESET_FIELD, CAL_SESSION_ACK_FIELD, CAL_SESSION_OPEN_FIELD, - CAL_TYPES, - type CalNackName + CAL_TYPES } from '@shared/firmware' /** @@ -74,27 +73,13 @@ export const CAL_TYPE = byName(CAL_TYPES) */ export const CAL_NACK = byName(CAL_NACK_REASONS) -/** The five NACK reasons that can actually reach a client (see CAL_NACK). */ -const REACHABLE_NACK: ReadonlySet = new Set([ - 'BAD_INDEX', - 'BAD_ORDER', - 'NO_SESSION', - 'NO_STORAGE', - 'BAD_DEADZONE' -]) - /** - * A NACK reason as CalLink's refusal message names it: one of the five reachable reasons by - * name, anything else as `reason 0x…`. + * A NACK reason's name (X11): the synced firmware table's name for any reason it lists, the five + * reachable ones and the three that never reach a client alike; anything else as `reason 0x…`. + * The one name both CalLink's refusal message and a calibration failure's `reasonName` carry. */ -export function nackRefusalName(reason: number): string { - const name = CAL_NACK_REASONS[reason]?.name - return name !== undefined && REACHABLE_NACK.has(name) ? name : `reason 0x${reason.toString(16)}` -} - -/** A NACK reason as a calibration failure reports it: any known reason by name, else `0x…`. */ -export function nackReasonName(reason: number): string { - return CAL_NACK_REASONS[reason]?.name ?? `0x${reason.toString(16)}` +export function nackName(reason: number): string { + return CAL_NACK_REASONS[reason]?.name ?? `reason 0x${reason.toString(16)}` } /** diff --git a/src/main/hid/calibration/hid-calibration-service.ts b/src/main/hid/calibration/hid-calibration-service.ts index 8426cd9..9163dfd 100644 --- a/src/main/hid/calibration/hid-calibration-service.ts +++ b/src/main/hid/calibration/hid-calibration-service.ts @@ -25,7 +25,7 @@ import type { CalFrameFilter, LinkRider, LinkTimer } from '../../gateway' import { CalLink, CalNackError, CalTimeoutError, type Log } from './cal-link' import { loadCalCache, saveCalCache, type CacheFileEvents, type CacheFileIO } from './cache-store' import { boardFor, dropAxis, regressedAxes, storeAxes } from './cache-rules' -import { CAL_AXIS_NONE, nackReasonName } from './codec' +import { CAL_AXIS_NONE, nackName } from './codec' import { CalibrationWorkflowHost } from './workflow-host' /** RAW batches (`cal:raw` before R3.1): the serial monitor's cadence, as before the split. */ @@ -215,7 +215,7 @@ export class HidCalibrationService implements LinkRider, CalFrameFilter { return { ok: false, kind: 'timeout', message: err.message } } if (err instanceof CalNackError) { - const name = nackReasonName(err.reason) + const name = nackName(err.reason) return { ok: false, kind: 'nack', diff --git a/src/main/hid/calibration/workflow-host.test.ts b/src/main/hid/calibration/workflow-host.test.ts index 17e4839..85f7031 100644 --- a/src/main/hid/calibration/workflow-host.test.ts +++ b/src/main/hid/calibration/workflow-host.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { CALIBRATION_IDLE, type CalibrationState, type CalSnapshot } from '@shared/hid/calibration' import type { CalibrationCommands } from './calibration-workflow' +import { tabState } from './calibration-view-model' import { CalibrationWorkflowHost } from './workflow-host' const snapshot = (calibratedMask: number, serial = 'BOARD-A'): CalSnapshot => ({ @@ -158,3 +159,33 @@ describe('calibration state lifecycle', () => { expect(snap()?.calibratedMask).toBe(0b00) }) }) + +// N6: the per-axis badge opened the dialog with the link down while Calibrate was greyed out. +describe('the badges follow the Calibrate button’s link rule (N6)', () => { + it('with the link down a badge shows its state but does not open the dialog', () => { + const tab = tabState(snapshot(0b01), false, undefined) + expect(tab.badges).toEqual({ 0: 'cal', 1: 'raw' }) + expect(tab.calibrate.enabled).toBe(false) + expect(tab.badgeOpens, 'the same rule as Calibrate').toBe(false) + }) + + it('with the link up both open it', () => { + const tab = tabState(snapshot(0b01), true, undefined) + expect(tab.calibrate.enabled).toBe(true) + expect(tab.badgeOpens).toBe(true) + }) + + it('the host pushes it with the link: a report that lands after a drop opens nothing', () => { + const { h } = host() + h.linkStatus('connected') + h.reported(snapshot(0b01)) + expect(h.state().tab).toMatchObject({ badgeOpens: true, calibrate: { enabled: true } }) + h.linkStatus('reconnecting') + h.reported(snapshot(0b01)) + expect(h.state().tab).toMatchObject({ + badges: { 0: 'cal', 1: 'raw' }, + badgeOpens: false, + calibrate: { enabled: false } + }) + }) +}) diff --git a/src/main/nodes/cockpit-host.test.ts b/src/main/nodes/cockpit-host.test.ts index 3e370ff..7e7efe3 100644 --- a/src/main/nodes/cockpit-host.test.ts +++ b/src/main/nodes/cockpit-host.test.ts @@ -96,12 +96,12 @@ describe('node state (#63) — the peak W, from the store', () => { expect(host.state().band.peakW).toBeCloseTo(29, 0) }) - it('a renderer reload starts the peak over from what main holds now', () => { + it('a renderer reload keeps the session peak (N1)', () => { const { host } = rig() host.modelPushed(pduModel) host.telemetryPushed(telem(2000)) host.rendererLoaded(pduModel, telem(1000)) - expect(host.state().band.peakW).toBeCloseTo(12) + expect(host.state().band.peakW).toBeCloseTo(24) }) }) diff --git a/src/main/nodes/cockpit-host.ts b/src/main/nodes/cockpit-host.ts index 9b171be..16853be 100644 --- a/src/main/nodes/cockpit-host.ts +++ b/src/main/nodes/cockpit-host.ts @@ -1,7 +1,8 @@ // The Cockpit snapshot as the app runs it (R3.2): what the renderer kept and re-rendered on before // the move, mirrored here so `cockpit:snapshot` goes out at the same moments with the same inputs. // - The renderer's store held the last pushed model and telemetry, and the session's peak W -// (setNodes: each push, and the renderer's first pull, raise it); a reload started it over. +// (setNodes: each push, and the renderer's first pull, raise it). Main keeps the peak for the +// session: a reload no longer starts it over (N1). // - The Cockpit re-rendered on each of those pushes and on `device:status`, and its stray timer // re-rendered it `nextAt` + 50 ms after a board still identifying was due. Its clock for the // strays (its `now` state) moved only then: a push in between was judged at the old time. @@ -90,13 +91,14 @@ export class CockpitSnapshotHost { } /** - * A renderer (re)loaded (`relay:status`, its first pull): its store starts over from what main - * holds now — the model and telemetry its `nodes:get` read — and its peak with them. + * A renderer (re)loaded (`relay:status`, its first pull): its snapshot starts from what main + * holds now — the model and telemetry its `nodes:get` read — and the session's peak W, which a + * reload keeps (N1). */ rendererLoaded(model: NodeModel, telemetry: NodeTelemSnapshot[]): void { this.model = model this.telemetry = telemetry - this.peakW = sessionPeakW(0, model, telemetry) + this.peakW = sessionPeakW(this.peakW, model, telemetry) this.strayClock = this.now() this.publish() } diff --git a/src/main/test-harness/services.ts b/src/main/test-harness/services.ts index 4881368..03c3f5c 100644 --- a/src/main/test-harness/services.ts +++ b/src/main/test-harness/services.ts @@ -16,8 +16,9 @@ import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync, existsSy import { tmpdir } from 'node:os' import { join } from 'node:path' import { onTestFinished, vi } from 'vitest' -import { CTRL, type PushChannel, type PushChannels } from '@shared/app' +import { CTRL, type IpcListener, type PushChannel, type PushChannels } from '@shared/app' import type { AppConfig, ConfigEdit } from '@shared/config' +import type { RecordState, ReplayLoad } from '@shared/dcs' import type { AdoptSpec, MappingPatch, ResetOptions } from '@shared/nodes' // ── the outside world, faked ───────────────────────────────────────────────── @@ -31,7 +32,12 @@ const world = { serialOut: [] as Buffer[], /** Every byte sent to DCS, in order, across relay restarts. */ dcsOut: [] as Buffer[], - transportConnectsOnStart: false + transportConnectsOnStart: false, + /** What the file dialogs answer: a file name in , or none (cancelled). */ + dialogs: { + saveCapture: undefined as string | undefined, + openCapture: undefined as string | undefined + } } /** Stand-in for SerialBridge: one instance per port open, the latest is the board. */ @@ -136,6 +142,7 @@ vi.doMock('../dcs/transports', () => ({ createTransport: () => new FakeTransport vi.doMock('../hid/hid-reader', () => ({ HidReader: FakeHid })) const { AppServices } = await import('../app/app-services') +const { registerDcsIpc } = await import('../dcs') const { storePaths } = await import('../app/user-data') const { PortNotFoundError } = await import('../gateway/serial-bridge') @@ -196,7 +203,8 @@ function launch(opts: ServicesOptions) { serialNumber: opts.serial?.serialNumber ?? 'TESTSERIAL', serialOut: [], dcsOut: [], - transportConnectsOnStart: opts.transport?.connectOnStart ?? false + transportConnectsOnStart: opts.transport?.connectOnStart ?? false, + dialogs: { saveCapture: undefined, openCapture: undefined } }) FakeSerial.last = undefined FakeTransport.last = undefined @@ -209,6 +217,24 @@ function launch(opts: ServicesOptions) { pushes.push({ channel, payload } as Push) }, paths) + // dcs's capture intents, as dcs/ipc.ts registers them, over dialogs that answer from `world`. + const dcsIntents = new Map() + const inUserData = (name?: string) => (name === undefined ? undefined : join(userData, name)) + registerDcsIpc( + { handle: (channel, listener) => void dcsIntents.set(channel, listener) }, + { + relay: services.relay, + recorder: services.recorder, + dialogs: { + saveLog: () => Promise.resolve(undefined), + saveCapture: () => Promise.resolve(inUserData(world.dialogs.saveCapture)), + openCapture: () => Promise.resolve(inUserData(world.dialogs.openCapture)) + } + } + ) + const dcsIntent = (channel: string): Promise => + dcsIntents.get(channel)!(undefined) as Promise + // app/ipc.ts registerIpc() and the topics' ipc.ts: the renderer's requests main answers. const intents = { [CTRL.configGet]: () => services.settings.view(), @@ -217,7 +243,10 @@ function launch(opts: ServicesOptions) { [CTRL.relayStart]: () => services.relay.start(), [CTRL.relayStop]: () => services.relay.stop(), [CTRL.relayToggle]: (relaying: boolean) => services.relay.toggle(relaying), - // A renderer's first pull on (re)load: what the old page held goes with it (app/ipc.ts). + [CTRL.captureToggle]: () => dcsIntent(CTRL.captureToggle), + [CTRL.replayOpen]: () => dcsIntent(CTRL.replayOpen), + // A renderer's first pull on (re)load (app/ipc.ts): the old page's calibration view goes with + // it; what main holds for the screen comes back in the bundle (N1). [CTRL.relayStatus]: () => { services.calibration.workflow.rendererLoaded() services.nodes.rendererLoaded() @@ -370,6 +399,16 @@ function launch(opts: ServicesOptions) { } }, + /** The file dialogs: what the next save / open picks, as a file name in . */ + dialogs: { + saveCapture(name: string | undefined): void { + world.dialogs.saveCapture = name + }, + openCapture(name: string | undefined): void { + world.dialogs.openCapture = name + } + }, + /** The window closes (`window-all-closed`): the relay stops, the app keeps running. */ closeWindow(): void { services.relay.stop() diff --git a/src/renderer/src/app/ipc-adapter.test.ts b/src/renderer/src/app/ipc-adapter.test.ts index c033b2e..63519f0 100644 --- a/src/renderer/src/app/ipc-adapter.test.ts +++ b/src/renderer/src/app/ipc-adapter.test.ts @@ -6,6 +6,7 @@ import type { LogEvent } from '@shared/events' import type { DeviceStatus, DeviceStatusPush } from '@shared/gateway' import type { HidSnapshot } from '@shared/hid' import { BACKUP_LIST_DEFAULT, DEFAULT_CONFIG, PSU_VIEW_DEFAULT } from '@shared/config' +import { AIRCRAFT_NONE, TELEMETRY_IDLE } from '@shared/dcs' import { isLinkUp, LinkDownClock, linkFacts } from '../../../main/gateway/link-state' import { relayFacts } from '../../../main/dcs/relay-rules' import { hidFacts } from '../../../main/hid/hid-facts' @@ -25,7 +26,8 @@ interface Given { 'hid:report': HidSnapshot } type GivenPushes = Omit & Given -type GivenStatus = Omit & { device: DeviceStatus } +type GivenStatus = Omit & + Partial> & { device: DeviceStatus } /** Main's GatewayConnection: the page's "last known" time, judged per push (link-state.ts). */ let linkDown = new LinkDownClock() @@ -61,8 +63,10 @@ function fakeApi() { return () => {} }, getStatus: () => { - linkDown.pageLoaded(status.device.state) // a (re)loaded page starts over (N1) return Promise.resolve({ + // main's DcsRelay: the aircraft and the gauges as last pushed (none pushed by default) + aircraft: AIRCRAFT_NONE, + telemetry: TELEMETRY_IDLE, ...status, device: withFacts(status.device), // main's DcsRelay, over the default settings and this status @@ -157,9 +161,15 @@ const calibration = (snap: CalSnapshot): CalibrationState => ({ * Since R3.4b the renderer's state is split across the topic stores; the cases read it as one, the * way the single store held it (the Log tab's Pause is the events slice's `togglePause`). */ -async function freshStore() { +async function freshStore(mainClock?: LinkDownClock) { listeners.clear() - linkDown = new LinkDownClock() + // Main's clock has judged the link's state it already pushed, before this page loaded; a reload + // of a page main kept serving hands its own clock on. + if (mainClock) linkDown = mainClock + else { + linkDown = new LinkDownClock() + linkDown.pushed(status.device.state) + } vi.resetModules() ;(globalThis as { window?: unknown }).window = { skyhawk: fakeApi() } const { initBridge } = await import('./ipc-adapter') @@ -251,6 +261,21 @@ describe('relay state (#69)', () => { expect(s.getState().deviceState, 'stopping the relay leaves the link up').toBe('connected') }) + it('rehydrates what main still shows on a reload: the aircraft, the gauges, the link (N1)', async () => { + const aircraft = { name: 'A-4E-C', inferred: false, supported: true } + const telemetry = TELEMETRY_IDLE.map((g) => ({ ...g, value: 42, pct: 0.42 })) + status = { running: false, relay: { running: false }, device: { state: 'connected' } } + const before = await freshStore() + push('device:status', { state: 'no-device' }) + const at = before.getState().linkDownAt + expect(at).toBeDefined() + status = { ...status, device: { state: 'no-device' }, aircraft, telemetry } + const s = await freshStore(linkDown) + expect(s.getState().aircraft).toEqual(aircraft) + expect(s.getState().telemetry).toEqual(telemetry) + expect(s.getState().linkDownAt, 'the last-known time main holds').toBe(at) + }) + it('rehydrates the relay state on a reload', async () => { status = { running: true, diff --git a/src/renderer/src/dcs/store.ts b/src/renderer/src/dcs/store.ts index b53e0ea..e379b5f 100644 --- a/src/renderer/src/dcs/store.ts +++ b/src/renderer/src/dcs/store.ts @@ -143,10 +143,13 @@ export const dcsFromMain = { * The rehydrate bundle (`relay:status`): a dev reload resets the renderer but the main-process * session keeps running, so the UI must not assume "stopped". */ - status: (st: Pick): void => + status: (st: Pick): void => useDcsStore.setState({ relaying: st.running, - relayFacts: st.relayFacts + relayFacts: st.relayFacts, + // What main still shows: the aircraft and the gauges, pushed only on change or per tick (N1). + aircraft: st.aircraft, + telemetry: st.telemetry }), /** `relay:state`. */ diff --git a/src/renderer/src/hid/HidPanel.tsx b/src/renderer/src/hid/HidPanel.tsx index f92e9d1..293927b 100644 --- a/src/renderer/src/hid/HidPanel.tsx +++ b/src/renderer/src/hid/HidPanel.tsx @@ -217,7 +217,7 @@ export function HidPanel({ raw={v} avail={axisLive(i)} cal={calState(i)} - onCalibrate={calState(i) ? () => onCalibrate(i) : undefined} + onCalibrate={calState(i) && cal.badgeOpens ? () => onCalibrate(i) : undefined} /> ))}
diff --git a/src/renderer/test/render.ts b/src/renderer/test/render.ts index 1ca1858..18cca83 100644 --- a/src/renderer/test/render.ts +++ b/src/renderer/test/render.ts @@ -6,6 +6,7 @@ import userEvent, { type UserEvent } from '@testing-library/user-event' import { onTestFinished, vi } from 'vitest' import { initBridge } from '../src/app/ipc-adapter' import { skyhawk, type Scenario, type SkyhawkMock } from './skyhawk-mock' +import { resetStore } from './store-reset' /** Let pending IPC answers (the renderer's pulls) land. */ export async function settle(): Promise { @@ -14,6 +15,15 @@ export async function settle(): Promise { }) } +/** The page's subscription to main, as App starts it on mount; undone when the test ends. */ +let unsubscribe: (() => void) | undefined +function subscribe(): void { + const off = initBridge() + if (!off) return + unsubscribe = off + onTestFinished(off) +} + /** * Main is in `scenario` when the renderer loads: the renderer subscribes (what `App` does on * mount), its pulls answer from the scenario, then main pushes it. Either way the store ends up @@ -22,8 +32,7 @@ export async function settle(): Promise { async function startBridge(scenario: Scenario = {}): Promise { const sky = skyhawk() sky.emitScenario(scenario) // nobody subscribed yet: main's state as the renderer loads - const off = initBridge() - if (off) onTestFinished(off) + subscribe() await settle() sky.emitScenario(scenario) return sky @@ -45,3 +54,22 @@ export async function renderScenario( await settle() return { ...result, sky, user } } + +/** + * The window reloads (a dev reload, or the renderer crashing and coming back) while main keeps + * running: the page and its stores go, and the new page subscribes and pulls again (initBridge, + * the mock answering as main does — `relay:status` included), then `ui` renders on it. + */ +export async function reloadRenderer( + ui: ReactElement, + page: Pick +): Promise { + page.unmount() + unsubscribe?.() + resetStore() + subscribe() + await settle() + const result = render(ui) + await settle() + return result +} diff --git a/src/renderer/test/skyhawk-mock.ts b/src/renderer/test/skyhawk-mock.ts index bb11e15..674e2aa 100644 --- a/src/renderer/test/skyhawk-mock.ts +++ b/src/renderer/test/skyhawk-mock.ts @@ -64,15 +64,17 @@ import { type ConfigChanged, type ConfigEdit } from '@shared/config' -import type { - AircraftStatus, - RecordState, - StreamRow, - RelayFacts, - RelayState, - ReplayLoad, - StatsSnapshot, - TelemetryReadout +import { + AIRCRAFT_NONE, + TELEMETRY_IDLE, + type AircraftStatus, + type RecordState, + type RelayFacts, + type RelayState, + type ReplayLoad, + type StatsSnapshot, + type StreamRow, + type TelemetryReadout } from '@shared/dcs' import type { CalCache, @@ -273,17 +275,15 @@ export function installSkyhawkMock(): SkyhawkMock { cockpit.rendererLoaded(state.model ?? EMPTY_MODEL, state.telemetry ?? []) // …and HidService hands it the report with its live axes (R3.3), in the same batch. push('hid:report', hidPush()) - // …and what the old page held for itself starts over (GatewayConnection, SerialTrafficMonitor, - // DcsRelay). - linkDown.pageLoaded(device().state) - monitorShown = false - recordingShown = { on: false } - replayShown = undefined - factsKey = JSON.stringify(relayFactsNow()) + // …and what main holds for the screen comes back in the bundle (N1): the last-known time and + // the monitor's flag (GatewayConnection, SerialTrafficMonitor), the recording and the capture + // (DcsRelay's facts), the aircraft and the gauges as last pushed (DcsRelay). return resolve({ running: state.relay?.running ?? false, relay: state.relay ?? { running: false }, relayFacts: relayFactsNow(), + aircraft: state.aircraft ?? AIRCRAFT_NONE, + telemetry: state.gauges ?? TELEMETRY_IDLE, device: devicePush(), cal: host.state() }) @@ -446,7 +446,7 @@ export function installSkyhawkMock(): SkyhawkMock { // ── the device link, as main's GatewayConnection pushes it (link-state.ts) ── const device = (): DeviceStatus => state.device ?? { state: 'no-device' } - /** The Cockpit's "last known" time and the serial monitor's flag: the page's, kept in main. */ + /** The Cockpit's "last known" time and the serial monitor's flag, kept in main. */ const linkDown = new LinkDownClock() let monitorShown = false /** @@ -483,7 +483,7 @@ export function installSkyhawkMock(): SkyhawkMock { } // ── the relay's facts, as main's DcsRelay pushes them (relay-rules.ts) ── - /** The recording and the capture as the page set them; a reloaded page starts over (N1). */ + /** The recording and the capture as their intents last answered; a reload keeps them (N1). */ let recordingShown: RelayFacts['recording'] = { on: false } let replayShown: RelayFacts['replay'] let factsKey: string | undefined diff --git a/src/renderer/test/t25-hid-tab.test.tsx b/src/renderer/test/t25-hid-tab.test.tsx index 5deab85..6d2c715 100644 --- a/src/renderer/test/t25-hid-tab.test.tsx +++ b/src/renderer/test/t25-hid-tab.test.tsx @@ -100,6 +100,27 @@ describe('T25 HID tab (B80)', () => { expect(axis('Roll')).toBe('Roll1200') }) + it('with the link down a badge shows its state but does not open calibration (N6)', async () => { + // The device's report lands after the link went down: its badges show, greyed out like + // Calibrate, since calibration needs the serial link. + const { user, sky } = await renderScenario(, { + device: link('no-device'), + hidAvailability: catalogue, + hid: report(), + cal: calSnapshot([{ idx: 0, calibrated: true }, { idx: 1 }]) + }) + expect(screen.getByRole('button', { name: 'Calibrate' })).toBeDisabled() + const cal = screen.getByRole('button', { name: 'CAL' }) + const raw = screen.getByRole('button', { name: 'RAW' }) + expect(cal).toBeDisabled() + expect(raw).toBeDisabled() + expect(cal).toHaveAttribute('title', 'Calibrated — endpoints stored on the device') + expect(raw).toHaveAttribute('title', 'Uncalibrated — values pass through untransformed') + await user.click(raw) + expect(sky.calOpen).not.toHaveBeenCalled() + expect(screen.queryByText(/^Axis Calibration/)).toBeNull() + }) + it('offers "Review & restore" when the device lost an axis this client holds a confirmed copy of', async () => { const { user } = await renderScenario(, { device: connected(), diff --git a/src/renderer/test/t38-reload-rehydrate.test.tsx b/src/renderer/test/t38-reload-rehydrate.test.tsx new file mode 100644 index 0000000..eace98b --- /dev/null +++ b/src/renderer/test/t38-reload-rehydrate.test.tsx @@ -0,0 +1,111 @@ +// T38 — a window reload (dev, or a renderer crash) while main keeps running: what main still holds +// for the screen comes back with the new page (N1) — the recording, the loaded capture, the serial +// monitor, the aircraft, the gauges, the session's peak W and the link's last-known time. Purely +// visual view state (the tab, an open dialog) still starts over. +import { describe, expect, it } from 'vitest' +import { screen } from '@testing-library/react' +import { Connection } from '../src/app/connection/Connection' +import { Cockpit } from '../src/app/cockpit/Cockpit' +import { Overview } from '../src/app/overview/Overview' +import { Serial } from '../src/gateway/Serial' +import { App } from '../src/app/App' +import { reloadRenderer, renderScenario, settle } from './render' +import { connected, model, pdu, pduTelemetry, simTelemetry } from './scenarios' + +/** A hero tile's value line, by its label. */ +const tileValue = (label: string) => screen.getByText(label).parentElement!.children[1]! +/** A gauge's readout, by its label. */ +const gauge = (label: string) => + screen.getByText(label).parentElement!.querySelector('.gauge__center')!.textContent +/** A Cockpit band card's whole text, by its label. */ +const card = (label: string) => screen.getByText(label).parentElement!.textContent + +describe('T38 a reload keeps what main holds (N1)', () => { + it('the recording that is running', async () => { + const page = await renderScenario(, { + device: connected(), + relay: { running: true, stream: 'connected' } + }) + page.sky.toggleCapture.mockResolvedValueOnce({ recording: true, events: 42 }) + await page.user.click(screen.getByRole('button', { name: 'Start recording…' })) + await settle() + expect(screen.getByRole('button', { name: 'Stop recording · 42 events' })).toBeEnabled() + + await reloadRenderer(, page) + expect(screen.getByRole('button', { name: 'Stop recording · 42 events' })).toBeEnabled() + }) + + it('the capture loaded for Replay', async () => { + const page = await renderScenario(, { + device: connected(), + config: { sourceMode: 'replay' } + }) + page.sky.openReplay.mockResolvedValueOnce({ + loaded: true, + path: '/Users/me/captures/carrier-trap.skycap', + events: 1200, + durationMs: 45_300 + }) + await page.user.click(screen.getByRole('button', { name: 'Load capture…' })) + await settle() + const loaded = 'carrier-trap.skycap · 1200 events · 45.3s — press Start to play.' + expect(screen.getByText('carrier-trap.skycap').parentElement).toHaveTextContent(loaded) + + await reloadRenderer(, page) + expect(screen.getByText('carrier-trap.skycap').parentElement).toHaveTextContent(loaded) + }) + + it('the serial monitor, still running', async () => { + const page = await renderScenario(, { device: connected() }) + await page.user.click(screen.getByRole('button', { name: 'Start monitor' })) + expect(screen.getByText('MONITORING')).toBeInTheDocument() + + await reloadRenderer(, page) + expect(screen.getByText('MONITORING')).toBeInTheDocument() + expect(screen.getByRole('button', { name: '● Monitoring' })).toBeInTheDocument() + }) + + it('the aircraft and the gauges, after the relay stopped', async () => { + const page = await renderScenario(, { + device: connected(), + relay: { running: true, stream: 'connected' }, + aircraft: { name: 'A-4E-C', inferred: false, supported: true }, + gauges: simTelemetry({ RPM: 0.85, D_FUEL: 0.6 }) + }) + page.sky.emitScenario({ relay: { running: false } }) + expect(tileValue('Aircraft')).toHaveTextContent('A-4E-C') + expect([gauge('RPM'), gauge('Fuel')]).toEqual(['85% RPM', '60% QTY']) + + await reloadRenderer(, page) + expect(tileValue('Aircraft')).toHaveTextContent('A-4E-C') + expect([gauge('RPM'), gauge('Fuel')]).toEqual(['85% RPM', '60% QTY']) + }) + + it('the session’s peak W and, with the link down, the last-known time', async () => { + const page = await renderScenario(, { + device: connected(), + model: model([ + pdu(3, 'PDU_Left', { console: 'Left', readings: 'live', everHadReadings: true }) + ]), + telemetry: [pduTelemetry(3, { rail12V: { iAvgMa: 2000 } })] + }) + page.sky.emitScenario({ telemetry: [pduTelemetry(3, { rail12V: { iAvgMa: 500 } })] }) + expect(card('Total power')).toBe('Total power6.0 W / 650peak 24.0 W') + page.sky.emitScenario({ device: { state: 'no-device' } }) + const lastKnown = card('Nodes online') + expect(lastKnown).toMatch(/^Nodes onlinelast known · \d\d:\d\d$/) + + await reloadRenderer(, page) + expect(card('Total power')).toMatch(/peak 24\.0 W$/) + expect(card('Nodes online')).toBe(lastKnown) + }) + + it('the tab the page showed still starts over (view state)', async () => { + const page = await renderScenario(, { device: connected() }) + await page.user.click(screen.getByRole('tab', { name: 'Serial' })) + expect(screen.getByRole('tab', { name: 'Serial' })).toHaveAttribute('aria-selected', 'true') + + await reloadRenderer(, page) + expect(screen.getByRole('tab', { name: 'Overview' })).toHaveAttribute('aria-selected', 'true') + }) +}) diff --git a/src/shared/app/ipc.compat.test-d.ts b/src/shared/app/ipc.compat.test-d.ts index cfc64c9..d3ef279 100644 --- a/src/shared/app/ipc.compat.test-d.ts +++ b/src/shared/app/ipc.compat.test-d.ts @@ -92,6 +92,13 @@ // below, and `DeviceStatusPush` lost `canRefresh` (its only reader was that button; a type change // only, no channel renamed). // +// Fix track #132 (N6, N1: the calibrate badge, a reload): no channel, intent or method was added, +// removed or renamed. The rehydrate bundle (`relay:status`) gained `aircraft` (`AircraftStatus`) +// and `telemetry` (`TelemetryReadout[]`), as last pushed, so a reload keeps them; its `relayFacts` +// and `device` now carry the recording, the capture, the last-known time and the serial monitor's +// flag as main holds them (no type change there). `CalibrationTabState` (`cal:workflow`) gained +// `badgeOpens`: the badges follow the Calibrate button's link rule (a type change only). +// // Type-only: never imported, never run, not matched by vitest (`*.test.ts`) or the build. import type { Mutate, StoreApi } from 'zustand/vanilla' import type {} from 'zustand/middleware' // registers the subscribeWithSelector store mutator @@ -173,6 +180,8 @@ interface OldRelayStatus { running: boolean relay: RelayState relayFacts: RelayFacts + aircraft: AircraftStatus + telemetry: TelemetryReadout[] device: DeviceStatusPush cal?: CalibrationState } diff --git a/src/shared/app/ipc.ts b/src/shared/app/ipc.ts index ee7b40e..69513e7 100644 --- a/src/shared/app/ipc.ts +++ b/src/shared/app/ipc.ts @@ -20,13 +20,15 @@ import { import { DCS_CTRL, DCS_PUSH, + type AircraftStatus, type DcsPushChannels, type ExportResult, type RecordState, type RelayFacts, type RelayResult, type RelayState, - type ReplayLoad + type ReplayLoad, + type TelemetryReadout } from '../dcs/ipc' import { EVENTS_CTRL, EVENTS_PUSH, type EventsPushChannels, type LogEvent } from '../events/ipc' import { @@ -61,9 +63,19 @@ import { export interface RelayStatus { running: boolean relay: RelayState - /** What the relay's surfaces show, as `relay:facts` carries it (R3.3). */ + /** + * What the relay's surfaces show, as `relay:facts` carries it (R3.3): the recording and the + * loaded capture included, as main holds them (N1). + */ relayFacts: RelayFacts - /** The link, with what main concludes from it, as `device:status` carries it (R3.3). */ + /** The aircraft as `aircraft:changed` last carried it (N1); pushed only when it changes. */ + aircraft: AircraftStatus + /** The gauges as the last `telemetry:tick` carried them, relaying or not (N1). */ + telemetry: TelemetryReadout[] + /** + * The link, with what main concludes from it, as `device:status` carries it (R3.3): the + * serial monitor's flag and the last-known time included (N1). + */ device: DeviceStatusPush /** * The calibration workflow's last `cal:workflow` push: the device's last report (if the link is diff --git a/src/shared/dcs/ipc.ts b/src/shared/dcs/ipc.ts index 2b74c42..e34271f 100644 --- a/src/shared/dcs/ipc.ts +++ b/src/shared/dcs/ipc.ts @@ -96,8 +96,8 @@ export interface RelayFacts { /** Each surface's own endpoint label (X9: they disagree). */ endpoint: { pill: EndpointLabel; overview: EndpointLabel } /** - * The recording as this page started it: off for a page that (re)loaded, whatever main is - * doing (N1). `events`: how many the last stop saved. + * The recording, as the Record control last answered: a page that (re)loads is handed it too + * (N1). `events`: how many the last stop saved. */ recording: { on: boolean; events?: number } /** @@ -111,7 +111,7 @@ export interface RelayFacts { * in the same push and are never on screen together. */ captureShown: boolean - /** The capture this page loaded for Replay; none for a page that (re)loaded (N1). */ + /** The capture loaded for Replay; a page that (re)loads is handed it too (N1). */ replay?: { file: string; events: number; durationMs: number } } diff --git a/src/shared/gateway/ipc.ts b/src/shared/gateway/ipc.ts index f1c2e88..977d6ca 100644 --- a/src/shared/gateway/ipc.ts +++ b/src/shared/gateway/ipc.ts @@ -91,11 +91,11 @@ export interface DeviceLinkFacts { /** The port would not open because another program holds it (from `detail`). */ busy: boolean /** - * When the link last went down after being up: the Cockpit's "last known" time. Absent while up, - * and, as before, for a page that (re)loaded until the link next drops (N1). + * When the link last went down after being up: the Cockpit's "last known" time. Absent while up; + * a page that (re)loads is handed it too (N1). */ linkDownAt?: number - /** The Serial tab's raw monitor is on, as this page last set it; off for a reloaded page (N1). */ + /** The Serial tab's raw monitor is on, as it was last set; a reloaded page too (N1). */ serialMonitor: boolean /** The device card's and the Device popover's button (R3.4a). */ action: LinkAction diff --git a/src/shared/hid/calibration/ipc.ts b/src/shared/hid/calibration/ipc.ts index 5ac4622..cf1f267 100644 --- a/src/shared/hid/calibration/ipc.ts +++ b/src/shared/hid/calibration/ipc.ts @@ -539,6 +539,11 @@ export interface CalibrationTabState { snapshot?: CalSnapshot /** Per axis index; absent = not declared (or not known), so no badge. */ badges: Partial> + /** + * A badge opens the dialog on its axis. The Calibrate button's link rule (N6): with the link + * down a badge still shows its state, greyed out, as the button is. + */ + badgeOpens: boolean /** Declared axes with no calibration on the device. */ uncalibrated: number /** Axes the device lost that the cache holds a confirmed copy of. */ @@ -564,6 +569,7 @@ export interface CalibrationState { export const CALIBRATION_IDLE: CalibrationState = { tab: { badges: {}, + badgeOpens: false, uncalibrated: 0, showOffer: false, calibrate: { shown: true, enabled: false } From eae910ae8064b4b36ecd19a4d130634d735e5ec4 Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Sat, 17 Oct 2026 13:18:40 -0400 Subject: [PATCH 3/3] =?UTF-8?q?fix(nodes):=20one=20"on=20the=20bus"=20rule?= =?UTF-8?q?=20=E2=80=94=20telemetry=20counts=20as=20online,=20quiet=20whil?= =?UTF-8?q?e=20unplugged,=20incomplete=20replies=20ignored=20(#151)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last PR of fix-track chunk 3. Two behaviours change on purpose (decided 2026-10-16); nothing else does. ## 1. X2 / R5 / S10 — one "on the bus" rule | | Before | After | |---|---|---| | **Fresh PDU telemetry** | Made a board visible but not online. A registry PDU seen by its pages alone (no roster row, or a complete roster that left it out) was a "new but offline" board: not counted, not news, never adoptable (S10). | Fresh PDU pages are evidence in the one availability rule (`node-model.ts nodeAvailability` → `{ availability, evidence: 'roster' \| 'telemetry' \| 'none' }`). Order: roster present → online (roster); fresh PDU pages **newer than the roster's last "absent" reading** → online (telemetry); roster absent / complete roster without it → offline (roster); else unknown (none). `availabilityOf` (`node-model.ts`) is the one place that weighs them. | | **Hide / Forget** | `canHide` / `canForget` treated any fresh pages as live, separately from the availability, so the panel could read OFFLINE beside the "Online — can't be removed" footer. | The gate reads only the one availability. NodeHub's `offlineSignals` computes it with `availabilityOf` over the same signals; the footer reads the model's. `OfflineSignals.pduTelemetry` and `offline-actions.ts pduTelemetryLive` are gone. | | **Every "on the bus" surface** | While the Gateway was unplugged, the Cockpit's "New on the bus · N / Adopt…", Settings › About's On the bus column ("yes" / "offline") and Connected nodes all read the stale roster as live. | One `onBusNow(entry, live)` (`node-model.ts`) = link up and online. `isReportableNew(e, live)` is built on it. Every surface reads it: the Cockpit CTA (`newCount`), the strays (`strayNew`), the Overview's new-devices notice (`newDevices`), Connected nodes (`nodesSummary`: `COCKPIT_IDLE.nodesCard` while down) and About's registry rows (`registryOnBus`: `—` while down; a hidden row still reads `hidden`). They all go quiet while unplugged and come back when the link does. | | **Adopt evidence** | An online board with no status row read "not reporting right now". | A PDU online by its pages reads `telemetry: 12 V …` or `telemetry: board status`. | **X2 variants deleted** (the co-located answers from the rewrite): - `cockpit-snapshot/index.ts`: the `live ? strays : []` gate is now `strayNew(..., live)`. The X2 rows are gone from both variant lists in the header. - `attention.ts`: "New on the bus counts whatever the last model said" is gone. `newCount(model, live)` replaces it. - `summaries.ts`: "Connected nodes reads the last model whatever the link" is gone. `nodesSummary(..., live)` replaces it, and `newDevices`' own `if (!live)` is folded into `isReportableNew`. - `registry-rows.ts`: "a CAN row reads the last model whatever the link" is gone. - `node-model.ts`: the two "visible vs online" copies of the telemetry rule are gone. `sending` and `availability` both read `node-telem.ts pduPagesLive`, which `offline-actions.ts pduTelemetryLive` reads too. ### Newest evidence wins (follow-up commit, from review) The first commit let fresh pages win whatever the roster said. A PDU that lost power then read online until its last page went stale (10 s) instead of when the roster's heartbeat timeout reported it absent (3 s), so its outage formed about 7 s late. Now `node-telem.ts pduPagesLive(t, now, absentAt)`, still the one freshness helper, with `newestPduPageAt` next to it, counts pages only while the newest one arrived after the roster's last "absent" reading for that board (`seen.offlineAt`, while its row is absent): - **The roster says absent after the last page:** the PDU is offline at once and folds into its outage on the roster's timing. - **Pages arrive after that "absent":** the PDU is online again. - **No absent reading at all:** pages count. That covers no row, or a complete roster that left it out, which is the S10 case. - **A tie** goes to the roster. `NodeModelInput` gains `now` (when the telemetry snapshot was taken) so page ages can be compared with the roster's times. ## 2. A lost line inside a reply - **END count semantics (confirmed in the firmware).** `PanelBridge.cpp emitAllNodes` at the pinned commit `a78dd90` and on OpenSkyhawk `main`: - it sends `emitSelf()`, then one row per alive node, then `_NODE_STATUS_END `; - `count` is decimal and starts at 1, so it **includes the node-0 row**: `1` means bridge only; - every row in a reply has `present = 1`; - the bridge writes the burst synchronously, so no delta can interleave with it. One caveat: the A-4 worktree named in the brief is on `feat/pdu-telemetry-216`, which still has the pre-#344 v2 version (no node-0 row, count from 0). The client is pinned to the v3 one. - **Before.** One `_NODE_STATUS` line lost inside a reply made that board offline for one poll. That is a false offline row, an extra save and possibly a false PDU outage, all against the #290 "no false offline" gate. - **After.** `NodeRoster` compares the END count with the distinct NODE_IDs the reply brought. - A reply starts at its node-0 row. From there its rows are the reply: not the deltas before it, and not what an earlier reply with a lost END brought. - On a mismatch, absences are not reconciled. The roster does not count as complete (so remembered boards stay `unknown`, not `offline`), and the next whole reply decides. - NodeHub logs one debug line (`roster reply incomplete: 2 of 3 rows; absences not reconciled`), with no event row. - An END with no count completes as before: there is nothing to check it against, and no v3 bridge sends one. - **No firmware change.** ## Changed assertions (each on purpose) | Test | Was | Now | Why | |---|---|---|---| | T9 `stays from the last model while the link is down (R5 today)` → `goes quiet while the link is down, and returns when it is back (R5, #132)` | CTA "New on the bus · 1" and Adopt… enabled with the link down | CTA and Adopt… absent while down, back on reconnect | Contract R5 / F12 (X2 final): `onBusNow(live)` | | T32 `unplugged: … CAN nodes still read "yes" (R5 today)` → `… no CAN row says it is on the bus (R5, #132)` | `#1 … yes` while unplugged | Every CAN row `—` (hidden stays `hidden`), back to `yes` on reconnect | Contract X7 / R5: on the bus means now | | T32 `opened while unplugged: the same` | `#1 … yes` | `#1 … —` | Same | | `registry-rows.test` `lists each registry row … before and after the model` (built with link down) | rows 1/2 `yes` / `offline` | `—` / `—` | The X2 variant's own unit pin; S10 / R5 | | `offline-actions.test` `allowed only offline with no fresh PDU pages` → `allowed only offline` | `gate({ availability: 'offline' \| 'unknown', pduTelemetry: true })` refused | The gate reads only `{ availability }`; that loop is removed | Newest-evidence rule: pages are weighed inside the availability (`availabilityOf`), so a separate "pages win" signal would contradict it. The footer and main agreement is still pinned by `node-hub.test` "one rule for the footer and main", unchanged. | | `offline-actions.test` `PDU pages count only while fresh, and only for that NODE_ID` | `pduTelemetryLive` | Removed with the helper | Replaced by `node-telem.test` "pduPagesLive: the newest evidence wins" | | main `phase0-probes` `M3: a v2 bridge — END completes the roster, added boards read offline, Forget allowed` → `M3 / #132 (fixed): a reply whose rows were dropped completes nothing` | END `2` with its only (malformed, 22-hex) row dropped completed the roster: board `offline`, Forget allowed | Incomplete reply: board `unknown`, Forget refused | The new count check. That row was never a real v2 row (v2 is 26 hex, already handled by M3) | Every other assertion is unchanged. Some call sites changed without changing what they assert: `isReportableNew`, `strayNew`, `newCount` and `nodesSummary` gained the explicit `live` argument (`true` in the old cases), `boardActions` lost its `pduTelemetry` argument, and `buildNodeModel` inputs gained `now`. The soak's seeded PRNG now also draws for line drops, so its random sequence shifted, but its assertions are the same. ## New tests, each shown able to fail Each was run against a temporarily broken source, then restored: | Test | Broken source | Result | |---|---|---| | Telemetry-only PDU counts online: `node-model.test` "the one availability rule" (rule, PDU in your cockpit roster-or-not, stale pages not); `node-hub.test` (online, and Hide / Forget refused) | Drop the telemetry branch | 5 fail | | No unadoptable new-but-offline board: `node-model.test` (a pages-only registry PDU is online, reportable and an Adopt row with `kind: pdu` and telemetry evidence; every new board in the model is online) | Drop the telemetry branch | Covered by the 5 above | | Every on-the-bus surface goes quiet with the link down and returns when it is up: `cockpit-snapshot/on-the-bus.test.ts` (CTA, strays incl. band, Overview notice, Connected nodes, About), through the snapshot builder and through `CockpitSnapshotHost` unplug → replug with no new model; plus T9 / T32 above | Make `onBusNow` ignore `live` and remove the two `!live` guards | 12 fail | | Incomplete reply never marks a board offline; a whole reply still does: `node-status-wire.test` "a reply missing a line reconciles nothing" (lost row, lost node-0 row, lost END, a short first reply leaves the roster incomplete, deltas before node 0 not counted, END without a count); `node-hub.test` (one debug line, no event row, a later whole reply still logs `offline`) | Disable the count check | 5 fail (incl. the soak) | | Same, the node-0 framing | Disable the node-0 reset | "deltas before node 0" fails | | Newest evidence wins: `node-hub.test` "a PDU's pages against the roster". A PDU that loses power is offline at 13.2 s (absent at 13 s, last page at 10 s, still fresh) and folds into its outage (`at: 13000`), shown as one Attention item, with the footer and main both allowing Hide. Pages after the absent bring it back (outage gone; footer, Forget and Hide all refuse). Also `node-model.test` (absent before vs after the newest page) and `node-telem.test` (`pduPagesLive` / `newestPduPageAt`) | Ignore `absentAt` in `pduPagesLive` | 3 fail | | Same: every surface and the gate agree | Make `NodeHub.offlineSignals` drop the pages | 4 fail (footer-vs-main matrix, M1/C3 probe, both new hub cases) | | #150's 30-minute soak: one in five replies now loses a single random line (a board row, node 0 or END). It asserts no false offline, no extra saves, no node event rows and no "not responding" flash, and that over 50 lines were dropped | Disable the count check | Fails: a false offline | ## Checks - `npm test`: 1394 passed (baseline 1372, +22) - `npm run lint`: 0 / 0 - `npm run lint:deps`: clean - `npm run typecheck` (3 projects): clean The renderer only displays: no renderer source changed. Part of #132 --- src/main/app/roster-poll.seam.test.ts | 23 ++- src/main/nodes/cockpit-snapshot/adopt.test.ts | 10 +- src/main/nodes/cockpit-snapshot/adopt.ts | 7 +- src/main/nodes/cockpit-snapshot/attention.ts | 36 +++-- .../cockpit-snapshot/board-opens.test.ts | 3 +- .../nodes/cockpit-snapshot/cockpit.test.ts | 3 +- .../connection-summary.test.ts | 9 +- src/main/nodes/cockpit-snapshot/index.ts | 24 +-- .../nodes/cockpit-snapshot/on-the-bus.test.ts | 106 +++++++++++++ .../nodes/cockpit-snapshot/panels.test.ts | 22 +-- src/main/nodes/cockpit-snapshot/panels.ts | 19 +-- .../cockpit-snapshot/power-zones.test.ts | 3 +- .../cockpit-snapshot/registry-rows.test.ts | 5 +- .../nodes/cockpit-snapshot/registry-rows.ts | 16 +- src/main/nodes/cockpit-snapshot/summaries.ts | 46 +++--- src/main/nodes/node-hub.test.ts | 139 ++++++++++++++++++ src/main/nodes/node-hub.ts | 27 +++- src/main/nodes/node-model.test.ts | 122 ++++++++++++++- src/main/nodes/node-model.ts | 90 ++++++++++-- src/main/nodes/node-status-wire.test.ts | 105 +++++++++++++ src/main/nodes/node-status-wire.ts | 59 +++++++- src/main/nodes/node-telem.test.ts | 34 +++++ src/main/nodes/node-telem.ts | 22 +++ src/main/nodes/offline-actions.test.ts | 52 +------ src/main/nodes/offline-actions.ts | 21 +-- src/main/nodes/phase0-probes.test.ts | 19 ++- src/renderer/src/nodes/node-view.test.ts | 7 +- src/renderer/src/nodes/phase0-probes.test.ts | 10 +- src/renderer/test/t32-settings-about.test.tsx | 20 ++- .../test/t9-cockpit-new-on-bus.test.tsx | 9 +- 30 files changed, 868 insertions(+), 200 deletions(-) create mode 100644 src/main/nodes/cockpit-snapshot/on-the-bus.test.ts diff --git a/src/main/app/roster-poll.seam.test.ts b/src/main/app/roster-poll.seam.test.ts index be6c51f..0b0f4aa 100644 --- a/src/main/app/roster-poll.seam.test.ts +++ b/src/main/app/roster-poll.seam.test.ts @@ -52,6 +52,11 @@ interface BridgeModel { * late reply is brought forward to this. Unset: no limit. */ answerWithinMs?: number + /** + * One line of the n-th reply lost on the way (#132): the index into its lines (0 = the node-0 + * row, the last = `_NODE_STATUS_END`), or undefined to send it whole. + */ + dropLine?: (n: number, lines: number) => number | undefined } /** @@ -64,6 +69,7 @@ function bridge(app: Services, boards: number[], model: BridgeModel = {}) { const PDU = 0x02 const kind = (id: number) => (id >= 3 && id <= 5 ? PDU : PANEL) // NODE_IDS.md: 3–5 are PDUs let asked = 0 + let dropped = 0 let lastLineAt = 0 /** The first request since the last line sent: what the app's not-responding clock counts from. */ let waitingSince: number | undefined @@ -78,6 +84,10 @@ function bridge(app: Services, boards: number[], model: BridgeModel = {}) { get asked() { return asked }, + /** Reply lines lost on the way so far (dropLine). */ + get dropped() { + return dropped + }, /** Read what the app wrote, and send every reply line that is due. */ step(): void { const now = app.clock.now() @@ -91,7 +101,12 @@ function bridge(app: Services, boards: number[], model: BridgeModel = {}) { at = Math.min(at, waitingSince + model.answerWithinMs) } at = Math.max(at, lastLineAt) // one serial line: replies arrive in the order asked - for (const text of burst) { + const drop = model.dropLine?.(asked, burst.length) + for (const [i, text] of burst.entries()) { + if (i === drop) { + dropped++ + continue + } lines.push({ at, text }) lastLineAt = at at += model.gap?.() ?? 0 @@ -185,7 +200,10 @@ describe('soak: 30 simulated minutes of lost and late replies (M2, G1, the #290 return r < 0.6 ? 2 + rnd() * 48 : r < 0.85 ? 800 + rnd() * 800 : 2500 + rnd() * 2000 }, gap: () => (rnd() < 0.85 ? rnd() * 40 : 100 + rnd() * 200), - answerWithinMs: 4500 + answerWithinMs: 4500, + // #132: one in five replies loses a single line on the way — a board's row, the node-0 row + // or the END — so its END count no longer matches what arrived. + dropLine: (_n, lines) => (rnd() < 0.2 ? Math.floor(rnd() * lines) : undefined) }) app.serial.open() // Until the first answer arms the re-ask, only the 5 s poll asks: two lost in a row, then a @@ -208,6 +226,7 @@ describe('soak: 30 simulated minutes of lost and late replies (M2, G1, the #290 expect(offline, 'a false offline').toBeUndefined() expect(flashed, 'a "not responding" flash').toBe(false) expect(b.asked, 'the poll and its re-asks kept asking').toBeGreaterThan(360) + expect(b.dropped, 'single lines were lost inside replies').toBeGreaterThan(50) expect(seen(), 'no extra saves').toEqual(onlineAt) const events = app.invoke(CTRL.eventsGet) expect( diff --git a/src/main/nodes/cockpit-snapshot/adopt.test.ts b/src/main/nodes/cockpit-snapshot/adopt.test.ts index cfb0275..f43f62f 100644 --- a/src/main/nodes/cockpit-snapshot/adopt.test.ts +++ b/src/main/nodes/cockpit-snapshot/adopt.test.ts @@ -259,12 +259,12 @@ describe('strayNew', () => { it('waits IDENTIFY_WAIT_MS for an unknown board, and says when the next one is due', () => { const views = at([fresh(41, { firstSeenAt: 0 }), fresh(43, { firstSeenAt: 3000 })]) - expect(strayNew(views, 4000)).toEqual({ strays: [], nextAt: 5000 }) - const r = strayNew(views, 5000) + expect(strayNew(views, 4000, true)).toEqual({ strays: [], nextAt: 5000 }) + const r = strayNew(views, 5000, true) expect(ids(r)).toEqual([41]) expect(r.nextAt).toBe(8000) - expect(strayNew(views, 8000).nextAt).toBeUndefined() - expect(ids(strayNew(views, 8000))).toEqual([41, 43]) + expect(strayNew(views, 8000, true).nextAt).toBeUndefined() + expect(ids(strayNew(views, 8000, true))).toEqual([41, 43]) }) it('takes a bridge or an unknown type at once; never an adoptable or offline node', () => { @@ -274,7 +274,7 @@ describe('strayNew', () => { fresh(41, { identified: 'panel', firstSeenAt: 0 }), fresh(46, { availability: 'offline', firstSeenAt: 0 }) ]) - expect(ids(strayNew(views, 1000))).toEqual([44, 45]) + expect(ids(strayNew(views, 1000, true))).toEqual([44, 45]) }) }) diff --git a/src/main/nodes/cockpit-snapshot/adopt.ts b/src/main/nodes/cockpit-snapshot/adopt.ts index be407d2..df053f5 100644 --- a/src/main/nodes/cockpit-snapshot/adopt.ts +++ b/src/main/nodes/cockpit-snapshot/adopt.ts @@ -97,7 +97,7 @@ export function adoptEvidence(v: NodeView): Evidence { const registry = e.registry ? `NODE_IDS.md: ${e.registry.name}, ${consoleLabel(e.registry.console)}` : 'not in NODE_IDS.md' - if (e.availability !== 'online' || !st) { + if (e.availability !== 'online') { return { detected, reporting: 'not reporting right now', registry } } const { rail12V, rail5V } = v.readings @@ -105,6 +105,11 @@ export function adoptEvidence(v: NodeView): Evidence { const rails = [rail('12 V', rail12V), rail('5 V', rail5V)].filter(Boolean).join(', ') return { detected, reporting: `telemetry: ${rails}`, registry } } + if (!st) { + // Online by its PDU pages alone (S10): its board-status page is what it sends. + const reporting = e.kind === 'pdu' ? 'telemetry: board status' : 'not reporting right now' + return { detected, reporting, registry } + } const faults = [ st.boff && 'bus-off', st.degraded && nodeFaultTag(st), diff --git a/src/main/nodes/cockpit-snapshot/attention.ts b/src/main/nodes/cockpit-snapshot/attention.ts index 7645c50..6cb2cb4 100644 --- a/src/main/nodes/cockpit-snapshot/attention.ts +++ b/src/main/nodes/cockpit-snapshot/attention.ts @@ -13,7 +13,7 @@ import type { } from '@shared/nodes' import { lastSeenLabel } from '@shared/format' import { shownHealth } from '../../gateway' -import { isReportableNew } from '../node-model' +import { isReportableNew, onBusNow } from '../node-model' import { adoptability } from './adopt' /** "1 node affected" / "3 nodes affected" — a PDU outage, wherever it is shown. */ @@ -72,11 +72,12 @@ export function attentionItems( export const ATTENTION_SHOWN = 3 /** - * "New on the bus · N": new NODE_IDs on the bus that have identified as a PDU or a panel group. - * One still identifying is not news yet (isReportableNew — the Overview and event log agree). + * "New on the bus · N": new NODE_IDs on the bus now that have identified as a PDU or a panel group. + * One still identifying is not news yet, and with the link down none is on the bus now + * (isReportableNew — the Overview's notice agrees). */ -export function newCount(model: NodeModel): number { - return model.nodes.filter(isReportableNew).length +export function newCount(model: NodeModel, live: boolean): number { + return model.nodes.filter((e) => isReportableNew(e, live)).length } /** @@ -86,17 +87,22 @@ export function newCount(model: NodeModel): number { export const IDENTIFY_WAIT_MS = 5000 /** - * New boards on the bus that can't be adopted (#93): a kind that can't be adopted (a second - * SimGateway, a type this client doesn't know), or still unidentified after IDENTIFY_WAIT_MS. A - * board on the bus is active, so it can't be hidden — it shows in Attention as a warning instead. - * `nextAt` = when one still identifying will cross the line. + * New boards on the bus now (onBusNow) that can't be adopted (#93): a kind that can't be adopted (a + * second SimGateway, a type this client doesn't know), or still unidentified after + * IDENTIFY_WAIT_MS. A board on the bus is active, so it can't be hidden — it shows in Attention as + * a warning instead. None while the link is down. `nextAt` = when one still identifying will cross + * the line. */ -export function strayNew(views: NodeView[], now: number): { strays: NodeView[]; nextAt?: number } { +export function strayNew( + views: NodeView[], + now: number, + live: boolean +): { strays: NodeView[]; nextAt?: number } { const strays: NodeView[] = [] let nextAt: number | undefined for (const v of views) { const e = v.entry - if (e.source !== 'new' || e.availability !== 'online' || adoptability(e).kind) continue + if (e.source !== 'new' || !onBusNow(e, live) || adoptability(e).kind) continue const due = (e.firstSeenAt ?? 0) + IDENTIFY_WAIT_MS if (e.identified || due <= now) strays.push(v) else nextAt = Math.min(nextAt ?? due, due) @@ -129,9 +135,9 @@ function gatewayAttention( } /** - * The Attention card. Strays and items only while the link is up (X2 co-located: the Cockpit - * says nothing about boards on the bus while unplugged); the Gateway's item takes a shown slot - * first; "New on the bus" counts whatever the last model said. + * The Attention card. Items only while the link is up (the strays are on the bus now already, + * strayNew); the Gateway's item takes a shown slot first; "New on the bus" counts the boards on + * the bus now (newCount), so it too is quiet while unplugged (X2 / R5). */ export function cockpitAttention( model: NodeModel | undefined, @@ -149,7 +155,7 @@ export function cockpitAttention( shown: items.slice(0, ATTENTION_SHOWN - (gateway ? 1 : 0)), count, more: Math.max(0, count - ATTENTION_SHOWN), - newCount: model ? newCount(model) : 0 + newCount: model ? newCount(model, live) : 0 } if (gateway) out.gateway = gateway return out diff --git a/src/main/nodes/cockpit-snapshot/board-opens.test.ts b/src/main/nodes/cockpit-snapshot/board-opens.test.ts index 2674c19..b767213 100644 --- a/src/main/nodes/cockpit-snapshot/board-opens.test.ts +++ b/src/main/nodes/cockpit-snapshot/board-opens.test.ts @@ -53,7 +53,8 @@ function snapshotOf(f: NodeMappingFile) { rosterCompleteAt: T, telemetry: [], defaults, - file: f + file: f, + now: T }) return buildCockpitSnapshot({ model, diff --git a/src/main/nodes/cockpit-snapshot/cockpit.test.ts b/src/main/nodes/cockpit-snapshot/cockpit.test.ts index 076d7eb..fefa304 100644 --- a/src/main/nodes/cockpit-snapshot/cockpit.test.ts +++ b/src/main/nodes/cockpit-snapshot/cockpit.test.ts @@ -282,7 +282,8 @@ describe('newCount', () => { node(43, { source: 'new' }), // still identifying: not news yet node(44, { source: 'new', identified: 'bridge' }), // never adoptable node(45, { source: 'new', identified: 'pdu', availability: 'offline' }) // left the bus - ]) + ]), + true ) ).toBe(2) }) diff --git a/src/main/nodes/cockpit-snapshot/connection-summary.test.ts b/src/main/nodes/cockpit-snapshot/connection-summary.test.ts index 82078bd..fc09fd0 100644 --- a/src/main/nodes/cockpit-snapshot/connection-summary.test.ts +++ b/src/main/nodes/cockpit-snapshot/connection-summary.test.ts @@ -44,7 +44,12 @@ const summary = ( telemetry: NodeTelemSnapshot[] = [] ) => { const m = model(nodes, outages) - return nodesSummary(nodeViews(m, telemetry), outages, boardZones(m.nodes, cockpitMembers(m).get)) + return nodesSummary( + nodeViews(m, telemetry), + outages, + boardZones(m.nodes, cockpitMembers(m).get), + true + ) } const T = 1_760_000_000_000 @@ -131,7 +136,7 @@ describe('nodesSummary rows', () => { ] } const meta = (mem: ReturnType) => - nodesSummary(nodeViews(m, []), [], boardZones(m.nodes, mem.get)).rows.map((r) => r.meta) + nodesSummary(nodeViews(m, []), [], boardZones(m.nodes, mem.get), true).rows.map((r) => r.meta) expect(meta(cockpitMembers(m))).toEqual([ `#13 · PDU_Left (hidden) · offline since ${fmtTime(T).slice(0, 8)}` ]) diff --git a/src/main/nodes/cockpit-snapshot/index.ts b/src/main/nodes/cockpit-snapshot/index.ts index 696ca6e..24f7485 100644 --- a/src/main/nodes/cockpit-snapshot/index.ts +++ b/src/main/nodes/cockpit-snapshot/index.ts @@ -10,11 +10,13 @@ // boards a PDU feeds is one rule too (power-zone.ts fedBy, X7): Feeds and Forget count hidden // boards, an outage only the ones that could have dropped with it. // +// What is on the bus now is one rule for every section (node-model.ts onBusNow, X2): fresh PDU +// pages count as online, and while the link is down "New on the bus", the strays, the Overview's +// notice, Connected nodes and About's On the bus all say nothing. +// // Where the Cockpit and another surface answer differently today, this builder keeps the Cockpit's -// own answer as its own function (X2–X8 option a, co-located, not unified — the fix track does +// own answer as its own function (X3–X8 option a, co-located, not unified — the fix track does // that): -// X2 on the bus unplugged strays and Attention items only while the link is up -// (attention.ts cockpitAttention, here) // X3 strays counted in the Cockpit's band, online and warning (band.ts // cockpitBand); the "New on the bus" count is isReportableNew // (attention.ts newCount); strays wait IDENTIFY_WAIT_MS (strayNew) @@ -29,9 +31,6 @@ // // The Overview, Connection and Settings read their own sections (R3.2b), each with its own answer // where it differs from the Cockpit's — the same option a, co-located: -// X2 on the bus unplugged the Overview's banner and notice only while the link is up; Connected -// nodes and the registry's CAN rows read the last model whatever the link -// (summaries.ts, registry-rows.ts registryOnBus) // X3 strays the Overview and Connection count none (summaries.ts: healthBand and // attentionItems with no strays); the notice is isReportableNew // (newDevices) @@ -85,10 +84,11 @@ export function buildCockpitSnapshot(i: CockpitSnapshotInput): CockpitBuild { const zones = boardZones(i.model?.nodes ?? [], members.get) // New boards that can't be adopted are active on the bus, so they are warnings (#93) — once an // unidentified one has had IDENTIFY_WAIT_MS to report its kind, so a normal board never flashes. - const stray = strayNew(views, i.now) - const strays: StrayItem[] = live - ? stray.strays.map((v) => ({ nodeId: v.entry.nodeId, flag: strayFlag(v.entry) })) - : [] + const stray = strayNew(views, i.now, live) + const strays: StrayItem[] = stray.strays.map((v) => ({ + nodeId: v.entry.nodeId, + flag: strayFlag(v.entry) + })) const snapshot: CockpitSnapshot = { rev: i.rev, live, @@ -97,12 +97,12 @@ export function buildCockpitSnapshot(i: CockpitSnapshotInput): CockpitBuild { plan: planZones(views, outages, zones), band: cockpitBand(views, strays.length, i.peakW), attention: cockpitAttention(i.model, views, outages, strays, live, i.device.gateway), - panels: boardPanels(views, outages, i.telemetry, members, zones), + panels: boardPanels(views, outages, members, zones), opens: boardOpens(views, members), zones: zoneChoices(views, members), adopt: adoptRows(views, members), overview: overviewSummary(views, outages, live), - nodesCard: nodesSummary(views, outages, zones), + nodesCard: nodesSummary(views, outages, zones, live), settings: settingsNodes(i.model, i.registry, live) } return stray.nextAt === undefined ? { snapshot } : { snapshot, nextAt: stray.nextAt } diff --git a/src/main/nodes/cockpit-snapshot/on-the-bus.test.ts b/src/main/nodes/cockpit-snapshot/on-the-bus.test.ts new file mode 100644 index 0000000..1e84286 --- /dev/null +++ b/src/main/nodes/cockpit-snapshot/on-the-bus.test.ts @@ -0,0 +1,106 @@ +// "On the bus" is one rule for every surface (X2 / R5, #132): node-model.ts onBusNow(live). While +// the Gateway is unplugged the Cockpit's "New on the bus" and its strays, the Overview's +// new-devices notice, Connection › Connected nodes and Settings › About's On the bus all go quiet, +// whatever the last model said; when the link is back they return. +import { describe, expect, it } from 'vitest' +import { COCKPIT_IDLE, type CockpitSnapshot, type NodeRegistryInfo } from '@shared/nodes' +import type { DeviceState } from '@shared/gateway' +import { modelNode as node, nodeModel as model } from '../../../test-fixtures/nodes' +import { buildCockpitSnapshot } from '.' +import { CockpitSnapshotHost } from '../cockpit-host' + +const REG: NodeRegistryInfo = { + file: 'Firmware/NODE_IDS.md', + commit: '22b6a87e73068ca2bfd964d067af1247771d5854', + date: '2026-10-15', + nodes: [ + { nodeId: 0, name: 'PanelBridge', console: '—', kind: 'bridge' }, + { nodeId: 1, name: 'Center_Armament', console: 'Center', kind: 'panel' }, + { nodeId: 2, name: 'Right_Navigation', console: 'Right', kind: 'panel' } + ] +} + +/** + * The last model before the link drops: Center_Armament (#1) online, Right_Navigation (#2) + * offline, a new panel group (#41) waiting to be adopted, and an unidentified new board (#9) seen + * long enough ago to be a stray. + */ +const LAST = model([ + node(1, { name: 'Center_Armament' }), + node(2, { name: 'Right_Navigation', console: 'Right', availability: 'offline', offlineAt: 0 }), + node(9, { name: 'Node 9', console: null, source: 'new', firstSeenAt: 0 }), + node(41, { name: 'Node 41', console: null, source: 'new', identified: 'panel', firstSeenAt: 0 }) +]) + +function snapshot(state: DeviceState): CockpitSnapshot { + return buildCockpitSnapshot({ + model: LAST, + telemetry: [], + device: { state }, + peakW: 0, + registry: REG, + now: 60_000, + rev: 1 + }).snapshot +} + +/** What each "on the bus" surface says. */ +function onTheBus(s: CockpitSnapshot) { + return { + newOnTheBus: s.attention.newCount, + strays: s.attention.items.filter((i) => i.stray).map((i) => i.nodeId), + bandStrays: s.band.band.total - s.views.filter((v) => v.entry.source !== 'new').length, + newDevices: s.overview.newDevices, + nodesCard: s.nodesCard, + aboutOnBus: s.settings.registry.rows.map((r) => r.onBus.text) + } +} + +describe('every "on the bus" surface goes quiet with the link down (X2 / R5, #132)', () => { + it('link up: the surfaces read the model', () => { + const up = onTheBus(snapshot('connected')) + expect(up.newOnTheBus).toBe(1) + expect(up.strays).toEqual([9]) + expect(up.bandStrays).toBe(1) + expect(up.newDevices).toEqual({ count: 1, panels: 1, pdus: 0, registry: 0 }) + expect(up.nodesCard).toMatchObject({ known: 2, healthy: 1, off: 1 }) + expect(up.nodesCard.rows.map((r) => r.nodeId)).toEqual([2]) + expect(up.aboutOnBus).toEqual(['serial link', 'yes', 'offline']) + }) + + for (const state of ['no-device', 'disconnected'] as const) { + it(`${state}: none of them says anything about the bus`, () => { + const down = onTheBus(snapshot(state)) + expect(down).toEqual({ + newOnTheBus: 0, + strays: [], + bandStrays: 0, + newDevices: null, + nodesCard: COCKPIT_IDLE.nodesCard, + aboutOnBus: ['no device', '—', '—'] + }) + }) + } + + it('unplugged and plugged back in, with no new model meanwhile: quiet, then back', () => { + // Node pushes stop while the link is down, so the host still holds the last model: only the + // link changes, as the app's `device:status` pushes say. + const device: { state: DeviceState } = { state: 'connected' } + const pushed: CockpitSnapshot[] = [] + const host = new CockpitSnapshotHost((s) => pushed.push(s), { + device: () => device, + registry: () => REG + }) + host.modelPushed(LAST) + const before = onTheBus(host.state()) + expect(before.newOnTheBus).toBe(1) + device.state = 'no-device' + host.linkChanged() + expect(onTheBus(host.state())).toMatchObject({ newOnTheBus: 0, strays: [], newDevices: null }) + expect(onTheBus(host.state()).aboutOnBus).toEqual(['no device', '—', '—']) + device.state = 'connected' + host.linkChanged() + expect(onTheBus(host.state())).toEqual(before) + host.dispose() + }) +}) diff --git a/src/main/nodes/cockpit-snapshot/panels.test.ts b/src/main/nodes/cockpit-snapshot/panels.test.ts index d8911ab..cd6932b 100644 --- a/src/main/nodes/cockpit-snapshot/panels.test.ts +++ b/src/main/nodes/cockpit-snapshot/panels.test.ts @@ -81,18 +81,18 @@ describe('boardActions', () => { const off = { availability: 'offline' as const } it('Forget and Hide wait until the board is offline, with the reason', () => { - const on = boardActions(node(1), false) + const on = boardActions(node(1)) expect(on.forget.disabled).toBe(true) expect(on.hide.disabled).toBe(true) expect(on.forget.tip).toMatch(/^Online — it’s active on the bus/) expect(on.note).toBe(on.forget.tip) - const a = boardActions(node(1, off), false) + const a = boardActions(node(1, off)) expect(a.forget.disabled).toBe(false) expect(a.hide.disabled).toBe(false) }) it('never before the roster says offline — and says so, not "online" (#93 review)', () => { - const a = boardActions(node(1, { availability: 'unknown', status: undefined }), false) + const a = boardActions(node(1, { availability: 'unknown', status: undefined })) expect(a.forget.disabled).toBe(true) expect(a.hide.disabled).toBe(true) expect(a.forget.tip).toBe( @@ -102,28 +102,28 @@ describe('boardActions', () => { }) it('Reset is for registry boards, greyed out at the defaults', () => { - expect(boardActions(node(1), false).reset).toEqual({ + expect(boardActions(node(1)).reset).toEqual({ disabled: true, tip: 'Already at the NODE_IDS.md defaults' }) - expect(boardActions(node(1, { differs: true }), false).reset?.disabled).toBe(false) - expect(boardActions(node(9, { source: 'adopted' }), false).reset).toBeUndefined() + expect(boardActions(node(1, { differs: true })).reset?.disabled).toBe(false) + expect(boardActions(node(9, { source: 'adopted' })).reset).toBeUndefined() }) it('the note says where a forgotten board comes back', () => { - expect(boardActions(node(1, off), false).note).toMatch(/waits in New on the bus, pre-filled/) - expect(boardActions(node(9, { ...off, source: 'adopted' }), false).note).toMatch(/shows as new/) + expect(boardActions(node(1, off)).note).toMatch(/waits in New on the bus, pre-filled/) + expect(boardActions(node(9, { ...off, source: 'adopted' })).note).toMatch(/shows as new/) }) it('a PDU: Forget unlinks its boards, Hide keeps the links', () => { const p = node(4, { ...off, kind: 'pdu', pduId: null }) - expect(boardActions(p, false, 3).note).toBe( + expect(boardActions(p, 3).note).toBe( 'Forget removes this PDU from your cockpit. Its 3 boards move to No power zone and keep no link to it, even if it comes back. Hide keeps the links: they return under it when it reports.' ) - expect(boardActions(p, false, 0).note).toBe( + expect(boardActions(p, 0).note).toBe( 'Forget removes this PDU from your cockpit. Hide keeps the links: they return under it when it reports.' ) - expect(boardActions(p, false).hide.tip).toMatch(/keep the link/) + expect(boardActions(p).hide.tip).toMatch(/keep the link/) }) }) diff --git a/src/main/nodes/cockpit-snapshot/panels.ts b/src/main/nodes/cockpit-snapshot/panels.ts index e12d3e2..7e7e489 100644 --- a/src/main/nodes/cockpit-snapshot/panels.ts +++ b/src/main/nodes/cockpit-snapshot/panels.ts @@ -12,7 +12,6 @@ import { type FaultBox, type FedBoard, type NodeModelEntry, - type NodeTelemSnapshot, type NodeView, type PduOutage, type PduRailPage, @@ -24,7 +23,7 @@ import { import { PDU_STATUS_FLAGS as PDU_FLAG } from '@shared/firmware' import { nodeFaultTag, pduAlertFlags, pduFlagMeanings } from '../faults' import { displayCurrentMa } from '../node-telem' -import { canForget, canHide, pduTelemetryLive, type OfflineGate } from '../offline-actions' +import { canForget, canHide, type OfflineGate } from '../offline-actions' import { fedBy, zoneLabel } from '../power-zone' import type { Members } from './members' @@ -32,14 +31,14 @@ import type { Members } from './members' /** * Same rules for a panel group and a PDU (#93). Hide and Forget wait until the board is offline, - * by the one rule main refuses them by (offline-actions.ts canHide / canForget), so the footer and - * main always agree: `pduTelemetry` = fresh PDU pages from it now (pduTelemetryLive), which prove - * it live whatever the roster says. + * by the one rule main refuses them by (offline-actions.ts canHide / canForget) over the one + * availability (node-model.ts availabilityOf: a PDU's pages weighed against the roster), so the + * footer and main always agree. * `fed` = boards in your cockpit this PDU feeds, hidden ones included (power-zone.ts fedBy, X7): * Forget unlinks them all; Hide keeps the links. */ -export function boardActions(e: NodeModelEntry, pduTelemetry: boolean, fed = 0): BoardActions { - const signals = { availability: e.availability, pduTelemetry } +export function boardActions(e: NodeModelEntry, fed = 0): BoardActions { + const signals = { availability: e.availability } const off = (gate: OfflineGate, tip: string): BoardAction => gate.ok ? { disabled: false, tip } : { disabled: true, tip: gate.note } const forget = canForget(signals) @@ -372,7 +371,6 @@ function fedBoards(members: Members, views: NodeView[], pduId: number): FedBoard export function boardPanels( views: NodeView[], outages: PduOutage[], - telemetry: NodeTelemSnapshot[], members: Members, zones: ReadonlyMap ): Record { @@ -380,7 +378,6 @@ export function boardPanels( for (const v of views) { const e = v.entry if (!members.get(e.nodeId)) continue - const live = pduTelemetryLive(telemetry, e.nodeId) let p: BoardPanel if (e.kind === 'pdu') { const feeds = fedBoards(members, views, e.nodeId) @@ -389,7 +386,7 @@ export function boardPanels( v, outages.some((o) => o.pduId === e.nodeId) ), - actions: boardActions(e, live, feeds.length), + actions: boardActions(e, feeds.length), rails: pduRailRows(v), board: pduBoardRows(v), feeds, @@ -406,7 +403,7 @@ export function boardPanels( } else { p = { faults: nodeFaultBoxes(v), - actions: boardActions(e, live), + actions: boardActions(e), status: nodeStatusRows(v), zone: zones.get(e.nodeId) ?? { status: 'none' } } diff --git a/src/main/nodes/cockpit-snapshot/power-zones.test.ts b/src/main/nodes/cockpit-snapshot/power-zones.test.ts index abbc289..71d61f5 100644 --- a/src/main/nodes/cockpit-snapshot/power-zones.test.ts +++ b/src/main/nodes/cockpit-snapshot/power-zones.test.ts @@ -66,7 +66,8 @@ const model = buildNodeModel({ rosterCompleteAt: T, telemetry: [], defaults, - file + file, + now: T }) const { snapshot: snap } = buildCockpitSnapshot({ model, diff --git a/src/main/nodes/cockpit-snapshot/registry-rows.test.ts b/src/main/nodes/cockpit-snapshot/registry-rows.test.ts index c48ce2e..5d256a3 100644 --- a/src/main/nodes/cockpit-snapshot/registry-rows.test.ts +++ b/src/main/nodes/cockpit-snapshot/registry-rows.test.ts @@ -98,8 +98,9 @@ describe('settingsNodes', () => { s.registry.rows.map((r) => [r.nodeId, r.name, r.console, r.onBus.text, r.unloaded.text]) ).toEqual([ [0, 'Gateway', '—', 'no device', 'no device'], - [1, 'Center_Armament', 'Center', 'yes', '—'], - [2, 'Right_Navigation', 'Right', 'offline', '—'], + // The link is down: no CAN row is on the bus or offline by the last model (X2, #132). + [1, 'Center_Armament', 'Center', '—', '—'], + [2, 'Right_Navigation', 'Right', '—', '—'], [3, 'PDU_Left', 'Left', '—', '—'], [4, 'PDU_Right', 'Right', '—', '—'], [5, 'PDU_Center', 'Center', '—', '—'] diff --git a/src/main/nodes/cockpit-snapshot/registry-rows.ts b/src/main/nodes/cockpit-snapshot/registry-rows.ts index 38c6d46..ea25098 100644 --- a/src/main/nodes/cockpit-snapshot/registry-rows.ts +++ b/src/main/nodes/cockpit-snapshot/registry-rows.ts @@ -5,13 +5,14 @@ // Reset… reads your cockpit as every surface does (members.ts, X1): a hidden adopted board is still // one it can forget. // -// Each keeps Settings' own answer where it differs from the Cockpit's (co-located, not unified): -// X2 on the bus a CAN row reads the last model whatever the link; only the Gateway row follows -// the link (registryOnBus) +// "On the bus" is the one rule every surface reads (node-model.ts onBusNow, X2): with the link down +// no CAN row says yes or offline. Settings keeps its own answer where it differs from the Cockpit's +// (co-located, not unified): // X8 wording the bridge row is "Gateway", whatever NODE_IDS.md calls it (registryName) import { GATEWAY_NODE_ID } from '@shared/firmware' import { plural } from '@shared/format' import type { NodeModel, NodeRegistryInfo, OnBus, RegistryRow, SettingsNodes } from '@shared/nodes' +import { onBusNow } from '../node-model' import { cockpitMembers } from './members' /** @@ -35,7 +36,9 @@ export function registrySummary(r: NodeRegistryInfo): string { /** * Registry list "On the bus" column. PanelBridge is the serial link itself; a CAN node is what - * the roster says — "—" before any roster, since that is no data rather than "absent". + * the one availability rule says while the link is up (onBusNow) — "—" before any roster, and + * while the link is down, since that is no data rather than "absent". Hidden is your cockpit's, + * not the bus's: it reads so whatever the link. */ export function registryOnBus( nodeId: number, @@ -46,8 +49,9 @@ export function registryOnBus( return linkUp ? { text: 'serial link', dot: 'on' } : { text: 'no device', dot: '' } if (model?.hidden.includes(nodeId)) return { text: 'hidden', dot: '' } const e = model?.nodes.find((n) => n.nodeId === nodeId) - if (e?.availability === 'online') return { text: 'yes', dot: 'on' } - if (e?.availability === 'offline') return { text: 'offline', dot: 'off' } + if (!e || !linkUp) return { text: '—', dot: '' } + if (onBusNow(e, linkUp)) return { text: 'yes', dot: 'on' } + if (e.availability === 'offline') return { text: 'offline', dot: 'off' } return { text: '—', dot: '' } } diff --git a/src/main/nodes/cockpit-snapshot/summaries.ts b/src/main/nodes/cockpit-snapshot/summaries.ts index c49672b..44b9f0e 100644 --- a/src/main/nodes/cockpit-snapshot/summaries.ts +++ b/src/main/nodes/cockpit-snapshot/summaries.ts @@ -6,25 +6,25 @@ // Each keeps its own answer where it differs from the Cockpit's (co-located, not unified): // X3 strays neither counts a new board that can't be adopted: the band is healthBand(views) // with no strays, the items attentionItems(views, outages) with none -// X2 link the banner and the notice only while the link is up (the tile says "no live node -// status"); Connected nodes reads the last model whatever the link (its tab shows -// the link's hint instead while it is down) +// What is on the bus is one rule (node-model.ts onBusNow, X2): while the link is down the tile says +// "no live node status", and the banner, the notice and Connected nodes say nothing. // A Connected nodes row names its board's resolved power zone (D7, the same one the Cockpit reads): // `PDU_Right`, `PDU_Right (hidden)`, else "no power zone". import { hhmmss, lastSeenLabel, plural } from '@shared/format' -import type { - AttentionItem, - AttentionSummary, - CockpitTile, - HealthBand, - NewDevices, - NodeModelEntry, - NodesSummary, - NodeView, - OverviewSummary, - PduOutage, - PowerZone, - SummaryRow +import { + COCKPIT_IDLE, + type AttentionItem, + type AttentionSummary, + type CockpitTile, + type HealthBand, + type NewDevices, + type NodeModelEntry, + type NodesSummary, + type NodeView, + type OverviewSummary, + type PduOutage, + type PowerZone, + type SummaryRow } from '@shared/nodes' import { isAdoptableKind } from '../node-kind' import { isReportableNew } from '../node-model' @@ -107,11 +107,11 @@ export function reportedKind(e: NodeModelEntry): 'pdu' | 'panel' | null { /** * NODE_IDs waiting to be adopted that are on the bus now and have said what they are, by kind. * A node is not news until it has identified itself (no "unknown" count), and one that left the - * bus is not "on the bus". null = nothing to show, including while the link is down. + * bus — or any, while the link is down — is not "on the bus" (isReportableNew). null = nothing to + * show. */ export function newDevices(views: NodeView[], live: boolean): NewDevices | null { - if (!live) return null - const fresh = views.filter((v) => isReportableNew(v.entry)) + const fresh = views.filter((v) => isReportableNew(v.entry, live)) const kinds = fresh.map((v) => reportedKind(v.entry)) const panels = kinds.filter((k) => k === 'panel').length const pdus = kinds.filter((k) => k === 'pdu').length @@ -138,11 +138,17 @@ export function overviewSummary( // ── Connection › Connected nodes ──────────────────────────────────────────── +/** + * Connected nodes: the counts and the rows that need attention. Quiet while the link is down — it + * says nothing about the bus then (X2), and its tab shows the link's hint instead. + */ export function nodesSummary( views: NodeView[], outages: PduOutage[], - zones: ReadonlyMap + zones: ReadonlyMap, + live: boolean ): NodesSummary { + if (!live) return COCKPIT_IDLE.nodesCard const band = healthBand(views) const known = views.filter((v) => v.entry.source !== 'new') const byId = new Map(views.map((v) => [v.entry.nodeId, v.entry])) diff --git a/src/main/nodes/node-hub.test.ts b/src/main/nodes/node-hub.test.ts index 2f1a726..9bf8027 100644 --- a/src/main/nodes/node-hub.test.ts +++ b/src/main/nodes/node-hub.test.ts @@ -752,3 +752,142 @@ describe('one rule for the footer and main (M1/C3, S7)', () => { }) } }) + +describe('on the bus (X2 / S10) and a lost line inside a reply (#132)', () => { + const NOW = 50_000 + const line = (id: number, present = true, kind = 1) => + `${id.toString(16).padStart(2, '0')}${present ? '01' : '00'}0000000000000080000000${kind + .toString(16) + .padStart(2, '0')}` + + /** A hub fed as the Gateway's line tap feeds it, with its debug log and event rows recorded. */ + function tapped() { + const roster = new NodeRoster() + const logged: string[] = [] + const events: string[] = [] + const h = new NodeHub( + () => {}, + roster, + (e) => events.push(...e.map((x) => `${x.nodeId ?? '-'} ${x.event}`)), + { log: (tag, data) => logged.push(`${tag} ${String(data)}`) } + ) + const tap = (identifier: string, arg: string, t: number) => + h.nodeLine(identifier, arg, `${identifier} ${arg}`, t) + /** One roster reply, node 0 first, END with PanelBridge's count; `lost` lines never arrive. */ + const reply = (ids: number[], t: number, lost: number[] = []) => { + h.requestNodes() + for (const id of [0, ...ids]) + if (!lost.includes(id)) tap(NODE_MSG, line(id, true, id ? 1 : 3), t) + tap(NODE_END_MSG, String(ids.length + 1), t) + h.flush(roster.takeDirty(), t) + } + const node = (id: number, t: number) => h.snapshot(t).model.nodes.find((n) => n.nodeId === id) + return { h, roster, logged, events, reply, node, tap } + } + + it('a PDU in your cockpit sending fresh pages is online, and Hide and Forget agree', () => { + disk.file = added() + const { h, reply, node } = tapped() + reply([1], 0) // a whole roster without PDU_Right (#4) + expect(node(4, 0)?.availability).toBe('offline') + h.telemetry('040100E02E182EBC02', NOW) // its fresh 12 V page + expect(node(4, NOW)).toMatchObject({ availability: 'online', readings: 'live' }) + expect(h.forget(4, NOW).ok).toBe(false) + expect(h.hide(4, true, NOW).ok).toBe(false) + }) + + it('a reply short of its count marks nobody offline, logs one debug line and no event row', () => { + disk.file = added() + const { reply, node, logged, events } = tapped() + reply([1, 2], 0) + const before = events.length + reply([1, 2], 5000, [2]) // board 2's line is lost + expect(node(2, 5000)?.availability).toBe('online') + expect(logged.filter((l) => /incomplete/.test(l))).toEqual([ + 'node roster reply incomplete: 2 of 3 rows; absences not reconciled' + ]) + expect(events.slice(before)).toEqual([]) + reply([1, 2], 10_000) // the next whole reply: still there + expect(node(2, 10_000)?.availability).toBe('online') + reply([1], 15_000) // board 2 really left: a whole reply says so, and the log has its row + expect(node(2, 15_000)?.availability).toBe('offline') + expect(events.slice(before)).toEqual(['2 offline']) + expect(logged.filter((l) => /incomplete/.test(l))).toHaveLength(1) + }) +}) + +describe('a PDU’s pages against the roster: the newest evidence wins (X2, #132)', () => { + const PAGE = '040100E02E182EBC02' // PDU_Right's (#4) 12 V page + const row = (id: number, present = true, kind = 1) => + `${id.toString(16).padStart(2, '0')}${present ? '01' : '00'}0000000000000080000000${kind + .toString(16) + .padStart(2, '0')}` + + /** + * PDU_Right (#4) feeds Right_Navigation (#2); both and Center_Armament (#1) are on the bus, and + * the PDU sends a page every second until `pagesUntil`. At 13 s the bridge's heartbeat timeout + * reports #4 and #2 absent: the PDU lost power at 10 s. + */ + function powerLoss(pagesUntil: number, now: number) { + disk.file = { + version: 4, + nodes: { 1: { added: true }, 2: { added: true, pduId: 4 }, 4: { added: true } } + } + const roster = new NodeRoster() + const h = new NodeHub(() => {}, roster) + const tap = (identifier: string, arg: string, t: number) => + h.nodeLine(identifier, arg, `${identifier} ${arg}`, t) + tap(NODE_MSG, row(0, true, 3), 0) + for (const id of [1, 2, 4]) tap(NODE_MSG, row(id, true, id === 4 ? 2 : 1), 0) + tap(NODE_END_MSG, '4', 0) + h.flush(roster.takeDirty(), 0) + for (let t = 0; t <= Math.min(pagesUntil, now); t += 1000) h.telemetry(PAGE, t) + if (now >= 13_000) { + tap(NODE_MSG, row(4, false, 2), 13_000) // live deltas: the heartbeat timeout + tap(NODE_MSG, row(2, false), 13_000) + } + h.flush(roster.takeDirty(), now) + const { model, telemetry } = h.snapshot(now) + const { snapshot } = buildCockpitSnapshot({ + model, + telemetry, + device: { state: 'connected' }, + peakW: 0, + now, + rev: 0 + }) + return { h, model, snapshot } + } + + it('a PDU that loses power is offline on the roster’s timing and folds into its outage', () => { + // 13.2 s: its last page (10 s) is still fresh for 6.8 s more, but the roster said absent after it. + const { h, model, snapshot } = powerLoss(10_000, 13_200) + expect(model.nodes.find((n) => n.nodeId === 4)).toMatchObject({ + availability: 'offline', + offlineAt: 13_000 + }) + expect(model.outages).toEqual([ + expect.objectContaining({ pduId: 4, nodeIds: [2], fedCount: 1, at: 13_000 }) + ]) + // Every surface reads the same: one outage item, and the footer offers what main allows. + expect(snapshot.attention.items.map((i) => [i.nodeId, i.flag])).toEqual([ + [4, 'OFFLINE · 1 node affected'] + ]) + expect(snapshot.panels[4]!.actions.forget.disabled).toBe(false) + expect(h.hide(4, true, 13_200).ok).toBe(true) + }) + + it('pages arriving after the roster said absent bring it back online', () => { + const { h, model, snapshot } = powerLoss(14_000, 14_200) // a page at 14 s, after the 13 s absent + expect(model.nodes.find((n) => n.nodeId === 4)?.availability).toBe('online') + expect(model.outages).toEqual([]) + expect(snapshot.panels[4]!.actions.forget.disabled).toBe(true) + expect(h.forget(4, 14_200).ok).toBe(false) + expect(h.hide(4, true, 14_200).ok).toBe(false) + }) + + it('before the roster says absent, a powered PDU is online by both', () => { + const { model } = powerLoss(10_000, 12_000) + expect(model.nodes.find((n) => n.nodeId === 4)?.availability).toBe('online') + }) +}) diff --git a/src/main/nodes/node-hub.ts b/src/main/nodes/node-hub.ts index f989ed1..f0f3808 100644 --- a/src/main/nodes/node-hub.ts +++ b/src/main/nodes/node-hub.ts @@ -47,14 +47,13 @@ import { NODE_REGISTRY_SOURCE, type NodeFaultName } from '@shared/firmware' -import { buildNodeModel, nodeAvailability } from './node-model' +import { availabilityOf, buildNodeModel } from './node-model' import { NodeEventTracker } from './node-events' import { boardIdentity, identityOf, isAdoptableKind } from './node-kind' import { canForget, canHide, offlineRefusal, - pduTelemetryLive, type OfflineGate, type OfflineSignals } from './offline-actions' @@ -296,6 +295,14 @@ export class NodeHub implements NodeLineSink { if (identifier === NODE_MSG || identifier === NODE_END_MSG) { this.log('node', `${identifier} ${arg}`.trim()) this.roster.applyMessage(identifier, arg, t) + const short = this.roster.takeIncompleteReply() + if (short) { + // A line of the reply was lost: its absences were not reconciled (#132). Debug only. + this.log( + 'node', + `roster reply incomplete: ${short.received} of ${short.expected} rows; absences not reconciled` + ) + } if (!this.legacyNodesWarned && this.roster.legacyBridge()) { // Every v2 row is dropped as malformed (and its END ignored, M3): say why the Cockpit is // empty, once. @@ -464,11 +471,14 @@ export class NodeHub implements NodeLineSink { * availability by the model's one rule, and whether fresh PDU pages say it is live. */ private offlineSignals(nodeId: number, now: number): OfflineSignals { - const st = this.roster.snapshot().find((n) => n.nodeId === nodeId) - return { - availability: nodeAvailability(st, this.roster.rosterCompleteAt() !== undefined), - pduTelemetry: pduTelemetryLive(this.telem.snapshot(now), nodeId) - } + const { availability } = availabilityOf({ + status: this.roster.snapshot().find((n) => n.nodeId === nodeId), + seen: this.roster.seenInfo().get(nodeId), + rosterComplete: this.roster.rosterCompleteAt() !== undefined, + telemetry: this.telem.snapshot(now).find((t) => t.nodeId === nodeId), + now + }) + return { availability } } private refusal(gate: OfflineGate, what: 'hidden' | 'forgotten'): NodeMappingResult | undefined { @@ -556,7 +566,8 @@ export class NodeHub implements NodeLineSink { rosterCompleteAt: this.roster.rosterCompleteAt(), telemetry: this.telem.snapshot(now), defaults: this.defaults, - file: this.mapping() + file: this.mapping(), + now }) } diff --git a/src/main/nodes/node-model.test.ts b/src/main/nodes/node-model.test.ts index 10652e0..842f519 100644 --- a/src/main/nodes/node-model.test.ts +++ b/src/main/nodes/node-model.test.ts @@ -1,5 +1,14 @@ import { describe, expect, it } from 'vitest' -import { buildNodeModel, isReportableNew, type NodeModelInput } from './node-model' +import { + buildNodeModel, + isReportableNew, + nodeAvailability, + onBusNow, + type NodeModelInput +} from './node-model' +import { cockpitMembers } from './cockpit-snapshot/members' +import { adoptRows } from './cockpit-snapshot/adopt' +import { nodeViews } from './cockpit-snapshot/views' import { PDU_GROUP_POLL_CATCH_MS, PDU_GROUP_WINDOW_MS } from './power-zone' import { EMPTY_MAPPING } from './mapping-rules' import { registryDefaults } from './registry' @@ -54,6 +63,7 @@ const input = (over: Partial): NodeModelInput => ({ seen: new Map(), telemetry: [], defaults, + now: 0, ...over, file: everSeen(over.file ?? EMPTY_MAPPING) }) @@ -63,6 +73,7 @@ const fresh = (over: Partial): NodeModelInput => ({ seen: new Map(), telemetry: [], defaults, + now: 0, file: EMPTY_MAPPING, ...over }) @@ -172,6 +183,107 @@ describe('availability', () => { }) }) +describe('the one availability rule: roster, telemetry, none (X2 / S10, #132)', () => { + it('online by the roster, else by fresh PDU pages, else offline by the roster, else unknown', () => { + expect(nodeAvailability(st(4), false, false)).toEqual({ + availability: 'online', + evidence: 'roster' + }) + // Fresh pages count as online whatever the roster says: no row, absent, or no roster yet. + for (const [row, complete] of [ + [undefined, false], + [undefined, true], + [st(4, false), true] + ] as const) { + expect(nodeAvailability(row, complete, true)).toEqual({ + availability: 'online', + evidence: 'telemetry' + }) + } + expect(nodeAvailability(st(4, false), false, false)).toEqual({ + availability: 'offline', + evidence: 'roster' + }) + expect(nodeAvailability(undefined, true, false)).toEqual({ + availability: 'offline', + evidence: 'roster' + }) + expect(nodeAvailability(undefined, false, false)).toEqual({ + availability: 'unknown', + evidence: 'none' + }) + }) + + it('a PDU in your cockpit sending fresh pages is online, unless the roster said absent after them', () => { + // A complete roster left PDU_Left (#3) out, or listed it absent: its fresh pages say it is on. + expect(entry(input({ telemetry: [telem(3)], rosterCompleteAt: 0 }), 3)).toMatchObject({ + availability: 'online', + readings: 'live' + }) + // Listed absent before its newest page arrived (pages are 0 ms old at `now` 0): on by them. + const before = new Map([[3, { firstSeenAt: -9000, offlineAt: -5000 }]]) + expect( + entry(input({ roster: [st(3, false)], seen: before, telemetry: [telem(3)] }), 3) + ).toMatchObject({ availability: 'online', readings: 'live' }) + expect(entry(input({ telemetry: [telem(3)] }), 3)?.availability).toBe('online') // no roster yet + // Listed absent after its newest page (3 s old at `now` 5000): offline at once, on the + // roster's timing — not when the page goes stale. + const after = new Map([[3, { firstSeenAt: 0, offlineAt: 4000 }]]) + const old = [telem(3, { rail12V: { ...telem(3).rail12V!, ageMs: 3000 } })] + expect( + entry(input({ roster: [st(3, false)], seen: after, telemetry: old, now: 5000 }), 3) + ).toMatchObject({ availability: 'offline', offlineAt: 4000 }) + // A newer page after that absent reading: back on. + const newer = [telem(3, { rail12V: { ...telem(3).rail12V!, ageMs: 500 } })] + expect( + entry(input({ roster: [st(3, false)], seen: after, telemetry: newer, now: 5000 }), 3) + ?.availability + ).toBe('online') + const stale = [telem(3, { stale: true })] + expect(entry(input({ telemetry: stale, rosterCompleteAt: 0 }), 3)?.availability).toBe('offline') + expect(entry(input({ telemetry: stale }), 3)?.availability).toBe('unknown') + }) + + it('no "new but offline, can’t adopt" board: a registry PDU seen by its pages alone is adoptable', () => { + for (const roster of [[], [st(3, false)]]) { + const m = buildNodeModel(fresh({ roster, telemetry: [telem(3)], rosterCompleteAt: 0 })) + const e = m.nodes.find((n) => n.nodeId === 3)! + expect(e).toMatchObject({ source: 'new', availability: 'online', identified: 'pdu' }) + expect(isReportableNew(e, true)).toBe(true) + const [row] = adoptRows(nodeViews(m, [telem(3)]), cockpitMembers(m)) + expect(row).toMatchObject({ kind: 'pdu', badge: 'PDU' }) + expect(row?.evidence.reporting).toMatch(/^telemetry: 12 V/) + } + }) + + it('every new board in the model is on the bus: none is offline', () => { + const m = buildNodeModel( + fresh({ + roster: [{ ...st(1), nodeKind: 1 }, st(2, false), st(50), st(51, false)], + telemetry: [telem(3), telem(4, { stale: true })], + rosterCompleteAt: 0 + }) + ) + const newOnes = m.nodes.filter((n) => n.source === 'new') + expect(newOnes.map((n) => [n.nodeId, n.availability])).toEqual([ + [1, 'online'], + [3, 'online'], + [50, 'online'] + ]) + }) + + it('onBusNow: online, and only while the link is up', () => { + expect(onBusNow({ availability: 'online' }, true)).toBe(true) + expect(onBusNow({ availability: 'online' }, false)).toBe(false) + expect(onBusNow({ availability: 'offline' }, true)).toBe(false) + expect(onBusNow({ availability: 'unknown' }, true)).toBe(false) + const e = buildNodeModel(fresh({ roster: [{ ...st(1), nodeKind: 1 }], rosterCompleteAt: 0 })) + .nodes[0]! + expect(isReportableNew(e, true)).toBe(true) + expect(isReportableNew(e, false)).toBe(false) + }) +}) + describe('new-node detection', () => { it('a roster node outside the registry and mapping is new, with first-seen; PDU by its pages', () => { const seen = new Map([ @@ -419,14 +531,14 @@ describe('your cockpit (#93)', () => { // D7: its NODE_IDS.md PDU (PDU_Center) is not in your cockpit, so the pre-fill has none. registry: { name: 'Center_Armament', console: 'Center', pduId: null } }) - expect(isReportableNew(m.nodes[0]!)).toBe(true) + expect(isReportableNew(m.nodes[0]!, true)).toBe(true) expect(m.outages).toEqual([]) }) it('a registry board is identified only by what it reports, never by its NODE_ID (#93 re-review)', () => { const m = buildNodeModel(fresh({ roster: [st(1)], rosterCompleteAt: 0 })) // kind 0: not yet expect(m.nodes[0]?.identified).toBeUndefined() - expect(isReportableNew(m.nodes[0]!)).toBe(false) // not news until it says what it is + expect(isReportableNew(m.nodes[0]!, true)).toBe(false) // not news until it says what it is expect(m.nodes[0]?.registry?.name).toBe('Center_Armament') // the pre-fill is still there // Its own report wins over NODE_IDS.md: a registry panel ID reporting PDU is a PDU. const pdu = buildNodeModel(fresh({ roster: [{ ...st(2), nodeKind: 2 }], rosterCompleteAt: 0 })) @@ -508,7 +620,7 @@ describe('board kind (#344)', () => { const n41 = m.nodes.find((n) => n.nodeId === 41)! expect(n41.identified).toBeUndefined() expect(n41.kind).toBe('panel') // the drawing default - expect(isReportableNew(n41)).toBe(false) + expect(isReportableNew(n41, true)).toBe(false) }) it('isReportableNew: new, on the bus, and identified as a PDU or a panel group', () => { @@ -518,6 +630,6 @@ describe('board kind (#344)', () => { rosterCompleteAt: 0 }) ) - expect(m.nodes.filter(isReportableNew).map((n) => n.nodeId)).toEqual([41, 42]) + expect(m.nodes.filter((n) => isReportableNew(n, true)).map((n) => n.nodeId)).toEqual([41, 42]) }) }) diff --git a/src/main/nodes/node-model.ts b/src/main/nodes/node-model.ts index 54dddd0..1a10a7d 100644 --- a/src/main/nodes/node-model.ts +++ b/src/main/nodes/node-model.ts @@ -15,7 +15,7 @@ import type { import { differingCount, effectiveMapping } from './mapping-rules' import { boardIdentity, isAdoptableKind } from './node-kind' import { BRIDGE_NODE_ID } from './node-status-wire' -import { hasFreshPduPage } from './node-telem' +import { hasFreshPduPage, pduPagesLive } from './node-telem' import { pduOutages } from './power-zone' export interface NodeModelInput { @@ -25,19 +25,68 @@ export interface NodeModelInput { telemetry: NodeTelemSnapshot[] defaults: Map file: NodeMappingFile + /** When `telemetry` was taken: page ages are counted back from it. */ + now: number +} + +/** What a board's availability was decided from: its roster row, its PDU pages, or nothing. */ +export type AvailabilityEvidence = 'roster' | 'telemetry' | 'none' + +export interface Availability { + availability: NodeAvailability + evidence: AvailabilityEvidence } /** - * The one availability rule: online while the roster lists it present, offline once it lists it - * absent or a complete roster left it out (there is no "waiting"), else unknown — no complete - * roster since the link opened, and it has not reported. + * The one availability rule (X2 / S10), with what it was decided from: + * - online while the roster lists it present (roster); + * - online while its PDU pages say so (telemetry): fresh, and newer than the roster's last + * "absent" reading for it (node-telem.ts pduPagesLive — the newest evidence wins); + * - offline once the roster lists it absent, or a complete roster left it out (roster) — there is + * no "waiting"; + * - else unknown (none): no complete roster since the link opened, and it has not reported. + * `pduTelemetry` is that page evidence already weighed (availabilityOf). */ export function nodeAvailability( st: NodeStatus | undefined, + rosterComplete: boolean, + pduTelemetry: boolean +): Availability { + if (st?.present) return { availability: 'online', evidence: 'roster' } + if (pduTelemetry) return { availability: 'online', evidence: 'telemetry' } + if (st || rosterComplete) return { availability: 'offline', evidence: 'roster' } + return { availability: 'unknown', evidence: 'none' } +} + +/** What the rule reads for one board: its roster row and timing, and its PDU pages. */ +export interface AvailabilitySignals { + status?: NodeStatus + seen?: NodeSeen rosterComplete: boolean -): NodeAvailability { - if (st) return st.present ? 'online' : 'offline' - return rosterComplete ? 'offline' : 'unknown' + telemetry?: NodeTelemSnapshot + /** When `telemetry` was taken. */ + now: number +} + +/** + * A board's availability from its signals: the model's, and the one NodeHub refuses Hide and Forget + * by, so the two always agree. Pages count against the roster's last "absent" reading — the time + * it learned the board was offline (seen.offlineAt) — only while the roster lists it absent. + */ +export function availabilityOf(s: AvailabilitySignals): Availability { + const absentAt = s.status && !s.status.present ? s.seen?.offlineAt : undefined + const pages = !!s.telemetry && pduPagesLive(s.telemetry, s.now, absentAt) + return nodeAvailability(s.status, s.rosterComplete, pages) +} + +/** + * On the bus now: the link is up and the one availability rule says online. Every "on the bus" + * surface reads this — the Cockpit's "New on the bus" and its strays, the Overview's new-devices + * notice, Connection › Connected nodes and Settings › About's On the bus — so while the Gateway is + * unplugged they all go quiet, whatever the last model said (X2 / R5). + */ +export function onBusNow(e: Pick, live: boolean): boolean { + return live && e.availability === 'online' } /** @@ -51,14 +100,24 @@ export function buildNodeModel(i: NodeModelInput): NodeModel { const roster = new Map(i.roster.map((n) => [n.nodeId, n])) const telem = new Map(i.telemetry.map((t) => [t.nodeId, t])) const complete = i.rosterCompleteAt !== undefined + const signals = (nodeId: number): AvailabilitySignals => ({ + status: roster.get(nodeId), + seen: i.seen.get(nodeId), + rosterComplete: complete, + telemetry: telem.get(nodeId), + now: i.now + }) const known = [...mapping.values()].filter((m) => m.known).map((m) => m.nodeId) // Not in your cockpit, registry or not: shown as new only while it is on the bus. The roster // keeps absent rows; one that dropped (or that you just forgot) comes back when it reports. const waiting = [...roster.values()].filter((n) => n.present).map((n) => n.nodeId) - // PDU pages are a report too: a registry or adopted PDU sending them now is on the bus. Pages are - // kept (only marked stale), so one that stopped sending — dropped, or forgotten — is not. + // PDU pages are a report too: a registry or adopted PDU sending them now is on the bus — online + // by them (nodeAvailability), so it can be adopted (S10). Pages are kept (only marked stale), so + // one that stopped sending — dropped, or forgotten — is not. const sending = [...telem.values()] - .filter((t) => mapping.get(t.nodeId)?.known || (mapping.has(t.nodeId) && hasFreshPduPage(t))) + .filter( + (t) => mapping.has(t.nodeId) && availabilityOf(signals(t.nodeId)).availability === 'online' + ) .map((t) => t.nodeId) // The bridge's own row (node 0, v3) is this client's link, not a cockpit node. const ids = [...new Set([...known, ...waiting, ...sending])] @@ -96,7 +155,7 @@ export function buildNodeModel(i: NodeModelInput): NodeModel { pduTelemetry: t?.isPdu }) const kind: NodeKind = identified === 'pdu' ? 'pdu' : 'panel' - const availability = nodeAvailability(st, complete) + const { availability } = availabilityOf(signals(nodeId)) const e: NodeModelEntry = { nodeId, name: mapped?.name ?? `Node ${nodeId}`, @@ -141,11 +200,10 @@ export function buildNodeModel(i: NodeModelInput): NodeModel { } /** - * A new device worth telling the user about: not in the registry or adopted, on the bus now, and + * A new device worth telling the user about: not in your cockpit, on the bus now (onBusNow), and * identified as something that can be adopted. Until then it is not news — not on the Cockpit - * count, the Overview notice or the event log. One predicate for every tab (node 0 is never in the - * model). + * count or the Overview notice. One predicate for every tab (node 0 is never in the model). */ -export function isReportableNew(e: NodeModelEntry): boolean { - return e.source === 'new' && e.availability === 'online' && isAdoptableKind(e.identified) +export function isReportableNew(e: NodeModelEntry, live: boolean): boolean { + return e.source === 'new' && onBusNow(e, live) && isAdoptableKind(e.identified) } diff --git a/src/main/nodes/node-status-wire.test.ts b/src/main/nodes/node-status-wire.test.ts index da061d5..95f7ea3 100644 --- a/src/main/nodes/node-status-wire.test.ts +++ b/src/main/nodes/node-status-wire.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { canRequestRoster, cleanNodeLine, + endRowCount, isLegacyNodeStatus, NODE_END_MSG, NODE_MSG, @@ -268,6 +269,110 @@ describe('NodeRoster — a new request never cuts a reply short (M2)', () => { }) }) +describe('NodeRoster — a reply missing a line reconciles nothing (#132)', () => { + const row = (id: number) => statusRow(id, true, id === 0 ? 3 : 1) + /** One v3 reply: the node-0 row, a row per board (less any `lost`), END with the true count. */ + const reply = (r: NodeRoster, ids: number[], t: number, lost: number[] = []) => { + r.beginBurst(t) + for (const id of [0, ...ids]) if (!lost.includes(id)) r.applyMessage(NODE_MSG, row(id), t) + r.applyMessage(NODE_END_MSG, String(ids.length + 1), t) // PanelBridge counts node 0 too + } + const states = (r: NodeRoster) => + r + .snapshot() + .filter((n) => n.nodeId !== 0) + .map((n) => [n.nodeId, n.present]) + + it('the END count is PanelBridge’s rows, node 0 included; no count is undefined', () => { + expect(endRowCount('3')).toBe(3) + expect(endRowCount(' 1\r')).toBe(1) + expect(endRowCount('')).toBeUndefined() + expect(endRowCount('x')).toBeUndefined() + }) + + it('a board whose line was lost keeps its state; the next whole reply decides', () => { + const r = new NodeRoster() + reply(r, [1, 2, 3], 0) + reply(r, [1, 2, 3], 5000, [2]) // board 2's line is lost on the way + expect(states(r)).toEqual([ + [1, true], + [2, true], + [3, true] + ]) + expect(r.seenInfo().get(2)?.offlineAt).toBeUndefined() + expect(r.takeIncompleteReply()).toEqual({ expected: 4, received: 3 }) + expect(r.takeIncompleteReply()).toBeUndefined() // taken once + reply(r, [1, 3], 10_000) // board 2 really left: the next whole reply says so + expect(states(r)).toEqual([ + [1, true], + [2, false], + [3, true] + ]) + expect(r.seenInfo().get(2)).toMatchObject({ offlineAt: 10_000, offlineVia: 'roster' }) + expect(r.takeIncompleteReply()).toBeUndefined() + }) + + it('a whole reply still marks a missing board offline', () => { + const r = new NodeRoster() + reply(r, [1, 2], 0) + reply(r, [1], 5000) + expect(states(r)).toEqual([ + [1, true], + [2, false] + ]) + }) + + it('a lost node-0 row or END is a short reply too: nothing goes offline', () => { + const r = new NodeRoster() + reply(r, [1, 2], 0) + reply(r, [1, 2], 5000, [0]) // the node-0 row is lost + expect(r.takeIncompleteReply()).toEqual({ expected: 3, received: 2 }) + expect(states(r)).toEqual([ + [1, true], + [2, true] + ]) + // The END of the next reply is lost; the one after it ends whole. + r.beginBurst(10_000) + for (const id of [0, 1]) r.applyMessage(NODE_MSG, row(id), 10_000) + reply(r, [1, 2], 15_000) + expect(states(r)).toEqual([ + [1, true], + [2, true] + ]) + }) + + it('a first reply that is short leaves the roster incomplete: remembered boards stay unknown', () => { + const r = new NodeRoster() + reply(r, [1, 2], 0, [2]) + expect(r.rosterCompleteAt()).toBeUndefined() + reply(r, [1, 2], 5000) + expect(r.rosterCompleteAt()).toBe(5000) + }) + + it('deltas before a reply’s node-0 row are not counted as its rows', () => { + const r = new NodeRoster() + reply(r, [1, 2], 0) + r.beginBurst(5000) + r.applyMessage(NODE_MSG, statusRow(2, false, 1), 5001) // a live delta: board 2 dropped + for (const id of [0, 1]) r.applyMessage(NODE_MSG, row(id), 5002) + r.applyMessage(NODE_END_MSG, '2', 5002) // whole: node 0 and board 1 + expect(r.takeIncompleteReply()).toBeUndefined() + expect(states(r)).toEqual([ + [1, true], + [2, false] + ]) + }) + + it('an END with no count completes as before', () => { + const r = new NodeRoster() + r.applyMessage(NODE_MSG, row(1), 0) + r.beginBurst(5000) + r.applyMessage(NODE_END_MSG, '', 5000) + expect(states(r)).toEqual([[1, false]]) + expect(r.rosterCompleteAt()).toBe(5000) + }) +}) + describe('NodeRoster — a v2 bridge’s END completes nothing (M3)', () => { const V2 = '01010000000000000080000000' // 26 hex: a v2 row, dropped diff --git a/src/main/nodes/node-status-wire.ts b/src/main/nodes/node-status-wire.ts index 3cb7304..26755ce 100644 --- a/src/main/nodes/node-status-wire.ts +++ b/src/main/nodes/node-status-wire.ts @@ -117,6 +117,18 @@ export function canRequestRoster(attached: boolean, state: DeviceState): boolean */ export const ROSTER_REPLY_MS = 1000 +/** A roster reply that ended short of its count: the rows it said, and the ones that arrived. */ +export interface IncompleteReply { + expected: number + received: number +} + +/** The row count `_NODE_STATUS_END ` carries (decimal), or undefined when it has none. */ +export function endRowCount(arg: string): number | undefined { + const a = arg.trim() + return /^\d+$/.test(a) ? Number(a) : undefined +} + /** * Tracks every node seen since reset. Bare messages are live deltas; a request/boot * burst ends with `_NODE_STATUS_END`, after which nodes absent from the burst are @@ -128,6 +140,13 @@ export const ROSTER_REPLY_MS = 1000 * `_NODE_STATUS_END`, in the order asked. So from a node-0 row to the next END a reply is * arriving: a request then keeps the rows already in, and the END reconciles all of them. Only a * request with no reply arriving isolates the next burst from prior deltas. + * + * A reply missing a line never reconciles (#132). `_NODE_STATUS_END ` carries how many rows + * PanelBridge sent in it, its own node-0 row included (PanelBridge.cpp emitAllNodes). When that is + * not how many different NODE_IDs the reply brought, a line was lost on the way: the boards it + * left out keep their state, the roster does not count as complete, and the next whole reply + * decides — one lost line never reads as a board going offline. An END with no count completes as + * before (nothing to check it against). */ export class NodeRoster { private nodes = new Map() @@ -144,6 +163,8 @@ export class NodeRoster { private legacy = false private completeAt?: number private dirty = false + /** The last reply that ended short of its count, until taken (takeIncompleteReply). */ + private incomplete?: IncompleteReply reset(): void { this.nodes.clear() @@ -153,6 +174,7 @@ export class NodeRoster { this.replying = false this.legacy = false this.completeAt = undefined + this.incomplete = undefined this.dirty = true } @@ -184,8 +206,13 @@ export class NodeRoster { if (isLegacyNodeStatus(arg.trim())) this.legacy = true return } + if (ns.nodeId === BRIDGE_NODE_ID) { + // A reply starts with node 0: from here its rows are the reply — not the deltas before it, + // nor what an earlier reply that never ended brought — so its count can be checked. + this.burst = [] + this.replying = true + } this.burst.push(ns) - if (ns.nodeId === BRIDGE_NODE_ID) this.replying = true // a reply starts with node 0 const wasPresent = this.nodes.get(ns.nodeId)?.present ?? true this.nodes.set(ns.nodeId, ns) // keep absent nodes too; present flag carries state const seen = this.seen.get(ns.nodeId) ?? { firstSeenAt: now } @@ -201,8 +228,15 @@ export class NodeRoster { } else if (name === NODE_END_MSG) { // A v2 bridge's END completes nothing: its rows never reached the roster (M3). if (this.legacy) return - // Nodes not in this burst went silent → mark offline, but keep them visible. const inBurst = new Set(this.burst.map((n) => n.nodeId)) + const expected = endRowCount(arg) + if (expected !== undefined && expected !== inBurst.size) { + // A line of this reply was lost: reconcile nothing from it; the next whole reply decides. + this.incomplete = { expected, received: inBurst.size } + this.endReply() + return + } + // Nodes not in this burst went silent → mark offline, but keep them visible. for (const [id, node] of this.nodes) { if (!inBurst.has(id) && node.present) { this.nodes.set(id, { ...node, present: false }) @@ -210,14 +244,29 @@ export class NodeRoster { this.seen.set(id, { ...seen, offlineAt: now, offlineVia: 'roster' }) } } - this.burst = [] - this.burstAt = undefined - this.replying = false + this.endReply() this.completeAt ??= now this.dirty = true } } + /** A reply ended (whole or not): the next request starts a fresh one. */ + private endReply(): void { + this.burst = [] + this.burstAt = undefined + this.replying = false + } + + /** + * The last reply that ended short of its `_NODE_STATUS_END` count since the last call, if any: + * how many rows it said and how many arrived. NodeHub logs it (a debug line, not an event). + */ + takeIncompleteReply(): IncompleteReply | undefined { + const r = this.incomplete + this.incomplete = undefined + return r + } + snapshot(): NodeStatus[] { return [...this.nodes.values()].sort((a, b) => a.nodeId - b.nodeId) } diff --git a/src/main/nodes/node-telem.test.ts b/src/main/nodes/node-telem.test.ts index e22a831..11f592d 100644 --- a/src/main/nodes/node-telem.test.ts +++ b/src/main/nodes/node-telem.test.ts @@ -3,7 +3,9 @@ import { PDU_RAIL_FLAGS as RAIL_FLAG, PDU_STATUS_FLAGS as PDU_FLAG } from '@shar import { displayCurrentMa, hasFreshPduPage, + newestPduPageAt, NodeTelemStore, + pduPagesLive, parseNodeTelem, TELEM_PAGE, TELEM_STALE_MS @@ -224,3 +226,35 @@ describe('hasFreshPduPage', () => { expect(hasFreshPduPage(other.snapshot(0)[0]!)).toBe(false) }) }) + +describe('pduPagesLive: the newest evidence wins (X2, #132)', () => { + const page = (id: number) => parse(telem(id, [0x02, 0, 0, 0, 0, 0, 0, 0])) + const store = (...at: number[]) => { + const s = new NodeTelemStore() + for (const t of at) s.apply(page(4), t) + return s + } + + it('the newest fresh page’s arrival time, counted back from the snapshot', () => { + const s = store(1000, 4000) + expect(newestPduPageAt(s.snapshot(6000)[0]!, 6000)).toBe(4000) + expect( + newestPduPageAt(s.snapshot(4000 + TELEM_STALE_MS + 1)[0]!, 4000 + TELEM_STALE_MS + 1) + ).toBeUndefined() // stale + const other = new NodeTelemStore() + other.apply(parse('0a7f0102030405060a'), 0) // an unknown page type only: not a PDU + expect(newestPduPageAt(other.snapshot(0)[0]!, 0)).toBeUndefined() + }) + + it('fresh pages count unless the roster said absent after the newest one', () => { + const t = store(10_000).snapshot(13_200)[0]! + expect(pduPagesLive(t, 13_200)).toBe(true) // no absent reading + expect(pduPagesLive(t, 13_200, 9000)).toBe(true) // absent before the page + expect(pduPagesLive(t, 13_200, 13_000)).toBe(false) // absent after it: offline at once + expect(pduPagesLive(t, 13_200, 10_000)).toBe(false) // a tie goes to the roster + const back = store(10_000, 14_000).snapshot(14_200)[0]! + expect(pduPagesLive(back, 14_200, 13_000)).toBe(true) // a page after the absent: back on + const stale = store(0).snapshot(TELEM_STALE_MS + 1)[0]! + expect(pduPagesLive(stale, TELEM_STALE_MS + 1)).toBe(false) + }) +}) diff --git a/src/main/nodes/node-telem.ts b/src/main/nodes/node-telem.ts index 786a68a..e413d0b 100644 --- a/src/main/nodes/node-telem.ts +++ b/src/main/nodes/node-telem.ts @@ -98,6 +98,28 @@ export function hasFreshPduPage(t: NodeTelemSnapshot): boolean { return [t.rail12V, t.rail5V, t.status].some((p) => p !== undefined && !p.stale) } +/** + * When the newest fresh PDU page (12 V, 5 V or status) arrived, for a snapshot taken at `now`; + * undefined when it has none. + */ +export function newestPduPageAt(t: NodeTelemSnapshot, now: number): number | undefined { + if (!t.isPdu) return undefined + const ages = [t.rail12V, t.rail5V, t.status].flatMap((p) => (p && !p.stale ? [p.ageMs] : [])) + return ages.length ? now - Math.min(...ages) : undefined +} + +/** + * A PDU's pages say it is on the bus: one is fresh, and the newest arrived after the roster's last + * "absent" reading for it (`absentAt`, its offline time) — the newest evidence wins. So a PDU that + * loses power reads offline as soon as the roster says so, not when its pages go stale, and one + * whose pages keep arriving after that is on again. The availability rule's telemetry evidence + * (node-model.ts availabilityOf), which Hide and Forget read too. + */ +export function pduPagesLive(t: NodeTelemSnapshot, now: number, absentAt?: number): boolean { + const at = newestPduPageAt(t, now) + return at !== undefined && (absentAt === undefined || at > absentAt) +} + /** iAvgMa for display: residual negative offset reads as 0. */ export function displayCurrentMa(ma: number): number { return Math.max(0, ma) diff --git a/src/main/nodes/offline-actions.test.ts b/src/main/nodes/offline-actions.test.ts index 5ee9edc..afee65c 100644 --- a/src/main/nodes/offline-actions.test.ts +++ b/src/main/nodes/offline-actions.test.ts @@ -1,54 +1,23 @@ import { describe, expect, it } from 'vitest' -import type { NodeTelemSnapshot } from '@shared/nodes' -import { - canForget, - canHide, - NO_ROSTER_NOTE, - offlineRefusal, - ONLINE_NOTE, - pduTelemetryLive -} from './offline-actions' - -const page = (stale: boolean) => ({ - kind: 'rail' as const, - page: 1 as const, - flags: 0, - vAvgMv: 12_000, - vMinMv: 11_980, - iAvgMa: 500, - ageMs: stale ? 9000 : 500, - stale -}) -const telem = (nodeId: number, stale: boolean): NodeTelemSnapshot => ({ - nodeId, - isPdu: true, - rail12V: page(stale), - other: [], - live: !stale -}) +import { canForget, canHide, NO_ROSTER_NOTE, offlineRefusal, ONLINE_NOTE } from './offline-actions' describe('canHide / canForget (M1/C3: offline only, by the one availability rule)', () => { for (const gate of [canHide, canForget]) { - it(`${gate.name}: allowed only offline with no fresh PDU pages`, () => { - expect(gate({ availability: 'offline', pduTelemetry: false })).toEqual({ ok: true }) - expect(gate({ availability: 'online', pduTelemetry: false })).toEqual({ + it(`${gate.name}: allowed only offline`, () => { + expect(gate({ availability: 'offline' })).toEqual({ ok: true }) + expect(gate({ availability: 'online' })).toEqual({ ok: false, why: 'online', note: ONLINE_NOTE }) // Just after a reconnect: not known, so not offline. - expect(gate({ availability: 'unknown', pduTelemetry: false })).toEqual({ + expect(gate({ availability: 'unknown' })).toEqual({ ok: false, why: 'no-roster', note: NO_ROSTER_NOTE }) - // Fresh PDU pages prove it live, whatever the roster says. - for (const availability of ['offline', 'unknown'] as const) { - expect(gate({ availability, pduTelemetry: true })).toMatchObject({ - ok: false, - why: 'online' - }) - } + // A PDU's fresh pages are weighed in the availability itself (node-model.ts + // availabilityOf, the newest evidence wins), so the gate reads nothing else. }) } @@ -63,11 +32,4 @@ describe('canHide / canForget (M1/C3: offline only, by the one availability rule 'No roster yet — connect the Gateway. Forget and Hide work once the board shows offline.' ) }) - - it('PDU pages count only while fresh, and only for that NODE_ID', () => { - expect(pduTelemetryLive([telem(4, false)], 4)).toBe(true) - expect(pduTelemetryLive([telem(4, true)], 4)).toBe(false) - expect(pduTelemetryLive([telem(4, false)], 3)).toBe(false) - expect(pduTelemetryLive([], 4)).toBe(false) - }) }) diff --git a/src/main/nodes/offline-actions.ts b/src/main/nodes/offline-actions.ts index 7a14408..7a20388 100644 --- a/src/main/nodes/offline-actions.ts +++ b/src/main/nodes/offline-actions.ts @@ -4,17 +4,15 @@ // // Both only tidy the display, and a board that is active on the bus — its controls reach DCS, it // draws power, it affects bus health — always shows. So both wait until the board is offline by the -// one availability rule (node-model.ts nodeAvailability). Fresh PDU pages prove a PDU is live -// whatever the roster says, and "unknown" (no complete roster since the link opened, the seconds -// after every reconnect) is not offline. -import type { NodeAvailability, NodeTelemSnapshot } from '@shared/nodes' -import { hasFreshPduPage } from './node-telem' +// one availability rule (node-model.ts availabilityOf), which already weighs a PDU's pages against +// the roster — the newest evidence wins (X2) — so this reads nothing else: the footer reads the +// model's answer, NodeHub the same rule over the same signals. "unknown" (no complete roster since +// the link opened, the seconds after every reconnect) is not offline. +import type { NodeAvailability } from '@shared/nodes' export interface OfflineSignals { - /** The board's availability (node-model.ts nodeAvailability). */ + /** The board's availability (node-model.ts availabilityOf), its PDU pages included. */ availability: NodeAvailability - /** It is sending fresh PDU pages now (pduTelemetryLive). */ - pduTelemetry: boolean } /** Why it can't be hidden or forgotten: it is live, or whether it is on the bus is not known. */ @@ -29,13 +27,8 @@ export const ONLINE_NOTE = export const NO_ROSTER_NOTE = 'No roster yet — connect the Gateway. Forget and Hide work once the board shows offline.' -/** Fresh PDU pages from this NODE_ID in the telemetry snapshot: it is live. */ -export function pduTelemetryLive(telemetry: readonly NodeTelemSnapshot[], nodeId: number): boolean { - return telemetry.some((t) => t.nodeId === nodeId && t.isPdu && hasFreshPduPage(t)) -} - function offlineOnly(s: OfflineSignals): OfflineGate { - if (s.pduTelemetry || s.availability === 'online') { + if (s.availability === 'online') { return { ok: false, why: 'online', note: ONLINE_NOTE } } if (s.availability === 'unknown') return { ok: false, why: 'no-roster', note: NO_ROSTER_NOTE } diff --git a/src/main/nodes/phase0-probes.test.ts b/src/main/nodes/phase0-probes.test.ts index b2249a8..f82ccb5 100644 --- a/src/main/nodes/phase0-probes.test.ts +++ b/src/main/nodes/phase0-probes.test.ts @@ -109,14 +109,16 @@ describe('fix-track probes (today)', () => { expect(b.h.forget(4).ok).toBe(false) }) - it('M3: a v2 bridge — END completes the roster, added boards read offline, Forget allowed', () => { + it('M3 / #132 (fixed): a reply whose rows were dropped completes nothing — unknown, Forget refused', () => { + // Was: the END completed the roster, so added boards read offline and Forget was allowed. The + // END says 2 rows and none arrived, so the reply is incomplete and reconciles nothing (#132). const a = hubWith(added()) a.roster.beginBurst(0) - a.roster.applyMessage(NODE_MSG, '0101000000000000008000', 0) // v2 row (no kind) -> dropped + a.roster.applyMessage(NODE_MSG, '0101000000000000008000', 0) // malformed row -> dropped a.roster.applyMessage(NODE_END_MSG, '2', 0) const n1 = a.h.snapshot(0).model.nodes.find((n) => n.nodeId === 1) - expect(n1?.availability).toBe('offline') - expect(a.h.forget(1).ok).toBe(true) + expect(n1?.availability).toBe('unknown') + expect(a.h.forget(1).ok).toBe(false) }) it('C4: a node identified only by PDU telemetry logs "new on the bus" only at its next status row', () => { @@ -223,7 +225,14 @@ describe('fix-track probes (today)', () => { { name: 'Right_Navigation', console: 'Right', pduId: null, kind: 'pdu' }, () => 'pdu' ) - const m = buildNodeModel({ roster: [], seen: new Map(), telemetry: [], defaults, file: f }) + const m = buildNodeModel({ + roster: [], + seen: new Map(), + telemetry: [], + defaults, + file: f, + now: 0 + }) expect(m.nodes.find((x) => x.nodeId === 2)?.kind).toBe('pdu') }) }) diff --git a/src/renderer/src/nodes/node-view.test.ts b/src/renderer/src/nodes/node-view.test.ts index fac5172..358330c 100644 --- a/src/renderer/src/nodes/node-view.test.ts +++ b/src/renderer/src/nodes/node-view.test.ts @@ -48,7 +48,12 @@ const summary = ( telemetry: NodeTelemSnapshot[] = [] ) => { const m = model(nodes, outages) - return nodesSummary(nodeViews(m, telemetry), outages, boardZones(m.nodes, cockpitMembers(m).get)) + return nodesSummary( + nodeViews(m, telemetry), + outages, + boardZones(m.nodes, cockpitMembers(m).get), + true + ) } const T = 1_760_000_000_000 diff --git a/src/renderer/src/nodes/phase0-probes.test.ts b/src/renderer/src/nodes/phase0-probes.test.ts index e66ac09..aeb0dba 100644 --- a/src/renderer/src/nodes/phase0-probes.test.ts +++ b/src/renderer/src/nodes/phase0-probes.test.ts @@ -58,12 +58,13 @@ describe('fix-track probes (today)', () => { seen: roster.seenInfo(), rosterCompleteAt: 0, telemetry: [], + now: 0, defaults, file: EMPTY_MAPPING }) const views = nodeViews(model, []) const cockpit = cockpitViews(model, []) - const { strays } = strayNew(cockpit, 6000) + const { strays } = strayNew(cockpit, 6000, true) expect(strays).toHaveLength(1) const cockpitBand = cockpitHealthBand(cockpit, strays.length) expect(cockpitBand.warn).toBe(1) @@ -77,7 +78,7 @@ describe('fix-track probes (today)', () => { expect(newDevices(views, true)).toBeNull() // Connection expect( - nodesSummary(views, [], boardZones(model.nodes, cockpitMembers(model).get)) + nodesSummary(views, [], boardZones(model.nodes, cockpitMembers(model).get), true) ).toMatchObject({ known: 0, warn: 0, @@ -94,10 +95,11 @@ describe('fix-track probes (today)', () => { seen: roster.seenInfo(), rosterCompleteAt: 0, telemetry: [], + now: 0, defaults, file: EMPTY_MAPPING }) - expect(strayNew(cockpitViews(model, []), 60_001).strays).toHaveLength(1) + expect(strayNew(cockpitViews(model, []), 60_001, true).strays).toHaveLength(1) }) it('C6: a registry board waiting to be added is labelled "NOT IN REGISTRY" / "Not in registry"', () => { @@ -108,6 +110,7 @@ describe('fix-track probes (today)', () => { seen: roster.seenInfo(), rosterCompleteAt: 0, telemetry: [], + now: 0, defaults, file: EMPTY_MAPPING }) @@ -129,6 +132,7 @@ describe('fix-track probes (today)', () => { seen: roster.seenInfo(), rosterCompleteAt: 0, telemetry: [], + now: 0, defaults, file: f }) diff --git a/src/renderer/test/t32-settings-about.test.tsx b/src/renderer/test/t32-settings-about.test.tsx index f9d667d..1e1a2d6 100644 --- a/src/renderer/test/t32-settings-about.test.tsx +++ b/src/renderer/test/t32-settings-about.test.tsx @@ -83,19 +83,29 @@ describe('T32 Settings › About (B92, X7)', () => { ]) }) - it('unplugged: the Gateway reads "no device", but CAN nodes still read "yes" (R5 today)', async () => { - // contract X7/R5: on the bus means now; today the CAN rows keep the last roster while the - // link is down (they ignore it), and only the Gateway row follows the link. + it('unplugged: the Gateway reads "no device", and no CAN row says it is on the bus (R5, #132)', async () => { + // contract X7/R5 (fixed, #132 X2): on the bus means now (`onBusNow(live)`), so with the link + // down every CAN row reads "—" — no data — and a hidden one still reads hidden. Back when the + // link is. const { sky, openRegistry } = await openAbout() const dlg = await openRegistry() sky.emitScenario({ device: link('no-device') }) - expect(rows(dlg).slice(0, 2)).toEqual(['#0Gateway—no device', '#1Center_ArmamentCenteryes']) + expect(rows(dlg)).toEqual([ + '#0Gateway—no device', + '#1Center_ArmamentCenter—', + '#2Right_NavigationRight—', + '#3PDU_LeftLeft—', + '#4PDU_RightRight—', + '#5PDU_CenterCenterhidden' + ]) + sky.emitScenario({ device: connected() }) + expect(rows(dlg).slice(0, 2)).toEqual(['#0Gateway—serial link', '#1Center_ArmamentCenteryes']) }) it('opened while unplugged: the same', async () => { const { openRegistry } = await openAbout(bus(), link('disconnected')) const dlg = await openRegistry() - expect(rows(dlg).slice(0, 2)).toEqual(['#0Gateway—no device', '#1Center_ArmamentCenteryes']) + expect(rows(dlg).slice(0, 2)).toEqual(['#0Gateway—no device', '#1Center_ArmamentCenter—']) }) it('Close shuts the dialog', async () => { diff --git a/src/renderer/test/t9-cockpit-new-on-bus.test.tsx b/src/renderer/test/t9-cockpit-new-on-bus.test.tsx index 0afcc59..9abc380 100644 --- a/src/renderer/test/t9-cockpit-new-on-bus.test.tsx +++ b/src/renderer/test/t9-cockpit-new-on-bus.test.tsx @@ -47,9 +47,9 @@ describe('T9 Cockpit "New on the bus" CTA (B68, F12, X7)', () => { expect(adoptButton()).toBeNull() }) - it('stays from the last model while the link is down (R5 today)', async () => { - // contract R5 / F12: the CTA should count only boards on the bus now (`onBusNow(live)`); - // today it keeps showing the last model's new boards after the link drops. + it('goes quiet while the link is down, and returns when it is back (R5, #132)', async () => { + // contract R5 / F12 (fixed, #132 X2): the CTA counts only boards on the bus now + // (`onBusNow(live)`), so the last model's new boards are not "on the bus" once the link drops. const { sky } = await renderScenario(, { device: connected(), model: model([registryPanel()]) @@ -57,6 +57,9 @@ describe('T9 Cockpit "New on the bus" CTA (B68, F12, X7)', () => { expect(cta()).toHaveTextContent('New on the bus · 1') sky.emitScenario({ device: link('no-device') }) expect(screen.getByText('No live node status — connect the Gateway.')).toBeInTheDocument() + expect(cta()).toBeNull() + expect(adoptButton()).toBeNull() + sky.emitScenario({ device: connected() }) expect(cta()).toHaveTextContent('New on the bus · 1') expect(adoptButton()).toBeEnabled() })