Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions src/main/app/roster-poll.seam.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

/**
Expand All @@ -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
Expand All @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down
10 changes: 5 additions & 5 deletions src/main/nodes/cockpit-snapshot/adopt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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])
})
})

Expand Down
7 changes: 6 additions & 1 deletion src/main/nodes/cockpit-snapshot/adopt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,14 +97,19 @@ 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
if (e.kind === 'pdu' && (rail12V || rail5V)) {
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),
Expand Down
36 changes: 21 additions & 15 deletions src/main/nodes/cockpit-snapshot/attention.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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
}

/**
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/main/nodes/cockpit-snapshot/board-opens.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ function snapshotOf(f: NodeMappingFile) {
rosterCompleteAt: T,
telemetry: [],
defaults,
file: f
file: f,
now: T
})
return buildCockpitSnapshot({
model,
Expand Down
3 changes: 2 additions & 1 deletion src/main/nodes/cockpit-snapshot/cockpit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Expand Down
9 changes: 7 additions & 2 deletions src/main/nodes/cockpit-snapshot/connection-summary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -131,7 +136,7 @@ describe('nodesSummary rows', () => {
]
}
const meta = (mem: ReturnType<typeof cockpitMembers>) =>
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)}`
])
Expand Down
24 changes: 12 additions & 12 deletions src/main/nodes/cockpit-snapshot/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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 }
Expand Down
106 changes: 106 additions & 0 deletions src/main/nodes/cockpit-snapshot/on-the-bus.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
Loading
Loading