diff --git a/src/main/app/roster-poll.seam.test.ts b/src/main/app/roster-poll.seam.test.ts index be6c51f..0b0f4aa 100644 --- a/src/main/app/roster-poll.seam.test.ts +++ b/src/main/app/roster-poll.seam.test.ts @@ -52,6 +52,11 @@ interface BridgeModel { * late reply is brought forward to this. Unset: no limit. */ answerWithinMs?: number + /** + * One line of the n-th reply lost on the way (#132): the index into its lines (0 = the node-0 + * row, the last = `_NODE_STATUS_END`), or undefined to send it whole. + */ + dropLine?: (n: number, lines: number) => number | undefined } /** @@ -64,6 +69,7 @@ function bridge(app: Services, boards: number[], model: BridgeModel = {}) { const PDU = 0x02 const kind = (id: number) => (id >= 3 && id <= 5 ? PDU : PANEL) // NODE_IDS.md: 3–5 are PDUs let asked = 0 + let dropped = 0 let lastLineAt = 0 /** The first request since the last line sent: what the app's not-responding clock counts from. */ let waitingSince: number | undefined @@ -78,6 +84,10 @@ function bridge(app: Services, boards: number[], model: BridgeModel = {}) { get asked() { return asked }, + /** Reply lines lost on the way so far (dropLine). */ + get dropped() { + return dropped + }, /** Read what the app wrote, and send every reply line that is due. */ step(): void { const now = app.clock.now() @@ -91,7 +101,12 @@ function bridge(app: Services, boards: number[], model: BridgeModel = {}) { at = Math.min(at, waitingSince + model.answerWithinMs) } at = Math.max(at, lastLineAt) // one serial line: replies arrive in the order asked - for (const text of burst) { + const drop = model.dropLine?.(asked, burst.length) + for (const [i, text] of burst.entries()) { + if (i === drop) { + dropped++ + continue + } lines.push({ at, text }) lastLineAt = at at += model.gap?.() ?? 0 @@ -185,7 +200,10 @@ describe('soak: 30 simulated minutes of lost and late replies (M2, G1, the #290 return r < 0.6 ? 2 + rnd() * 48 : r < 0.85 ? 800 + rnd() * 800 : 2500 + rnd() * 2000 }, gap: () => (rnd() < 0.85 ? rnd() * 40 : 100 + rnd() * 200), - answerWithinMs: 4500 + answerWithinMs: 4500, + // #132: one in five replies loses a single line on the way — a board's row, the node-0 row + // or the END — so its END count no longer matches what arrived. + dropLine: (_n, lines) => (rnd() < 0.2 ? Math.floor(rnd() * lines) : undefined) }) app.serial.open() // Until the first answer arms the re-ask, only the 5 s poll asks: two lost in a row, then a @@ -208,6 +226,7 @@ describe('soak: 30 simulated minutes of lost and late replies (M2, G1, the #290 expect(offline, 'a false offline').toBeUndefined() expect(flashed, 'a "not responding" flash').toBe(false) expect(b.asked, 'the poll and its re-asks kept asking').toBeGreaterThan(360) + expect(b.dropped, 'single lines were lost inside replies').toBeGreaterThan(50) expect(seen(), 'no extra saves').toEqual(onlineAt) const events = app.invoke(CTRL.eventsGet) expect( diff --git a/src/main/nodes/cockpit-snapshot/adopt.test.ts b/src/main/nodes/cockpit-snapshot/adopt.test.ts index cfb0275..f43f62f 100644 --- a/src/main/nodes/cockpit-snapshot/adopt.test.ts +++ b/src/main/nodes/cockpit-snapshot/adopt.test.ts @@ -259,12 +259,12 @@ describe('strayNew', () => { it('waits IDENTIFY_WAIT_MS for an unknown board, and says when the next one is due', () => { const views = at([fresh(41, { firstSeenAt: 0 }), fresh(43, { firstSeenAt: 3000 })]) - expect(strayNew(views, 4000)).toEqual({ strays: [], nextAt: 5000 }) - const r = strayNew(views, 5000) + expect(strayNew(views, 4000, true)).toEqual({ strays: [], nextAt: 5000 }) + const r = strayNew(views, 5000, true) expect(ids(r)).toEqual([41]) expect(r.nextAt).toBe(8000) - expect(strayNew(views, 8000).nextAt).toBeUndefined() - expect(ids(strayNew(views, 8000))).toEqual([41, 43]) + expect(strayNew(views, 8000, true).nextAt).toBeUndefined() + expect(ids(strayNew(views, 8000, true))).toEqual([41, 43]) }) it('takes a bridge or an unknown type at once; never an adoptable or offline node', () => { @@ -274,7 +274,7 @@ describe('strayNew', () => { fresh(41, { identified: 'panel', firstSeenAt: 0 }), fresh(46, { availability: 'offline', firstSeenAt: 0 }) ]) - expect(ids(strayNew(views, 1000))).toEqual([44, 45]) + expect(ids(strayNew(views, 1000, true))).toEqual([44, 45]) }) }) diff --git a/src/main/nodes/cockpit-snapshot/adopt.ts b/src/main/nodes/cockpit-snapshot/adopt.ts index be407d2..df053f5 100644 --- a/src/main/nodes/cockpit-snapshot/adopt.ts +++ b/src/main/nodes/cockpit-snapshot/adopt.ts @@ -97,7 +97,7 @@ export function adoptEvidence(v: NodeView): Evidence { const registry = e.registry ? `NODE_IDS.md: ${e.registry.name}, ${consoleLabel(e.registry.console)}` : 'not in NODE_IDS.md' - if (e.availability !== 'online' || !st) { + if (e.availability !== 'online') { return { detected, reporting: 'not reporting right now', registry } } const { rail12V, rail5V } = v.readings @@ -105,6 +105,11 @@ export function adoptEvidence(v: NodeView): Evidence { const rails = [rail('12 V', rail12V), rail('5 V', rail5V)].filter(Boolean).join(', ') return { detected, reporting: `telemetry: ${rails}`, registry } } + if (!st) { + // Online by its PDU pages alone (S10): its board-status page is what it sends. + const reporting = e.kind === 'pdu' ? 'telemetry: board status' : 'not reporting right now' + return { detected, reporting, registry } + } const faults = [ st.boff && 'bus-off', st.degraded && nodeFaultTag(st), diff --git a/src/main/nodes/cockpit-snapshot/attention.ts b/src/main/nodes/cockpit-snapshot/attention.ts index 7645c50..6cb2cb4 100644 --- a/src/main/nodes/cockpit-snapshot/attention.ts +++ b/src/main/nodes/cockpit-snapshot/attention.ts @@ -13,7 +13,7 @@ import type { } from '@shared/nodes' import { lastSeenLabel } from '@shared/format' import { shownHealth } from '../../gateway' -import { isReportableNew } from '../node-model' +import { isReportableNew, onBusNow } from '../node-model' import { adoptability } from './adopt' /** "1 node affected" / "3 nodes affected" — a PDU outage, wherever it is shown. */ @@ -72,11 +72,12 @@ export function attentionItems( export const ATTENTION_SHOWN = 3 /** - * "New on the bus · N": new NODE_IDs on the bus that have identified as a PDU or a panel group. - * One still identifying is not news yet (isReportableNew — the Overview and event log agree). + * "New on the bus · N": new NODE_IDs on the bus now that have identified as a PDU or a panel group. + * One still identifying is not news yet, and with the link down none is on the bus now + * (isReportableNew — the Overview's notice agrees). */ -export function newCount(model: NodeModel): number { - return model.nodes.filter(isReportableNew).length +export function newCount(model: NodeModel, live: boolean): number { + return model.nodes.filter((e) => isReportableNew(e, live)).length } /** @@ -86,17 +87,22 @@ export function newCount(model: NodeModel): number { export const IDENTIFY_WAIT_MS = 5000 /** - * New boards on the bus that can't be adopted (#93): a kind that can't be adopted (a second - * SimGateway, a type this client doesn't know), or still unidentified after IDENTIFY_WAIT_MS. A - * board on the bus is active, so it can't be hidden — it shows in Attention as a warning instead. - * `nextAt` = when one still identifying will cross the line. + * New boards on the bus now (onBusNow) that can't be adopted (#93): a kind that can't be adopted (a + * second SimGateway, a type this client doesn't know), or still unidentified after + * IDENTIFY_WAIT_MS. A board on the bus is active, so it can't be hidden — it shows in Attention as + * a warning instead. None while the link is down. `nextAt` = when one still identifying will cross + * the line. */ -export function strayNew(views: NodeView[], now: number): { strays: NodeView[]; nextAt?: number } { +export function strayNew( + views: NodeView[], + now: number, + live: boolean +): { strays: NodeView[]; nextAt?: number } { const strays: NodeView[] = [] let nextAt: number | undefined for (const v of views) { const e = v.entry - if (e.source !== 'new' || e.availability !== 'online' || adoptability(e).kind) continue + if (e.source !== 'new' || !onBusNow(e, live) || adoptability(e).kind) continue const due = (e.firstSeenAt ?? 0) + IDENTIFY_WAIT_MS if (e.identified || due <= now) strays.push(v) else nextAt = Math.min(nextAt ?? due, due) @@ -129,9 +135,9 @@ function gatewayAttention( } /** - * The Attention card. Strays and items only while the link is up (X2 co-located: the Cockpit - * says nothing about boards on the bus while unplugged); the Gateway's item takes a shown slot - * first; "New on the bus" counts whatever the last model said. + * The Attention card. Items only while the link is up (the strays are on the bus now already, + * strayNew); the Gateway's item takes a shown slot first; "New on the bus" counts the boards on + * the bus now (newCount), so it too is quiet while unplugged (X2 / R5). */ export function cockpitAttention( model: NodeModel | undefined, @@ -149,7 +155,7 @@ export function cockpitAttention( shown: items.slice(0, ATTENTION_SHOWN - (gateway ? 1 : 0)), count, more: Math.max(0, count - ATTENTION_SHOWN), - newCount: model ? newCount(model) : 0 + newCount: model ? newCount(model, live) : 0 } if (gateway) out.gateway = gateway return out diff --git a/src/main/nodes/cockpit-snapshot/board-opens.test.ts b/src/main/nodes/cockpit-snapshot/board-opens.test.ts index 2674c19..b767213 100644 --- a/src/main/nodes/cockpit-snapshot/board-opens.test.ts +++ b/src/main/nodes/cockpit-snapshot/board-opens.test.ts @@ -53,7 +53,8 @@ function snapshotOf(f: NodeMappingFile) { rosterCompleteAt: T, telemetry: [], defaults, - file: f + file: f, + now: T }) return buildCockpitSnapshot({ model, diff --git a/src/main/nodes/cockpit-snapshot/cockpit.test.ts b/src/main/nodes/cockpit-snapshot/cockpit.test.ts index 076d7eb..fefa304 100644 --- a/src/main/nodes/cockpit-snapshot/cockpit.test.ts +++ b/src/main/nodes/cockpit-snapshot/cockpit.test.ts @@ -282,7 +282,8 @@ describe('newCount', () => { node(43, { source: 'new' }), // still identifying: not news yet node(44, { source: 'new', identified: 'bridge' }), // never adoptable node(45, { source: 'new', identified: 'pdu', availability: 'offline' }) // left the bus - ]) + ]), + true ) ).toBe(2) }) diff --git a/src/main/nodes/cockpit-snapshot/connection-summary.test.ts b/src/main/nodes/cockpit-snapshot/connection-summary.test.ts index 82078bd..fc09fd0 100644 --- a/src/main/nodes/cockpit-snapshot/connection-summary.test.ts +++ b/src/main/nodes/cockpit-snapshot/connection-summary.test.ts @@ -44,7 +44,12 @@ const summary = ( telemetry: NodeTelemSnapshot[] = [] ) => { const m = model(nodes, outages) - return nodesSummary(nodeViews(m, telemetry), outages, boardZones(m.nodes, cockpitMembers(m).get)) + return nodesSummary( + nodeViews(m, telemetry), + outages, + boardZones(m.nodes, cockpitMembers(m).get), + true + ) } const T = 1_760_000_000_000 @@ -131,7 +136,7 @@ describe('nodesSummary rows', () => { ] } const meta = (mem: ReturnType) => - nodesSummary(nodeViews(m, []), [], boardZones(m.nodes, mem.get)).rows.map((r) => r.meta) + nodesSummary(nodeViews(m, []), [], boardZones(m.nodes, mem.get), true).rows.map((r) => r.meta) expect(meta(cockpitMembers(m))).toEqual([ `#13 · PDU_Left (hidden) · offline since ${fmtTime(T).slice(0, 8)}` ]) diff --git a/src/main/nodes/cockpit-snapshot/index.ts b/src/main/nodes/cockpit-snapshot/index.ts index 696ca6e..24f7485 100644 --- a/src/main/nodes/cockpit-snapshot/index.ts +++ b/src/main/nodes/cockpit-snapshot/index.ts @@ -10,11 +10,13 @@ // boards a PDU feeds is one rule too (power-zone.ts fedBy, X7): Feeds and Forget count hidden // boards, an outage only the ones that could have dropped with it. // +// What is on the bus now is one rule for every section (node-model.ts onBusNow, X2): fresh PDU +// pages count as online, and while the link is down "New on the bus", the strays, the Overview's +// notice, Connected nodes and About's On the bus all say nothing. +// // Where the Cockpit and another surface answer differently today, this builder keeps the Cockpit's -// own answer as its own function (X2–X8 option a, co-located, not unified — the fix track does +// own answer as its own function (X3–X8 option a, co-located, not unified — the fix track does // that): -// X2 on the bus unplugged strays and Attention items only while the link is up -// (attention.ts cockpitAttention, here) // X3 strays counted in the Cockpit's band, online and warning (band.ts // cockpitBand); the "New on the bus" count is isReportableNew // (attention.ts newCount); strays wait IDENTIFY_WAIT_MS (strayNew) @@ -29,9 +31,6 @@ // // The Overview, Connection and Settings read their own sections (R3.2b), each with its own answer // where it differs from the Cockpit's — the same option a, co-located: -// X2 on the bus unplugged the Overview's banner and notice only while the link is up; Connected -// nodes and the registry's CAN rows read the last model whatever the link -// (summaries.ts, registry-rows.ts registryOnBus) // X3 strays the Overview and Connection count none (summaries.ts: healthBand and // attentionItems with no strays); the notice is isReportableNew // (newDevices) @@ -85,10 +84,11 @@ export function buildCockpitSnapshot(i: CockpitSnapshotInput): CockpitBuild { const zones = boardZones(i.model?.nodes ?? [], members.get) // New boards that can't be adopted are active on the bus, so they are warnings (#93) — once an // unidentified one has had IDENTIFY_WAIT_MS to report its kind, so a normal board never flashes. - const stray = strayNew(views, i.now) - const strays: StrayItem[] = live - ? stray.strays.map((v) => ({ nodeId: v.entry.nodeId, flag: strayFlag(v.entry) })) - : [] + const stray = strayNew(views, i.now, live) + const strays: StrayItem[] = stray.strays.map((v) => ({ + nodeId: v.entry.nodeId, + flag: strayFlag(v.entry) + })) const snapshot: CockpitSnapshot = { rev: i.rev, live, @@ -97,12 +97,12 @@ export function buildCockpitSnapshot(i: CockpitSnapshotInput): CockpitBuild { plan: planZones(views, outages, zones), band: cockpitBand(views, strays.length, i.peakW), attention: cockpitAttention(i.model, views, outages, strays, live, i.device.gateway), - panels: boardPanels(views, outages, i.telemetry, members, zones), + panels: boardPanels(views, outages, members, zones), opens: boardOpens(views, members), zones: zoneChoices(views, members), adopt: adoptRows(views, members), overview: overviewSummary(views, outages, live), - nodesCard: nodesSummary(views, outages, zones), + nodesCard: nodesSummary(views, outages, zones, live), settings: settingsNodes(i.model, i.registry, live) } return stray.nextAt === undefined ? { snapshot } : { snapshot, nextAt: stray.nextAt } diff --git a/src/main/nodes/cockpit-snapshot/on-the-bus.test.ts b/src/main/nodes/cockpit-snapshot/on-the-bus.test.ts new file mode 100644 index 0000000..1e84286 --- /dev/null +++ b/src/main/nodes/cockpit-snapshot/on-the-bus.test.ts @@ -0,0 +1,106 @@ +// "On the bus" is one rule for every surface (X2 / R5, #132): node-model.ts onBusNow(live). While +// the Gateway is unplugged the Cockpit's "New on the bus" and its strays, the Overview's +// new-devices notice, Connection › Connected nodes and Settings › About's On the bus all go quiet, +// whatever the last model said; when the link is back they return. +import { describe, expect, it } from 'vitest' +import { COCKPIT_IDLE, type CockpitSnapshot, type NodeRegistryInfo } from '@shared/nodes' +import type { DeviceState } from '@shared/gateway' +import { modelNode as node, nodeModel as model } from '../../../test-fixtures/nodes' +import { buildCockpitSnapshot } from '.' +import { CockpitSnapshotHost } from '../cockpit-host' + +const REG: NodeRegistryInfo = { + file: 'Firmware/NODE_IDS.md', + commit: '22b6a87e73068ca2bfd964d067af1247771d5854', + date: '2026-10-15', + nodes: [ + { nodeId: 0, name: 'PanelBridge', console: '—', kind: 'bridge' }, + { nodeId: 1, name: 'Center_Armament', console: 'Center', kind: 'panel' }, + { nodeId: 2, name: 'Right_Navigation', console: 'Right', kind: 'panel' } + ] +} + +/** + * The last model before the link drops: Center_Armament (#1) online, Right_Navigation (#2) + * offline, a new panel group (#41) waiting to be adopted, and an unidentified new board (#9) seen + * long enough ago to be a stray. + */ +const LAST = model([ + node(1, { name: 'Center_Armament' }), + node(2, { name: 'Right_Navigation', console: 'Right', availability: 'offline', offlineAt: 0 }), + node(9, { name: 'Node 9', console: null, source: 'new', firstSeenAt: 0 }), + node(41, { name: 'Node 41', console: null, source: 'new', identified: 'panel', firstSeenAt: 0 }) +]) + +function snapshot(state: DeviceState): CockpitSnapshot { + return buildCockpitSnapshot({ + model: LAST, + telemetry: [], + device: { state }, + peakW: 0, + registry: REG, + now: 60_000, + rev: 1 + }).snapshot +} + +/** What each "on the bus" surface says. */ +function onTheBus(s: CockpitSnapshot) { + return { + newOnTheBus: s.attention.newCount, + strays: s.attention.items.filter((i) => i.stray).map((i) => i.nodeId), + bandStrays: s.band.band.total - s.views.filter((v) => v.entry.source !== 'new').length, + newDevices: s.overview.newDevices, + nodesCard: s.nodesCard, + aboutOnBus: s.settings.registry.rows.map((r) => r.onBus.text) + } +} + +describe('every "on the bus" surface goes quiet with the link down (X2 / R5, #132)', () => { + it('link up: the surfaces read the model', () => { + const up = onTheBus(snapshot('connected')) + expect(up.newOnTheBus).toBe(1) + expect(up.strays).toEqual([9]) + expect(up.bandStrays).toBe(1) + expect(up.newDevices).toEqual({ count: 1, panels: 1, pdus: 0, registry: 0 }) + expect(up.nodesCard).toMatchObject({ known: 2, healthy: 1, off: 1 }) + expect(up.nodesCard.rows.map((r) => r.nodeId)).toEqual([2]) + expect(up.aboutOnBus).toEqual(['serial link', 'yes', 'offline']) + }) + + for (const state of ['no-device', 'disconnected'] as const) { + it(`${state}: none of them says anything about the bus`, () => { + const down = onTheBus(snapshot(state)) + expect(down).toEqual({ + newOnTheBus: 0, + strays: [], + bandStrays: 0, + newDevices: null, + nodesCard: COCKPIT_IDLE.nodesCard, + aboutOnBus: ['no device', '—', '—'] + }) + }) + } + + it('unplugged and plugged back in, with no new model meanwhile: quiet, then back', () => { + // Node pushes stop while the link is down, so the host still holds the last model: only the + // link changes, as the app's `device:status` pushes say. + const device: { state: DeviceState } = { state: 'connected' } + const pushed: CockpitSnapshot[] = [] + const host = new CockpitSnapshotHost((s) => pushed.push(s), { + device: () => device, + registry: () => REG + }) + host.modelPushed(LAST) + const before = onTheBus(host.state()) + expect(before.newOnTheBus).toBe(1) + device.state = 'no-device' + host.linkChanged() + expect(onTheBus(host.state())).toMatchObject({ newOnTheBus: 0, strays: [], newDevices: null }) + expect(onTheBus(host.state()).aboutOnBus).toEqual(['no device', '—', '—']) + device.state = 'connected' + host.linkChanged() + expect(onTheBus(host.state())).toEqual(before) + host.dispose() + }) +}) diff --git a/src/main/nodes/cockpit-snapshot/panels.test.ts b/src/main/nodes/cockpit-snapshot/panels.test.ts index d8911ab..cd6932b 100644 --- a/src/main/nodes/cockpit-snapshot/panels.test.ts +++ b/src/main/nodes/cockpit-snapshot/panels.test.ts @@ -81,18 +81,18 @@ describe('boardActions', () => { const off = { availability: 'offline' as const } it('Forget and Hide wait until the board is offline, with the reason', () => { - const on = boardActions(node(1), false) + const on = boardActions(node(1)) expect(on.forget.disabled).toBe(true) expect(on.hide.disabled).toBe(true) expect(on.forget.tip).toMatch(/^Online — it’s active on the bus/) expect(on.note).toBe(on.forget.tip) - const a = boardActions(node(1, off), false) + const a = boardActions(node(1, off)) expect(a.forget.disabled).toBe(false) expect(a.hide.disabled).toBe(false) }) it('never before the roster says offline — and says so, not "online" (#93 review)', () => { - const a = boardActions(node(1, { availability: 'unknown', status: undefined }), false) + const a = boardActions(node(1, { availability: 'unknown', status: undefined })) expect(a.forget.disabled).toBe(true) expect(a.hide.disabled).toBe(true) expect(a.forget.tip).toBe( @@ -102,28 +102,28 @@ describe('boardActions', () => { }) it('Reset is for registry boards, greyed out at the defaults', () => { - expect(boardActions(node(1), false).reset).toEqual({ + expect(boardActions(node(1)).reset).toEqual({ disabled: true, tip: 'Already at the NODE_IDS.md defaults' }) - expect(boardActions(node(1, { differs: true }), false).reset?.disabled).toBe(false) - expect(boardActions(node(9, { source: 'adopted' }), false).reset).toBeUndefined() + expect(boardActions(node(1, { differs: true })).reset?.disabled).toBe(false) + expect(boardActions(node(9, { source: 'adopted' })).reset).toBeUndefined() }) it('the note says where a forgotten board comes back', () => { - expect(boardActions(node(1, off), false).note).toMatch(/waits in New on the bus, pre-filled/) - expect(boardActions(node(9, { ...off, source: 'adopted' }), false).note).toMatch(/shows as new/) + expect(boardActions(node(1, off)).note).toMatch(/waits in New on the bus, pre-filled/) + expect(boardActions(node(9, { ...off, source: 'adopted' })).note).toMatch(/shows as new/) }) it('a PDU: Forget unlinks its boards, Hide keeps the links', () => { const p = node(4, { ...off, kind: 'pdu', pduId: null }) - expect(boardActions(p, false, 3).note).toBe( + expect(boardActions(p, 3).note).toBe( 'Forget removes this PDU from your cockpit. Its 3 boards move to No power zone and keep no link to it, even if it comes back. Hide keeps the links: they return under it when it reports.' ) - expect(boardActions(p, false, 0).note).toBe( + expect(boardActions(p, 0).note).toBe( 'Forget removes this PDU from your cockpit. Hide keeps the links: they return under it when it reports.' ) - expect(boardActions(p, false).hide.tip).toMatch(/keep the link/) + expect(boardActions(p).hide.tip).toMatch(/keep the link/) }) }) diff --git a/src/main/nodes/cockpit-snapshot/panels.ts b/src/main/nodes/cockpit-snapshot/panels.ts index e12d3e2..7e7e489 100644 --- a/src/main/nodes/cockpit-snapshot/panels.ts +++ b/src/main/nodes/cockpit-snapshot/panels.ts @@ -12,7 +12,6 @@ import { type FaultBox, type FedBoard, type NodeModelEntry, - type NodeTelemSnapshot, type NodeView, type PduOutage, type PduRailPage, @@ -24,7 +23,7 @@ import { import { PDU_STATUS_FLAGS as PDU_FLAG } from '@shared/firmware' import { nodeFaultTag, pduAlertFlags, pduFlagMeanings } from '../faults' import { displayCurrentMa } from '../node-telem' -import { canForget, canHide, pduTelemetryLive, type OfflineGate } from '../offline-actions' +import { canForget, canHide, type OfflineGate } from '../offline-actions' import { fedBy, zoneLabel } from '../power-zone' import type { Members } from './members' @@ -32,14 +31,14 @@ import type { Members } from './members' /** * Same rules for a panel group and a PDU (#93). Hide and Forget wait until the board is offline, - * by the one rule main refuses them by (offline-actions.ts canHide / canForget), so the footer and - * main always agree: `pduTelemetry` = fresh PDU pages from it now (pduTelemetryLive), which prove - * it live whatever the roster says. + * by the one rule main refuses them by (offline-actions.ts canHide / canForget) over the one + * availability (node-model.ts availabilityOf: a PDU's pages weighed against the roster), so the + * footer and main always agree. * `fed` = boards in your cockpit this PDU feeds, hidden ones included (power-zone.ts fedBy, X7): * Forget unlinks them all; Hide keeps the links. */ -export function boardActions(e: NodeModelEntry, pduTelemetry: boolean, fed = 0): BoardActions { - const signals = { availability: e.availability, pduTelemetry } +export function boardActions(e: NodeModelEntry, fed = 0): BoardActions { + const signals = { availability: e.availability } const off = (gate: OfflineGate, tip: string): BoardAction => gate.ok ? { disabled: false, tip } : { disabled: true, tip: gate.note } const forget = canForget(signals) @@ -372,7 +371,6 @@ function fedBoards(members: Members, views: NodeView[], pduId: number): FedBoard export function boardPanels( views: NodeView[], outages: PduOutage[], - telemetry: NodeTelemSnapshot[], members: Members, zones: ReadonlyMap ): Record { @@ -380,7 +378,6 @@ export function boardPanels( for (const v of views) { const e = v.entry if (!members.get(e.nodeId)) continue - const live = pduTelemetryLive(telemetry, e.nodeId) let p: BoardPanel if (e.kind === 'pdu') { const feeds = fedBoards(members, views, e.nodeId) @@ -389,7 +386,7 @@ export function boardPanels( v, outages.some((o) => o.pduId === e.nodeId) ), - actions: boardActions(e, live, feeds.length), + actions: boardActions(e, feeds.length), rails: pduRailRows(v), board: pduBoardRows(v), feeds, @@ -406,7 +403,7 @@ export function boardPanels( } else { p = { faults: nodeFaultBoxes(v), - actions: boardActions(e, live), + actions: boardActions(e), status: nodeStatusRows(v), zone: zones.get(e.nodeId) ?? { status: 'none' } } diff --git a/src/main/nodes/cockpit-snapshot/power-zones.test.ts b/src/main/nodes/cockpit-snapshot/power-zones.test.ts index abbc289..71d61f5 100644 --- a/src/main/nodes/cockpit-snapshot/power-zones.test.ts +++ b/src/main/nodes/cockpit-snapshot/power-zones.test.ts @@ -66,7 +66,8 @@ const model = buildNodeModel({ rosterCompleteAt: T, telemetry: [], defaults, - file + file, + now: T }) const { snapshot: snap } = buildCockpitSnapshot({ model, diff --git a/src/main/nodes/cockpit-snapshot/registry-rows.test.ts b/src/main/nodes/cockpit-snapshot/registry-rows.test.ts index c48ce2e..5d256a3 100644 --- a/src/main/nodes/cockpit-snapshot/registry-rows.test.ts +++ b/src/main/nodes/cockpit-snapshot/registry-rows.test.ts @@ -98,8 +98,9 @@ describe('settingsNodes', () => { s.registry.rows.map((r) => [r.nodeId, r.name, r.console, r.onBus.text, r.unloaded.text]) ).toEqual([ [0, 'Gateway', '—', 'no device', 'no device'], - [1, 'Center_Armament', 'Center', 'yes', '—'], - [2, 'Right_Navigation', 'Right', 'offline', '—'], + // The link is down: no CAN row is on the bus or offline by the last model (X2, #132). + [1, 'Center_Armament', 'Center', '—', '—'], + [2, 'Right_Navigation', 'Right', '—', '—'], [3, 'PDU_Left', 'Left', '—', '—'], [4, 'PDU_Right', 'Right', '—', '—'], [5, 'PDU_Center', 'Center', '—', '—'] diff --git a/src/main/nodes/cockpit-snapshot/registry-rows.ts b/src/main/nodes/cockpit-snapshot/registry-rows.ts index 38c6d46..ea25098 100644 --- a/src/main/nodes/cockpit-snapshot/registry-rows.ts +++ b/src/main/nodes/cockpit-snapshot/registry-rows.ts @@ -5,13 +5,14 @@ // Reset… reads your cockpit as every surface does (members.ts, X1): a hidden adopted board is still // one it can forget. // -// Each keeps Settings' own answer where it differs from the Cockpit's (co-located, not unified): -// X2 on the bus a CAN row reads the last model whatever the link; only the Gateway row follows -// the link (registryOnBus) +// "On the bus" is the one rule every surface reads (node-model.ts onBusNow, X2): with the link down +// no CAN row says yes or offline. Settings keeps its own answer where it differs from the Cockpit's +// (co-located, not unified): // X8 wording the bridge row is "Gateway", whatever NODE_IDS.md calls it (registryName) import { GATEWAY_NODE_ID } from '@shared/firmware' import { plural } from '@shared/format' import type { NodeModel, NodeRegistryInfo, OnBus, RegistryRow, SettingsNodes } from '@shared/nodes' +import { onBusNow } from '../node-model' import { cockpitMembers } from './members' /** @@ -35,7 +36,9 @@ export function registrySummary(r: NodeRegistryInfo): string { /** * Registry list "On the bus" column. PanelBridge is the serial link itself; a CAN node is what - * the roster says — "—" before any roster, since that is no data rather than "absent". + * the one availability rule says while the link is up (onBusNow) — "—" before any roster, and + * while the link is down, since that is no data rather than "absent". Hidden is your cockpit's, + * not the bus's: it reads so whatever the link. */ export function registryOnBus( nodeId: number, @@ -46,8 +49,9 @@ export function registryOnBus( return linkUp ? { text: 'serial link', dot: 'on' } : { text: 'no device', dot: '' } if (model?.hidden.includes(nodeId)) return { text: 'hidden', dot: '' } const e = model?.nodes.find((n) => n.nodeId === nodeId) - if (e?.availability === 'online') return { text: 'yes', dot: 'on' } - if (e?.availability === 'offline') return { text: 'offline', dot: 'off' } + if (!e || !linkUp) return { text: '—', dot: '' } + if (onBusNow(e, linkUp)) return { text: 'yes', dot: 'on' } + if (e.availability === 'offline') return { text: 'offline', dot: 'off' } return { text: '—', dot: '' } } diff --git a/src/main/nodes/cockpit-snapshot/summaries.ts b/src/main/nodes/cockpit-snapshot/summaries.ts index c49672b..44b9f0e 100644 --- a/src/main/nodes/cockpit-snapshot/summaries.ts +++ b/src/main/nodes/cockpit-snapshot/summaries.ts @@ -6,25 +6,25 @@ // Each keeps its own answer where it differs from the Cockpit's (co-located, not unified): // X3 strays neither counts a new board that can't be adopted: the band is healthBand(views) // with no strays, the items attentionItems(views, outages) with none -// X2 link the banner and the notice only while the link is up (the tile says "no live node -// status"); Connected nodes reads the last model whatever the link (its tab shows -// the link's hint instead while it is down) +// What is on the bus is one rule (node-model.ts onBusNow, X2): while the link is down the tile says +// "no live node status", and the banner, the notice and Connected nodes say nothing. // A Connected nodes row names its board's resolved power zone (D7, the same one the Cockpit reads): // `PDU_Right`, `PDU_Right (hidden)`, else "no power zone". import { hhmmss, lastSeenLabel, plural } from '@shared/format' -import type { - AttentionItem, - AttentionSummary, - CockpitTile, - HealthBand, - NewDevices, - NodeModelEntry, - NodesSummary, - NodeView, - OverviewSummary, - PduOutage, - PowerZone, - SummaryRow +import { + COCKPIT_IDLE, + type AttentionItem, + type AttentionSummary, + type CockpitTile, + type HealthBand, + type NewDevices, + type NodeModelEntry, + type NodesSummary, + type NodeView, + type OverviewSummary, + type PduOutage, + type PowerZone, + type SummaryRow } from '@shared/nodes' import { isAdoptableKind } from '../node-kind' import { isReportableNew } from '../node-model' @@ -107,11 +107,11 @@ export function reportedKind(e: NodeModelEntry): 'pdu' | 'panel' | null { /** * NODE_IDs waiting to be adopted that are on the bus now and have said what they are, by kind. * A node is not news until it has identified itself (no "unknown" count), and one that left the - * bus is not "on the bus". null = nothing to show, including while the link is down. + * bus — or any, while the link is down — is not "on the bus" (isReportableNew). null = nothing to + * show. */ export function newDevices(views: NodeView[], live: boolean): NewDevices | null { - if (!live) return null - const fresh = views.filter((v) => isReportableNew(v.entry)) + const fresh = views.filter((v) => isReportableNew(v.entry, live)) const kinds = fresh.map((v) => reportedKind(v.entry)) const panels = kinds.filter((k) => k === 'panel').length const pdus = kinds.filter((k) => k === 'pdu').length @@ -138,11 +138,17 @@ export function overviewSummary( // ── Connection › Connected nodes ──────────────────────────────────────────── +/** + * Connected nodes: the counts and the rows that need attention. Quiet while the link is down — it + * says nothing about the bus then (X2), and its tab shows the link's hint instead. + */ export function nodesSummary( views: NodeView[], outages: PduOutage[], - zones: ReadonlyMap + zones: ReadonlyMap, + live: boolean ): NodesSummary { + if (!live) return COCKPIT_IDLE.nodesCard const band = healthBand(views) const known = views.filter((v) => v.entry.source !== 'new') const byId = new Map(views.map((v) => [v.entry.nodeId, v.entry])) diff --git a/src/main/nodes/node-hub.test.ts b/src/main/nodes/node-hub.test.ts index 2f1a726..9bf8027 100644 --- a/src/main/nodes/node-hub.test.ts +++ b/src/main/nodes/node-hub.test.ts @@ -752,3 +752,142 @@ describe('one rule for the footer and main (M1/C3, S7)', () => { }) } }) + +describe('on the bus (X2 / S10) and a lost line inside a reply (#132)', () => { + const NOW = 50_000 + const line = (id: number, present = true, kind = 1) => + `${id.toString(16).padStart(2, '0')}${present ? '01' : '00'}0000000000000080000000${kind + .toString(16) + .padStart(2, '0')}` + + /** A hub fed as the Gateway's line tap feeds it, with its debug log and event rows recorded. */ + function tapped() { + const roster = new NodeRoster() + const logged: string[] = [] + const events: string[] = [] + const h = new NodeHub( + () => {}, + roster, + (e) => events.push(...e.map((x) => `${x.nodeId ?? '-'} ${x.event}`)), + { log: (tag, data) => logged.push(`${tag} ${String(data)}`) } + ) + const tap = (identifier: string, arg: string, t: number) => + h.nodeLine(identifier, arg, `${identifier} ${arg}`, t) + /** One roster reply, node 0 first, END with PanelBridge's count; `lost` lines never arrive. */ + const reply = (ids: number[], t: number, lost: number[] = []) => { + h.requestNodes() + for (const id of [0, ...ids]) + if (!lost.includes(id)) tap(NODE_MSG, line(id, true, id ? 1 : 3), t) + tap(NODE_END_MSG, String(ids.length + 1), t) + h.flush(roster.takeDirty(), t) + } + const node = (id: number, t: number) => h.snapshot(t).model.nodes.find((n) => n.nodeId === id) + return { h, roster, logged, events, reply, node, tap } + } + + it('a PDU in your cockpit sending fresh pages is online, and Hide and Forget agree', () => { + disk.file = added() + const { h, reply, node } = tapped() + reply([1], 0) // a whole roster without PDU_Right (#4) + expect(node(4, 0)?.availability).toBe('offline') + h.telemetry('040100E02E182EBC02', NOW) // its fresh 12 V page + expect(node(4, NOW)).toMatchObject({ availability: 'online', readings: 'live' }) + expect(h.forget(4, NOW).ok).toBe(false) + expect(h.hide(4, true, NOW).ok).toBe(false) + }) + + it('a reply short of its count marks nobody offline, logs one debug line and no event row', () => { + disk.file = added() + const { reply, node, logged, events } = tapped() + reply([1, 2], 0) + const before = events.length + reply([1, 2], 5000, [2]) // board 2's line is lost + expect(node(2, 5000)?.availability).toBe('online') + expect(logged.filter((l) => /incomplete/.test(l))).toEqual([ + 'node roster reply incomplete: 2 of 3 rows; absences not reconciled' + ]) + expect(events.slice(before)).toEqual([]) + reply([1, 2], 10_000) // the next whole reply: still there + expect(node(2, 10_000)?.availability).toBe('online') + reply([1], 15_000) // board 2 really left: a whole reply says so, and the log has its row + expect(node(2, 15_000)?.availability).toBe('offline') + expect(events.slice(before)).toEqual(['2 offline']) + expect(logged.filter((l) => /incomplete/.test(l))).toHaveLength(1) + }) +}) + +describe('a PDU’s pages against the roster: the newest evidence wins (X2, #132)', () => { + const PAGE = '040100E02E182EBC02' // PDU_Right's (#4) 12 V page + const row = (id: number, present = true, kind = 1) => + `${id.toString(16).padStart(2, '0')}${present ? '01' : '00'}0000000000000080000000${kind + .toString(16) + .padStart(2, '0')}` + + /** + * PDU_Right (#4) feeds Right_Navigation (#2); both and Center_Armament (#1) are on the bus, and + * the PDU sends a page every second until `pagesUntil`. At 13 s the bridge's heartbeat timeout + * reports #4 and #2 absent: the PDU lost power at 10 s. + */ + function powerLoss(pagesUntil: number, now: number) { + disk.file = { + version: 4, + nodes: { 1: { added: true }, 2: { added: true, pduId: 4 }, 4: { added: true } } + } + const roster = new NodeRoster() + const h = new NodeHub(() => {}, roster) + const tap = (identifier: string, arg: string, t: number) => + h.nodeLine(identifier, arg, `${identifier} ${arg}`, t) + tap(NODE_MSG, row(0, true, 3), 0) + for (const id of [1, 2, 4]) tap(NODE_MSG, row(id, true, id === 4 ? 2 : 1), 0) + tap(NODE_END_MSG, '4', 0) + h.flush(roster.takeDirty(), 0) + for (let t = 0; t <= Math.min(pagesUntil, now); t += 1000) h.telemetry(PAGE, t) + if (now >= 13_000) { + tap(NODE_MSG, row(4, false, 2), 13_000) // live deltas: the heartbeat timeout + tap(NODE_MSG, row(2, false), 13_000) + } + h.flush(roster.takeDirty(), now) + const { model, telemetry } = h.snapshot(now) + const { snapshot } = buildCockpitSnapshot({ + model, + telemetry, + device: { state: 'connected' }, + peakW: 0, + now, + rev: 0 + }) + return { h, model, snapshot } + } + + it('a PDU that loses power is offline on the roster’s timing and folds into its outage', () => { + // 13.2 s: its last page (10 s) is still fresh for 6.8 s more, but the roster said absent after it. + const { h, model, snapshot } = powerLoss(10_000, 13_200) + expect(model.nodes.find((n) => n.nodeId === 4)).toMatchObject({ + availability: 'offline', + offlineAt: 13_000 + }) + expect(model.outages).toEqual([ + expect.objectContaining({ pduId: 4, nodeIds: [2], fedCount: 1, at: 13_000 }) + ]) + // Every surface reads the same: one outage item, and the footer offers what main allows. + expect(snapshot.attention.items.map((i) => [i.nodeId, i.flag])).toEqual([ + [4, 'OFFLINE · 1 node affected'] + ]) + expect(snapshot.panels[4]!.actions.forget.disabled).toBe(false) + expect(h.hide(4, true, 13_200).ok).toBe(true) + }) + + it('pages arriving after the roster said absent bring it back online', () => { + const { h, model, snapshot } = powerLoss(14_000, 14_200) // a page at 14 s, after the 13 s absent + expect(model.nodes.find((n) => n.nodeId === 4)?.availability).toBe('online') + expect(model.outages).toEqual([]) + expect(snapshot.panels[4]!.actions.forget.disabled).toBe(true) + expect(h.forget(4, 14_200).ok).toBe(false) + expect(h.hide(4, true, 14_200).ok).toBe(false) + }) + + it('before the roster says absent, a powered PDU is online by both', () => { + const { model } = powerLoss(10_000, 12_000) + expect(model.nodes.find((n) => n.nodeId === 4)?.availability).toBe('online') + }) +}) diff --git a/src/main/nodes/node-hub.ts b/src/main/nodes/node-hub.ts index f989ed1..f0f3808 100644 --- a/src/main/nodes/node-hub.ts +++ b/src/main/nodes/node-hub.ts @@ -47,14 +47,13 @@ import { NODE_REGISTRY_SOURCE, type NodeFaultName } from '@shared/firmware' -import { buildNodeModel, nodeAvailability } from './node-model' +import { availabilityOf, buildNodeModel } from './node-model' import { NodeEventTracker } from './node-events' import { boardIdentity, identityOf, isAdoptableKind } from './node-kind' import { canForget, canHide, offlineRefusal, - pduTelemetryLive, type OfflineGate, type OfflineSignals } from './offline-actions' @@ -296,6 +295,14 @@ export class NodeHub implements NodeLineSink { if (identifier === NODE_MSG || identifier === NODE_END_MSG) { this.log('node', `${identifier} ${arg}`.trim()) this.roster.applyMessage(identifier, arg, t) + const short = this.roster.takeIncompleteReply() + if (short) { + // A line of the reply was lost: its absences were not reconciled (#132). Debug only. + this.log( + 'node', + `roster reply incomplete: ${short.received} of ${short.expected} rows; absences not reconciled` + ) + } if (!this.legacyNodesWarned && this.roster.legacyBridge()) { // Every v2 row is dropped as malformed (and its END ignored, M3): say why the Cockpit is // empty, once. @@ -464,11 +471,14 @@ export class NodeHub implements NodeLineSink { * availability by the model's one rule, and whether fresh PDU pages say it is live. */ private offlineSignals(nodeId: number, now: number): OfflineSignals { - const st = this.roster.snapshot().find((n) => n.nodeId === nodeId) - return { - availability: nodeAvailability(st, this.roster.rosterCompleteAt() !== undefined), - pduTelemetry: pduTelemetryLive(this.telem.snapshot(now), nodeId) - } + const { availability } = availabilityOf({ + status: this.roster.snapshot().find((n) => n.nodeId === nodeId), + seen: this.roster.seenInfo().get(nodeId), + rosterComplete: this.roster.rosterCompleteAt() !== undefined, + telemetry: this.telem.snapshot(now).find((t) => t.nodeId === nodeId), + now + }) + return { availability } } private refusal(gate: OfflineGate, what: 'hidden' | 'forgotten'): NodeMappingResult | undefined { @@ -556,7 +566,8 @@ export class NodeHub implements NodeLineSink { rosterCompleteAt: this.roster.rosterCompleteAt(), telemetry: this.telem.snapshot(now), defaults: this.defaults, - file: this.mapping() + file: this.mapping(), + now }) } diff --git a/src/main/nodes/node-model.test.ts b/src/main/nodes/node-model.test.ts index 10652e0..842f519 100644 --- a/src/main/nodes/node-model.test.ts +++ b/src/main/nodes/node-model.test.ts @@ -1,5 +1,14 @@ import { describe, expect, it } from 'vitest' -import { buildNodeModel, isReportableNew, type NodeModelInput } from './node-model' +import { + buildNodeModel, + isReportableNew, + nodeAvailability, + onBusNow, + type NodeModelInput +} from './node-model' +import { cockpitMembers } from './cockpit-snapshot/members' +import { adoptRows } from './cockpit-snapshot/adopt' +import { nodeViews } from './cockpit-snapshot/views' import { PDU_GROUP_POLL_CATCH_MS, PDU_GROUP_WINDOW_MS } from './power-zone' import { EMPTY_MAPPING } from './mapping-rules' import { registryDefaults } from './registry' @@ -54,6 +63,7 @@ const input = (over: Partial): NodeModelInput => ({ seen: new Map(), telemetry: [], defaults, + now: 0, ...over, file: everSeen(over.file ?? EMPTY_MAPPING) }) @@ -63,6 +73,7 @@ const fresh = (over: Partial): NodeModelInput => ({ seen: new Map(), telemetry: [], defaults, + now: 0, file: EMPTY_MAPPING, ...over }) @@ -172,6 +183,107 @@ describe('availability', () => { }) }) +describe('the one availability rule: roster, telemetry, none (X2 / S10, #132)', () => { + it('online by the roster, else by fresh PDU pages, else offline by the roster, else unknown', () => { + expect(nodeAvailability(st(4), false, false)).toEqual({ + availability: 'online', + evidence: 'roster' + }) + // Fresh pages count as online whatever the roster says: no row, absent, or no roster yet. + for (const [row, complete] of [ + [undefined, false], + [undefined, true], + [st(4, false), true] + ] as const) { + expect(nodeAvailability(row, complete, true)).toEqual({ + availability: 'online', + evidence: 'telemetry' + }) + } + expect(nodeAvailability(st(4, false), false, false)).toEqual({ + availability: 'offline', + evidence: 'roster' + }) + expect(nodeAvailability(undefined, true, false)).toEqual({ + availability: 'offline', + evidence: 'roster' + }) + expect(nodeAvailability(undefined, false, false)).toEqual({ + availability: 'unknown', + evidence: 'none' + }) + }) + + it('a PDU in your cockpit sending fresh pages is online, unless the roster said absent after them', () => { + // A complete roster left PDU_Left (#3) out, or listed it absent: its fresh pages say it is on. + expect(entry(input({ telemetry: [telem(3)], rosterCompleteAt: 0 }), 3)).toMatchObject({ + availability: 'online', + readings: 'live' + }) + // Listed absent before its newest page arrived (pages are 0 ms old at `now` 0): on by them. + const before = new Map([[3, { firstSeenAt: -9000, offlineAt: -5000 }]]) + expect( + entry(input({ roster: [st(3, false)], seen: before, telemetry: [telem(3)] }), 3) + ).toMatchObject({ availability: 'online', readings: 'live' }) + expect(entry(input({ telemetry: [telem(3)] }), 3)?.availability).toBe('online') // no roster yet + // Listed absent after its newest page (3 s old at `now` 5000): offline at once, on the + // roster's timing — not when the page goes stale. + const after = new Map([[3, { firstSeenAt: 0, offlineAt: 4000 }]]) + const old = [telem(3, { rail12V: { ...telem(3).rail12V!, ageMs: 3000 } })] + expect( + entry(input({ roster: [st(3, false)], seen: after, telemetry: old, now: 5000 }), 3) + ).toMatchObject({ availability: 'offline', offlineAt: 4000 }) + // A newer page after that absent reading: back on. + const newer = [telem(3, { rail12V: { ...telem(3).rail12V!, ageMs: 500 } })] + expect( + entry(input({ roster: [st(3, false)], seen: after, telemetry: newer, now: 5000 }), 3) + ?.availability + ).toBe('online') + const stale = [telem(3, { stale: true })] + expect(entry(input({ telemetry: stale, rosterCompleteAt: 0 }), 3)?.availability).toBe('offline') + expect(entry(input({ telemetry: stale }), 3)?.availability).toBe('unknown') + }) + + it('no "new but offline, can’t adopt" board: a registry PDU seen by its pages alone is adoptable', () => { + for (const roster of [[], [st(3, false)]]) { + const m = buildNodeModel(fresh({ roster, telemetry: [telem(3)], rosterCompleteAt: 0 })) + const e = m.nodes.find((n) => n.nodeId === 3)! + expect(e).toMatchObject({ source: 'new', availability: 'online', identified: 'pdu' }) + expect(isReportableNew(e, true)).toBe(true) + const [row] = adoptRows(nodeViews(m, [telem(3)]), cockpitMembers(m)) + expect(row).toMatchObject({ kind: 'pdu', badge: 'PDU' }) + expect(row?.evidence.reporting).toMatch(/^telemetry: 12 V/) + } + }) + + it('every new board in the model is on the bus: none is offline', () => { + const m = buildNodeModel( + fresh({ + roster: [{ ...st(1), nodeKind: 1 }, st(2, false), st(50), st(51, false)], + telemetry: [telem(3), telem(4, { stale: true })], + rosterCompleteAt: 0 + }) + ) + const newOnes = m.nodes.filter((n) => n.source === 'new') + expect(newOnes.map((n) => [n.nodeId, n.availability])).toEqual([ + [1, 'online'], + [3, 'online'], + [50, 'online'] + ]) + }) + + it('onBusNow: online, and only while the link is up', () => { + expect(onBusNow({ availability: 'online' }, true)).toBe(true) + expect(onBusNow({ availability: 'online' }, false)).toBe(false) + expect(onBusNow({ availability: 'offline' }, true)).toBe(false) + expect(onBusNow({ availability: 'unknown' }, true)).toBe(false) + const e = buildNodeModel(fresh({ roster: [{ ...st(1), nodeKind: 1 }], rosterCompleteAt: 0 })) + .nodes[0]! + expect(isReportableNew(e, true)).toBe(true) + expect(isReportableNew(e, false)).toBe(false) + }) +}) + describe('new-node detection', () => { it('a roster node outside the registry and mapping is new, with first-seen; PDU by its pages', () => { const seen = new Map([ @@ -419,14 +531,14 @@ describe('your cockpit (#93)', () => { // D7: its NODE_IDS.md PDU (PDU_Center) is not in your cockpit, so the pre-fill has none. registry: { name: 'Center_Armament', console: 'Center', pduId: null } }) - expect(isReportableNew(m.nodes[0]!)).toBe(true) + expect(isReportableNew(m.nodes[0]!, true)).toBe(true) expect(m.outages).toEqual([]) }) it('a registry board is identified only by what it reports, never by its NODE_ID (#93 re-review)', () => { const m = buildNodeModel(fresh({ roster: [st(1)], rosterCompleteAt: 0 })) // kind 0: not yet expect(m.nodes[0]?.identified).toBeUndefined() - expect(isReportableNew(m.nodes[0]!)).toBe(false) // not news until it says what it is + expect(isReportableNew(m.nodes[0]!, true)).toBe(false) // not news until it says what it is expect(m.nodes[0]?.registry?.name).toBe('Center_Armament') // the pre-fill is still there // Its own report wins over NODE_IDS.md: a registry panel ID reporting PDU is a PDU. const pdu = buildNodeModel(fresh({ roster: [{ ...st(2), nodeKind: 2 }], rosterCompleteAt: 0 })) @@ -508,7 +620,7 @@ describe('board kind (#344)', () => { const n41 = m.nodes.find((n) => n.nodeId === 41)! expect(n41.identified).toBeUndefined() expect(n41.kind).toBe('panel') // the drawing default - expect(isReportableNew(n41)).toBe(false) + expect(isReportableNew(n41, true)).toBe(false) }) it('isReportableNew: new, on the bus, and identified as a PDU or a panel group', () => { @@ -518,6 +630,6 @@ describe('board kind (#344)', () => { rosterCompleteAt: 0 }) ) - expect(m.nodes.filter(isReportableNew).map((n) => n.nodeId)).toEqual([41, 42]) + expect(m.nodes.filter((n) => isReportableNew(n, true)).map((n) => n.nodeId)).toEqual([41, 42]) }) }) diff --git a/src/main/nodes/node-model.ts b/src/main/nodes/node-model.ts index 54dddd0..1a10a7d 100644 --- a/src/main/nodes/node-model.ts +++ b/src/main/nodes/node-model.ts @@ -15,7 +15,7 @@ import type { import { differingCount, effectiveMapping } from './mapping-rules' import { boardIdentity, isAdoptableKind } from './node-kind' import { BRIDGE_NODE_ID } from './node-status-wire' -import { hasFreshPduPage } from './node-telem' +import { hasFreshPduPage, pduPagesLive } from './node-telem' import { pduOutages } from './power-zone' export interface NodeModelInput { @@ -25,19 +25,68 @@ export interface NodeModelInput { telemetry: NodeTelemSnapshot[] defaults: Map file: NodeMappingFile + /** When `telemetry` was taken: page ages are counted back from it. */ + now: number +} + +/** What a board's availability was decided from: its roster row, its PDU pages, or nothing. */ +export type AvailabilityEvidence = 'roster' | 'telemetry' | 'none' + +export interface Availability { + availability: NodeAvailability + evidence: AvailabilityEvidence } /** - * The one availability rule: online while the roster lists it present, offline once it lists it - * absent or a complete roster left it out (there is no "waiting"), else unknown — no complete - * roster since the link opened, and it has not reported. + * The one availability rule (X2 / S10), with what it was decided from: + * - online while the roster lists it present (roster); + * - online while its PDU pages say so (telemetry): fresh, and newer than the roster's last + * "absent" reading for it (node-telem.ts pduPagesLive — the newest evidence wins); + * - offline once the roster lists it absent, or a complete roster left it out (roster) — there is + * no "waiting"; + * - else unknown (none): no complete roster since the link opened, and it has not reported. + * `pduTelemetry` is that page evidence already weighed (availabilityOf). */ export function nodeAvailability( st: NodeStatus | undefined, + rosterComplete: boolean, + pduTelemetry: boolean +): Availability { + if (st?.present) return { availability: 'online', evidence: 'roster' } + if (pduTelemetry) return { availability: 'online', evidence: 'telemetry' } + if (st || rosterComplete) return { availability: 'offline', evidence: 'roster' } + return { availability: 'unknown', evidence: 'none' } +} + +/** What the rule reads for one board: its roster row and timing, and its PDU pages. */ +export interface AvailabilitySignals { + status?: NodeStatus + seen?: NodeSeen rosterComplete: boolean -): NodeAvailability { - if (st) return st.present ? 'online' : 'offline' - return rosterComplete ? 'offline' : 'unknown' + telemetry?: NodeTelemSnapshot + /** When `telemetry` was taken. */ + now: number +} + +/** + * A board's availability from its signals: the model's, and the one NodeHub refuses Hide and Forget + * by, so the two always agree. Pages count against the roster's last "absent" reading — the time + * it learned the board was offline (seen.offlineAt) — only while the roster lists it absent. + */ +export function availabilityOf(s: AvailabilitySignals): Availability { + const absentAt = s.status && !s.status.present ? s.seen?.offlineAt : undefined + const pages = !!s.telemetry && pduPagesLive(s.telemetry, s.now, absentAt) + return nodeAvailability(s.status, s.rosterComplete, pages) +} + +/** + * On the bus now: the link is up and the one availability rule says online. Every "on the bus" + * surface reads this — the Cockpit's "New on the bus" and its strays, the Overview's new-devices + * notice, Connection › Connected nodes and Settings › About's On the bus — so while the Gateway is + * unplugged they all go quiet, whatever the last model said (X2 / R5). + */ +export function onBusNow(e: Pick, live: boolean): boolean { + return live && e.availability === 'online' } /** @@ -51,14 +100,24 @@ export function buildNodeModel(i: NodeModelInput): NodeModel { const roster = new Map(i.roster.map((n) => [n.nodeId, n])) const telem = new Map(i.telemetry.map((t) => [t.nodeId, t])) const complete = i.rosterCompleteAt !== undefined + const signals = (nodeId: number): AvailabilitySignals => ({ + status: roster.get(nodeId), + seen: i.seen.get(nodeId), + rosterComplete: complete, + telemetry: telem.get(nodeId), + now: i.now + }) const known = [...mapping.values()].filter((m) => m.known).map((m) => m.nodeId) // Not in your cockpit, registry or not: shown as new only while it is on the bus. The roster // keeps absent rows; one that dropped (or that you just forgot) comes back when it reports. const waiting = [...roster.values()].filter((n) => n.present).map((n) => n.nodeId) - // PDU pages are a report too: a registry or adopted PDU sending them now is on the bus. Pages are - // kept (only marked stale), so one that stopped sending — dropped, or forgotten — is not. + // PDU pages are a report too: a registry or adopted PDU sending them now is on the bus — online + // by them (nodeAvailability), so it can be adopted (S10). Pages are kept (only marked stale), so + // one that stopped sending — dropped, or forgotten — is not. const sending = [...telem.values()] - .filter((t) => mapping.get(t.nodeId)?.known || (mapping.has(t.nodeId) && hasFreshPduPage(t))) + .filter( + (t) => mapping.has(t.nodeId) && availabilityOf(signals(t.nodeId)).availability === 'online' + ) .map((t) => t.nodeId) // The bridge's own row (node 0, v3) is this client's link, not a cockpit node. const ids = [...new Set([...known, ...waiting, ...sending])] @@ -96,7 +155,7 @@ export function buildNodeModel(i: NodeModelInput): NodeModel { pduTelemetry: t?.isPdu }) const kind: NodeKind = identified === 'pdu' ? 'pdu' : 'panel' - const availability = nodeAvailability(st, complete) + const { availability } = availabilityOf(signals(nodeId)) const e: NodeModelEntry = { nodeId, name: mapped?.name ?? `Node ${nodeId}`, @@ -141,11 +200,10 @@ export function buildNodeModel(i: NodeModelInput): NodeModel { } /** - * A new device worth telling the user about: not in the registry or adopted, on the bus now, and + * A new device worth telling the user about: not in your cockpit, on the bus now (onBusNow), and * identified as something that can be adopted. Until then it is not news — not on the Cockpit - * count, the Overview notice or the event log. One predicate for every tab (node 0 is never in the - * model). + * count or the Overview notice. One predicate for every tab (node 0 is never in the model). */ -export function isReportableNew(e: NodeModelEntry): boolean { - return e.source === 'new' && e.availability === 'online' && isAdoptableKind(e.identified) +export function isReportableNew(e: NodeModelEntry, live: boolean): boolean { + return e.source === 'new' && onBusNow(e, live) && isAdoptableKind(e.identified) } diff --git a/src/main/nodes/node-status-wire.test.ts b/src/main/nodes/node-status-wire.test.ts index da061d5..95f7ea3 100644 --- a/src/main/nodes/node-status-wire.test.ts +++ b/src/main/nodes/node-status-wire.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { canRequestRoster, cleanNodeLine, + endRowCount, isLegacyNodeStatus, NODE_END_MSG, NODE_MSG, @@ -268,6 +269,110 @@ describe('NodeRoster — a new request never cuts a reply short (M2)', () => { }) }) +describe('NodeRoster — a reply missing a line reconciles nothing (#132)', () => { + const row = (id: number) => statusRow(id, true, id === 0 ? 3 : 1) + /** One v3 reply: the node-0 row, a row per board (less any `lost`), END with the true count. */ + const reply = (r: NodeRoster, ids: number[], t: number, lost: number[] = []) => { + r.beginBurst(t) + for (const id of [0, ...ids]) if (!lost.includes(id)) r.applyMessage(NODE_MSG, row(id), t) + r.applyMessage(NODE_END_MSG, String(ids.length + 1), t) // PanelBridge counts node 0 too + } + const states = (r: NodeRoster) => + r + .snapshot() + .filter((n) => n.nodeId !== 0) + .map((n) => [n.nodeId, n.present]) + + it('the END count is PanelBridge’s rows, node 0 included; no count is undefined', () => { + expect(endRowCount('3')).toBe(3) + expect(endRowCount(' 1\r')).toBe(1) + expect(endRowCount('')).toBeUndefined() + expect(endRowCount('x')).toBeUndefined() + }) + + it('a board whose line was lost keeps its state; the next whole reply decides', () => { + const r = new NodeRoster() + reply(r, [1, 2, 3], 0) + reply(r, [1, 2, 3], 5000, [2]) // board 2's line is lost on the way + expect(states(r)).toEqual([ + [1, true], + [2, true], + [3, true] + ]) + expect(r.seenInfo().get(2)?.offlineAt).toBeUndefined() + expect(r.takeIncompleteReply()).toEqual({ expected: 4, received: 3 }) + expect(r.takeIncompleteReply()).toBeUndefined() // taken once + reply(r, [1, 3], 10_000) // board 2 really left: the next whole reply says so + expect(states(r)).toEqual([ + [1, true], + [2, false], + [3, true] + ]) + expect(r.seenInfo().get(2)).toMatchObject({ offlineAt: 10_000, offlineVia: 'roster' }) + expect(r.takeIncompleteReply()).toBeUndefined() + }) + + it('a whole reply still marks a missing board offline', () => { + const r = new NodeRoster() + reply(r, [1, 2], 0) + reply(r, [1], 5000) + expect(states(r)).toEqual([ + [1, true], + [2, false] + ]) + }) + + it('a lost node-0 row or END is a short reply too: nothing goes offline', () => { + const r = new NodeRoster() + reply(r, [1, 2], 0) + reply(r, [1, 2], 5000, [0]) // the node-0 row is lost + expect(r.takeIncompleteReply()).toEqual({ expected: 3, received: 2 }) + expect(states(r)).toEqual([ + [1, true], + [2, true] + ]) + // The END of the next reply is lost; the one after it ends whole. + r.beginBurst(10_000) + for (const id of [0, 1]) r.applyMessage(NODE_MSG, row(id), 10_000) + reply(r, [1, 2], 15_000) + expect(states(r)).toEqual([ + [1, true], + [2, true] + ]) + }) + + it('a first reply that is short leaves the roster incomplete: remembered boards stay unknown', () => { + const r = new NodeRoster() + reply(r, [1, 2], 0, [2]) + expect(r.rosterCompleteAt()).toBeUndefined() + reply(r, [1, 2], 5000) + expect(r.rosterCompleteAt()).toBe(5000) + }) + + it('deltas before a reply’s node-0 row are not counted as its rows', () => { + const r = new NodeRoster() + reply(r, [1, 2], 0) + r.beginBurst(5000) + r.applyMessage(NODE_MSG, statusRow(2, false, 1), 5001) // a live delta: board 2 dropped + for (const id of [0, 1]) r.applyMessage(NODE_MSG, row(id), 5002) + r.applyMessage(NODE_END_MSG, '2', 5002) // whole: node 0 and board 1 + expect(r.takeIncompleteReply()).toBeUndefined() + expect(states(r)).toEqual([ + [1, true], + [2, false] + ]) + }) + + it('an END with no count completes as before', () => { + const r = new NodeRoster() + r.applyMessage(NODE_MSG, row(1), 0) + r.beginBurst(5000) + r.applyMessage(NODE_END_MSG, '', 5000) + expect(states(r)).toEqual([[1, false]]) + expect(r.rosterCompleteAt()).toBe(5000) + }) +}) + describe('NodeRoster — a v2 bridge’s END completes nothing (M3)', () => { const V2 = '01010000000000000080000000' // 26 hex: a v2 row, dropped diff --git a/src/main/nodes/node-status-wire.ts b/src/main/nodes/node-status-wire.ts index 3cb7304..26755ce 100644 --- a/src/main/nodes/node-status-wire.ts +++ b/src/main/nodes/node-status-wire.ts @@ -117,6 +117,18 @@ export function canRequestRoster(attached: boolean, state: DeviceState): boolean */ export const ROSTER_REPLY_MS = 1000 +/** A roster reply that ended short of its count: the rows it said, and the ones that arrived. */ +export interface IncompleteReply { + expected: number + received: number +} + +/** The row count `_NODE_STATUS_END ` carries (decimal), or undefined when it has none. */ +export function endRowCount(arg: string): number | undefined { + const a = arg.trim() + return /^\d+$/.test(a) ? Number(a) : undefined +} + /** * Tracks every node seen since reset. Bare messages are live deltas; a request/boot * burst ends with `_NODE_STATUS_END`, after which nodes absent from the burst are @@ -128,6 +140,13 @@ export const ROSTER_REPLY_MS = 1000 * `_NODE_STATUS_END`, in the order asked. So from a node-0 row to the next END a reply is * arriving: a request then keeps the rows already in, and the END reconciles all of them. Only a * request with no reply arriving isolates the next burst from prior deltas. + * + * A reply missing a line never reconciles (#132). `_NODE_STATUS_END ` carries how many rows + * PanelBridge sent in it, its own node-0 row included (PanelBridge.cpp emitAllNodes). When that is + * not how many different NODE_IDs the reply brought, a line was lost on the way: the boards it + * left out keep their state, the roster does not count as complete, and the next whole reply + * decides — one lost line never reads as a board going offline. An END with no count completes as + * before (nothing to check it against). */ export class NodeRoster { private nodes = new Map() @@ -144,6 +163,8 @@ export class NodeRoster { private legacy = false private completeAt?: number private dirty = false + /** The last reply that ended short of its count, until taken (takeIncompleteReply). */ + private incomplete?: IncompleteReply reset(): void { this.nodes.clear() @@ -153,6 +174,7 @@ export class NodeRoster { this.replying = false this.legacy = false this.completeAt = undefined + this.incomplete = undefined this.dirty = true } @@ -184,8 +206,13 @@ export class NodeRoster { if (isLegacyNodeStatus(arg.trim())) this.legacy = true return } + if (ns.nodeId === BRIDGE_NODE_ID) { + // A reply starts with node 0: from here its rows are the reply — not the deltas before it, + // nor what an earlier reply that never ended brought — so its count can be checked. + this.burst = [] + this.replying = true + } this.burst.push(ns) - if (ns.nodeId === BRIDGE_NODE_ID) this.replying = true // a reply starts with node 0 const wasPresent = this.nodes.get(ns.nodeId)?.present ?? true this.nodes.set(ns.nodeId, ns) // keep absent nodes too; present flag carries state const seen = this.seen.get(ns.nodeId) ?? { firstSeenAt: now } @@ -201,8 +228,15 @@ export class NodeRoster { } else if (name === NODE_END_MSG) { // A v2 bridge's END completes nothing: its rows never reached the roster (M3). if (this.legacy) return - // Nodes not in this burst went silent → mark offline, but keep them visible. const inBurst = new Set(this.burst.map((n) => n.nodeId)) + const expected = endRowCount(arg) + if (expected !== undefined && expected !== inBurst.size) { + // A line of this reply was lost: reconcile nothing from it; the next whole reply decides. + this.incomplete = { expected, received: inBurst.size } + this.endReply() + return + } + // Nodes not in this burst went silent → mark offline, but keep them visible. for (const [id, node] of this.nodes) { if (!inBurst.has(id) && node.present) { this.nodes.set(id, { ...node, present: false }) @@ -210,14 +244,29 @@ export class NodeRoster { this.seen.set(id, { ...seen, offlineAt: now, offlineVia: 'roster' }) } } - this.burst = [] - this.burstAt = undefined - this.replying = false + this.endReply() this.completeAt ??= now this.dirty = true } } + /** A reply ended (whole or not): the next request starts a fresh one. */ + private endReply(): void { + this.burst = [] + this.burstAt = undefined + this.replying = false + } + + /** + * The last reply that ended short of its `_NODE_STATUS_END` count since the last call, if any: + * how many rows it said and how many arrived. NodeHub logs it (a debug line, not an event). + */ + takeIncompleteReply(): IncompleteReply | undefined { + const r = this.incomplete + this.incomplete = undefined + return r + } + snapshot(): NodeStatus[] { return [...this.nodes.values()].sort((a, b) => a.nodeId - b.nodeId) } diff --git a/src/main/nodes/node-telem.test.ts b/src/main/nodes/node-telem.test.ts index e22a831..11f592d 100644 --- a/src/main/nodes/node-telem.test.ts +++ b/src/main/nodes/node-telem.test.ts @@ -3,7 +3,9 @@ import { PDU_RAIL_FLAGS as RAIL_FLAG, PDU_STATUS_FLAGS as PDU_FLAG } from '@shar import { displayCurrentMa, hasFreshPduPage, + newestPduPageAt, NodeTelemStore, + pduPagesLive, parseNodeTelem, TELEM_PAGE, TELEM_STALE_MS @@ -224,3 +226,35 @@ describe('hasFreshPduPage', () => { expect(hasFreshPduPage(other.snapshot(0)[0]!)).toBe(false) }) }) + +describe('pduPagesLive: the newest evidence wins (X2, #132)', () => { + const page = (id: number) => parse(telem(id, [0x02, 0, 0, 0, 0, 0, 0, 0])) + const store = (...at: number[]) => { + const s = new NodeTelemStore() + for (const t of at) s.apply(page(4), t) + return s + } + + it('the newest fresh page’s arrival time, counted back from the snapshot', () => { + const s = store(1000, 4000) + expect(newestPduPageAt(s.snapshot(6000)[0]!, 6000)).toBe(4000) + expect( + newestPduPageAt(s.snapshot(4000 + TELEM_STALE_MS + 1)[0]!, 4000 + TELEM_STALE_MS + 1) + ).toBeUndefined() // stale + const other = new NodeTelemStore() + other.apply(parse('0a7f0102030405060a'), 0) // an unknown page type only: not a PDU + expect(newestPduPageAt(other.snapshot(0)[0]!, 0)).toBeUndefined() + }) + + it('fresh pages count unless the roster said absent after the newest one', () => { + const t = store(10_000).snapshot(13_200)[0]! + expect(pduPagesLive(t, 13_200)).toBe(true) // no absent reading + expect(pduPagesLive(t, 13_200, 9000)).toBe(true) // absent before the page + expect(pduPagesLive(t, 13_200, 13_000)).toBe(false) // absent after it: offline at once + expect(pduPagesLive(t, 13_200, 10_000)).toBe(false) // a tie goes to the roster + const back = store(10_000, 14_000).snapshot(14_200)[0]! + expect(pduPagesLive(back, 14_200, 13_000)).toBe(true) // a page after the absent: back on + const stale = store(0).snapshot(TELEM_STALE_MS + 1)[0]! + expect(pduPagesLive(stale, TELEM_STALE_MS + 1)).toBe(false) + }) +}) diff --git a/src/main/nodes/node-telem.ts b/src/main/nodes/node-telem.ts index 786a68a..e413d0b 100644 --- a/src/main/nodes/node-telem.ts +++ b/src/main/nodes/node-telem.ts @@ -98,6 +98,28 @@ export function hasFreshPduPage(t: NodeTelemSnapshot): boolean { return [t.rail12V, t.rail5V, t.status].some((p) => p !== undefined && !p.stale) } +/** + * When the newest fresh PDU page (12 V, 5 V or status) arrived, for a snapshot taken at `now`; + * undefined when it has none. + */ +export function newestPduPageAt(t: NodeTelemSnapshot, now: number): number | undefined { + if (!t.isPdu) return undefined + const ages = [t.rail12V, t.rail5V, t.status].flatMap((p) => (p && !p.stale ? [p.ageMs] : [])) + return ages.length ? now - Math.min(...ages) : undefined +} + +/** + * A PDU's pages say it is on the bus: one is fresh, and the newest arrived after the roster's last + * "absent" reading for it (`absentAt`, its offline time) — the newest evidence wins. So a PDU that + * loses power reads offline as soon as the roster says so, not when its pages go stale, and one + * whose pages keep arriving after that is on again. The availability rule's telemetry evidence + * (node-model.ts availabilityOf), which Hide and Forget read too. + */ +export function pduPagesLive(t: NodeTelemSnapshot, now: number, absentAt?: number): boolean { + const at = newestPduPageAt(t, now) + return at !== undefined && (absentAt === undefined || at > absentAt) +} + /** iAvgMa for display: residual negative offset reads as 0. */ export function displayCurrentMa(ma: number): number { return Math.max(0, ma) diff --git a/src/main/nodes/offline-actions.test.ts b/src/main/nodes/offline-actions.test.ts index 5ee9edc..afee65c 100644 --- a/src/main/nodes/offline-actions.test.ts +++ b/src/main/nodes/offline-actions.test.ts @@ -1,54 +1,23 @@ import { describe, expect, it } from 'vitest' -import type { NodeTelemSnapshot } from '@shared/nodes' -import { - canForget, - canHide, - NO_ROSTER_NOTE, - offlineRefusal, - ONLINE_NOTE, - pduTelemetryLive -} from './offline-actions' - -const page = (stale: boolean) => ({ - kind: 'rail' as const, - page: 1 as const, - flags: 0, - vAvgMv: 12_000, - vMinMv: 11_980, - iAvgMa: 500, - ageMs: stale ? 9000 : 500, - stale -}) -const telem = (nodeId: number, stale: boolean): NodeTelemSnapshot => ({ - nodeId, - isPdu: true, - rail12V: page(stale), - other: [], - live: !stale -}) +import { canForget, canHide, NO_ROSTER_NOTE, offlineRefusal, ONLINE_NOTE } from './offline-actions' describe('canHide / canForget (M1/C3: offline only, by the one availability rule)', () => { for (const gate of [canHide, canForget]) { - it(`${gate.name}: allowed only offline with no fresh PDU pages`, () => { - expect(gate({ availability: 'offline', pduTelemetry: false })).toEqual({ ok: true }) - expect(gate({ availability: 'online', pduTelemetry: false })).toEqual({ + it(`${gate.name}: allowed only offline`, () => { + expect(gate({ availability: 'offline' })).toEqual({ ok: true }) + expect(gate({ availability: 'online' })).toEqual({ ok: false, why: 'online', note: ONLINE_NOTE }) // Just after a reconnect: not known, so not offline. - expect(gate({ availability: 'unknown', pduTelemetry: false })).toEqual({ + expect(gate({ availability: 'unknown' })).toEqual({ ok: false, why: 'no-roster', note: NO_ROSTER_NOTE }) - // Fresh PDU pages prove it live, whatever the roster says. - for (const availability of ['offline', 'unknown'] as const) { - expect(gate({ availability, pduTelemetry: true })).toMatchObject({ - ok: false, - why: 'online' - }) - } + // A PDU's fresh pages are weighed in the availability itself (node-model.ts + // availabilityOf, the newest evidence wins), so the gate reads nothing else. }) } @@ -63,11 +32,4 @@ describe('canHide / canForget (M1/C3: offline only, by the one availability rule 'No roster yet — connect the Gateway. Forget and Hide work once the board shows offline.' ) }) - - it('PDU pages count only while fresh, and only for that NODE_ID', () => { - expect(pduTelemetryLive([telem(4, false)], 4)).toBe(true) - expect(pduTelemetryLive([telem(4, true)], 4)).toBe(false) - expect(pduTelemetryLive([telem(4, false)], 3)).toBe(false) - expect(pduTelemetryLive([], 4)).toBe(false) - }) }) diff --git a/src/main/nodes/offline-actions.ts b/src/main/nodes/offline-actions.ts index 7a14408..7a20388 100644 --- a/src/main/nodes/offline-actions.ts +++ b/src/main/nodes/offline-actions.ts @@ -4,17 +4,15 @@ // // Both only tidy the display, and a board that is active on the bus — its controls reach DCS, it // draws power, it affects bus health — always shows. So both wait until the board is offline by the -// one availability rule (node-model.ts nodeAvailability). Fresh PDU pages prove a PDU is live -// whatever the roster says, and "unknown" (no complete roster since the link opened, the seconds -// after every reconnect) is not offline. -import type { NodeAvailability, NodeTelemSnapshot } from '@shared/nodes' -import { hasFreshPduPage } from './node-telem' +// one availability rule (node-model.ts availabilityOf), which already weighs a PDU's pages against +// the roster — the newest evidence wins (X2) — so this reads nothing else: the footer reads the +// model's answer, NodeHub the same rule over the same signals. "unknown" (no complete roster since +// the link opened, the seconds after every reconnect) is not offline. +import type { NodeAvailability } from '@shared/nodes' export interface OfflineSignals { - /** The board's availability (node-model.ts nodeAvailability). */ + /** The board's availability (node-model.ts availabilityOf), its PDU pages included. */ availability: NodeAvailability - /** It is sending fresh PDU pages now (pduTelemetryLive). */ - pduTelemetry: boolean } /** Why it can't be hidden or forgotten: it is live, or whether it is on the bus is not known. */ @@ -29,13 +27,8 @@ export const ONLINE_NOTE = export const NO_ROSTER_NOTE = 'No roster yet — connect the Gateway. Forget and Hide work once the board shows offline.' -/** Fresh PDU pages from this NODE_ID in the telemetry snapshot: it is live. */ -export function pduTelemetryLive(telemetry: readonly NodeTelemSnapshot[], nodeId: number): boolean { - return telemetry.some((t) => t.nodeId === nodeId && t.isPdu && hasFreshPduPage(t)) -} - function offlineOnly(s: OfflineSignals): OfflineGate { - if (s.pduTelemetry || s.availability === 'online') { + if (s.availability === 'online') { return { ok: false, why: 'online', note: ONLINE_NOTE } } if (s.availability === 'unknown') return { ok: false, why: 'no-roster', note: NO_ROSTER_NOTE } diff --git a/src/main/nodes/phase0-probes.test.ts b/src/main/nodes/phase0-probes.test.ts index b2249a8..f82ccb5 100644 --- a/src/main/nodes/phase0-probes.test.ts +++ b/src/main/nodes/phase0-probes.test.ts @@ -109,14 +109,16 @@ describe('fix-track probes (today)', () => { expect(b.h.forget(4).ok).toBe(false) }) - it('M3: a v2 bridge — END completes the roster, added boards read offline, Forget allowed', () => { + it('M3 / #132 (fixed): a reply whose rows were dropped completes nothing — unknown, Forget refused', () => { + // Was: the END completed the roster, so added boards read offline and Forget was allowed. The + // END says 2 rows and none arrived, so the reply is incomplete and reconciles nothing (#132). const a = hubWith(added()) a.roster.beginBurst(0) - a.roster.applyMessage(NODE_MSG, '0101000000000000008000', 0) // v2 row (no kind) -> dropped + a.roster.applyMessage(NODE_MSG, '0101000000000000008000', 0) // malformed row -> dropped a.roster.applyMessage(NODE_END_MSG, '2', 0) const n1 = a.h.snapshot(0).model.nodes.find((n) => n.nodeId === 1) - expect(n1?.availability).toBe('offline') - expect(a.h.forget(1).ok).toBe(true) + expect(n1?.availability).toBe('unknown') + expect(a.h.forget(1).ok).toBe(false) }) it('C4: a node identified only by PDU telemetry logs "new on the bus" only at its next status row', () => { @@ -223,7 +225,14 @@ describe('fix-track probes (today)', () => { { name: 'Right_Navigation', console: 'Right', pduId: null, kind: 'pdu' }, () => 'pdu' ) - const m = buildNodeModel({ roster: [], seen: new Map(), telemetry: [], defaults, file: f }) + const m = buildNodeModel({ + roster: [], + seen: new Map(), + telemetry: [], + defaults, + file: f, + now: 0 + }) expect(m.nodes.find((x) => x.nodeId === 2)?.kind).toBe('pdu') }) }) diff --git a/src/renderer/src/nodes/node-view.test.ts b/src/renderer/src/nodes/node-view.test.ts index fac5172..358330c 100644 --- a/src/renderer/src/nodes/node-view.test.ts +++ b/src/renderer/src/nodes/node-view.test.ts @@ -48,7 +48,12 @@ const summary = ( telemetry: NodeTelemSnapshot[] = [] ) => { const m = model(nodes, outages) - return nodesSummary(nodeViews(m, telemetry), outages, boardZones(m.nodes, cockpitMembers(m).get)) + return nodesSummary( + nodeViews(m, telemetry), + outages, + boardZones(m.nodes, cockpitMembers(m).get), + true + ) } const T = 1_760_000_000_000 diff --git a/src/renderer/src/nodes/phase0-probes.test.ts b/src/renderer/src/nodes/phase0-probes.test.ts index e66ac09..aeb0dba 100644 --- a/src/renderer/src/nodes/phase0-probes.test.ts +++ b/src/renderer/src/nodes/phase0-probes.test.ts @@ -58,12 +58,13 @@ describe('fix-track probes (today)', () => { seen: roster.seenInfo(), rosterCompleteAt: 0, telemetry: [], + now: 0, defaults, file: EMPTY_MAPPING }) const views = nodeViews(model, []) const cockpit = cockpitViews(model, []) - const { strays } = strayNew(cockpit, 6000) + const { strays } = strayNew(cockpit, 6000, true) expect(strays).toHaveLength(1) const cockpitBand = cockpitHealthBand(cockpit, strays.length) expect(cockpitBand.warn).toBe(1) @@ -77,7 +78,7 @@ describe('fix-track probes (today)', () => { expect(newDevices(views, true)).toBeNull() // Connection expect( - nodesSummary(views, [], boardZones(model.nodes, cockpitMembers(model).get)) + nodesSummary(views, [], boardZones(model.nodes, cockpitMembers(model).get), true) ).toMatchObject({ known: 0, warn: 0, @@ -94,10 +95,11 @@ describe('fix-track probes (today)', () => { seen: roster.seenInfo(), rosterCompleteAt: 0, telemetry: [], + now: 0, defaults, file: EMPTY_MAPPING }) - expect(strayNew(cockpitViews(model, []), 60_001).strays).toHaveLength(1) + expect(strayNew(cockpitViews(model, []), 60_001, true).strays).toHaveLength(1) }) it('C6: a registry board waiting to be added is labelled "NOT IN REGISTRY" / "Not in registry"', () => { @@ -108,6 +110,7 @@ describe('fix-track probes (today)', () => { seen: roster.seenInfo(), rosterCompleteAt: 0, telemetry: [], + now: 0, defaults, file: EMPTY_MAPPING }) @@ -129,6 +132,7 @@ describe('fix-track probes (today)', () => { seen: roster.seenInfo(), rosterCompleteAt: 0, telemetry: [], + now: 0, defaults, file: f }) diff --git a/src/renderer/test/t32-settings-about.test.tsx b/src/renderer/test/t32-settings-about.test.tsx index f9d667d..1e1a2d6 100644 --- a/src/renderer/test/t32-settings-about.test.tsx +++ b/src/renderer/test/t32-settings-about.test.tsx @@ -83,19 +83,29 @@ describe('T32 Settings › About (B92, X7)', () => { ]) }) - it('unplugged: the Gateway reads "no device", but CAN nodes still read "yes" (R5 today)', async () => { - // contract X7/R5: on the bus means now; today the CAN rows keep the last roster while the - // link is down (they ignore it), and only the Gateway row follows the link. + it('unplugged: the Gateway reads "no device", and no CAN row says it is on the bus (R5, #132)', async () => { + // contract X7/R5 (fixed, #132 X2): on the bus means now (`onBusNow(live)`), so with the link + // down every CAN row reads "—" — no data — and a hidden one still reads hidden. Back when the + // link is. const { sky, openRegistry } = await openAbout() const dlg = await openRegistry() sky.emitScenario({ device: link('no-device') }) - expect(rows(dlg).slice(0, 2)).toEqual(['#0Gateway—no device', '#1Center_ArmamentCenteryes']) + expect(rows(dlg)).toEqual([ + '#0Gateway—no device', + '#1Center_ArmamentCenter—', + '#2Right_NavigationRight—', + '#3PDU_LeftLeft—', + '#4PDU_RightRight—', + '#5PDU_CenterCenterhidden' + ]) + sky.emitScenario({ device: connected() }) + expect(rows(dlg).slice(0, 2)).toEqual(['#0Gateway—serial link', '#1Center_ArmamentCenteryes']) }) it('opened while unplugged: the same', async () => { const { openRegistry } = await openAbout(bus(), link('disconnected')) const dlg = await openRegistry() - expect(rows(dlg).slice(0, 2)).toEqual(['#0Gateway—no device', '#1Center_ArmamentCenteryes']) + expect(rows(dlg).slice(0, 2)).toEqual(['#0Gateway—no device', '#1Center_ArmamentCenter—']) }) it('Close shuts the dialog', async () => { diff --git a/src/renderer/test/t9-cockpit-new-on-bus.test.tsx b/src/renderer/test/t9-cockpit-new-on-bus.test.tsx index 0afcc59..9abc380 100644 --- a/src/renderer/test/t9-cockpit-new-on-bus.test.tsx +++ b/src/renderer/test/t9-cockpit-new-on-bus.test.tsx @@ -47,9 +47,9 @@ describe('T9 Cockpit "New on the bus" CTA (B68, F12, X7)', () => { expect(adoptButton()).toBeNull() }) - it('stays from the last model while the link is down (R5 today)', async () => { - // contract R5 / F12: the CTA should count only boards on the bus now (`onBusNow(live)`); - // today it keeps showing the last model's new boards after the link drops. + it('goes quiet while the link is down, and returns when it is back (R5, #132)', async () => { + // contract R5 / F12 (fixed, #132 X2): the CTA counts only boards on the bus now + // (`onBusNow(live)`), so the last model's new boards are not "on the bus" once the link drops. const { sky } = await renderScenario(, { device: connected(), model: model([registryPanel()]) @@ -57,6 +57,9 @@ describe('T9 Cockpit "New on the bus" CTA (B68, F12, X7)', () => { expect(cta()).toHaveTextContent('New on the bus · 1') sky.emitScenario({ device: link('no-device') }) expect(screen.getByText('No live node status — connect the Gateway.')).toBeInTheDocument() + expect(cta()).toBeNull() + expect(adoptButton()).toBeNull() + sky.emitScenario({ device: connected() }) expect(cta()).toHaveTextContent('New on the bus · 1') expect(adoptButton()).toBeEnabled() })