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
2 changes: 1 addition & 1 deletion .claude/skills/firmware/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Open the one relevant doc via the maps below rather than reading everything.
- `10-implementation-plan.md` / `11-open-issues.md` — phased plan / deferred items

**TechSpec:** one file per library/class (`CANProtocol`, `STM32Board`, `PanelBridge`,
`SimGateway`, `HIDControls`, `A4ECGenerator`, and `PanelGroup/{PinRef, PanelGroup,
`SimGateway`, `HIDControls`, `A4ECGenerator`, `PowerMonitor` (the PDU node), and `PanelGroup/{PinRef, PanelGroup,
Inputs/*, Outputs/*}`). Each cites its FirmwarePlan section. See `TechSpec/README.md`.

**Availability guard:** these specs are committed but may be absent in a partial checkout —
Expand Down
22 changes: 22 additions & 0 deletions .github/firmware-platformio-impact.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
"Firmware/Examples/E2E_DCS_Test/PanelGroup",
"Firmware/Examples/E2E_DCS_Test/PanelGroupInputs",
"Firmware/Panels/Center_Armament",
"Firmware/Panels/PDU_Center",
"Firmware/Panels/PDU_Left",
"Firmware/Panels/PDU_Right",
"Firmware/Templates/PanelBridge",
"Firmware/Templates/PanelGroup",
"Firmware/Tests/ActionButton",
Expand All @@ -38,6 +41,7 @@
"Firmware/Tests/PanelBridge",
"Firmware/Tests/PanelGroup",
"Firmware/Tests/PinRef",
"Firmware/Tests/PowerMonitor",
"Firmware/Tests/RotaryAcceleratedEncoder",
"Firmware/Tests/RotaryEncoder",
"Firmware/Tests/STM32Board",
Expand All @@ -58,6 +62,9 @@
"Firmware/Examples/E2E_DCS_Test/PanelGroupInputs",
"Firmware/Examples/E2E_DCS_Test/SimGateway",
"Firmware/Panels/Center_Armament",
"Firmware/Panels/PDU_Center",
"Firmware/Panels/PDU_Left",
"Firmware/Panels/PDU_Right",
"Firmware/Templates/PanelBridge",
"Firmware/Templates/PanelGroup",
"Firmware/Templates/SimGateway",
Expand All @@ -74,6 +81,7 @@
"Firmware/Tests/PanelBridge",
"Firmware/Tests/PanelGroup",
"Firmware/Tests/PinRef",
"Firmware/Tests/PowerMonitor",
"Firmware/Tests/RotaryAcceleratedEncoder",
"Firmware/Tests/RotaryEncoder",
"Firmware/Tests/STM32Board",
Expand All @@ -90,6 +98,9 @@
"Firmware/Examples/E2E_DCS_Test/PanelGroup",
"Firmware/Examples/E2E_DCS_Test/PanelGroupInputs",
"Firmware/Panels/Center_Armament",
"Firmware/Panels/PDU_Center",
"Firmware/Panels/PDU_Left",
"Firmware/Panels/PDU_Right",
"Firmware/Templates/PanelBridge",
"Firmware/Templates/PanelGroup",
"Firmware/Tests/ActionButton",
Expand All @@ -106,6 +117,7 @@
"Firmware/Tests/PanelBridge",
"Firmware/Tests/PanelGroup",
"Firmware/Tests/PinRef",
"Firmware/Tests/PowerMonitor",
"Firmware/Tests/RotaryAcceleratedEncoder",
"Firmware/Tests/RotaryEncoder",
"Firmware/Tests/STM32Board",
Expand Down Expand Up @@ -146,11 +158,20 @@
"Firmware/Tests/SwitchMultiPos",
"Firmware/Tests/SwitchWithCover2Pos"
],
"PowerMonitor": [
"Firmware/Panels/PDU_Center",
"Firmware/Panels/PDU_Left",
"Firmware/Panels/PDU_Right",
"Firmware/Tests/PowerMonitor"
],
"STM32Board": [
"Firmware/Examples/E2E_DCS_Test/PanelBridge",
"Firmware/Examples/E2E_DCS_Test/PanelGroup",
"Firmware/Examples/E2E_DCS_Test/PanelGroupInputs",
"Firmware/Panels/Center_Armament",
"Firmware/Panels/PDU_Center",
"Firmware/Panels/PDU_Left",
"Firmware/Panels/PDU_Right",
"Firmware/Templates/PanelBridge",
"Firmware/Templates/PanelGroup",
"Firmware/Tests/ActionButton",
Expand All @@ -167,6 +188,7 @@
"Firmware/Tests/PanelBridge",
"Firmware/Tests/PanelGroup",
"Firmware/Tests/PinRef",
"Firmware/Tests/PowerMonitor",
"Firmware/Tests/RotaryAcceleratedEncoder",
"Firmware/Tests/RotaryEncoder",
"Firmware/Tests/STM32Board",
Expand Down
25 changes: 24 additions & 1 deletion Firmware/Libraries/CANProtocol/CANProtocol.h
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ struct __attribute__((packed)) HeartbeatPayload {
* measures DIE temperature (not ambient) with a self-heat offset. Use for relative
* trend and per-node overheat flagging, not precise ambient measurement.
* @note Rail voltage/current is intentionally NOT here — that is the PDU's dedicated power
* telemetry (#202, real INA226 sensors), not generic per-node health.
* telemetry (TELEM_n / TelemPayload, #216; INA180A2 current sense on #202), not generic
* per-node health.
*/
struct __attribute__((packed)) NodeHealthPayload {
uint8_t nodeId; ///< 0: node ID — redundant with CAN ID, aids logging
Expand All @@ -82,6 +83,23 @@ struct __attribute__((packed)) NodeHealthPayload {
uint8_t rsvd[3]; ///< 5-7: reserved, transmit 0 — future generic health (resetCause, freeRAM, …)
};

/**
* @brief 8-byte payload carried by TELEM_n frames — paged node telemetry (#216).
*
* Byte 0 is a TelemPage (NodeStatus.h) naming the layout of the other seven bytes; page ids are
* global and append-only, so a page means the same thing whichever node sends it. Multi-byte
* fields inside a page are little-endian. PanelBridge forwards every TELEM_n frame to the host
* unread (`_NODE_TELEM`), so a new page type needs no bridge change — the client owns the decode.
*
* First user: the PDU monitoring node — pages PDU_RAIL_12V / PDU_RAIL_5V / PDU_STATUS. Generic
* node health (die temp, fault id) stays in HEALTH_n and is never repeated here.
*/
struct __attribute__((packed)) TelemPayload {
uint8_t page; ///< 0: TelemPage (NodeStatus.h); 0x00 is invalid
uint8_t body[7]; ///< 1-7: page-defined, little-endian multi-byte fields
};
static_assert(sizeof(TelemPayload) == 8, "TelemPayload must fill one classic CAN frame");

/** @brief CAN bus health states. Reported to STM32Board via onStatusChange(). */
enum class CanStatus {
STARTING, ///< CAN peripheral configured, not yet started
Expand All @@ -105,6 +123,11 @@ constexpr uint32_t canIdHb(uint8_t n) { return 0x100 + n; }
* Carries NodeHealthPayload (internal die temp; degraded/faultId once #163 lands). */
constexpr uint32_t canIdHealth(uint8_t n) { return 0x140 + n; }

/** @brief Paged node-telemetry frame ID for node n (TelemPayload, #216). Range 0x181-0x1BF.
* Sits with HB (0x100) and HEALTH (0x140) in the node-status block; PanelBridge forwards
* it to the host unread as `_NODE_TELEM`. */
constexpr uint32_t canIdTelem(uint8_t n) { return 0x180 + n; }

/** @brief Input event frame ID for node n. Range 0x201-0x23F. */
constexpr uint32_t canIdEvt(uint8_t n) { return 0x200 + n; }

Expand Down
51 changes: 50 additions & 1 deletion Firmware/Libraries/NodeStatus/NodeStatus.h
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
* names) — **the canonical source the client's `sync-a4ec.ts` parses**;
* - `NodeHealthFlag` (HEALTH_n flag bits) and `NodeFaultCode` (the compact wire faultId dictionary
* the client maps to labels);
* - the `_NODE_TELEM` host contract and the TELEM_n page vocabulary (`TelemPage`, `PduRailFlag`,
* `PduStatusFlag`) — #216;
* - `FaultSource` — the interface a fault-producing object implements so a node-level aggregator
* can roll it up. A `FaultSource` is *one piece* of node status, not the whole story.
*
Expand All @@ -31,6 +33,15 @@
#define NODE_STATUS_MSG_NAME "_NODE_STATUS"
#define NODE_STATUS_END_MSG_NAME "_NODE_STATUS_END"

// ── _NODE_TELEM DCS-BIOS host-reporting contract (#216) ───────────────────────
// PanelBridge forwards every TELEM_n CAN frame to the host unread, as one DCS-BIOS message per
// frame: `_NODE_TELEM <nodeId(2 hex)><8 frame bytes as 16 hex, wire order>` (18 hex chars). Pushed
// as frames arrive — no request, no cache, no end marker. Versioned separately from
// _NODE_STATUS: a new TelemPage is additive and needs no bump; bump on a change to the message
// framing or to an existing page's layout. Full page layouts: FirmwarePlan/02-can-protocol.md.
#define NODE_TELEM_PROTO_VERSION 1
#define NODE_TELEM_MSG_NAME "_NODE_TELEM"

/** @brief HEALTH_n `flags` bits — node-level health conditions (maps to NodeHealthPayload.flags). */
enum class NodeHealthFlag : uint8_t {
OVERHEAT = 0x01, // die temp >= NODE_OVERHEAT_C (opt-in, #213)
Expand All @@ -51,7 +62,45 @@ enum class NodeFaultCode : uint8_t {
UNDER_VOLTAGE = 0x03, // rail under-voltage (PDU)
SHORT_CIRCUIT = 0x04, // rail short / over-current (PDU)
HOST_LINK_LOST = 0x05, // PanelBridge lost the host serial link
// 0x06–0xFF reserved for future fault sources
FUSE_OPEN = 0x06, // rail fuse blown — rail read ~0 V while the node is powered (PDU)
OVER_TEMPERATURE = 0x07, // board temperature at the critical threshold (PDU NTC)
SENSOR_FAULT = 0x08, // a measurement channel reads implausibly (railed / open / shorted)
// 0x09–0xFF reserved for future fault sources
};

/**
* @brief TELEM_n page ids (#216) — byte 0 of TelemPayload, naming the layout of bytes 1-7.
*
* Global and append-only: a page id means the same layout whichever node sends it, so the client
* decodes by page, not by node type. 0x00 is invalid. Layouts (little-endian):
* PDU_RAIL_12V / PDU_RAIL_5V: [1] PduRailFlag bits · [2-3] vAvgMv u16 · [4-5] vMinMv u16 ·
* [6-7] iAvgMa int16
* PDU_STATUS: [1] PduStatusFlag bits · [2-3] ntcTempDC int16 (0.1 °C,
* INT16_MIN = unavailable) · [4-5] i12PeakMa u16 · [6-7] i5PeakMa u16
*/
enum class TelemPage : uint8_t {
INVALID = 0x00, // never sent
PDU_RAIL_12V = 0x01, // PDU 12 V rail: flags, avg + min voltage, avg current
PDU_RAIL_5V = 0x02, // PDU 5 V rail: flags, avg + min voltage, avg current
PDU_STATUS = 0x03, // PDU board: flags, NTC temperature, per-rail peak current
};

/** @brief PDU_RAIL_* page byte 1 — per-rail condition bits (all zero = healthy). */
enum class PduRailFlag : uint8_t {
UNDER_V = 0x01, // rail below its under-voltage threshold
OVER_V = 0x02, // rail above its over-voltage threshold
OVER_CURRENT = 0x04, // sustained current above the over-current threshold
FUSE_OPEN = 0x08, // rail read ~0 V while the node is powered — fuse blown
SENSOR_FAULT = 0x10, // voltage or current channel reads implausibly (railed)
};

/** @brief PDU_STATUS page byte 1 — board condition bits (all zero = healthy, calibrated). */
enum class PduStatusFlag : uint8_t {
UNCAL_VDDA = 0x01, // no VDDA calibration stored — readings assume a nominal 3.3 V
UNCAL_ZERO = 0x02, // no current zero-offset calibration stored
TEMP_WARN = 0x04, // board temperature at the warning threshold
TEMP_CRIT = 0x08, // board temperature at the critical threshold
NTC_FAULT = 0x10, // NTC channel open or shorted
};

namespace OpenSkyhawk {
Expand Down
22 changes: 22 additions & 0 deletions Firmware/Libraries/PanelBridge/PanelBridge.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,18 @@ static void emitAllNodes() {
snprintf(arg, sizeof(arg), "%u", (unsigned)count);
DcsBios::sendDcsBiosMessage(NODE_STATUS_END_MSG_NAME, arg);
}

// Forward one TELEM_n frame to the host, unread (#216):
// _NODE_TELEM <nodeId(2 hex)><8 frame bytes, wire order, 16 hex>
// No cache and no decode — the page byte tells the client the layout, so a new TelemPage needs no
// bridge change. Contract: NODE_TELEM_* in NodeStatus.h.
static void emitTelem(uint8_t nodeId, const uint8_t* data) {
char hex[19];
snprintf(hex, sizeof(hex), "%02X%02X%02X%02X%02X%02X%02X%02X%02X",
(unsigned)nodeId, data[0], data[1], data[2], data[3],
data[4], data[5], data[6], data[7]);
DcsBios::sendDcsBiosMessage(NODE_TELEM_MSG_NAME, hex);
}
#endif // PANELBRIDGE_NODE_STATUS

// ── TEST_SEQ state ─────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -316,6 +328,16 @@ static void onCanRx(uint32_t canId, const uint8_t* data, uint8_t len) {
return;
}

// TELEM_1 – TELEM_63: paged node telemetry — forwarded to the host unread (#216). Like HEALTH,
// never a liveness signal (HB owns that).
if (canId >= canIdTelem(1) && canId <= canIdTelem(MAX_NODE_ID)) {
#ifdef PANELBRIDGE_NODE_STATUS
if (len == sizeof(TelemPayload) && data[0] != 0)
emitTelem((uint8_t)(canId - canIdTelem(0)), data);
#endif
return;
}

// EVT_1 – EVT_63: 8-byte ControlPacketPair, dispatch each non-null slot
if (canId >= canIdEvt(1) && canId <= canIdEvt(MAX_NODE_ID)) {
if (len < 8) return;
Expand Down
38 changes: 38 additions & 0 deletions Firmware/Libraries/PowerMonitor/BoardThermal.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
#include "BoardThermal.h"
#include "PduConfig.h"

namespace OpenSkyhawk {

void BoardThermal::sample(int16_t tempDC, bool ntcFault, bool armed) {
_tempDC = ntcFault ? INT16_MIN : tempDC;
if (!armed) { _warn = _crit = _ntcFault = false; return; }

_ntcFault = ntcFault;
if (ntcFault) return; // keep warn/crit as they were — no reading to change them with

const int16_t h = PDU_TEMP_HYST_DC;
_warn = _warn ? tempDC > PDU_TEMP_WARN_DC - h : tempDC >= PDU_TEMP_WARN_DC;
_crit = _crit ? tempDC > PDU_TEMP_CRIT_DC - h : tempDC >= PDU_TEMP_CRIT_DC;
}

uint8_t BoardThermal::flags() const {
uint8_t f = 0;
if (_warn) f |= (uint8_t)PduStatusFlag::TEMP_WARN;
if (_crit) f |= (uint8_t)PduStatusFlag::TEMP_CRIT;
if (_ntcFault) f |= (uint8_t)PduStatusFlag::NTC_FAULT;
return f;
}

NodeFaultCode BoardThermal::faultCode() const {
if (_crit) return NodeFaultCode::OVER_TEMPERATURE;
if (_ntcFault) return NodeFaultCode::SENSOR_FAULT;
return NodeFaultCode::NONE;
}

const char* BoardThermal::faultDetail() const {
if (_crit) return "board temperature critical";
if (_ntcFault) return "NTC open or shorted";
return "";
}

} // namespace OpenSkyhawk
51 changes: 51 additions & 0 deletions Firmware/Libraries/PowerMonitor/BoardThermal.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* @file BoardThermal.h
* @brief PDU monitoring node — board temperature from the NTC: warn / critical / sensor fault (#216).
*
* Fed a converted reading (0.1 °C, from PduMath::ntcTempDC) roughly every 100 ms. Thresholds use
* hysteresis only — board temperature moves slowly, so no persistence timers. A FaultSource:
* critical → OVER_TEMPERATURE, an open/shorted NTC → SENSOR_FAULT. TEMP_WARN is a telemetry flag
* only, not a node fault.
*
* This is the PDU's real board temperature (±0.5 °C class NTC between the 12 V and 5 V pours). The
* MCU die temperature in HEALTH_n is a separate, uncalibrated reading.
*
* @note Static lifetime only (FaultSource registry rule) — construct as a global.
* @copyright GPL-2.0-only — see Firmware/LICENSE
*/

#pragma once
#include <stdint.h>
#include <NodeStatus.h>

namespace OpenSkyhawk {

class BoardThermal : public FaultSource {
public:
BoardThermal() = default;

/**
* @brief Feed one reading.
* @param tempDC Board temperature, 0.1 °C (ignored when `ntcFault`).
* @param ntcFault NTC channel open or shorted.
* @param armed false during power-up blanking — the flags are held clear.
*/
void sample(int16_t tempDC, bool ntcFault, bool armed);

/** @brief PduStatusFlag bits this monitor owns: TEMP_WARN, TEMP_CRIT, NTC_FAULT. */
uint8_t flags() const;

/** @brief Latest temperature, 0.1 °C; INT16_MIN when unavailable (no reading yet / NTC fault). */
int16_t tempDC() const { return _tempDC; }

NodeFaultCode faultCode() const override;
const char* faultDetail() const override;

private:
int16_t _tempDC = INT16_MIN;
bool _warn = false;
bool _crit = false;
bool _ntcFault = false;
};

} // namespace OpenSkyhawk
Loading
Loading