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.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..b35c781 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. @@ -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 f97b7db..8ebf617 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', @@ -116,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 03ceeb9..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) }) }) @@ -94,32 +95,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/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/app/roster-poll.seam.test.ts b/src/main/app/roster-poll.seam.test.ts new file mode 100644 index 0000000..0b0f4aa --- /dev/null +++ b/src/main/app/roster-poll.seam.test.ts @@ -0,0 +1,285 @@ +// 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 + /** + * 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 +} + +/** + * 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 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 + 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 + }, + /** 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() + 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 + 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 + } + } + 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, + // #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 + // 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(b.dropped, 'single lines were lost inside replies').toBeGreaterThan(50) + 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/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 829b57e..497f042 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. */ @@ -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 @@ -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' }) @@ -296,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) @@ -415,14 +420,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..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)', () => { @@ -86,12 +67,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..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,25 +94,12 @@ 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. `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 +107,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/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/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/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.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 c596bd1..f0f3808 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, @@ -46,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' @@ -61,7 +61,6 @@ import { BRIDGE_NODE_ID, canRequestRoster, cleanNodeLine, - isLegacyNodeStatus, NODE_END_MSG, NODE_MSG, nodeRosterRequest, @@ -117,8 +116,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 +218,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 +233,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 +271,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 +291,27 @@ 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) + 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. + 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 @@ -458,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 { @@ -550,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 da8a156..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, @@ -12,7 +13,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 +105,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 +192,210 @@ 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 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 + + 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..26755ce 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) @@ -117,33 +117,73 @@ 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 * 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. + * + * 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() 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 + /** The last reply that ended short of its count, until taken (takeIncompleteReply). */ + private incomplete?: IncompleteReply reset(): void { this.nodes.clear() this.seen.clear() this.burst = [] this.burstAt = undefined + this.replying = false + this.legacy = false this.completeAt = undefined + this.incomplete = 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,7 +202,16 @@ 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 + } + 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) const wasPresent = this.nodes.get(ns.nodeId)?.present ?? true this.nodes.set(ns.nodeId, ns) // keep absent nodes too; present flag carries state @@ -177,8 +226,17 @@ export class NodeRoster { } this.dirty = true } else if (name === NODE_END_MSG) { - // Nodes not in this burst went silent → mark offline, but keep them visible. + // A v2 bridge's END completes nothing: its rows never reached the roster (M3). + if (this.legacy) return 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 }) @@ -186,13 +244,29 @@ export class NodeRoster { this.seen.set(id, { ...seen, offlineAt: now, offlineVia: 'roster' }) } } - this.burst = [] - this.burstAt = undefined + 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) } @@ -202,6 +276,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/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/main/test-harness/services.ts b/src/main/test-harness/services.ts index 714e5dd..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() @@ -226,7 +255,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), @@ -371,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/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/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/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/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/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/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/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/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 8a63745..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, @@ -98,7 +100,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' @@ -274,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() }) @@ -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())), @@ -448,26 +446,16 @@ 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 - /** - * 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 => { @@ -495,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/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/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/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/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() }) diff --git a/src/shared/app/ipc.compat.test-d.ts b/src/shared/app/ipc.compat.test-d.ts index 604945f..d3ef279 100644 --- a/src/shared/app/ipc.compat.test-d.ts +++ b/src/shared/app/ipc.compat.test-d.ts @@ -86,6 +86,19 @@ // `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). +// +// 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 @@ -167,6 +180,8 @@ interface OldRelayStatus { running: boolean relay: RelayState relayFacts: RelayFacts + aircraft: AircraftStatus + telemetry: TelemetryReadout[] device: DeviceStatusPush cal?: CalibrationState } @@ -227,7 +242,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 +302,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..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 @@ -256,7 +268,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/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 81df94d..977d6ca 100644 --- a/src/shared/gateway/ipc.ts +++ b/src/shared/gateway/ipc.ts @@ -91,19 +91,14 @@ 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 - /** - * 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/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 } 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',