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
167 changes: 147 additions & 20 deletions src/main/events/events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,22 @@ import { NodeEventTracker, type NodeLookup } from '../nodes/node-events'
import type { LogEventInput } from '@shared/events'
import { eventText } from '@shared/format'
import { NODE_END_MSG, NODE_MSG, ROSTER_REPLY_MS } from '../nodes/node-status-wire'
import type { NodeStatus, NodeTelem } from '@shared/nodes'
import type { NodeModelEntry, NodeStatus, NodeTelem } from '@shared/nodes'
import { PDU_RAIL_FLAGS as RAIL_FLAG, PDU_STATUS_FLAGS as PDU_FLAG } from '@shared/firmware'
import { nodeStatus } from '../../test-fixtures/nodes'
import { modelNode, nodeStatus } from '../../test-fixtures/nodes'

const st = (nodeId: number, over: Partial<NodeStatus> = {}): NodeStatus =>
nodeStatus(nodeId, { uptimeSec: 0, rxCount: 0, dieTempC: null, ...over })

const NAMES: Record<number, string> = { 2: 'Right_Navigation', 4: 'PDU_Right', 7: 'Left_Fuel' }
const lookup = (): NodeLookup => ({
/** Boards in your cockpit are NAMES; any other id is outside it, on the bus as a panel group. */
const lookup = (entries: Record<number, Partial<NodeModelEntry>> = {}): NodeLookup => ({
name: (id) => NAMES[id] ?? `Node ${id}`,
isNew: (id) => !(id in NAMES),
unmapped: (id) => !(id in NAMES)
outside: (id) => !(id in NAMES),
entry: (id) =>
id in NAMES
? undefined
: modelNode(id, { source: 'new', identified: 'panel', onlineSince: 0, ...entries[id] })
})

/** Short form for assertions: `name: event · detail [severity]`. */
Expand Down Expand Up @@ -90,32 +94,39 @@ describe('NodeEventTracker — changes', () => {
])
})

it('raises and clears bus-off, error-passive (with TEC), overheat', () => {
// X6/R8 (#133): in the Cockpit's order — bus-off, fault, overheat, error-passive (was bus-off,
// error-passive, overheat).
it('raises and clears bus-off, overheat, error-passive (with TEC)', () => {
const t = live([st(2)])
expect(
short(
t.status([st(2, { boff: true, epvf: true, tec: 131, overheat: true })], NODE_MSG, '', 20)
)
).toEqual([
'Right_Navigation: bus-off [error]',
'Right_Navigation: error-passive · TEC 131 [warn]',
'Right_Navigation: overheat [warn]'
'Right_Navigation: overheat [warn]',
'Right_Navigation: error-passive · TEC 131 [warn]'
])
expect(short(t.status([st(2)], NODE_MSG, '', 30))).toEqual([
'Right_Navigation: bus-off cleared [info]',
'Right_Navigation: error-passive cleared [info]',
'Right_Navigation: overheat cleared [info]'
'Right_Navigation: overheat cleared [info]',
'Right_Navigation: error-passive cleared [info]'
])
})

it('degraded carries the fault label (or Fault N), and a new fault id is a new row', () => {
it('degraded carries the fault label (or Fault N), once per episode (D8, #133)', () => {
const t = live([st(2)])
const deg = (faultId: number, faultLabel?: string) =>
st(2, { degraded: true, faultId, faultLabel })
expect(short(t.status([deg(2, 'I2C peripheral')], NODE_MSG, '', 20))).toEqual([
'Right_Navigation: degraded · I2C peripheral [warn]'
])
expect(short(t.status([deg(9)], NODE_MSG, '', 30))).toEqual([
// Was: a new fault id while degraded was a new row. Same episode: none.
expect(short(t.status([deg(9)], NODE_MSG, '', 30))).toEqual([])
expect(short(t.status([st(2)], NODE_MSG, '', 35))).toEqual([
'Right_Navigation: degraded cleared [info]'
])
expect(short(t.status([deg(9)], NODE_MSG, '', 37))).toEqual([
'Right_Navigation: degraded · Fault 9 [warn]'
])
expect(short(t.status([st(2)], NODE_MSG, '', 40))).toEqual([
Expand All @@ -134,24 +145,25 @@ describe('NodeEventTracker — changes', () => {
])
})

it('a registry board not added yet is new too, and says it is in NODE_IDS.md (#93)', () => {
const t = new NodeEventTracker({ ...lookup(), inRegistry: (id) => id === 41 })
it('a registry board not added yet is new too, and says it is from NODE_IDS.md (#93, X8)', () => {
const registry = { name: 'Left_Oxygen', console: 'Left', pduId: null }
const t = new NodeEventTracker(lookup({ 41: { registry } }))
t.status([st(2)], NODE_MSG, '', 0)
t.status([st(2)], NODE_END_MSG, '_NODE_STATUS_END', 10)
expect(short(t.status([st(2), st(41)], NODE_MSG, '', 20))).toEqual([
'Node 41: new on the bus · in NODE_IDS.md [new]'
'Node 41: new on the bus · from NODE_IDS.md [new]'
])
})

it('reports a new node once per app run, even across a reopen', () => {
const t = live([st(2)])
expect(short(t.status([st(2), st(41)], NODE_MSG, '', 20))).toEqual([
'Node 41: new on the bus · not in registry [new]'
'Node 41: new on the bus · not in NODE_IDS.md [new]'
])
t.reset()
t.status([st(41)], NODE_MSG, '', 30)
expect(short(t.status([st(41)], NODE_END_MSG, '', 31))).toEqual([
'roster: 1 node online [info]'
'roster: 1 node online · 1 not in your cockpit [info]'
])
})

Expand All @@ -161,18 +173,133 @@ describe('NodeEventTracker — changes', () => {
t.reset() // port reopens with 41 unplugged
t.status([st(2)], NODE_MSG, '', 30)
expect(short(t.status([st(2)], NODE_END_MSG, '', 31))).toEqual(['roster: 1 node online [info]'])
expect(short(t.status([st(2), st(41)], NODE_MSG, '', 40))).toEqual(['Node 41: online [info]'])
expect(short(t.status([st(2), st(41)], NODE_MSG, '', 40))).toEqual([
'Node 41: online · not in your cockpit [info]'
])
expect(short(t.status([st(2), st(41, { present: false })], NODE_MSG, '', 50))).toEqual([
'Node 41: offline · no heartbeat [error]'
'Node 41: offline · no heartbeat · not in your cockpit [info]'
])
})

it('a new node seen at baseline still gets its row', () => {
const t = new NodeEventTracker(lookup())
expect(short(t.status([st(41)], NODE_MSG, '', 0))).toEqual([
'Node 41: new on the bus · not in registry [new]'
'Node 41: new on the bus · not in NODE_IDS.md [new]'
])
})
})

describe('NodeEventTracker — the physical bus (D8 / S5 / C4, #133)', () => {
it('a board outside your cockpit: every row at info, labelled; yours keep their severity', () => {
const t = live([st(2)])
t.status([st(2), st(41)], NODE_MSG, '', 20) // new on the bus
expect(
short(
t.status(
[st(2, { boff: true }), st(41, { boff: true, epvf: true, tec: 140 })],
NODE_MSG,
'',
30
)
)
).toEqual([
'Right_Navigation: bus-off [error]',
'Node 41: bus-off · not in your cockpit [info]',
'Node 41: error-passive · TEC 140 · not in your cockpit [info]'
])
})

it('the baseline counts every board on the bus, and says how many are not yours', () => {
const t = new NodeEventTracker(lookup())
t.status([st(2), st(41), st(42)], NODE_MSG, '', 0)
const out = t.status([st(2), st(41), st(42)], NODE_END_MSG, 'end', 1)
expect(short(out)).toEqual(['roster: 3 nodes online · 2 not in your cockpit [info]'])
})

it('a stray is logged once newState says so: at once when it can’t be adopted, else after 5 s', () => {
const t = new NodeEventTracker(
lookup({
9: { identified: 'bridge' },
10: { identified: undefined, onlineSince: 0 }
})
)
t.status([st(2)], NODE_END_MSG, '', 0)
// #9 says it is a Gateway: a stray at once. #10 says nothing: still identifying at 1 s.
expect(short(t.status([st(2), st(9), st(10)], NODE_MSG, '', 1000))).toEqual([
'Node 9: new on the bus · second Gateway · can’t be adopted [info]'
])
expect(short(t.status([st(2), st(9), st(10)], NODE_MSG, '', 5000))).toEqual([
'Node 10: new on the bus · unidentified · can’t be adopted [info]'
])
expect(t.status([st(2), st(9), st(10)], NODE_MSG, '', 10_000)).toEqual([]) // once
})

it('a stray that later identifies as something adoptable is news then', () => {
const entries: Record<number, Partial<NodeModelEntry>> = { 10: { identified: undefined } }
const t = new NodeEventTracker(lookup(entries))
t.status([st(2)], NODE_END_MSG, '', 0)
expect(short(t.status([st(2), st(10)], NODE_MSG, '', 6000))).toEqual([
'Node 10: new on the bus · unidentified · can’t be adopted [info]'
])
entries[10] = { identified: 'panel' }
expect(short(t.status([st(2), st(10, { nodeKind: 1 })], NODE_MSG, '', 7000))).toEqual([
'Node 10: new on the bus · not in NODE_IDS.md [new]'
])
})

it('PDU pages that identify a board outside your cockpit make its "new on the bus" row', () => {
const t = new NodeEventTracker(lookup({ 42: { kind: 'pdu', identified: 'pdu' } }))
t.status([st(2)], NODE_END_MSG, '', 0)
const page: NodeTelem = {
nodeId: 42,
kind: 'rail',
page: 1,
flags: 0,
vAvgMv: 12000,
vMinMv: 11900,
iAvgMa: 500
}
expect(short(t.telemetry(page, 'p', 1000))).toEqual([
'Node 42: new on the bus · not in NODE_IDS.md [new]'
])
expect(t.telemetry(page, 'p', 2000)).toEqual([])
})

it('the "seen new" record goes once the board is in your cockpit: forgotten, it is new again', () => {
const outside = new Set([41])
const t = new NodeEventTracker({
...lookup(),
outside: (id) => !(id in NAMES) && outside.has(id)
})
t.status([st(2)], NODE_END_MSG, '', 0)
expect(short(t.status([st(2), st(41)], NODE_MSG, '', 10))).toEqual([
'Node 41: new on the bus · not in NODE_IDS.md [new]'
])
outside.delete(41) // adopted
t.cockpitChanged()
t.status([st(2), st(41, { present: false })], NODE_MSG, '', 20) // unplugged
outside.add(41) // forgotten
t.cockpitChanged()
expect(short(t.status([st(2), st(41)], NODE_MSG, '', 30))).toEqual([
'Node 41: new on the bus · not in NODE_IDS.md [new]',
'Node 41: online · not in your cockpit [info]'
])
})

it('a board online by its pages alone gets online / offline rows; the roster taking over adds none', () => {
const t = live([st(2)])
const pdu = (over: Partial<NodeModelEntry> = {}) =>
modelNode(4, { kind: 'pdu', status: undefined, ...over })
expect(short(t.pagesOnline([pdu()], 20))).toEqual(['PDU_Right: online [info]'])
expect(t.pagesOnline([pdu()], 30)).toEqual([]) // still online
expect(short(t.pagesOnline([pdu({ availability: 'offline' })], 40))).toEqual([
'PDU_Right: offline · no telemetry [error]'
])
expect(short(t.pagesOnline([pdu()], 50))).toEqual(['PDU_Right: online [info]'])
// The roster lists it now: no second "online", and its pages no longer decide.
expect(t.status([st(2), st(4)], NODE_MSG, '', 60)).toEqual([])
expect(t.pagesOnline([pdu({ status: st(4) })], 70)).toEqual([])
})
})

describe('NodeEventTracker — PDU telemetry', () => {
Expand Down
21 changes: 14 additions & 7 deletions src/main/events/status-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,20 @@ import type { NodeStatus } from '@shared/nodes'

export type StatusFlag = 'boff' | 'epvf' | 'degraded' | 'overheat'

/** A node's own heartbeat / health flags, as every row and the Gateway (#94) name them. */
export const STATUS_FLAGS: { key: StatusFlag; label: string; severity: EventSeverity }[] = [
{ key: 'boff', label: 'bus-off', severity: 'error' },
{ key: 'epvf', label: 'error-passive', severity: 'warn' },
{ key: 'degraded', label: 'degraded', severity: 'warn' },
{ key: 'overheat', label: 'overheat', severity: 'warn' }
]
/**
* A node's own heartbeat / health flags, as every row and the Gateway (#94) name them: the label and
* the severity, by flag. No order here: the order a node's flags are named in is nodes/faults.ts's
* (NODE_FLAG_ORDER), the Gateway's own is gateway-health's.
*/
export const STATUS_FLAGS: Record<
StatusFlag,
{ key: StatusFlag; label: string; severity: EventSeverity }
> = {
boff: { key: 'boff', label: 'bus-off', severity: 'error' },
epvf: { key: 'epvf', label: 'error-passive', severity: 'warn' },
degraded: { key: 'degraded', label: 'degraded', severity: 'warn' },
overheat: { key: 'overheat', label: 'overheat', severity: 'warn' }
}

/** The fault-dictionary label, else `Fault N`. */
export function faultLabel(n: Pick<NodeStatus, 'faultId' | 'faultLabel'>): string {
Expand Down
4 changes: 2 additions & 2 deletions src/main/gateway/gateway-health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ export const GATEWAY_REASK_MS = 1000

const SECS = GATEWAY_NO_ANSWER_MS / 1000

type Flag = (typeof STATUS_FLAGS)[number]
const flag = (key: StatusFlag): Flag => STATUS_FLAGS.find((f) => f.key === key)!
type Flag = (typeof STATUS_FLAGS)[StatusFlag]
const flag = (key: StatusFlag): Flag => STATUS_FLAGS[key]

/**
* Client wording for the faults a Gateway reports about itself, keyed by NODE_FAULT_CODES name.
Expand Down
11 changes: 8 additions & 3 deletions src/main/nodes/cockpit-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,12 @@ describe('CockpitSnapshotHost', () => {
it('a board still identifying: rebuilt nextAt + 50 ms later, when it becomes a stray', () => {
vi.useFakeTimers({ now: 10_000 })
const { host, pushed } = rig()
const fresh = modelNode(9, { source: 'new', console: null, firstSeenAt: 10_000 })
const fresh = modelNode(9, {
source: 'new',
console: null,
firstSeenAt: 10_000,
onlineSince: 10_000
})
host.modelPushed(nodeModel([fresh]))
expect(pushed.at(-1)!.attention.items).toEqual([])
const due = 10_000 + IDENTIFY_WAIT_MS + STRAY_RECHECK_MS
Expand All @@ -186,7 +191,7 @@ describe('CockpitSnapshotHost', () => {
it('keeps one timer per nextAt, as the renderer effect did, and drops it once nothing waits', () => {
vi.useFakeTimers({ now: 0 })
const { host, pushed } = rig()
const a = modelNode(9, { source: 'new', console: null, firstSeenAt: 0 })
const a = modelNode(9, { source: 'new', console: null, firstSeenAt: 0, onlineSince: 0 })
host.modelPushed(nodeModel([a]))
host.telemetryPushed([]) // same nextAt: the timer stays
vi.advanceTimersByTime(2000)
Expand All @@ -201,7 +206,7 @@ describe('CockpitSnapshotHost — the strays’ clock', () => {
it('a push inside the 50 ms after a board is due is judged at the old time, as the tab was', () => {
vi.useFakeTimers({ now: 0 })
const { host, pushed } = rig()
const a = modelNode(9, { source: 'new', console: null, firstSeenAt: 0 })
const a = modelNode(9, { source: 'new', console: null, firstSeenAt: 0, onlineSince: 0 })
host.modelPushed(nodeModel([a]))
vi.advanceTimersByTime(IDENTIFY_WAIT_MS + 20)
host.telemetryPushed([])
Expand Down
17 changes: 12 additions & 5 deletions src/main/nodes/cockpit-snapshot/adopt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ describe('strayNew', () => {
const ids = (r: ReturnType<typeof strayNew>) => r.strays.map((v) => v.entry.nodeId)

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 })])
const views = at([fresh(41, { onlineSince: 0 }), fresh(43, { onlineSince: 3000 })])
expect(strayNew(views, 4000, true)).toEqual({ strays: [], nextAt: 5000 })
const r = strayNew(views, 5000, true)
expect(ids(r)).toEqual([41])
Expand All @@ -269,13 +269,20 @@ describe('strayNew', () => {

it('takes a bridge or an unknown type at once; never an adoptable or offline node', () => {
const views = at([
fresh(44, { identified: 'bridge', firstSeenAt: 1000 }),
fresh(45, { identified: 'other', firstSeenAt: 1000 }),
fresh(41, { identified: 'panel', firstSeenAt: 0 }),
fresh(46, { availability: 'offline', firstSeenAt: 0 })
fresh(44, { identified: 'bridge', onlineSince: 1000 }),
fresh(45, { identified: 'other', onlineSince: 1000 }),
fresh(41, { identified: 'panel', onlineSince: 0 }),
fresh(46, { availability: 'offline', onlineSince: 0 })
])
expect(ids(strayNew(views, 1000, true))).toEqual([44, 45])
})

it('counts the grace from when it came online, not from when it was first seen (S6)', () => {
// First seen (absent) at 0, online at 60 s: identifying until 65 s.
const views = at([fresh(9, { firstSeenAt: 0, onlineSince: 60_000 })])
expect(strayNew(views, 60_001, true)).toEqual({ strays: [], nextAt: 65_000 })
expect(ids(strayNew(views, 65_000, true))).toEqual([9])
})
})

describe('strayFlag', () => {
Expand Down
9 changes: 2 additions & 7 deletions src/main/nodes/cockpit-snapshot/adopt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
type NodeView
} from '@shared/nodes'
import { amps, fmtUptime, volts } from '@shared/format'
import { nodeFaultTag } from '../faults'
import { nodeFlagWords } from '../faults'
import { isAdoptableKind, reportedIdentity } from '../node-kind'
import { displayCurrentMa } from '../node-telem'
import { resolveZone } from '../power-zone'
Expand Down Expand Up @@ -110,12 +110,7 @@ export function adoptEvidence(v: NodeView): Evidence {
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),
st.overheat && 'overheat',
st.epvf && 'error-passive'
].filter((f): f is string => !!f)
const faults = nodeFlagWords(st)
return {
detected,
reporting: `status only · up ${fmtUptime(st.uptimeSec)} · ${faults.length ? faults.join(', ') : 'healthy'}`,
Expand Down
Loading
Loading