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
12 changes: 7 additions & 5 deletions src/main/events/events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,21 +90,23 @@ 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]'
])
})

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
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
57 changes: 29 additions & 28 deletions src/main/nodes/cockpit-snapshot/panels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
type FaultBox,
type FedBoard,
type NodeModelEntry,
type NodeStatus,
type NodeView,
type PduOutage,
type PduRailPage,
Expand All @@ -21,7 +22,8 @@ import {
type ZoneChoices
} from '@shared/nodes'
import { PDU_STATUS_FLAGS as PDU_FLAG } from '@shared/firmware'
import { nodeFaultTag, pduAlertFlags, pduFlagMeanings } from '../faults'
import type { StatusFlag } from '../../events'
import { nodeFaultTag, pduAlertFlags, pduFlagMeanings, raisedNodeFlags } from '../faults'
import { displayCurrentMa } from '../node-telem'
import { canForget, canHide, type OfflineGate } from '../offline-actions'
import { fedBy, zoneLabel } from '../power-zone'
Expand Down Expand Up @@ -100,7 +102,7 @@ function notSeenBox(e: NodeModelEntry): FaultBox {
* The boxes at the top of the node panel's Status tab, stacked; empty when there is nothing to
* explain. New, no-status-yet and offline each stand alone. Otherwise every flag the node raised
* gets its own box — bus-off, then degraded (the fault table's label), overheat, error-passive,
* the order the tile and Attention name them in — so a second fault never hides behind the first.
* the one order (faults.ts NODE_FLAG_ORDER) — so a second fault never hides behind the first.
*/
export function nodeFaultBoxes(v: NodeView): FaultBox[] {
const e = v.entry
Expand Down Expand Up @@ -134,32 +136,31 @@ export function nodeFaultBoxes(v: NodeView): FaultBox[] {
]
}
if (!st) return []
const out: FaultBox[] = []
if (st.boff) {
out.push({
tone: 'err',
title: 'Bus-off',
text: 'its CAN controller stopped transmitting after too many errors. Reported by the node.'
})
}
if (st.degraded) {
out.push({
tone: 'warn',
title: st.faultLabel ?? nodeFaultTag(st),
text: st.faultDesc || 'reported by the node; this client has no description for this code.'
})
}
if (st.overheat) {
out.push({ tone: 'warn', title: 'Overheat', text: 'the node raised its own temperature trip.' })
}
if (st.epvf) {
out.push({
tone: 'warn',
title: 'Error-passive',
text: 'its CAN error counters are high; it still communicates but can only signal errors passively.'
})
}
return out
return raisedNodeFlags(st).map((k) => FLAG_BOX[k](st))
}

/** Each flag's box, as nodeFaultBoxes stacks them (in faults.ts NODE_FLAG_ORDER). */
const FLAG_BOX: Record<StatusFlag, (st: NodeStatus) => FaultBox> = {
boff: () => ({
tone: 'err',
title: 'Bus-off',
text: 'its CAN controller stopped transmitting after too many errors. Reported by the node.'
}),
degraded: (st) => ({
tone: 'warn',
title: st.faultLabel ?? nodeFaultTag(st),
text: st.faultDesc || 'reported by the node; this client has no description for this code.'
}),
overheat: () => ({
tone: 'warn',
title: 'Overheat',
text: 'the node raised its own temperature trip.'
}),
epvf: () => ({
tone: 'warn',
title: 'Error-passive',
text: 'its CAN error counters are high; it still communicates but can only signal errors passively.'
})
}

/**
Expand Down
7 changes: 2 additions & 5 deletions src/main/nodes/cockpit-snapshot/views.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type {
NodeView,
PduReadings
} from '@shared/nodes'
import { nodeFaultTag, pduAlertFlags } from '../faults'
import { nodeFlagChips, pduAlertFlags } from '../faults'

/** A PDU's pages that are fresh right now. A stale page is not a reading. */
function freshReadings(t: NodeTelemSnapshot | undefined): PduReadings {
Expand All @@ -28,10 +28,7 @@ export function nodeHealth(e: NodeModelEntry, r: PduReadings = {}): NodeHealth {
if (e.availability === 'offline') return { state: 'off', flag: 'OFFLINE' }
const st = e.status
if (st?.boff) return { state: 'off', flag: 'BUS-OFF' }
const flags: string[] = []
if (st?.degraded) flags.push(nodeFaultTag(st))
if (st?.overheat) flags.push('OVERHEAT')
if (st?.epvf) flags.push('ERR-PASSIVE')
const flags = st ? nodeFlagChips(st) : []
if (e.kind === 'pdu') flags.push(...pduAlertFlags(r))
return flags.length ? { state: 'warn', flag: flags.join(' · ') } : { state: 'on' }
}
Expand Down
73 changes: 73 additions & 0 deletions src/main/nodes/faults.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// X6 / R8: one order for a node's own flags. The Log and every Cockpit surface name a board's flags
// the same way round: bus-off, the fault it reports (degraded), overheat, error-passive.
import { describe, expect, it } from 'vitest'
import type { LogEventInput } from '@shared/events'
import type { NodeStatus } from '@shared/nodes'
import { modelNode, nodeModel, nodeStatus } from '../../test-fixtures/nodes'
import { adoptEvidence } from './cockpit-snapshot/adopt'
import { nodeFaultBoxes } from './cockpit-snapshot/panels'
import { nodeViews } from './cockpit-snapshot/views'
import { NODE_FLAG_ORDER, nodeFlagChips, nodeFlagWords, raisedNodeFlags } from './faults'
import { NodeEventTracker } from './node-events'
import { NODE_END_MSG, NODE_MSG } from './node-status-wire'

const ALL: Partial<NodeStatus> = {
boff: true,
degraded: true,
faultId: 1,
faultAbbr: 'I2C',
faultLabel: 'I2C peripheral',
overheat: true,
epvf: true,
tec: 131
}
const st = (over: Partial<NodeStatus> = {}): NodeStatus =>
nodeStatus(2, { uptimeSec: 130, dieTempC: null, ...over })

/** The rows the Log gets when the board raises `over`, after a healthy baseline. */
function logRows(over: Partial<NodeStatus>): string[] {
const t = new NodeEventTracker({
name: () => 'Right_Navigation',
isNew: () => false,
unmapped: () => false
})
t.status([st()], NODE_MSG, '', 0)
t.status([st()], NODE_END_MSG, '', 10)
return t.status([st(over)], NODE_MSG, '', 20).map((e: LogEventInput) => e.event)
}

const view = (over: Partial<NodeStatus>, source: 'registry' | 'new' = 'registry') =>
nodeViews(nodeModel([modelNode(2, { source, status: st(over), firstSeenAt: 1 })]), [])[0]!

describe('node flag order (X6/R8)', () => {
it('is the Cockpit’s: bus-off, fault, overheat, error-passive', () => {
expect(NODE_FLAG_ORDER).toEqual(['boff', 'degraded', 'overheat', 'epvf'])
expect(raisedNodeFlags(st(ALL))).toEqual(['boff', 'degraded', 'overheat', 'epvf'])
expect(nodeFlagWords(st(ALL))).toEqual(['bus-off', 'I2C', 'overheat', 'error-passive'])
expect(nodeFlagChips(st(ALL))).toEqual(['BUS-OFF', 'I2C', 'OVERHEAT', 'ERR-PASSIVE'])
})

it('a board with every flag: the Log and the Cockpit name them in the same order', () => {
expect(logRows(ALL)).toEqual(['bus-off', 'degraded', 'overheat', 'error-passive'])
expect(nodeFaultBoxes(view(ALL)).map((b) => b.title)).toEqual([
'Bus-off',
'I2C peripheral',
'Overheat',
'Error-passive'
])
expect(adoptEvidence(view(ALL, 'new')).reporting).toBe(
'status only · up 00:02:10 · bus-off, I2C, overheat, error-passive'
)
})

it('without bus-off: the tile’s chip, the boxes and the Log agree', () => {
const { boff: _boff, ...three } = ALL
expect(logRows(three)).toEqual(['degraded', 'overheat', 'error-passive'])
expect(view(three).health).toEqual({ state: 'warn', flag: 'I2C · OVERHEAT · ERR-PASSIVE' })
expect(nodeFaultBoxes(view(three)).map((b) => b.title)).toEqual([
'I2C peripheral',
'Overheat',
'Error-passive'
])
})
})
61 changes: 44 additions & 17 deletions src/main/nodes/faults.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,49 @@
// Node fault and flag text (pure, no Node/Electron deps): what a raised PDU rail or status flag is
// called in an event row and on the Cockpit, what it means, and the reading that goes with it.
// called in an event row and on the Cockpit, what it means, and the reading that goes with it — and
// the one order a node's own flags are named in, on the Cockpit and in the event log alike (X6/R8).
import type { EventSeverity } from '@shared/events'
import { amps, volts } from '@shared/format'
import { PDU_RAIL_FLAGS as RAIL_FLAG, PDU_STATUS_FLAGS as PDU_FLAG } from '@shared/firmware'
import type { NodeStatus, NodeView, PduRailPage, PduReadings } from '@shared/nodes'
import { STATUS_FLAGS, type StatusFlag } from '../events'
import { displayCurrentMa } from './node-telem'

// ── a node's own flags ──

/**
* The order a node's own flags are named in, everywhere (X6/R8): bus-off, then the fault it reports
* (degraded), overheat, error-passive. The Cockpit's tile, Attention, status boxes and Adopt
* evidence and the event log's rows all read it from here.
*/
export const NODE_FLAG_ORDER: readonly StatusFlag[] = ['boff', 'degraded', 'overheat', 'epvf']

/** The status-flag vocabulary (label, severity) in NODE_FLAG_ORDER: what the event log walks. */
export const NODE_FLAGS = NODE_FLAG_ORDER.map((k) => STATUS_FLAGS[k])

/** The flags a node raised, in NODE_FLAG_ORDER. */
export function raisedNodeFlags(n: NodeStatus): StatusFlag[] {
return NODE_FLAG_ORDER.filter((k) => n[k])
}

/** The flags a node raised, in words (`bus-off`, the fault tag, `overheat`, `error-passive`). */
export function nodeFlagWords(n: NodeStatus): string[] {
return raisedNodeFlags(n).map((k) => (k === 'degraded' ? nodeFaultTag(n) : STATUS_FLAGS[k].label))
}

const CHIP: Record<StatusFlag, string> = {
boff: 'BUS-OFF',
degraded: '',
overheat: 'OVERHEAT',
epvf: 'ERR-PASSIVE'
}

/** The flags a node raised, as the Cockpit's chip prints them (`BUS-OFF`, the fault tag, …). */
export function nodeFlagChips(n: NodeStatus): string[] {
return raisedNodeFlags(n).map((k) => (k === 'degraded' ? nodeFaultTag(n) : CHIP[k]))
}

// ── PDU flags ──

/** Rail flag text, as the Cockpit prints it (`5V UNDER-V`). */
export const RAIL_FLAGS: [number, string][] = [
[RAIL_FLAG.UNDER_V, 'UNDER-V'],
Expand Down Expand Up @@ -38,31 +76,20 @@ export function nodeFaultTag(n: NodeStatus): string {
return n.faultAbbr ?? `Fault ${n.faultId}`
}

// ── the Cockpit's flag text (moved from the renderer, R3.2) ──
// ── the Cockpit's flag text (moved from the renderer, R3.2): the same tables as the log's ──

const RAIL_FLAG_TEXT: [number, string][] = [
[RAIL_FLAG.UNDER_V, 'UNDER-V'],
[RAIL_FLAG.OVER_V, 'OVER-V'],
[RAIL_FLAG.OVER_CURRENT, 'OVER-I'],
[RAIL_FLAG.FUSE_OPEN, 'FUSE OPEN'],
[RAIL_FLAG.SENSOR_FAULT, 'SENSOR FAULT']
]
/** Status flags that are alerts. UNCAL_VDDA / UNCAL_ZERO are calibration state, not alerts. */
const STATUS_FLAG_TEXT: [number, string][] = [
[PDU_FLAG.TEMP_CRIT, 'TEMP CRIT'],
[PDU_FLAG.TEMP_WARN, 'TEMP WARN'],
[PDU_FLAG.NTC_FAULT, 'NTC FAULT']
]
/** Status flags that are alerts. UNCAL_VDDA / UNCAL_ZERO are calibration state (info), not alerts. */
const STATUS_ALERTS = PDU_STATUS_FLAGS.filter(([, , severity]) => severity !== 'info')

/** The alert flags a PDU raises on its fresh pages, e.g. `5V UNDER-V`. Empty = healthy. */
export function pduAlertFlags(r: PduReadings): string[] {
const out: string[] = []
const rail = (label: string, p?: PduRailPage) => {
for (const [bit, text] of RAIL_FLAG_TEXT) if (p && p.flags & bit) out.push(`${label} ${text}`)
for (const [bit, text] of RAIL_FLAGS) if (p && p.flags & bit) out.push(`${label} ${text}`)
}
rail('12V', r.rail12V)
rail('5V', r.rail5V)
for (const [bit, text] of STATUS_FLAG_TEXT) if (r.status && r.status.flags & bit) out.push(text)
for (const [bit, text] of STATUS_ALERTS) if (r.status && r.status.flags & bit) out.push(text)
return out
}

Expand Down
Loading
Loading