diff --git a/scripts/sync-a4ec.ts b/scripts/sync-a4ec.ts index d2f1dc9..824f30c 100644 --- a/scripts/sync-a4ec.ts +++ b/scripts/sync-a4ec.ts @@ -34,7 +34,7 @@ import { format } from 'prettier' const PIN = { repo: process.env.OPENSKYHAWK_REPO_URL ?? 'https://github.com/OpenSkyhawk/OpenSkyhawk.git', - commit: 'd29433ab149fcff1695e3d5eb07e2fab4211a603' + commit: 'a78dd90276fffb820b64ba74b82f354ab167348a' } const SRC = { @@ -205,18 +205,53 @@ const labelize = (name: string): string => * `` is a literal, or a sum of literals and other constants from the same source * (`CAL_MAX_FRAME = CAL_ENVELOPE_BYTES + CAL_MAX_PAYLOAD`), each resolved the same way. Anything * else (a cast, a product, a call) is not a value this parser can vouch for, so it fails the sync. + * The first term may carry a leading `-`, for the signed sentinels + * (`DIE_TEMP_UNAVAILABLE = -128`); a `-` anywhere else is a subtraction, which still fails. */ function constexprNum(src: string, name: string, where: string): number { const m = new RegExp(`constexpr\\s+\\w+\\s+${name}\\s*=\\s*([^;]+);`).exec(src) if (!m) throw new Error(`[sync] constant ${name} not found in ${where}`) - return m[1]!.split('+').reduce((sum, raw) => { - const term = raw.trim() - if (/^(0x[0-9A-Fa-f]+|\d+)$/.test(term)) return sum + Number(term) - if (/^[A-Z_][A-Z0-9_]*$/.test(term)) return sum + constexprNum(src, term, where) + return m[1]!.split('+').reduce((sum, raw, i) => { + let term = raw.trim() + let sign = 1 + if (i === 0 && term.startsWith('-')) { + sign = -1 + term = term.slice(1).trim() + } + if (/^(0x[0-9A-Fa-f]+|\d+)$/.test(term)) return sum + sign * Number(term) + if (/^[A-Z_][A-Z0-9_]*$/.test(term)) return sum + sign * constexprNum(src, term, where) throw new Error(`[sync] ${name} in ${where}: cannot evaluate '${m[1]!.trim()}'`) }, 0) } +/** + * A field-offset enum (`NodeStatusField`, `CalCommitField`, …): parseEnum, plus the checks an + * offset table must pass — strictly ascending (a field runs to the next offset) and inside + * `bytes`, the length of the row or payload it indexes. A firmware layout the decoders cannot + * read field-by-field fails the sync here rather than mis-decoding. + */ +function parseOffsets( + header: string, + enumName: string, + where: string, + bytes: number +): { name: string; value: number; comment: string }[] { + const es = parseEnum(header, enumName, where) + es.forEach((e, i) => { + if (i > 0 && e.value <= es[i - 1]!.value) { + throw new Error(`[sync] ${enumName}.${e.name} in ${where}: offsets must ascend`) + } + if (e.value >= bytes) { + throw new Error(`[sync] ${enumName}.${e.name} = ${e.value} in ${where}: past ${bytes} bytes`) + } + }) + return es +} + +/** Offset entries -> `NAME: offset, // firmware comment` lines of a name-keyed object. */ +const offsetLines = (es: { name: string; value: number; comment: string }[]): string => + es.map((e) => ` ${e.name}: ${e.value},${e.comment ? ` // ${e.comment}` : ''}`).join('\n') + /** Enum entry names -> a TS string-literal union, so name-keyed lookups are typo-checked. */ const nameUnion = (names: string[]): string => names.map(q).join(' | ') @@ -560,6 +595,21 @@ function genNodeStatus(header: string): string { .map((e) => ` ${hex(e.value)}: { name: ${q(e.name)}, description: ${q(e.comment)} }`) .join(',\n') + // Wire layout named by #346: the row offsets, flag bits and sentinels the decoders read. + const nsNum = (name: string) => constexprNum(header, name, 'NodeStatus.h') + const statusRowBytes = nsNum('NODE_STATUS_ROW_BYTES') + const statusFields = parseOffsets(header, 'NodeStatusField', 'NodeStatus.h', statusRowBytes) + const telemRowBytes = nsNum('NODE_TELEM_ROW_BYTES') + const telemFields = parseOffsets(header, 'NodeTelemField', 'NodeStatus.h', telemRowBytes) + const hbFlags = parseEnum(header, 'HeartbeatFlag', 'NodeStatus.h') + // The TELEM_n frame a PDU page lives in: the _NODE_TELEM row after its FRAME offset (8 bytes). + const frameAt = telemFields.find((e) => e.name === 'FRAME')?.value + if (frameAt === undefined) + throw new Error('[sync] NodeTelemField.FRAME not found in NodeStatus.h') + const frameBytes = telemRowBytes - frameAt + const railFields = parseOffsets(header, 'PduRailField', 'NodeStatus.h', frameBytes) + const pduStatusFields = parseOffsets(header, 'PduStatusField', 'NodeStatus.h', frameBytes) + const faultAbbr = abbrs(faultCodes.map((e) => e.name)) const flagEntries = healthFlags.map((e) => ` ${e.name}: ${hex(e.value)}`).join(',\n') const faultEntries = faultCodes @@ -586,6 +636,41 @@ export const NODE_STATUS = { endMsgName: ${q(endMsgName)} } as const +/** + * NODE_ID of the Gateway board's PanelBridge MCU, the CAN master. Its own row leads every + * _NODE_STATUS roster reply (#344); CAN nodes are 1..MAX_NODE_ID. + */ +export const GATEWAY_NODE_ID = ${nsNum('GATEWAY_NODE_ID')} + +/** _NODE_STATUS row length in bytes. Sent as hex, so the argument is twice as many chars. */ +export const NODE_STATUS_ROW_BYTES = ${statusRowBytes} + +/** + * _NODE_STATUS row field offsets, in bytes of the decoded row — from NodeStatusField + * (NodeStatus.h). Hex index = 2 × offset. Each field is fixed-width hex, most-significant byte + * first; a field runs to the next offset, the last to NODE_STATUS_ROW_BYTES. + */ +export const NODE_STATUS_FIELD = { +${offsetLines(statusFields)} +} as const + +/** HB_n \`flags\` bits (the row's HB_FLAGS field) — from HeartbeatFlag (NodeStatus.h). */ +export const HEARTBEAT_FLAGS = { +${bits(hbFlags)} +} as const + +/** HB_n \`esr\` (the row's ESR field): bit position of the transmit error counter, the low byte. */ +export const HB_ESR_TEC_SHIFT = ${nsNum('HB_ESR_TEC_SHIFT')} + +/** HB_n \`esr\`: bit position of the receive error counter, the high byte. */ +export const HB_ESR_REC_SHIFT = ${nsNum('HB_ESR_REC_SHIFT')} + +/** + * HEALTH_n \`dieTempC\` meaning "unavailable / not yet seen". Signed (int8): the row carries its + * two's-complement byte. + */ +export const DIE_TEMP_UNAVAILABLE = ${nsNum('DIE_TEMP_UNAVAILABLE')} + /** HEALTH_n \`flags\` bits — from NodeHealthFlag (NodeStatus.h). Decoder masks against these. */ export const NODE_HEALTH_FLAGS = { ${flagEntries} @@ -631,6 +716,33 @@ export const NODE_TELEM = { msgName: ${q(telemMsgName)} } as const +/** _NODE_TELEM row length in bytes. Sent as hex, so the argument is twice as many chars. */ +export const NODE_TELEM_ROW_BYTES = ${telemRowBytes} + +/** + * _NODE_TELEM row field offsets, in bytes of the decoded row — from NodeTelemField (NodeStatus.h). + * Hex index = 2 × offset. FRAME is the TELEM_n frame unread, in wire order; its byte 0 is the page. + */ +export const NODE_TELEM_FIELD = { +${offsetLines(telemFields)} +} as const + +/** + * PDU_RAIL_12V / PDU_RAIL_5V field offsets, in bytes of the TELEM_n frame — from PduRailField + * (NodeStatus.h). Little-endian; a field runs to the next offset, the last to the frame's end. + */ +export const PDU_RAIL_FIELD = { +${offsetLines(railFields)} +} as const + +/** PDU_STATUS field offsets, in bytes of the TELEM_n frame — from PduStatusField (NodeStatus.h). */ +export const PDU_STATUS_FIELD = { +${offsetLines(pduStatusFields)} +} as const + +/** PDU_STATUS \`ntcTempDC\` meaning "unavailable": no reading yet, or the NTC is open/shorted. int16. */ +export const NTC_TEMP_UNAVAILABLE = ${nsNum('NTC_TEMP_UNAVAILABLE')} + /** TELEM_n page ids — from TelemPage (NodeStatus.h). Global and append-only; 0x00 is invalid. */ export const TELEM_PAGES = { ${bits(telemPages)} @@ -748,6 +860,42 @@ function genAxisCal(header: string, cpp: string, simGateway: string): string { const lenEntries = types.map((t) => ` ${hex(t.value)}: ${lens[t.name]}`).join(',\n') + // Payload field offsets (#346): one enum per type with a payload, entries prefixed by the type + // (CAL_COMMIT_MIN). Each is bounded by its type's payload length; CAL_DATA's per-axis record by + // CAL_DATA_REC_BYTES. The prefix is stripped so the generated table reads CAL_COMMIT_FIELD.MIN. + const dataRecBytes = constexprNum(header, 'CAL_DATA_REC_BYTES', 'AxisCal.h') + const fieldTables = [ + ['CalHelloAckField', 'CAL_HELLO_ACK', lens.CAL_T_HELLO_ACK], + ['CalDataField', 'CAL_DATA', lens.CAL_T_CAL_DATA], + ['CalDataRecField', 'CAL_DATA_REC', dataRecBytes], + ['CalSessionOpenField', 'CAL_SESSION_OPEN', lens.CAL_T_SESSION_OPEN], + ['CalSessionAckField', 'CAL_SESSION_ACK', lens.CAL_T_SESSION_ACK], + ['CalStreamSelectField', 'CAL_STREAM_SELECT', lens.CAL_T_STREAM_SELECT], + ['CalCommitField', 'CAL_COMMIT', lens.CAL_T_COMMIT], + ['CalResetField', 'CAL_RESET', lens.CAL_T_RESET], + ['CalAckField', 'CAL_ACK', lens.CAL_T_ACK], + ['CalNackField', 'CAL_NACK', lens.CAL_T_NACK], + ['CalRawField', 'CAL_RAW', lens.CAL_T_RAW] + ] as const + const fieldConsts = fieldTables + .map(([enumName, prefix, bytes]) => { + if (bytes === undefined) throw new Error(`[sync] no payload length bounds ${enumName}`) + const es = parseOffsets(header, enumName, 'AxisCal.h', bytes).map((e) => { + if (!e.name.startsWith(`${prefix}_`)) { + throw new Error( + `[sync] ${enumName}.${e.name} in AxisCal.h: expected the ${prefix}_ prefix` + ) + } + return { ...e, name: e.name.slice(prefix.length + 1) } + }) + const into = enumName === 'CalDataRecField' ? 'one CAL_DATA record' : 'the payload' + return `/** ${enumName} (AxisCal.h): byte offsets into ${into}, ${bytes} byte${bytes === 1 ? '' : 's'}. */ +export const ${prefix}_FIELD = { +${offsetLines(es)} +} as const` + }) + .join('\n\n') + return ( banner([ `${SRC.axisCal} (calibration wire contract)`, @@ -780,6 +928,8 @@ export const CAL_PROTO = { maxFrame: ${constexprNum(header, 'CAL_MAX_FRAME', 'AxisCal.h')}, /** The gateway closes a calibration session idle this long. SESSION_ACK reports it too. */ sessionTimeoutMs: ${constexprNum(simGateway, 'CAL_SESSION_TIMEOUT_MS', 'SimGateway.cpp')}, + /** One CAL_DATA per-axis record, in bytes. Record i starts at CAL_DATA_FIELD.RECORDS + i × this. */ + dataRecBytes: ${dataRecBytes}, /** Frame lead-in "\\xAA S K C". Not collision-proof outbound, which is why the CRC is mandatory. */ magic: [${magic.map(hex).join(', ')}] } as const @@ -835,6 +985,11 @@ ${nackEntries} export const CAL_PAYLOAD_LEN: Partial> = { ${lenEntries} } as const + +// Payload field offsets — byte offsets into a type's payload (not the frame). Multi-byte fields +// are little-endian; a field runs to the next offset, the last to the type's payload length. + +${fieldConsts} ` ) } diff --git a/src/renderer/src/tabs/settings/settings-view.ts b/src/renderer/src/tabs/settings/settings-view.ts index d34af5e..2ebfb4c 100644 --- a/src/renderer/src/tabs/settings/settings-view.ts +++ b/src/renderer/src/tabs/settings/settings-view.ts @@ -1,4 +1,5 @@ // Settings text decisions (#67), pure so they are unit-tested: what each Cockpit / About row says. +import { GATEWAY_NODE_ID } from '@shared/firmware' import type { NodeRegistryInfo } from '@shared/ipc' import type { HiddenNode, NodeModel } from '@shared/node-rules' import { consoleLabel } from '../../cockpit-panels' @@ -80,7 +81,7 @@ export function registryOnBus( model: NodeModel | undefined, linkUp: boolean ): OnBus { - if (nodeId === 0) + if (nodeId === GATEWAY_NODE_ID) 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) diff --git a/src/shared/calibration.ts b/src/shared/calibration.ts index c4272f2..a0ae8e0 100644 --- a/src/shared/calibration.ts +++ b/src/shared/calibration.ts @@ -13,9 +13,20 @@ // where the firmware applied the offset; nothing here re-derives it. import { + CAL_ACK_FIELD, + CAL_COMMIT_FIELD, + CAL_DATA_FIELD, + CAL_DATA_REC_FIELD, + CAL_HELLO_ACK_FIELD, + CAL_NACK_FIELD, CAL_NACK_REASONS, CAL_PAYLOAD_LEN, CAL_PROTO, + CAL_RAW_FIELD, + CAL_RESET_FIELD, + CAL_SESSION_ACK_FIELD, + CAL_SESSION_OPEN_FIELD, + CAL_STREAM_SELECT_FIELD, CAL_TYPES, type CalNackName } from './firmware' @@ -295,6 +306,9 @@ export class CalDemux { } // ── decoders ───────────────────────────────────────────────────────────────── +// +// Payload offsets are the generated CAL_*_FIELD tables (AxisCal.h). Multi-byte fields are +// little-endian. const u16 = (p: Uint8Array, off: number) => p[off]! | (p[off + 1]! << 8) @@ -307,11 +321,12 @@ export interface HelloAck { } export function decodeHelloAck(p: Uint8Array): HelloAck { + const F = CAL_HELLO_ACK_FIELD return { - proto: p[0]!, - blobVersion: p[1]!, - axisSlots: p[2]!, - fw: { major: p[3]!, minor: p[4]!, patch: p[5]! } + proto: p[F.PROTO]!, + blobVersion: p[F.BLOB_VERSION]!, + axisSlots: p[F.AXIS_SLOTS]!, + fw: { major: p[F.FW_MAJOR]!, minor: p[F.FW_MINOR]!, patch: p[F.FW_PATCH]! } } } @@ -339,18 +354,19 @@ export interface CalData { } export function decodeCalData(p: Uint8Array): CalData { - const presentMask = p[0]! - const calibratedMask = p[1]! + const presentMask = p[CAL_DATA_FIELD.PRESENT_MASK]! + const calibratedMask = p[CAL_DATA_FIELD.CALIBRATED_MASK]! + const R = CAL_DATA_REC_FIELD const axes: CalAxis[] = [] for (let i = 0; i < AXIS_CAL_SLOTS; i++) { - const o = 2 + i * 10 + const o = CAL_DATA_FIELD.RECORDS + i * CAL_PROTO.dataRecBytes axes.push({ idx: i, - controlId: u16(p, o), - min: u16(p, o + 2), - centre: u16(p, o + 4), - max: u16(p, o + 6), - deadzone: u16(p, o + 8), + controlId: u16(p, o + R.CONTROL_ID), + min: u16(p, o + R.MIN), + centre: u16(p, o + R.CENTRE), + max: u16(p, o + R.MAX), + deadzone: u16(p, o + R.DEADZONE), present: (presentMask & (1 << i)) !== 0, calibrated: (calibratedMask & (1 << i)) !== 0 }) @@ -365,12 +381,16 @@ export interface SessionAck { } export function decodeSessionAck(p: Uint8Array): SessionAck { - return { timeoutMs: u16(p, 0) | (u16(p, 2) << 16), axisIdx: p[4]! } + const F = CAL_SESSION_ACK_FIELD + return { + timeoutMs: u16(p, F.TIMEOUT_MS) | (u16(p, F.TIMEOUT_MS + 2) << 16), // u32 as two u16 halves + axisIdx: p[F.AXIS_IDX]! + } } /** The type being acknowledged. */ export function decodeAck(p: Uint8Array): { type: number } { - return { type: p[0]! } + return { type: p[CAL_ACK_FIELD.TYPE]! } } export interface Nack { @@ -381,7 +401,8 @@ export interface Nack { } export function decodeNack(p: Uint8Array): Nack { - return { type: p[0]!, reason: p[1]!, detail: p[2]! } + const F = CAL_NACK_FIELD + return { type: p[F.TYPE]!, reason: p[F.REASON]!, detail: p[F.DETAIL]! } } export interface RawSample { @@ -397,13 +418,21 @@ export interface RawSample { } export function decodeRaw(p: Uint8Array): RawSample { - return { idx: p[0]!, raw: u16(p, 1), cal: u16(p, 3) } + const F = CAL_RAW_FIELD + return { idx: p[F.IDX]!, raw: u16(p, F.RAW), cal: u16(p, F.CAL) } } // ── encoders ───────────────────────────────────────────────────────────────── -const one = (type: number, seq: number, byte: number) => - buildFrame(type, seq, Uint8Array.from([byte])) +/** + * A one-field, one-byte payload: `byte` at offset `at`. Sized `at + 1`, so a firmware layout that + * moved the field off byte 0 fails buildFrame's length check instead of sending a zero. + */ +const one = (type: number, seq: number, at: number, byte: number) => { + const p = new Uint8Array(at + 1) + p[at] = byte + return buildFrame(type, seq, p) +} export const encodeHello = (seq: number) => buildFrame(CAL_TYPE.HELLO, seq) export const encodeGetCal = (seq: number) => buildFrame(CAL_TYPE.GET_CAL, seq) @@ -412,14 +441,15 @@ export const encodeKeepalive = (seq: number) => buildFrame(CAL_TYPE.KEEPALIVE, s /** Opens a session and starts RAW for `axisIdx` (CAL_AXIS_NONE for none). */ export const encodeSessionOpen = (seq: number, axisIdx: number) => - one(CAL_TYPE.SESSION_OPEN, seq, axisIdx) + one(CAL_TYPE.SESSION_OPEN, seq, CAL_SESSION_OPEN_FIELD.AXIS_IDX, axisIdx) /** Changes which axis streams mid-session. Requires an open session. */ export const encodeStreamSelect = (seq: number, axisIdx: number) => - one(CAL_TYPE.STREAM_SELECT, seq, axisIdx) + one(CAL_TYPE.STREAM_SELECT, seq, CAL_STREAM_SELECT_FIELD.AXIS_IDX, axisIdx) /** Deletes stored calibration for one axis, or all with CAL_AXIS_NONE. Persists immediately. */ -export const encodeReset = (seq: number, idx: number) => one(CAL_TYPE.RESET, seq, idx) +export const encodeReset = (seq: number, idx: number) => + one(CAL_TYPE.RESET, seq, CAL_RESET_FIELD.IDX, idx) /** * Writes one axis and persists it. Exactly one axis per COMMIT, never a batch — a batch could @@ -432,15 +462,16 @@ export function encodeCommit( seq: number, axis: { idx: number; min: number; centre: number; max: number } ): Uint8Array { - const p = new Uint8Array(9) - p[0] = axis.idx + const F = CAL_COMMIT_FIELD + const p = new Uint8Array(CAL_PAYLOAD_LEN[CAL_TYPE.COMMIT]!) + p[F.IDX] = axis.idx const put = (off: number, v: number) => { p[off] = v & 0xff p[off + 1] = (v >> 8) & 0xff } - put(1, axis.min) - put(3, axis.centre) - put(5, axis.max) - put(7, 0) + put(F.MIN, axis.min) + put(F.CENTRE, axis.centre) + put(F.MAX, axis.max) + put(F.DEADZONE, 0) return buildFrame(CAL_TYPE.COMMIT, seq, p) } diff --git a/src/shared/firmware/a4ec-controls.generated.ts b/src/shared/firmware/a4ec-controls.generated.ts index 43d23d1..b85be50 100644 --- a/src/shared/firmware/a4ec-controls.generated.ts +++ b/src/shared/firmware/a4ec-controls.generated.ts @@ -1,5 +1,5 @@ // AUTO-GENERATED by `npm run sync` (scripts/sync-a4ec.ts) — DO NOT EDIT. -// Source: https://github.com/OpenSkyhawk/OpenSkyhawk.git @ d29433ab149fcff1695e3d5eb07e2fab4211a603 +// Source: https://github.com/OpenSkyhawk/OpenSkyhawk.git @ a78dd90276fffb820b64ba74b82f354ab167348a // tools/gen_a4ec/data/A-4E-C.jsonp (313 outputs, 358 inputs) export interface A4ecOutput { diff --git a/src/shared/firmware/axis-cal.generated.ts b/src/shared/firmware/axis-cal.generated.ts index 0ac4add..4d92cc3 100644 --- a/src/shared/firmware/axis-cal.generated.ts +++ b/src/shared/firmware/axis-cal.generated.ts @@ -1,5 +1,5 @@ // AUTO-GENERATED by `npm run sync` (scripts/sync-a4ec.ts) — DO NOT EDIT. -// Source: https://github.com/OpenSkyhawk/OpenSkyhawk.git @ d29433ab149fcff1695e3d5eb07e2fab4211a603 +// Source: https://github.com/OpenSkyhawk/OpenSkyhawk.git @ a78dd90276fffb820b64ba74b82f354ab167348a // Firmware/Libraries/SimGateway/AxisCal.h (calibration wire contract) // Firmware/Libraries/SimGateway/AxisCal.cpp (payload length table) // Firmware/Libraries/SimGateway/SimGateway.cpp (calibration session timeout) @@ -29,6 +29,8 @@ export const CAL_PROTO = { maxFrame: 92, /** The gateway closes a calibration session idle this long. SESSION_ACK reports it too. */ sessionTimeoutMs: 30000, + /** One CAL_DATA per-axis record, in bytes. Record i starts at CAL_DATA_FIELD.RECORDS + i × this. */ + dataRecBytes: 10, /** Frame lead-in "\xAA S K C". Not collision-proof outbound, which is why the CRC is mandatory. */ magic: [0xaa, 0x53, 0x4b, 0x43] } as const @@ -187,3 +189,81 @@ export const CAL_PAYLOAD_LEN: Partial> = { 0x85: 3, 0x86: 5 } as const + +// Payload field offsets — byte offsets into a type's payload (not the frame). Multi-byte fields +// are little-endian; a field runs to the next offset, the last to the type's payload length. + +/** CalHelloAckField (AxisCal.h): byte offsets into the payload, 6 bytes. */ +export const CAL_HELLO_ACK_FIELD = { + PROTO: 0, // u8: CAL_PROTO_VERSION + BLOB_VERSION: 1, // u8: CAL_VERSION + AXIS_SLOTS: 2, // u8: AXIS_CAL_SLOTS + FW_MAJOR: 3, // u8: SimGateway firmware version + FW_MINOR: 4, // u8 + FW_PATCH: 5 // u8 +} as const + +/** CalDataField (AxisCal.h): byte offsets into the payload, 82 bytes. */ +export const CAL_DATA_FIELD = { + PRESENT_MASK: 0, // u8: bit i = an HIDAxis uses slot i + CALIBRATED_MASK: 1, // u8: bit i = slot i passes axisCalValid() + RECORDS: 2 // AXIS_CAL_SLOTS records of CAL_DATA_REC_BYTES, slot order +} as const + +/** CalDataRecField (AxisCal.h): byte offsets into one CAL_DATA record, 10 bytes. */ +export const CAL_DATA_REC_FIELD = { + CONTROL_ID: 0, // u16: HID controlId of the axis in this slot, 0 = absent + MIN: 2, // u16: AxisCal::min + CENTRE: 4, // u16: AxisCal::centre + MAX: 6, // u16: AxisCal::max + DEADZONE: 8 // u16: AxisCal::deadzone, always 0 +} as const + +/** CalSessionOpenField (AxisCal.h): byte offsets into the payload, 1 byte. */ +export const CAL_SESSION_OPEN_FIELD = { + AXIS_IDX: 0 // u8: axis to stream, CAL_AXIS_NONE = none +} as const + +/** CalSessionAckField (AxisCal.h): byte offsets into the payload, 5 bytes. */ +export const CAL_SESSION_ACK_FIELD = { + TIMEOUT_MS: 0, // u32: session inactivity timeout, ms + AXIS_IDX: 4 // u8: echoed streamed-axis selection +} as const + +/** CalStreamSelectField (AxisCal.h): byte offsets into the payload, 1 byte. */ +export const CAL_STREAM_SELECT_FIELD = { + AXIS_IDX: 0 // u8: axis to stream, CAL_AXIS_NONE = none +} as const + +/** CalCommitField (AxisCal.h): byte offsets into the payload, 9 bytes. */ +export const CAL_COMMIT_FIELD = { + IDX: 0, // u8: axis slot + MIN: 1, // u16 + CENTRE: 3, // u16 + MAX: 5, // u16 + DEADZONE: 7 // u16: must be 0, else NACK BAD_DEADZONE +} as const + +/** CalResetField (AxisCal.h): byte offsets into the payload, 1 byte. */ +export const CAL_RESET_FIELD = { + IDX: 0 // u8: axis slot, CAL_AXIS_NONE = all +} as const + +/** CalAckField (AxisCal.h): byte offsets into the payload, 1 byte. */ +export const CAL_ACK_FIELD = { + TYPE: 0 // u8: CalType being acknowledged +} as const + +/** CalNackField (AxisCal.h): byte offsets into the payload, 3 bytes. */ +export const CAL_NACK_FIELD = { + TYPE: 0, // u8: CalType being refused + REASON: 1, // u8: CalNackReason + DETAIL: 2 // u8: offending axis slot, else CAL_AXIS_NONE +} as const + +/** CalRawField (AxisCal.h): byte offsets into the payload, 5 bytes. */ +export const CAL_RAW_FIELD = { + IDX: 0, // u8: streamed axis slot + RAW: 1, // u16: reading before calibration + CAL: 3 // u16: the same reading through axisCalApply() +} as const diff --git a/src/shared/firmware/hid-controls.generated.ts b/src/shared/firmware/hid-controls.generated.ts index 90ad39c..81b3efe 100644 --- a/src/shared/firmware/hid-controls.generated.ts +++ b/src/shared/firmware/hid-controls.generated.ts @@ -1,5 +1,5 @@ // AUTO-GENERATED by `npm run sync` (scripts/sync-a4ec.ts) — DO NOT EDIT. -// Source: https://github.com/OpenSkyhawk/OpenSkyhawk.git @ d29433ab149fcff1695e3d5eb07e2fab4211a603 +// Source: https://github.com/OpenSkyhawk/OpenSkyhawk.git @ a78dd90276fffb820b64ba74b82f354ab167348a // Firmware/Libraries/HIDControls/HIDControls.h (9 controls) export interface HidControl { diff --git a/src/shared/firmware/hid-report-layout.generated.ts b/src/shared/firmware/hid-report-layout.generated.ts index 296289c..ca10210 100644 --- a/src/shared/firmware/hid-report-layout.generated.ts +++ b/src/shared/firmware/hid-report-layout.generated.ts @@ -1,5 +1,5 @@ // AUTO-GENERATED by `npm run sync` (scripts/sync-a4ec.ts) — DO NOT EDIT. -// Source: https://github.com/OpenSkyhawk/OpenSkyhawk.git @ d29433ab149fcff1695e3d5eb07e2fab4211a603 +// Source: https://github.com/OpenSkyhawk/OpenSkyhawk.git @ a78dd90276fffb820b64ba74b82f354ab167348a // Firmware/Libraries/SimGateway/SimGateway.cpp (HIDReport struct + descriptor) /** diff --git a/src/shared/firmware/node-bus.generated.ts b/src/shared/firmware/node-bus.generated.ts index 16282b1..cbb6e4b 100644 --- a/src/shared/firmware/node-bus.generated.ts +++ b/src/shared/firmware/node-bus.generated.ts @@ -1,5 +1,5 @@ // AUTO-GENERATED by `npm run sync` (scripts/sync-a4ec.ts) — DO NOT EDIT. -// Source: https://github.com/OpenSkyhawk/OpenSkyhawk.git @ d29433ab149fcff1695e3d5eb07e2fab4211a603 +// Source: https://github.com/OpenSkyhawk/OpenSkyhawk.git @ a78dd90276fffb820b64ba74b82f354ab167348a // Firmware/Libraries/PanelBridge/PanelBridge.cpp (MAX_NODE_ID) // Firmware/Libraries/PowerMonitor/TelemSchedule.h (TELEM_PERIOD_MS) diff --git a/src/shared/firmware/node-names.generated.ts b/src/shared/firmware/node-names.generated.ts index f8ceea1..9551cc6 100644 --- a/src/shared/firmware/node-names.generated.ts +++ b/src/shared/firmware/node-names.generated.ts @@ -1,5 +1,5 @@ // AUTO-GENERATED by `npm run sync` (scripts/sync-a4ec.ts) — DO NOT EDIT. -// Source: https://github.com/OpenSkyhawk/OpenSkyhawk.git @ d29433ab149fcff1695e3d5eb07e2fab4211a603 +// Source: https://github.com/OpenSkyhawk/OpenSkyhawk.git @ a78dd90276fffb820b64ba74b82f354ab167348a // Firmware/NODE_IDS.md (6 entries) /** CAN NODE_ID -> panel group name, from the firmware NODE_IDS.md registry. */ @@ -15,6 +15,6 @@ export const NODE_NAMES: Record = { /** Where NODE_NAMES came from, shown in Settings › About. `date` is the commit's date. */ export const NODE_REGISTRY_SOURCE = { file: 'Firmware/NODE_IDS.md', - commit: 'd29433ab149fcff1695e3d5eb07e2fab4211a603', + commit: 'a78dd90276fffb820b64ba74b82f354ab167348a', date: '2026-10-16' } diff --git a/src/shared/firmware/node-status.generated.ts b/src/shared/firmware/node-status.generated.ts index 0d1cfc6..fab602b 100644 --- a/src/shared/firmware/node-status.generated.ts +++ b/src/shared/firmware/node-status.generated.ts @@ -1,5 +1,5 @@ // AUTO-GENERATED by `npm run sync` (scripts/sync-a4ec.ts) — DO NOT EDIT. -// Source: https://github.com/OpenSkyhawk/OpenSkyhawk.git @ d29433ab149fcff1695e3d5eb07e2fab4211a603 +// Source: https://github.com/OpenSkyhawk/OpenSkyhawk.git @ a78dd90276fffb820b64ba74b82f354ab167348a // Firmware/Libraries/NodeStatus/NodeStatus.h (NODE_STATUS contract v3, NODE_TELEM contract v1) /** @@ -14,6 +14,52 @@ export const NODE_STATUS = { endMsgName: '_NODE_STATUS_END' } as const +/** + * NODE_ID of the Gateway board's PanelBridge MCU, the CAN master. Its own row leads every + * _NODE_STATUS roster reply (#344); CAN nodes are 1..MAX_NODE_ID. + */ +export const GATEWAY_NODE_ID = 0 + +/** _NODE_STATUS row length in bytes. Sent as hex, so the argument is twice as many chars. */ +export const NODE_STATUS_ROW_BYTES = 14 + +/** + * _NODE_STATUS row field offsets, in bytes of the decoded row — from NodeStatusField + * (NodeStatus.h). Hex index = 2 × offset. Each field is fixed-width hex, most-significant byte + * first; a field runs to the next offset, the last to NODE_STATUS_ROW_BYTES. + */ +export const NODE_STATUS_FIELD = { + ID: 0, // u8: node ID; GATEWAY_NODE_ID = the bridge's own row, 1-63 = CAN nodes + PRESENT: 1, // u8: 01 alive, 00 removed + HB_FLAGS: 2, // u8: HeartbeatFlag bits + UPTIME: 3, // u16: seconds since boot, wraps at 65535 + RX_COUNT: 5, // u16: node-owned accepted RX count, wraps at 65535 + ESR: 7, // u16: TEC at HB_ESR_TEC_SHIFT, REC at HB_ESR_REC_SHIFT + DIE_TEMP_C: 9, // i8: whole degC, DIE_TEMP_UNAVAILABLE = not seen + HEALTH_FLAGS: 10, // u8: NodeHealthFlag bits + FAULT_MASK: 11, // u8: reserved, 00 + FAULT_ID: 12, // u8: NodeFaultCode + NODE_KIND: 13 // u8: NodeKind +} as const + +/** HB_n `flags` bits (the row's HB_FLAGS field) — from HeartbeatFlag (NodeStatus.h). */ +export const HEARTBEAT_FLAGS = { + BOFF: 0x1, + EPVF: 0x2 +} as const + +/** HB_n `esr` (the row's ESR field): bit position of the transmit error counter, the low byte. */ +export const HB_ESR_TEC_SHIFT = 0 + +/** HB_n `esr`: bit position of the receive error counter, the high byte. */ +export const HB_ESR_REC_SHIFT = 8 + +/** + * HEALTH_n `dieTempC` meaning "unavailable / not yet seen". Signed (int8): the row carries its + * two's-complement byte. + */ +export const DIE_TEMP_UNAVAILABLE = -128 + /** HEALTH_n `flags` bits — from NodeHealthFlag (NodeStatus.h). Decoder masks against these. */ export const NODE_HEALTH_FLAGS = { OVERHEAT: 0x1, @@ -127,6 +173,40 @@ export const NODE_TELEM = { msgName: '_NODE_TELEM' } as const +/** _NODE_TELEM row length in bytes. Sent as hex, so the argument is twice as many chars. */ +export const NODE_TELEM_ROW_BYTES = 9 + +/** + * _NODE_TELEM row field offsets, in bytes of the decoded row — from NodeTelemField (NodeStatus.h). + * Hex index = 2 × offset. FRAME is the TELEM_n frame unread, in wire order; its byte 0 is the page. + */ +export const NODE_TELEM_FIELD = { + ID: 0, // u8: sending node ID (from the CAN ID) + FRAME: 1 // 8 bytes: the TELEM_n frame unread, wire order (TelemPage at FRAME + 0) +} as const + +/** + * PDU_RAIL_12V / PDU_RAIL_5V field offsets, in bytes of the TELEM_n frame — from PduRailField + * (NodeStatus.h). Little-endian; a field runs to the next offset, the last to the frame's end. + */ +export const PDU_RAIL_FIELD = { + FLAGS: 1, // u8: PduRailFlag bits + V_AVG_MV: 2, // u16: window-average rail voltage, mV + V_MIN_MV: 4, // u16: window-minimum rail voltage, mV + I_AVG_MA: 6 // int16: window-average rail current, mA (signed: zero offset stays visible) +} as const + +/** PDU_STATUS field offsets, in bytes of the TELEM_n frame — from PduStatusField (NodeStatus.h). */ +export const PDU_STATUS_FIELD = { + FLAGS: 1, // u8: PduStatusFlag bits + NTC_TEMP_DC: 2, // int16: board temperature, 0.1 degC (NTC_TEMP_UNAVAILABLE = no reading) + I12_PEAK_MA: 4, // u16: 12 V rail peak current since the last page, mA + I5_PEAK_MA: 6 // u16: 5 V rail peak current since the last page, mA +} as const + +/** PDU_STATUS `ntcTempDC` meaning "unavailable": no reading yet, or the NTC is open/shorted. int16. */ +export const NTC_TEMP_UNAVAILABLE = -32768 + /** TELEM_n page ids — from TelemPage (NodeStatus.h). Global and append-only; 0x00 is invalid. */ export const TELEM_PAGES = { PDU_RAIL_12V: 0x1, diff --git a/src/shared/firmware/reference.test.ts b/src/shared/firmware/reference.test.ts index cbf726a..6bf5e06 100644 --- a/src/shared/firmware/reference.test.ts +++ b/src/shared/firmware/reference.test.ts @@ -14,6 +14,29 @@ import { NODE_NAMES, CAL_PROTO, CAL_TYPES, + CAL_ACK_FIELD, + CAL_COMMIT_FIELD, + CAL_DATA_FIELD, + CAL_DATA_REC_FIELD, + CAL_HELLO_ACK_FIELD, + CAL_NACK_FIELD, + CAL_RAW_FIELD, + CAL_RESET_FIELD, + CAL_SESSION_ACK_FIELD, + CAL_SESSION_OPEN_FIELD, + CAL_STREAM_SELECT_FIELD, + DIE_TEMP_UNAVAILABLE, + GATEWAY_NODE_ID, + HB_ESR_REC_SHIFT, + HB_ESR_TEC_SHIFT, + HEARTBEAT_FLAGS, + NODE_STATUS_FIELD, + NODE_STATUS_ROW_BYTES, + NODE_TELEM_FIELD, + NODE_TELEM_ROW_BYTES, + NTC_TEMP_UNAVAILABLE, + PDU_RAIL_FIELD, + PDU_STATUS_FIELD, CAL_PAYLOAD_LEN, MAX_NODE_ID, TELEM_PERIOD_MS, @@ -176,6 +199,35 @@ describe('node-status.generated', () => { const tags = Object.values(NODE_FAULT_CODES).map((e) => e?.abbr) expect(new Set(tags).size).toBe(tags.length) }) + + // shared/nodes.ts decodes the row by these names (#346). Pinned at the values the decoder was + // proven against, so a firmware relayout fails here before it mis-decodes a node. + it('pins the _NODE_STATUS row layout the decoder reads', () => { + expect(GATEWAY_NODE_ID).toBe(0) + expect(NODE_STATUS_ROW_BYTES).toBe(14) + expect(NODE_STATUS_FIELD).toEqual({ + ID: 0, + PRESENT: 1, + HB_FLAGS: 2, + UPTIME: 3, + RX_COUNT: 5, + ESR: 7, + DIE_TEMP_C: 9, + HEALTH_FLAGS: 10, + FAULT_MASK: 11, + FAULT_ID: 12, + NODE_KIND: 13 + }) + }) + + it('pins the heartbeat flag bits, the ESR split and the die-temperature sentinel', () => { + expect(HEARTBEAT_FLAGS).toEqual({ BOFF: 0x01, EPVF: 0x02 }) + expect(HB_ESR_TEC_SHIFT).toBe(0) + expect(HB_ESR_REC_SHIFT).toBe(8) + // Signed int8; the row carries its byte, 0x80. + expect(DIE_TEMP_UNAVAILABLE).toBe(-128) + expect(DIE_TEMP_UNAVAILABLE & 0xff).toBe(0x80) + }) }) // shared/node-telem.ts imports its page ids and flag bits from here; only the decoder's protocol @@ -184,6 +236,16 @@ describe('node-telem contract', () => { it('matches the shared decoder (fails loudly on a firmware bump)', () => { expect(NODE_TELEM.protoVersion).toBe(SUPPORTED_TELEM_PROTO) }) + + it('pins the _NODE_TELEM framing and the PDU page offsets the decoder reads (#346)', () => { + expect(NODE_TELEM_ROW_BYTES).toBe(9) + expect(NODE_TELEM_FIELD).toEqual({ ID: 0, FRAME: 1 }) + expect(PDU_RAIL_FIELD).toEqual({ FLAGS: 1, V_AVG_MV: 2, V_MIN_MV: 4, I_AVG_MA: 6 }) + expect(PDU_STATUS_FIELD).toEqual({ FLAGS: 1, NTC_TEMP_DC: 2, I12_PEAK_MA: 4, I5_PEAK_MA: 6 }) + // Signed int16; the frame carries 0x8000. + expect(NTC_TEMP_UNAVAILABLE).toBe(-32768) + expect(NTC_TEMP_UNAVAILABLE & 0xffff).toBe(0x8000) + }) }) // shared/calibration.ts imports these from here. The codec's framing and payload decoders are @@ -216,6 +278,32 @@ describe('axis-cal.generated', () => { } }) + it('pins the payload field offsets the codec reads and writes (#346)', () => { + expect(CAL_HELLO_ACK_FIELD).toEqual({ + PROTO: 0, + BLOB_VERSION: 1, + AXIS_SLOTS: 2, + FW_MAJOR: 3, + FW_MINOR: 4, + FW_PATCH: 5 + }) + expect(CAL_DATA_FIELD).toEqual({ PRESENT_MASK: 0, CALIBRATED_MASK: 1, RECORDS: 2 }) + expect(CAL_PROTO.dataRecBytes).toBe(10) + expect(CAL_DATA_REC_FIELD).toEqual({ CONTROL_ID: 0, MIN: 2, CENTRE: 4, MAX: 6, DEADZONE: 8 }) + // CAL_DATA is exactly the two masks and the eight records. + expect(CAL_DATA_FIELD.RECORDS + CAL_PROTO.axisSlots * CAL_PROTO.dataRecBytes).toBe( + CAL_PROTO.maxPayload + ) + expect(CAL_SESSION_OPEN_FIELD).toEqual({ AXIS_IDX: 0 }) + expect(CAL_SESSION_ACK_FIELD).toEqual({ TIMEOUT_MS: 0, AXIS_IDX: 4 }) + expect(CAL_STREAM_SELECT_FIELD).toEqual({ AXIS_IDX: 0 }) + expect(CAL_COMMIT_FIELD).toEqual({ IDX: 0, MIN: 1, CENTRE: 3, MAX: 5, DEADZONE: 7 }) + expect(CAL_RESET_FIELD).toEqual({ IDX: 0 }) + expect(CAL_ACK_FIELD).toEqual({ TYPE: 0 }) + expect(CAL_NACK_FIELD).toEqual({ TYPE: 0, REASON: 1, DETAIL: 2 }) + expect(CAL_RAW_FIELD).toEqual({ IDX: 0, RAW: 1, CAL: 3 }) + }) + it('COMMIT carries exactly one axis — 9 bytes, never a batch', () => { // The batch form allowed a message to name the same axis twice and silently apply the last. // If this length ever grows, encodeCommit() is wrong in kind, not merely short. diff --git a/src/shared/firmware/usb-identity.generated.ts b/src/shared/firmware/usb-identity.generated.ts index 57a5d41..4ba5734 100644 --- a/src/shared/firmware/usb-identity.generated.ts +++ b/src/shared/firmware/usb-identity.generated.ts @@ -1,5 +1,5 @@ // AUTO-GENERATED by `npm run sync` (scripts/sync-a4ec.ts) — DO NOT EDIT. -// Source: https://github.com/OpenSkyhawk/OpenSkyhawk.git @ d29433ab149fcff1695e3d5eb07e2fab4211a603 +// Source: https://github.com/OpenSkyhawk/OpenSkyhawk.git @ a78dd90276fffb820b64ba74b82f354ab167348a // Firmware/Libraries/SimGateway/SimGateway.cpp (SimGateway::setup USB identity) /** diff --git a/src/shared/node-telem.ts b/src/shared/node-telem.ts index c673d5e..5460e3b 100644 --- a/src/shared/node-telem.ts +++ b/src/shared/node-telem.ts @@ -2,7 +2,18 @@ // Wire contract: NodeStatus.h NODE_TELEM_* / TelemPage / PduRailFlag / PduStatusFlag (generated // into shared/firmware; reference.test asserts the decoder's protoVersion matches). Page layouts: // FirmwarePlan/02-can-protocol.md § Node Telemetry Payload — TELEM_n (#216). -import { MAX_NODE_ID, NODE_TELEM, PDU_RAIL_FLAGS, PDU_STATUS_FLAGS, TELEM_PAGES } from './firmware' +import { + MAX_NODE_ID, + NODE_TELEM, + NODE_TELEM_FIELD, + NODE_TELEM_ROW_BYTES, + NTC_TEMP_UNAVAILABLE, + PDU_RAIL_FIELD, + PDU_RAIL_FLAGS, + PDU_STATUS_FIELD, + PDU_STATUS_FLAGS, + TELEM_PAGES +} from './firmware' /** protoVersion this decoder is written for. reference.test asserts the firmware matches. */ export const SUPPORTED_TELEM_PROTO = 1 @@ -27,8 +38,8 @@ export const PDU_FLAG = PDU_STATUS_FLAGS */ export const TELEM_STALE_MS = 10_000 -const HEX_LEN = 18 // nodeId(2) + 8 frame bytes (16), frame bytes in wire order -const NTC_UNAVAILABLE = -0x8000 // INT16_MIN +// NODE_TELEM_ROW_BYTES (9) as hex, 18 chars: nodeId + the 8 frame bytes in wire order. +const HEX_LEN = NODE_TELEM_ROW_BYTES * 2 /** * PDU_RAIL_12V / PDU_RAIL_5V. Averages and the minimum cover the window since this page's @@ -85,10 +96,12 @@ export function displayCurrentMa(ma: number): number { /** Decode the 18-hex `_NODE_TELEM` argument; null if malformed, nodeId out of range or page 0. */ export function parseNodeTelem(hex: string): NodeTelem | null { - if (hex.length !== HEX_LEN || !/^[0-9a-fA-F]{18}$/.test(hex)) return null - const nodeId = parseInt(hex.slice(0, 2), 16) + if (hex.length !== HEX_LEN || !/^[0-9a-fA-F]+$/.test(hex)) return null + // Row byte `at` (NODE_TELEM_FIELD offset): hex index 2 × at. + const row = (at: number) => parseInt(hex.slice(at * 2, at * 2 + 2), 16) + const nodeId = row(NODE_TELEM_FIELD.ID) if (nodeId < 1 || nodeId > MAX_NODE_ID) return null - const b = (i: number) => parseInt(hex.slice(2 + i * 2, 4 + i * 2), 16) // frame byte i + const b = (i: number) => row(NODE_TELEM_FIELD.FRAME + i) // frame byte i; byte 0 is the page const u16 = (i: number) => b(i) | (b(i + 1) << 8) // little-endian const i16 = (i: number) => { const v = u16(i) @@ -101,25 +114,31 @@ export function parseNodeTelem(hex: string): NodeTelem | null { nodeId, kind: 'rail', page, - flags: b(1), - vAvgMv: u16(2), - vMinMv: u16(4), - iAvgMa: i16(6) + flags: b(PDU_RAIL_FIELD.FLAGS), + vAvgMv: u16(PDU_RAIL_FIELD.V_AVG_MV), + vMinMv: u16(PDU_RAIL_FIELD.V_MIN_MV), + iAvgMa: i16(PDU_RAIL_FIELD.I_AVG_MA) } } if (page === TELEM_PAGE.PDU_STATUS) { - const t = i16(2) + const t = i16(PDU_STATUS_FIELD.NTC_TEMP_DC) return { nodeId, kind: 'status', page, - flags: b(1), - ntcTempC: t === NTC_UNAVAILABLE ? null : t / 10, - i12PeakMa: u16(4), - i5PeakMa: u16(6) + flags: b(PDU_STATUS_FIELD.FLAGS), + ntcTempC: t === NTC_TEMP_UNAVAILABLE ? null : t / 10, // the int16 sentinel, 0x8000 on the wire + i12PeakMa: u16(PDU_STATUS_FIELD.I12_PEAK_MA), + i5PeakMa: u16(PDU_STATUS_FIELD.I5_PEAK_MA) } } - return { nodeId, kind: 'unknown', page, body: hex.slice(4).toLowerCase() } + // Frame bytes 1–7: everything after the page byte. + return { + nodeId, + kind: 'unknown', + page, + body: hex.slice((NODE_TELEM_FIELD.FRAME + 1) * 2).toLowerCase() + } } /** diff --git a/src/shared/nodes.ts b/src/shared/nodes.ts index 79cab29..91afbac 100644 --- a/src/shared/nodes.ts +++ b/src/shared/nodes.ts @@ -2,7 +2,19 @@ // Wire contract: NodeStatus.h NODE_STATUS_* (generated into shared/firmware; reference.test // asserts the decoder's protoVersion matches). import { encodeExportFrame } from './dcsbios' -import { MAX_NODE_ID, NODE_HEALTH_FLAGS, NODE_KINDS, NODE_STATUS } from './firmware' +import { + DIE_TEMP_UNAVAILABLE, + GATEWAY_NODE_ID, + HB_ESR_REC_SHIFT, + HB_ESR_TEC_SHIFT, + HEARTBEAT_FLAGS, + MAX_NODE_ID, + NODE_HEALTH_FLAGS, + NODE_KINDS, + NODE_STATUS, + NODE_STATUS_FIELD, + NODE_STATUS_ROW_BYTES +} from './firmware' /** protoVersion this decoder is written for. reference.test asserts the firmware matches. */ export const SUPPORTED_NODE_PROTO = 3 @@ -20,14 +32,18 @@ export const HFLAG_DEGRADED = NODE_HEALTH_FLAGS.DEGRADED export const NODE_KIND = NODE_KINDS /** - * The bridge's own NODE_ID: PanelBridge is the CAN master at address 0, and since v3 every roster - * reply starts with its row. That row is this client's own link, never a cockpit node. (An address, - * not a board type: a node's type is only ever what it reports.) + * The bridge's own NODE_ID (GATEWAY_NODE_ID in the firmware reference): PanelBridge is the CAN + * master at address 0, and since v3 every roster reply starts with its row. That row is this + * client's own link, never a cockpit node. (An address, not a board type: a node's type is only + * ever what it reports.) */ -export const BRIDGE_NODE_ID = 0 +export const BRIDGE_NODE_ID = GATEWAY_NODE_ID -// proto v3: 28 hex — v2 plus the node's reported board kind (#344). -const HEX_LEN = 28 // nodeId(2) present(2) flags(2) uptime(4) rxCount(4) esr(4) dieTempC(2) hFlags(2) faultMask(2) faultId(2) nodeKind(2) +// proto v3: NODE_STATUS_ROW_BYTES (14) as hex, 28 chars — v2 plus the node's reported board kind +// (#344). Field offsets: NODE_STATUS_FIELD; each byte is two hex chars. +const HEX_LEN = NODE_STATUS_ROW_BYTES * 2 +// The legacy v2 row (26 hex), detected client-side only to say "this bridge is too old". The +// firmware no longer describes it, so it has nothing to sync from: it stays a client constant. const V2_HEX_LEN = 26 export interface NodeStatus { @@ -40,7 +56,7 @@ export interface NodeStatus { tec: number // transmit error counter (esr low byte) rec: number // receive error counter (esr high byte) // HEALTH_n telemetry (proto v2). Uncalibrated internal MCU sensor — die temp, not ambient. - dieTempC: number | null // whole °C; null = not yet seen (sentinel 0x80) + dieTempC: number | null // whole °C; null = not yet seen (DIE_TEMP_UNAVAILABLE, 0x80 on the wire) overheat: boolean // hFlags bit0 (opt-in firmware trip; usually false) degraded: boolean // hFlags bit1 — a registered FaultSource reports non-NONE (rendered by #40, parsed here) faultId: number // fault code, 0 = none; label via NODE_FAULT_CODES (node-status.generated.ts), render #40 @@ -61,28 +77,31 @@ export function nodeFaultTag(n: NodeStatus): string { /** Decode the 28-hex v3 `_NODE_STATUS` argument; null if malformed / nodeId out of range. */ export function parseNodeStatus(hex: string): NodeStatus | null { - if (hex.length !== HEX_LEN || !/^[0-9a-fA-F]{28}$/.test(hex)) return null - const f = (i: number, n: number) => parseInt(hex.slice(i, i + n), 16) - const nodeId = f(0, 2) + if (hex.length !== HEX_LEN || !/^[0-9a-fA-F]+$/.test(hex)) return null + const F = NODE_STATUS_FIELD + // A field at byte offset `at`, `bytes` wide: hex index 2 × at, most-significant byte first. + const f = (at: number, bytes: number) => parseInt(hex.slice(at * 2, (at + bytes) * 2), 16) + const nodeId = f(F.ID, 1) if (nodeId > MAX_NODE_ID) return null - const flags = f(4, 2) - const esr = f(14, 4) - const traw = f(18, 2) // int8 two's-complement; 0x80 = not-yet-seen sentinel - const hf = f(20, 2) // byte 22 (faultMask) reserved/unused; skipped + const flags = f(F.HB_FLAGS, 1) + const esr = f(F.ESR, 2) + const traw = f(F.DIE_TEMP_C, 1) // int8 two's-complement + const temp = traw < 128 ? traw : traw - 256 + const hf = f(F.HEALTH_FLAGS, 1) // FAULT_MASK (the next byte) is reserved/unused; skipped return { nodeId, - present: f(2, 2) === 1, - boff: (flags & 0x01) !== 0, - epvf: (flags & 0x02) !== 0, - uptimeSec: f(6, 4), - rxCount: f(10, 4), - tec: esr & 0xff, - rec: (esr >> 8) & 0xff, - dieTempC: traw === 0x80 ? null : traw < 128 ? traw : traw - 256, + present: f(F.PRESENT, 1) === 1, + boff: (flags & HEARTBEAT_FLAGS.BOFF) !== 0, + epvf: (flags & HEARTBEAT_FLAGS.EPVF) !== 0, + uptimeSec: f(F.UPTIME, 2), + rxCount: f(F.RX_COUNT, 2), + tec: (esr >> HB_ESR_TEC_SHIFT) & 0xff, + rec: (esr >> HB_ESR_REC_SHIFT) & 0xff, + dieTempC: temp === DIE_TEMP_UNAVAILABLE ? null : temp, // the int8 sentinel, -128 (0x80) overheat: (hf & HFLAG_OVERHEAT) !== 0, degraded: (hf & HFLAG_DEGRADED) !== 0, - faultId: f(24, 2), - nodeKind: f(26, 2) + faultId: f(F.FAULT_ID, 1), + nodeKind: f(F.NODE_KIND, 1) } }