From dc358ce2eadc38cc740cb1a9f705a4450384fb88 Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Thu, 15 Oct 2026 17:47:00 -0400 Subject: [PATCH 1/6] feat(firmware): PDU monitoring node and TELEM_n telemetry frame (#216) New PowerMonitor library and PDU_Left/Right/Center sketches (NODE_IDs 3/4/5): samples the 12 V / 5 V rails (voltage + current) and the board NTC at 1 kHz, converts with per-board calibration stored in the last flash page, raises UV / OV / over-current / fuse-open / temperature / sensor flags with persistence and hysteresis, and feeds faults into HEALTH_n via FaultSource. New paged frame TELEM_n = 0x180 + n (TelemPayload, byte 0 = TelemPage): one frame per second rotating PDU_RAIL_12V / PDU_RAIL_5V / PDU_STATUS, plus rate-limited edge sends on any flag change; SYNC_REQ sends all three. PanelBridge forwards every TELEM_n frame to the host unread as _NODE_TELEM. DiagSerial calibration: CAL SHOW / CAL VDDA / CAL ZERO / CAL CLEAR / RAW. Tests/PowerMonitor (5 envs) passes on hardware; real-bus smoke of the PDU firmware with a CANable2. Spec and docs synced (D18). --- .claude/skills/firmware/SKILL.md | 2 +- Firmware/Libraries/CANProtocol/CANProtocol.h | 25 +- Firmware/Libraries/NodeStatus/NodeStatus.h | 51 +++- .../Libraries/PanelBridge/PanelBridge.cpp | 22 ++ .../Libraries/PowerMonitor/BoardThermal.cpp | 38 +++ .../Libraries/PowerMonitor/BoardThermal.h | 51 ++++ .../Libraries/PowerMonitor/PduCalStore.cpp | 110 ++++++++ Firmware/Libraries/PowerMonitor/PduCalStore.h | 95 +++++++ Firmware/Libraries/PowerMonitor/PduConfig.h | 118 +++++++++ Firmware/Libraries/PowerMonitor/PduMath.cpp | 76 ++++++ Firmware/Libraries/PowerMonitor/PduMath.h | 80 ++++++ Firmware/Libraries/PowerMonitor/PduNode.cpp | 248 ++++++++++++++++++ Firmware/Libraries/PowerMonitor/PduNode.h | 56 ++++ .../Libraries/PowerMonitor/RailMonitor.cpp | 90 +++++++ Firmware/Libraries/PowerMonitor/RailMonitor.h | 100 +++++++ .../Libraries/PowerMonitor/TelemSchedule.cpp | 37 +++ .../Libraries/PowerMonitor/TelemSchedule.h | 53 ++++ Firmware/Libraries/PowerMonitor/library.json | 28 ++ Firmware/NODE_IDS.md | 5 +- Firmware/Panels/PDU_Center/platformio.ini | 31 +++ Firmware/Panels/PDU_Center/src/main.cpp | 14 + Firmware/Panels/PDU_Left/platformio.ini | 31 +++ Firmware/Panels/PDU_Left/src/main.cpp | 14 + Firmware/Panels/PDU_Right/platformio.ini | 31 +++ Firmware/Panels/PDU_Right/src/main.cpp | 14 + .../ScratchPad/FirmwarePlan/00-decisions.md | 35 ++- .../FirmwarePlan/02-can-protocol.md | 64 ++++- .../FirmwarePlan/04-dcs-bios-integration.md | 23 ++ .../FirmwarePlan/06-panelbridge-api.md | 5 + .../08-hardware-firmware-contracts.md | 37 +++ .../FirmwarePlan/10-implementation-plan.md | 10 +- Firmware/ScratchPad/TechSpec/CANProtocol.md | 17 ++ Firmware/ScratchPad/TechSpec/NodeStatus.md | 17 +- Firmware/ScratchPad/TechSpec/PanelBridge.md | 2 + Firmware/ScratchPad/TechSpec/PowerMonitor.md | 165 ++++++++++++ Firmware/ScratchPad/TechSpec/README.md | 2 + .../tests/protocol_layout/protocol_layout.cpp | 4 + Firmware/Tests/PanelBridge/platformio.ini | 15 ++ .../tests/node_telem/node_telem.cpp | 60 +++++ Firmware/Tests/PowerMonitor/platformio.ini | 51 ++++ .../tests/board_thermal/board_thermal.cpp | 51 ++++ .../tests/cal_store/cal_store.cpp | 78 ++++++ .../Tests/PowerMonitor/tests/common/check.h | 46 ++++ .../PowerMonitor/tests/pdu_math/pdu_math.cpp | 56 ++++ .../tests/rail_monitor/rail_monitor.cpp | 106 ++++++++ .../tests/telem_schedule/telem_schedule.cpp | 76 ++++++ PCB/Base/Power/PDU/DESIGN-NOTES.md | 4 + docs/_source/base-boards.md | 8 +- docs/architecture/can-bus.md | 5 + docs/architecture/power.md | 29 +- docs/firmware/build-flags.md | 24 ++ docs/firmware/index.md | 4 + docs/firmware/node-id.md | 2 +- 53 files changed, 2394 insertions(+), 22 deletions(-) create mode 100644 Firmware/Libraries/PowerMonitor/BoardThermal.cpp create mode 100644 Firmware/Libraries/PowerMonitor/BoardThermal.h create mode 100644 Firmware/Libraries/PowerMonitor/PduCalStore.cpp create mode 100644 Firmware/Libraries/PowerMonitor/PduCalStore.h create mode 100644 Firmware/Libraries/PowerMonitor/PduConfig.h create mode 100644 Firmware/Libraries/PowerMonitor/PduMath.cpp create mode 100644 Firmware/Libraries/PowerMonitor/PduMath.h create mode 100644 Firmware/Libraries/PowerMonitor/PduNode.cpp create mode 100644 Firmware/Libraries/PowerMonitor/PduNode.h create mode 100644 Firmware/Libraries/PowerMonitor/RailMonitor.cpp create mode 100644 Firmware/Libraries/PowerMonitor/RailMonitor.h create mode 100644 Firmware/Libraries/PowerMonitor/TelemSchedule.cpp create mode 100644 Firmware/Libraries/PowerMonitor/TelemSchedule.h create mode 100644 Firmware/Libraries/PowerMonitor/library.json create mode 100644 Firmware/Panels/PDU_Center/platformio.ini create mode 100644 Firmware/Panels/PDU_Center/src/main.cpp create mode 100644 Firmware/Panels/PDU_Left/platformio.ini create mode 100644 Firmware/Panels/PDU_Left/src/main.cpp create mode 100644 Firmware/Panels/PDU_Right/platformio.ini create mode 100644 Firmware/Panels/PDU_Right/src/main.cpp create mode 100644 Firmware/ScratchPad/TechSpec/PowerMonitor.md create mode 100644 Firmware/Tests/PanelBridge/tests/node_telem/node_telem.cpp create mode 100644 Firmware/Tests/PowerMonitor/platformio.ini create mode 100644 Firmware/Tests/PowerMonitor/tests/board_thermal/board_thermal.cpp create mode 100644 Firmware/Tests/PowerMonitor/tests/cal_store/cal_store.cpp create mode 100644 Firmware/Tests/PowerMonitor/tests/common/check.h create mode 100644 Firmware/Tests/PowerMonitor/tests/pdu_math/pdu_math.cpp create mode 100644 Firmware/Tests/PowerMonitor/tests/rail_monitor/rail_monitor.cpp create mode 100644 Firmware/Tests/PowerMonitor/tests/telem_schedule/telem_schedule.cpp diff --git a/.claude/skills/firmware/SKILL.md b/.claude/skills/firmware/SKILL.md index 0316207c..f8c6ea6c 100644 --- a/.claude/skills/firmware/SKILL.md +++ b/.claude/skills/firmware/SKILL.md @@ -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 — diff --git a/Firmware/Libraries/CANProtocol/CANProtocol.h b/Firmware/Libraries/CANProtocol/CANProtocol.h index fd96ea8b..74ba452d 100644 --- a/Firmware/Libraries/CANProtocol/CANProtocol.h +++ b/Firmware/Libraries/CANProtocol/CANProtocol.h @@ -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 @@ -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 @@ -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; } diff --git a/Firmware/Libraries/NodeStatus/NodeStatus.h b/Firmware/Libraries/NodeStatus/NodeStatus.h index 13089b94..5cde2bb1 100644 --- a/Firmware/Libraries/NodeStatus/NodeStatus.h +++ b/Firmware/Libraries/NodeStatus/NodeStatus.h @@ -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. * @@ -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 <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) @@ -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 { diff --git a/Firmware/Libraries/PanelBridge/PanelBridge.cpp b/Firmware/Libraries/PanelBridge/PanelBridge.cpp index 032d67ab..b2b1b5b2 100644 --- a/Firmware/Libraries/PanelBridge/PanelBridge.cpp +++ b/Firmware/Libraries/PanelBridge/PanelBridge.cpp @@ -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 <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 ───────────────────────────────────────────────────────────── @@ -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; diff --git a/Firmware/Libraries/PowerMonitor/BoardThermal.cpp b/Firmware/Libraries/PowerMonitor/BoardThermal.cpp new file mode 100644 index 00000000..2f63b433 --- /dev/null +++ b/Firmware/Libraries/PowerMonitor/BoardThermal.cpp @@ -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 diff --git a/Firmware/Libraries/PowerMonitor/BoardThermal.h b/Firmware/Libraries/PowerMonitor/BoardThermal.h new file mode 100644 index 00000000..ddbf3820 --- /dev/null +++ b/Firmware/Libraries/PowerMonitor/BoardThermal.h @@ -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 +#include + +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 diff --git a/Firmware/Libraries/PowerMonitor/PduCalStore.cpp b/Firmware/Libraries/PowerMonitor/PduCalStore.cpp new file mode 100644 index 00000000..9e66b9ff --- /dev/null +++ b/Firmware/Libraries/PowerMonitor/PduCalStore.cpp @@ -0,0 +1,110 @@ +#include "PduCalStore.h" +#include + +#ifdef ARDUINO_ARCH_STM32 +#include // STM32 HAL flash API +#endif + +namespace OpenSkyhawk { +namespace Pdu { + +CalRecord calDefaults() { + CalRecord r; + memset(&r, 0, sizeof(r)); + r.iGain12 = r.iGain5 = r.vGain12 = r.vGain5 = GAIN_UNITY; + calSeal(r); + return r; +} + +void calSeal(CalRecord& rec) { + rec.magic = CAL_MAGIC; + rec.version = CAL_VERSION; + rec.rsvd = 0; + rec.crc = crc32(reinterpret_cast(&rec), offsetof(CalRecord, crc)); +} + +bool calValid(const CalRecord& rec) { + return rec.magic == CAL_MAGIC && rec.version == CAL_VERSION && + rec.crc == crc32(reinterpret_cast(&rec), offsetof(CalRecord, crc)); +} + +CalValues calValues(const CalRecord& rec) { + CalValues v; + v.vrefintMv = (rec.calFlags & CAL_VDDA) ? rec.vrefintMv : 0; + v.zero12DmA = rec.zero12DmA; + v.zero5DmA = rec.zero5DmA; + v.iGain12 = rec.iGain12; + v.iGain5 = rec.iGain5; + v.vGain12 = rec.vGain12; + v.vGain5 = rec.vGain5; + v.ntcOffsetDC = rec.ntcOffsetDC; + return v; +} + +static bool slotEmpty(const CalRecord* s) { + uint32_t w[sizeof(CalRecord) / 4]; + memcpy(w, s, sizeof(w)); // CalRecord is packed — copy rather than alias as uint32_t + for (uint32_t x : w) + if (x != 0xFFFFFFFFu) return false; + return true; +} + +uint8_t CalStore::usedSlots() const { + uint8_t n = 0; + while (n < CAL_SLOTS && !slotEmpty(slot(n))) n++; + return n; +} + +bool CalStore::load(CalRecord& out) const { + const uint8_t used = usedSlots(); + for (int i = (int)used - 1; i >= 0; i--) { + if (calValid(*slot((uint8_t)i))) { out = *slot((uint8_t)i); return true; } + } + out = calDefaults(); + return false; +} + +#ifdef ARDUINO_ARCH_STM32 + +bool CalStore::erase() { + FLASH_EraseInitTypeDef e = {}; + e.TypeErase = FLASH_TYPEERASE_PAGES; + e.PageAddress = _page; + e.NbPages = 1; + uint32_t pageError = 0; + HAL_FLASH_Unlock(); + const bool ok = HAL_FLASHEx_Erase(&e, &pageError) == HAL_OK; + HAL_FLASH_Lock(); + return ok; +} + +bool CalStore::save(CalRecord& rec) { + CalRecord prev; + rec.seq = load(prev) ? prev.seq + 1 : 1; + calSeal(rec); + + uint8_t i = usedSlots(); + if (i >= CAL_SLOTS) { + if (!erase()) return false; + i = 0; + } + const uint32_t addr = _page + (uint32_t)i * sizeof(CalRecord); + uint32_t w[sizeof(CalRecord) / 4]; + memcpy(w, &rec, sizeof(w)); // packed struct → aligned words for the word programmer + HAL_FLASH_Unlock(); + bool ok = true; + for (size_t k = 0; ok && k < sizeof(CalRecord) / 4; k++) + ok = HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, addr + k * 4, w[k]) == HAL_OK; + HAL_FLASH_Lock(); + return ok && calValid(*slot(i)); +} + +#else + +bool CalStore::erase() { return false; } +bool CalStore::save(CalRecord&) { return false; } + +#endif + +} // namespace Pdu +} // namespace OpenSkyhawk diff --git a/Firmware/Libraries/PowerMonitor/PduCalStore.h b/Firmware/Libraries/PowerMonitor/PduCalStore.h new file mode 100644 index 00000000..8d37dbf6 --- /dev/null +++ b/Firmware/Libraries/PowerMonitor/PduCalStore.h @@ -0,0 +1,95 @@ +/** + * @file PduCalStore.h + * @brief PDU per-board calibration in a reserved flash page — append-only log, newest valid wins (#216). + * + * The F103 has no EEPROM, so calibration lives in the last 1 KB flash page (PDU_CAL_PAGE_ADDR), + * outside the firmware image: the PDU builds set `board_upload.maximum_size = 64512` so the build + * fails before code can grow into it, and an OpenOCD upload erases only the sectors it writes, so + * a reflash keeps the calibration. + * + * The page holds up to 32 fixed 32-byte records. save() writes the next empty slot; load() returns + * the last slot whose magic and CRC check out. The page is erased only when it is full, so a power + * cut can lose at most the record being written — never the previous good one — except during the + * rare full-page erase. + * + * @copyright GPL-2.0-only — see Firmware/LICENSE + */ + +#pragma once +#include +#include "PduConfig.h" +#include "PduMath.h" + +namespace OpenSkyhawk { +namespace Pdu { + +/** @brief CalRecord.calFlags bits — which calibrations this record holds. */ +enum CalFlag : uint8_t { + CAL_VDDA = 0x01, ///< vrefintMv anchored by CAL VDDA (DMM reading of the 3V3 rail) + CAL_ZERO = 0x02, ///< current zero offsets measured by CAL ZERO (no load) + CAL_GAIN = 0x04, ///< gain trims set (optional bench calibration) + CAL_NTC = 0x08, ///< NTC one-point offset set (optional) +}; + +/** @brief One stored calibration — 32 bytes, little-endian, CRC-32 over bytes 0–27. */ +struct __attribute__((packed)) CalRecord { + uint32_t magic; ///< 0x00: CAL_MAGIC + uint8_t version; ///< 0x04: CAL_VERSION + uint8_t calFlags; ///< 0x05: CalFlag bits + uint16_t vrefintMv; ///< 0x06: this chip's Vrefint, mV (0 = uncalibrated) + int16_t zero12DmA; ///< 0x08: 12 V current zero offset, 0.1 mA + int16_t zero5DmA; ///< 0x0A: 5 V current zero offset, 0.1 mA + uint16_t iGain12; ///< 0x0C: 12 V current gain trim, 10000 = ×1 + uint16_t iGain5; ///< 0x0E: 5 V current gain trim + uint16_t vGain12; ///< 0x10: 12 V voltage gain trim + uint16_t vGain5; ///< 0x12: 5 V voltage gain trim + int16_t ntcOffsetDC; ///< 0x14: NTC offset, 0.1 °C + uint16_t rsvd; ///< 0x16: reserved, 0 + uint32_t seq; ///< 0x18: write counter (diagnostic) + uint32_t crc; ///< 0x1C: CRC-32 over bytes 0x00–0x1B +}; +static_assert(sizeof(CalRecord) == 32, "CalRecord must be 32 bytes"); + +static constexpr uint32_t CAL_MAGIC = 0x4C414350; ///< 'PCAL' little-endian +static constexpr uint8_t CAL_VERSION = 1; +static constexpr uint32_t CAL_PAGE_SIZE = 1024; +static constexpr uint8_t CAL_SLOTS = CAL_PAGE_SIZE / sizeof(CalRecord); // 32 + +/** @brief An uncalibrated record: unity gains, zero offsets, no flags. */ +CalRecord calDefaults(); +/** @brief Fill magic/version/crc for `rec` (seq left as set). */ +void calSeal(CalRecord& rec); +/** @brief True when `rec` has the right magic, version and CRC. */ +bool calValid(const CalRecord& rec); +/** @brief The conversion values a record carries. */ +CalValues calValues(const CalRecord& rec); + +/** @brief The reserved flash page holding the calibration log. */ +class CalStore { +public: + explicit CalStore(uint32_t pageAddr = PDU_CAL_PAGE_ADDR) : _page(pageAddr) {} + + /** @brief Newest valid record into `out`. @return false (out = calDefaults()) when none. */ + bool load(CalRecord& out) const; + + /** + * @brief Append `rec` (sealed here, seq = previous + 1); erases the page first when full. + * @return true when the written slot reads back valid. + */ + bool save(CalRecord& rec); + + /** @brief Erase the whole page — back to uncalibrated. */ + bool erase(); + + /** @brief Slots holding a record (valid or not) — 0..CAL_SLOTS. */ + uint8_t usedSlots() const; + +private: + uint32_t _page; + const CalRecord* slot(uint8_t i) const { + return reinterpret_cast(_page + (uint32_t)i * sizeof(CalRecord)); + } +}; + +} // namespace Pdu +} // namespace OpenSkyhawk diff --git a/Firmware/Libraries/PowerMonitor/PduConfig.h b/Firmware/Libraries/PowerMonitor/PduConfig.h new file mode 100644 index 00000000..8aba36f2 --- /dev/null +++ b/Firmware/Libraries/PowerMonitor/PduConfig.h @@ -0,0 +1,118 @@ +/** + * @file PduConfig.h + * @brief PDU monitoring node — hardware constants and tunable thresholds (#216). + * + * Hardware constants match the PDU Rev 0.2.0 schematic (`PCB/Base/Power/PDU`, #202): pin map, + * divider ratios, INA180A2 gain and shunt, NTC bias. Every threshold is a `PDU_*` macro with a + * default, so a build overrides it with `build_flags = -DPDU_12V_UV_MV=11200` without a code + * change; the defaults are starting points to be tuned on the bench against the real load. + * + * Units: mV, mA, ms, and tenths of a degree C (`DC`). + * + * @copyright GPL-2.0-only — see Firmware/LICENSE + */ + +#pragma once +#include + +// ── Pin map (Rev 0.2.0 netlist) ────────────────────────────────────────────────── +#ifndef PDU_PIN_V12 +#define PDU_PIN_V12 PA0 ///< 12V_READ_ADC — 33k/10k divider +#endif +#ifndef PDU_PIN_V5 +#define PDU_PIN_V5 PA1 ///< 5V_READ_ADC — 10k/13k divider +#endif +#ifndef PDU_PIN_I12 +#define PDU_PIN_I12 PA2 ///< I_12V_ADC — INA180A2 output +#endif +#ifndef PDU_PIN_I5 +#define PDU_PIN_I5 PA3 ///< I_5V_ADC — INA180A2 output +#endif +#ifndef PDU_PIN_NTC +#define PDU_PIN_NTC PA6 ///< NTC_ADC — 10k from +3V3, NTC to GND +#endif + +namespace OpenSkyhawk { +namespace Pdu { + +// ── Analog front end ───────────────────────────────────────────────────────────── +static constexpr uint32_t ADC_FULL = 1UL << 16; ///< analogReadResolution(16), set by STM32Board +static constexpr uint16_t VDDA_NOMINAL_MV = 3300; ///< assumed VDDA until CAL VDDA anchors Vrefint +static constexpr uint16_t VREFINT_TYP_MV = 1200; ///< F103 Vrefint typical (untrimmed, ±~3 %) + +/// Rail = ADC × num / den. 12 V: (33k + 10k) / 10k. 5 V: (10k + 13k) / 13k. +static constexpr uint32_t V12_DIV_NUM = 43, V12_DIV_DEN = 10; +static constexpr uint32_t V5_DIV_NUM = 23, V5_DIV_DEN = 13; + +/// INA180A2 (gain 50 V/V) on a 10 mΩ shunt: I = Vout / (50 × 0.010 Ω) → mA = 2 × mV. +static constexpr uint32_t I_MA_PER_MV = 2; + +/// NTC: 10 kΩ ±1 % B3450 to GND, 10 kΩ bias from +3V3 (same rail as VDDA → ratiometric). +static constexpr float NTC_R0_OHM = 10000.0f; +static constexpr float NTC_BETA_K = 3450.0f; +static constexpr float NTC_T0_K = 298.15f; +static constexpr float NTC_BIAS_OHM = 10000.0f; + +static constexpr uint16_t GAIN_UNITY = 10000; ///< cal gain trims: 10000 = ×1.0000 + +} // namespace Pdu +} // namespace OpenSkyhawk + +// ── Thresholds (override with -D) ───────────────────────────────────────────────── +#ifndef PDU_12V_UV_MV +#define PDU_12V_UV_MV 11400 ///< 12 V under-voltage (ATX −5 %) +#endif +#ifndef PDU_12V_OV_MV +#define PDU_12V_OV_MV 13200 ///< 12 V over-voltage (below the SMBJ12A clamp) +#endif +#ifndef PDU_5V_UV_MV +#define PDU_5V_UV_MV 4750 ///< 5 V under-voltage (−5 %) +#endif +#ifndef PDU_5V_OV_MV +#define PDU_5V_OV_MV 5500 ///< 5 V over-voltage (below the SMBJ6.0A standoff) +#endif +#ifndef PDU_12V_OC_MA +#define PDU_12V_OC_MA 4500 ///< 12 V over-current — 90 % of the 5 A fuse +#endif +#ifndef PDU_5V_OC_MA +#define PDU_5V_OC_MA 1800 ///< 5 V over-current — 90 % of the 2 A fuse +#endif +#ifndef PDU_FUSE_OPEN_MV +#define PDU_FUSE_OPEN_MV 1000 ///< rail below this while the node runs → fuse open (12 V only) +#endif +#ifndef PDU_V_HYST_MV +#define PDU_V_HYST_MV 100 ///< voltage-threshold hysteresis +#endif +#ifndef PDU_V_SET_MS +#define PDU_V_SET_MS 50 ///< a voltage condition must persist this long to set +#endif +#ifndef PDU_OC_SET_MS +#define PDU_OC_SET_MS 100 ///< over-current must persist this long to set +#endif +#ifndef PDU_CLEAR_MS +#define PDU_CLEAR_MS 500 ///< a condition must be gone this long to clear +#endif +#ifndef PDU_TEMP_WARN_DC +#define PDU_TEMP_WARN_DC 600 ///< board temperature warning, 60.0 °C +#endif +#ifndef PDU_TEMP_CRIT_DC +#define PDU_TEMP_CRIT_DC 750 ///< board temperature critical, 75.0 °C +#endif +#ifndef PDU_TEMP_HYST_DC +#define PDU_TEMP_HYST_DC 30 ///< temperature-threshold hysteresis, 3.0 °C +#endif +#ifndef PDU_BOOT_BLANK_MS +#define PDU_BOOT_BLANK_MS 500 ///< ignore rail/thermal faults this long after boot (ramp + inrush) +#endif +#ifndef PDU_SAMPLE_US +#define PDU_SAMPLE_US 1000 ///< rail scan period (1 kHz target) +#endif +#ifndef PDU_ZERO_GUARD_12V_MA +#define PDU_ZERO_GUARD_12V_MA 75 ///< CAL ZERO refuses if the 12 V raw current exceeds this (load present) +#endif +#ifndef PDU_ZERO_GUARD_5V_MA +#define PDU_ZERO_GUARD_5V_MA 120 ///< 5 V guard — higher: the PDU's own ~30–50 mA flows through this shunt +#endif +#ifndef PDU_CAL_PAGE_ADDR +#define PDU_CAL_PAGE_ADDR 0x0800FC00UL ///< last 1 KB page of the STM32F103C8 (64 KB) — keep out of the image +#endif diff --git a/Firmware/Libraries/PowerMonitor/PduMath.cpp b/Firmware/Libraries/PowerMonitor/PduMath.cpp new file mode 100644 index 00000000..301bfe09 --- /dev/null +++ b/Firmware/Libraries/PowerMonitor/PduMath.cpp @@ -0,0 +1,76 @@ +#include "PduMath.h" +#include "PduConfig.h" +#include + +namespace OpenSkyhawk { +namespace Pdu { + +int16_t clampI16(int32_t v) { + if (v > INT16_MAX) return INT16_MAX; + if (v < INT16_MIN) return INT16_MIN; + return (int16_t)v; +} + +uint16_t clampU16(int32_t v) { + if (v < 0) return 0; + if (v > UINT16_MAX) return UINT16_MAX; + return (uint16_t)v; +} + +uint16_t vddaMv(uint16_t vrefintRaw, uint16_t vrefintMv) { + if (vrefintMv == 0 || vrefintRaw == 0) return VDDA_NOMINAL_MV; + return clampU16((int32_t)((uint32_t)vrefintMv * ADC_FULL / vrefintRaw)); +} + +uint32_t countsToMv(uint16_t counts, uint16_t vddaMv) { + return (uint32_t)counts * vddaMv / ADC_FULL; +} + +static uint16_t trimmed(uint32_t mv, uint16_t gain) { + return clampU16((int32_t)(mv * gain / GAIN_UNITY)); +} + +uint16_t rail12Mv(uint32_t adcMv, uint16_t vGain) { + return trimmed(adcMv * V12_DIV_NUM / V12_DIV_DEN, vGain); +} + +uint16_t rail5Mv(uint32_t adcMv, uint16_t vGain) { + return trimmed(adcMv * V5_DIV_NUM / V5_DIV_DEN, vGain); +} + +int32_t rawCurrentDmA(uint32_t adcMv) { + return (int32_t)(adcMv * I_MA_PER_MV * 10); +} + +int32_t currentMa(uint32_t adcMv, int16_t zeroDmA, uint16_t iGain) { + // Zero first (it was measured at unity gain), then the gain trim; round to nearest mA. + const int32_t dmA = (rawCurrentDmA(adcMv) - zeroDmA) * (int32_t)iGain / (int32_t)GAIN_UNITY; + return dmA >= 0 ? (dmA + 5) / 10 : (dmA - 5) / 10; +} + +bool railed(uint16_t counts) { + return counts >= (uint16_t)(ADC_FULL * 995 / 1000); +} + +int16_t ntcTempDC(uint16_t counts, int16_t offsetDC, bool& fault) { + // Open NTC → node pulled to +3V3 (top); shorted → 0. Either way the reading means nothing. + fault = counts < ADC_FULL * 2 / 100 || counts > ADC_FULL * 98 / 100; + if (fault) return INT16_MIN; + const float x = (float)counts / (float)ADC_FULL; // node / VDDA + const float rNtc = NTC_BIAS_OHM * x / (1.0f - x); // NTC is the low side + const float invT = 1.0f / NTC_T0_K + logf(rNtc / NTC_R0_OHM) / NTC_BETA_K; + const float dc = (1.0f / invT - 273.15f) * 10.0f; + return clampI16((int32_t)lroundf(dc) + offsetDC); +} + +uint32_t crc32(const uint8_t* data, size_t len) { + uint32_t crc = 0xFFFFFFFFu; + for (size_t i = 0; i < len; i++) { + crc ^= data[i]; + for (int b = 0; b < 8; b++) crc = (crc >> 1) ^ (0xEDB88320u & (0u - (crc & 1u))); + } + return ~crc; +} + +} // namespace Pdu +} // namespace OpenSkyhawk diff --git a/Firmware/Libraries/PowerMonitor/PduMath.h b/Firmware/Libraries/PowerMonitor/PduMath.h new file mode 100644 index 00000000..ee7b42c1 --- /dev/null +++ b/Firmware/Libraries/PowerMonitor/PduMath.h @@ -0,0 +1,80 @@ +/** + * @file PduMath.h + * @brief PDU monitoring node — pure conversions, no hardware access (#216). + * + * Everything here is a function of its arguments, so it is tested with injected values on any + * target. ADC counts are 16-bit (analogReadResolution(16)); VDDA is in mV. + * + * @copyright GPL-2.0-only — see Firmware/LICENSE + */ + +#pragma once +#include +#include + +namespace OpenSkyhawk { +namespace Pdu { + +/** @brief Calibration values the conversions need. Defaults = uncalibrated. */ +struct CalValues { + uint16_t vrefintMv = 0; ///< this chip's Vrefint in mV; 0 = not calibrated (use VDDA_NOMINAL_MV) + int16_t zero12DmA = 0; ///< 12 V current zero offset, 0.1 mA + int16_t zero5DmA = 0; ///< 5 V current zero offset, 0.1 mA + uint16_t iGain12 = 10000; ///< 12 V current gain trim, 10000 = ×1 + uint16_t iGain5 = 10000; ///< 5 V current gain trim + uint16_t vGain12 = 10000; ///< 12 V voltage gain trim + uint16_t vGain5 = 10000; ///< 5 V voltage gain trim + int16_t ntcOffsetDC = 0; ///< NTC one-point offset, 0.1 °C +}; + +/** + * @brief VDDA in mV from a raw Vrefint reading. + * @param vrefintRaw 16-bit ADC count of the internal reference (0 = unreadable). + * @param vrefintMv This chip's calibrated Vrefint, or 0 when uncalibrated. + * @return VDDA in mV: Vrefint-tracked when calibrated, VDDA_NOMINAL_MV otherwise (the untrimmed + * F103 Vrefint is a worse reference than the regulator's nominal until anchored). + */ +uint16_t vddaMv(uint16_t vrefintRaw, uint16_t vrefintMv); + +/** @brief ADC pin voltage in mV for a 16-bit count at the given VDDA. */ +uint32_t countsToMv(uint16_t counts, uint16_t vddaMv); + +/** @brief 12 V rail in mV from the ADC pin voltage, after the divider and gain trim. */ +uint16_t rail12Mv(uint32_t adcMv, uint16_t vGain); +/** @brief 5 V rail in mV from the ADC pin voltage, after the divider and gain trim. */ +uint16_t rail5Mv(uint32_t adcMv, uint16_t vGain); + +/** + * @brief Rail current in mA from the INA180 output voltage. + * @param adcMv INA180 output at the ADC pin, mV. + * @param zeroDmA Zero offset to subtract, 0.1 mA (from CAL ZERO). + * @param iGain Gain trim, 10000 = ×1. + * @return Signed mA — a small negative value is residual offset, kept so it stays visible. + */ +int32_t currentMa(uint32_t adcMv, int16_t zeroDmA, uint16_t iGain); + +/** @brief Uncorrected current in 0.1 mA (no zero, unity gain) — what CAL ZERO measures. */ +int32_t rawCurrentDmA(uint32_t adcMv); + +/** + * @brief Board temperature in 0.1 °C from the NTC channel (ratiometric — VDDA cancels). + * @param counts 16-bit ADC count of the NTC node. + * @param offsetDC One-point offset, 0.1 °C. + * @param fault Set true when the reading is outside 2–98 % of full scale (open / shorted NTC). + * @return Temperature in 0.1 °C, or INT16_MIN when `fault`. + */ +int16_t ntcTempDC(uint16_t counts, int16_t offsetDC, bool& fault); + +/** @brief True when a 16-bit count is at the top rail (≥ 99.5 % FS) — the channel can't measure. */ +bool railed(uint16_t counts); + +/** @brief CRC-32 (IEEE 802.3, reflected 0xEDB88320) over `len` bytes. */ +uint32_t crc32(const uint8_t* data, size_t len); + +/** @brief Clamp an int32 into int16 range. */ +int16_t clampI16(int32_t v); +/** @brief Clamp an int32 into uint16 range. */ +uint16_t clampU16(int32_t v); + +} // namespace Pdu +} // namespace OpenSkyhawk diff --git a/Firmware/Libraries/PowerMonitor/PduNode.cpp b/Firmware/Libraries/PowerMonitor/PduNode.cpp new file mode 100644 index 00000000..536bf480 --- /dev/null +++ b/Firmware/Libraries/PowerMonitor/PduNode.cpp @@ -0,0 +1,248 @@ +#ifdef ARDUINO_ARCH_STM32 + +#include "PduNode.h" +#include "PduConfig.h" +#include "PduMath.h" +#include "TelemSchedule.h" +#include +#include +#include + +using namespace OpenSkyhawk; +using namespace OpenSkyhawk::Pdu; + +namespace { + +// Fault sources — globals, as the FaultSource registry requires (static lifetime). +RailMonitor g12({TelemPage::PDU_RAIL_12V, "12V", PDU_12V_UV_MV, PDU_12V_OV_MV, PDU_12V_OC_MA, true}); +RailMonitor g5 ({TelemPage::PDU_RAIL_5V, "5V", PDU_5V_UV_MV, PDU_5V_OV_MV, PDU_5V_OC_MA, false}); +BoardThermal gTherm; + +CalStore gStore; +CalRecord gCal; +CalValues gVals; +TelemSchedule gSched; + +uint16_t gVdda = VDDA_NOMINAL_MV; +uint32_t gBootMs = 0; +uint32_t gNextScanUs = 0; +uint32_t gLastSlowMs = 0; +uint32_t gScans = 0; // scans in the current second +uint32_t gRateMarkMs = 0; +uint16_t gScanRate = 0; +bool gSyncReq = false; + +char gLine[40]; +uint8_t gLineLen = 0; + +constexpr uint32_t SLOW_MS = 100; // NTC + Vrefint cadence + +bool armed(uint32_t now) { return now - gBootMs >= PDU_BOOT_BLANK_MS; } + +void loadCal() { + gStore.load(gCal); + gVals = calValues(gCal); +} + +uint8_t statusFlags() { + uint8_t f = gTherm.flags(); + if (!(gCal.calFlags & CAL_VDDA)) f |= (uint8_t)PduStatusFlag::UNCAL_VDDA; + if (!(gCal.calFlags & CAL_ZERO)) f |= (uint8_t)PduStatusFlag::UNCAL_ZERO; + return f; +} + +TelemPayload statusPage(bool periodic) { + const int16_t t = gTherm.tempDC(); + const uint16_t p12 = g12.peakMa(), p5 = g5.peakMa(); + TelemPayload p; + p.page = (uint8_t)TelemPage::PDU_STATUS; + p.body[0] = statusFlags(); + p.body[1] = (uint8_t)((uint16_t)t & 0xFF); p.body[2] = (uint8_t)((uint16_t)t >> 8); + p.body[3] = (uint8_t)(p12 & 0xFF); p.body[4] = (uint8_t)(p12 >> 8); + p.body[5] = (uint8_t)(p5 & 0xFF); p.body[6] = (uint8_t)(p5 >> 8); + if (periodic) { g12.resetPeak(); g5.resetPeak(); } + return p; +} + +void sendPage(uint8_t slot, bool periodic, uint32_t now) { + TelemPayload p = slot == 0 ? g12.buildPage(periodic) + : slot == 1 ? g5.buildPage(periodic) + : statusPage(periodic); + CANProtocol::send(canIdTelem(NODE_ID), reinterpret_cast(&p), sizeof(p)); + gSched.sent(slot, p.body[0], now); +} + +void onSyncReq() { gSyncReq = true; } + +uint16_t readAdc(uint32_t pin) { return (uint16_t)analogRead(pin); } + +void scanRails(uint32_t now) { + const uint16_t cV12 = readAdc(PDU_PIN_V12), cV5 = readAdc(PDU_PIN_V5); + const uint16_t cI12 = readAdc(PDU_PIN_I12), cI5 = readAdc(PDU_PIN_I5); + const bool a = armed(now); + g12.sample(rail12Mv(countsToMv(cV12, gVdda), gVals.vGain12), + currentMa(countsToMv(cI12, gVdda), gVals.zero12DmA, gVals.iGain12), railed(cV12), now, a); + g5.sample(rail5Mv(countsToMv(cV5, gVdda), gVals.vGain5), + currentMa(countsToMv(cI5, gVdda), gVals.zero5DmA, gVals.iGain5), railed(cV5), now, a); + gScans++; +} + +void scanSlow(uint32_t now) { + gVdda = vddaMv(readAdc(AVREF), gVals.vrefintMv); + bool fault = false; + const int16_t t = ntcTempDC(readAdc(PDU_PIN_NTC), gVals.ntcOffsetDC, fault); + gTherm.sample(t, fault, armed(now)); +} + +// ── DiagSerial calibration commands ───────────────────────────────────────────── + +Print& out() { return STM32Board::diagSerial(); } + +void printCal() { + auto& d = out(); + d.print(F("[PDU] cal: VDDA=")); d.print((gCal.calFlags & CAL_VDDA) ? F("yes") : F("no")); + d.print(F(" vrefintMv=")); d.print(gCal.vrefintMv); + d.print(F(" ZERO=")); d.print((gCal.calFlags & CAL_ZERO) ? F("yes") : F("no")); + d.print(F(" zero12dmA=")); d.print(gCal.zero12DmA); + d.print(F(" zero5dmA=")); d.print(gCal.zero5DmA); + d.print(F(" seq=")); d.print(gCal.seq); + d.print(F(" slots=")); d.print(gStore.usedSlots()); d.print('/'); d.println(CAL_SLOTS); +} + +void saveCal(CalRecord& rec) { + if (gStore.save(rec)) { loadCal(); out().print(F("[PDU] saved. ")); printCal(); } + else out().println(F("[PDU] ERROR: flash write failed — calibration not saved")); +} + +void cmdCalVdda(const char* arg) { + const long mv = atol(arg); + if (mv < 3000 || mv > 3600) { out().println(F("[PDU] usage: CAL VDDA (DMM on 3V3)")); return; } + uint32_t sum = 0; + for (int i = 0; i < 64; i++) sum += readAdc(AVREF); + const uint32_t raw = sum / 64; + const uint32_t vref = raw ? (uint32_t)mv * raw / ADC_FULL : 0; + if (vref < 1100 || vref > 1300) { + out().print(F("[PDU] REFUSED: derived Vrefint ")); out().print(vref); + out().println(F(" mV is outside 1100-1300 — check the DMM reading")); + return; + } + CalRecord rec = gCal; + rec.vrefintMv = (uint16_t)vref; + rec.calFlags |= CAL_VDDA; + saveCal(rec); +} + +void cmdCalZero() { + int32_t s12 = 0, s5 = 0; + for (int i = 0; i < 256; i++) { + s12 += rawCurrentDmA(countsToMv(readAdc(PDU_PIN_I12), gVdda)); + s5 += rawCurrentDmA(countsToMv(readAdc(PDU_PIN_I5), gVdda)); + } + const int32_t z12 = s12 / 256, z5 = s5 / 256; + out().print(F("[PDU] zero read: 12V ")); out().print(z12 / 10); out().print(F(" mA, 5V ")); + out().print(z5 / 10); out().println(F(" mA (5V includes the PDU's own draw)")); + if (labs(z12) > PDU_ZERO_GUARD_12V_MA * 10L || labs(z5) > PDU_ZERO_GUARD_5V_MA * 10L) { + out().println(F("[PDU] REFUSED: current too high for a zero — disconnect the load and retry")); + return; + } + CalRecord rec = gCal; + rec.zero12DmA = clampI16(z12); + rec.zero5DmA = clampI16(z5); + rec.calFlags |= CAL_ZERO; + saveCal(rec); +} + +void cmdRaw() { + auto& d = out(); + d.print(F("[PDU] vdda=")); d.print(gVdda); + d.print(F("mV 12V=")); d.print(g12.lastMv()); d.print(F("mV/")); d.print(g12.lastMa()); + d.print(F("mA 5V=")); d.print(g5.lastMv()); d.print(F("mV/")); d.print(g5.lastMa()); + d.print(F("mA temp=")); d.print(gTherm.tempDC()); d.print(F("dC flags12=0x")); d.print(g12.flags(), HEX); + d.print(F(" flags5=0x")); d.print(g5.flags(), HEX); d.print(F(" status=0x")); d.print(statusFlags(), HEX); + d.print(F(" scans/s=")); d.println(gScanRate); +} + +void runCommand(char* line) { + for (char* p = line; *p; p++) *p = (char)toupper((unsigned char)*p); + if (!strcmp(line, "CAL SHOW")) printCal(); + else if (!strncmp(line, "CAL VDDA ", 9)) cmdCalVdda(line + 9); + else if (!strcmp(line, "CAL ZERO")) cmdCalZero(); + else if (!strcmp(line, "CAL CLEAR")) { gStore.erase(); loadCal(); out().print(F("[PDU] cleared. ")); printCal(); } + else if (!strcmp(line, "RAW")) cmdRaw(); + else if (line[0]) out().println(F("[PDU] commands: CAL SHOW | CAL VDDA | CAL ZERO | CAL CLEAR | RAW")); +} + +void pollDiag() { + auto& d = STM32Board::diagSerial(); + while (d.available()) { + const char c = (char)d.read(); + if (c == '\r' || c == '\n') { + gLine[gLineLen] = '\0'; + if (gLineLen) runCommand(gLine); + gLineLen = 0; + } else if (gLineLen < sizeof(gLine) - 1) { + gLine[gLineLen++] = c; + } + } +} + +} // namespace + +namespace PduNode { + +void setup() { + STM32Board::begin(); + loadCal(); + + CANProtocol::onStatusChange(STM32Board::onCanStatus); + CANProtocol::onSyncReq(onSyncReq); + CANProtocol::start(); // HB_0 accepted automatically; node status arms on the first loop() + + const uint32_t now = millis(); + gBootMs = gLastSlowMs = gRateMarkMs = now; + gNextScanUs = micros(); + gSched.start(now); + scanSlow(now); // VDDA + NTC before the first rail scan + + auto& d = out(); + d.print(F("[PDU] NODE_ID=")); d.println((int)NODE_ID); + printCal(); + CANProtocol::send(canIdReady(NODE_ID), nullptr, 0); +} + +void loop() { + STM32Board::tick(); + CANProtocol::drain(); + CANProtocol::serviceNodeStatus(0); + + const uint32_t us = micros(); + if ((int32_t)(us - gNextScanUs) >= 0) { + gNextScanUs += PDU_SAMPLE_US; + if ((int32_t)(us - gNextScanUs) >= (int32_t)(10 * PDU_SAMPLE_US)) gNextScanUs = us + PDU_SAMPLE_US; + scanRails(millis()); + } + + const uint32_t now = millis(); + if (now - gLastSlowMs >= SLOW_MS) { gLastSlowMs = now; scanSlow(now); } + if (now - gRateMarkMs >= 1000) { gRateMarkMs = now; gScanRate = (uint16_t)gScans; gScans = 0; } + + if (gSyncReq) { // host (re)connected: give it every page now; windows keep running + gSyncReq = false; + for (uint8_t s = 0; s < TelemSchedule::PAGES; s++) sendPage(s, false, now); + } + const uint8_t flags[TelemSchedule::PAGES] = {g12.flags(), g5.flags(), statusFlags()}; + bool periodic = false; + const int8_t slot = gSched.next(now, flags, periodic); + if (slot >= 0) sendPage((uint8_t)slot, periodic, now); + + pollDiag(); +} + +uint16_t scanRateHz() { return gScanRate; } +const RailMonitor& rail12() { return g12; } +const RailMonitor& rail5() { return g5; } +const BoardThermal& thermal() { return gTherm; } + +} // namespace PduNode + +#endif // ARDUINO_ARCH_STM32 diff --git a/Firmware/Libraries/PowerMonitor/PduNode.h b/Firmware/Libraries/PowerMonitor/PduNode.h new file mode 100644 index 00000000..754a1300 --- /dev/null +++ b/Firmware/Libraries/PowerMonitor/PduNode.h @@ -0,0 +1,56 @@ +/** + * @file PduNode.h + * @brief PDU monitoring node — the whole node in two calls (#216). + * + * A PDU sketch is just: + * @code + * #include + * void setup() { STM32Board::setDebug(true); PduNode::setup(); } + * void loop() { PduNode::loop(); } + * @endcode + * with `-DNODE_ID=N` in platformio.ini. What runs: + * - **Node status** — HB_n / HEALTH_n / master-loss watchdog via CANProtocol::serviceNodeStatus(). + * Rail and thermal faults reach HEALTH_n.faultId through the FaultSource registry. + * - **Sampling** — the four rail channels every PDU_SAMPLE_US (1 kHz target); the NTC and + * Vrefint every 100 ms. Faults are held clear for PDU_BOOT_BLANK_MS after boot. + * - **Telemetry** — one TELEM_n frame per second, rotating PDU_RAIL_12V → PDU_RAIL_5V → + * PDU_STATUS; a page whose flags change goes out at once (≤ 1 per 100 ms per page); + * SYNC_REQ sends all three pages immediately. + * - **Calibration** — DiagSerial (115200) commands, one per line: + * `CAL SHOW` · `CAL VDDA ` (DMM reading of the 3V3 rail) · `CAL ZERO` (no load + * connected) · `CAL CLEAR` · `RAW` (live readings + scan rate). + * Stored per board in the reserved flash page (PduCalStore). + * + * The status LED follows every other node: CAN / link state plus the master-loss WARNING; rail + * faults show on the client and the board's D3/D4 rail LEDs, not the MCU LED (FirmwarePlan D14). + * + * @copyright GPL-2.0-only — see Firmware/LICENSE + */ + +#pragma once +#include +#include +#include +#include "RailMonitor.h" +#include "BoardThermal.h" +#include "PduCalStore.h" + +namespace PduNode { + +/** @brief Start the board, CAN and calibration, then send READY_n. Call from setup(). */ +void setup(); + +/** @brief Run one iteration: CAN, node status, sampling, telemetry, DiagSerial. Call every loop(). */ +void loop(); + +/** @brief Rail scans completed in the last full second — bring-up check of the 1 kHz target. */ +uint16_t scanRateHz(); + +/** @brief The 12 V rail monitor (bring-up / tests). */ +const OpenSkyhawk::RailMonitor& rail12(); +/** @brief The 5 V rail monitor. */ +const OpenSkyhawk::RailMonitor& rail5(); +/** @brief The board thermal monitor. */ +const OpenSkyhawk::BoardThermal& thermal(); + +} // namespace PduNode diff --git a/Firmware/Libraries/PowerMonitor/RailMonitor.cpp b/Firmware/Libraries/PowerMonitor/RailMonitor.cpp new file mode 100644 index 00000000..b9930b1c --- /dev/null +++ b/Firmware/Libraries/PowerMonitor/RailMonitor.cpp @@ -0,0 +1,90 @@ +#include "RailMonitor.h" +#include "PduConfig.h" +#include "PduMath.h" +#include +#include + +namespace OpenSkyhawk { +namespace Pdu { + +void Latch::update(bool cond, bool clear, uint32_t setMs, uint32_t clearMs, uint32_t nowMs) { + const bool want = on ? clear : cond; // the transition we are waiting for + const uint32_t hold = on ? clearMs : setMs; + if (!want) { pending = false; return; } + if (!pending) { pending = true; since = nowMs; } + if (nowMs - since >= hold) { on = !on; pending = false; } +} + +} // namespace Pdu + +RailMonitor::RailMonitor(const Config& cfg) : _cfg(cfg) { + _detail[0] = '\0'; +} + +void RailMonitor::sample(uint16_t vMv, int32_t iMa, bool vRailed, uint32_t nowMs, bool armed) { + _lastMv = vMv; + _lastMa = iMa; + _sumMv += vMv; + _sumMa += iMa; + _n++; + if (vMv < _minMv) _minMv = vMv; + if (iMa > (int32_t)_peakMa) _peakMa = Pdu::clampU16(iMa); + + if (!armed) { + _uv.reset(); _ov.reset(); _oc.reset(); _fuse.reset(); _sensor.reset(); + return; + } + const uint16_t h = PDU_V_HYST_MV; + _uv.update(vMv < _cfg.uvMv, vMv > _cfg.uvMv + h, PDU_V_SET_MS, PDU_CLEAR_MS, nowMs); + _ov.update(vMv > _cfg.ovMv, vMv + h < _cfg.ovMv, PDU_V_SET_MS, PDU_CLEAR_MS, nowMs); + _oc.update(iMa > _cfg.ocMa, iMa < (int32_t)_cfg.ocMa * 9 / 10, PDU_OC_SET_MS, PDU_CLEAR_MS, nowMs); + if (_cfg.fuseOpenCheck) + _fuse.update(vMv < PDU_FUSE_OPEN_MV, vMv > PDU_FUSE_OPEN_MV + h, PDU_V_SET_MS, PDU_CLEAR_MS, nowMs); + _sensor.update(vRailed, !vRailed, PDU_V_SET_MS, PDU_CLEAR_MS, nowMs); +} + +uint8_t RailMonitor::flags() const { + uint8_t f = 0; + if (_uv.on) f |= (uint8_t)PduRailFlag::UNDER_V; + if (_ov.on) f |= (uint8_t)PduRailFlag::OVER_V; + if (_oc.on) f |= (uint8_t)PduRailFlag::OVER_CURRENT; + if (_fuse.on) f |= (uint8_t)PduRailFlag::FUSE_OPEN; + if (_sensor.on) f |= (uint8_t)PduRailFlag::SENSOR_FAULT; + return f; +} + +TelemPayload RailMonitor::buildPage(bool resetWindow) { + const uint16_t vAvg = _n ? (uint16_t)(_sumMv / _n) : _lastMv; + const uint16_t vMin = _n ? _minMv : _lastMv; + const int16_t iAvg = Pdu::clampI16(_n ? _sumMa / (int32_t)_n : _lastMa); + + TelemPayload p; + p.page = (uint8_t)_cfg.page; + p.body[0] = flags(); + p.body[1] = (uint8_t)(vAvg & 0xFF); p.body[2] = (uint8_t)(vAvg >> 8); + p.body[3] = (uint8_t)(vMin & 0xFF); p.body[4] = (uint8_t)(vMin >> 8); + p.body[5] = (uint8_t)((uint16_t)iAvg & 0xFF); + p.body[6] = (uint8_t)((uint16_t)iAvg >> 8); + + if (resetWindow) { _sumMv = 0; _sumMa = 0; _n = 0; _minMv = UINT16_MAX; } + return p; +} + +NodeFaultCode RailMonitor::faultCode() const { + if (_fuse.on) return NodeFaultCode::FUSE_OPEN; + if (_oc.on) return NodeFaultCode::SHORT_CIRCUIT; + if (_ov.on) return NodeFaultCode::OVER_VOLTAGE; + if (_uv.on) return NodeFaultCode::UNDER_VOLTAGE; + if (_sensor.on) return NodeFaultCode::SENSOR_FAULT; + return NodeFaultCode::NONE; +} + +const char* RailMonitor::faultDetail() const { + const char* what = _fuse.on ? "fuse open" : _oc.on ? "over-current" : _ov.on ? "over-voltage" + : _uv.on ? "under-voltage" : _sensor.on ? "voltage sensor railed" : nullptr; + if (!what) return ""; + snprintf(_detail, sizeof(_detail), "%s rail %s", _cfg.name, what); + return _detail; +} + +} // namespace OpenSkyhawk diff --git a/Firmware/Libraries/PowerMonitor/RailMonitor.h b/Firmware/Libraries/PowerMonitor/RailMonitor.h new file mode 100644 index 00000000..f52f40d4 --- /dev/null +++ b/Firmware/Libraries/PowerMonitor/RailMonitor.h @@ -0,0 +1,100 @@ +/** + * @file RailMonitor.h + * @brief PDU monitoring node — one supply rail: condition flags, window statistics, page packing (#216). + * + * Fed one converted sample per scan (rail mV, current mA). Keeps: + * - condition flags (PduRailFlag) with set persistence, clear persistence and hysteresis, so a + * brief blip neither raises nor drops a flag; + * - window statistics for its PDU_RAIL_* page — average + minimum voltage, average current — + * reset each time the page is sent periodically; + * - the peak current for the PDU_STATUS page, reset each time that page is sent periodically. + * It is a FaultSource, so its worst condition reaches HEALTH_n.faultId through aggregateFaults(). + * + * Hardware-free: the caller converts ADC counts (PduMath) and supplies the clock, so the logic is + * tested with injected values. + * + * @note Static lifetime only (FaultSource registry rule) — construct as a global. + * @copyright GPL-2.0-only — see Firmware/LICENSE + */ + +#pragma once +#include +#include +#include // TelemPayload + +namespace OpenSkyhawk { +namespace Pdu { + +/** @brief A condition that must hold `setMs` to raise and its clear condition hold `clearMs` to drop. */ +struct Latch { + bool on = false; + bool pending = false; + uint32_t since = 0; + /** @brief Advance the latch. `cond` raises it, `clear` (evaluated while on) drops it. */ + void update(bool cond, bool clear, uint32_t setMs, uint32_t clearMs, uint32_t nowMs); + void reset() { on = pending = false; } +}; + +} // namespace Pdu + +class RailMonitor : public FaultSource { +public: + /** @brief Per-rail limits. */ + struct Config { + TelemPage page; ///< PDU_RAIL_12V or PDU_RAIL_5V + const char* name; ///< "12V" / "5V" — DiagSerial detail only + uint16_t uvMv; ///< under-voltage threshold + uint16_t ovMv; ///< over-voltage threshold + uint16_t ocMa; ///< over-current threshold + bool fuseOpenCheck; ///< true on the 12 V rail; a blown 5 V fuse powers the node down + }; + + explicit RailMonitor(const Config& cfg); + + /** + * @brief Feed one scan. + * @param vMv Rail voltage, mV. + * @param iMa Rail current, mA (signed — residual offset may read slightly negative). + * @param vRailed Voltage channel at the top of the ADC range (can't measure). + * @param nowMs millis(). + * @param armed false during the power-up blanking window: statistics accumulate but every + * flag is held clear (rail ramp + inrush would otherwise trip UV / OC). + */ + void sample(uint16_t vMv, int32_t iMa, bool vRailed, uint32_t nowMs, bool armed); + + /** @brief Current PduRailFlag bits (all zero = healthy). */ + uint8_t flags() const; + + /** + * @brief Build this rail's PDU_RAIL_* page from the current window. + * @param resetWindow true for the periodic send (window restarts); false for an edge send. + */ + TelemPayload buildPage(bool resetWindow); + + /** @brief Peak current since the last reset, mA (≥ 0). */ + uint16_t peakMa() const { return _peakMa; } + /** @brief Restart the peak — called when the PDU_STATUS page is sent periodically. */ + void resetPeak() { _peakMa = 0; } + + /** @brief Last converted voltage, mV — DiagSerial / bring-up. */ + uint16_t lastMv() const { return _lastMv; } + /** @brief Last converted current, mA — DiagSerial / bring-up. */ + int32_t lastMa() const { return _lastMa; } + + NodeFaultCode faultCode() const override; + const char* faultDetail() const override; + +private: + Config _cfg; + Pdu::Latch _uv, _ov, _oc, _fuse, _sensor; + uint32_t _sumMv = 0; + int32_t _sumMa = 0; + uint32_t _n = 0; + uint16_t _minMv = UINT16_MAX; + uint16_t _peakMa = 0; + uint16_t _lastMv = 0; + int32_t _lastMa = 0; + mutable char _detail[32]; // faultDetail() formats here (const, slow health path) +}; + +} // namespace OpenSkyhawk diff --git a/Firmware/Libraries/PowerMonitor/TelemSchedule.cpp b/Firmware/Libraries/PowerMonitor/TelemSchedule.cpp new file mode 100644 index 00000000..570fece7 --- /dev/null +++ b/Firmware/Libraries/PowerMonitor/TelemSchedule.cpp @@ -0,0 +1,37 @@ +#include "TelemSchedule.h" + +namespace OpenSkyhawk { +namespace Pdu { + +void TelemSchedule::start(uint32_t nowMs) { + _lastPeriodic = nowMs; + _rr = 0; + for (uint8_t i = 0; i < PAGES; i++) { _lastSent[i] = nowMs - EDGE_MIN_MS; _lastFlags[i] = 0; } +} + +int8_t TelemSchedule::next(uint32_t nowMs, const uint8_t flags[PAGES], bool& periodic) { + if (nowMs - _lastPeriodic >= TELEM_PERIOD_MS) { + _lastPeriodic += TELEM_PERIOD_MS; + if (nowMs - _lastPeriodic >= TELEM_PERIOD_MS) _lastPeriodic = nowMs; // fell behind: don't burst + const uint8_t slot = _rr; + _rr = (uint8_t)((_rr + 1) % PAGES); + periodic = true; + return (int8_t)slot; + } + for (uint8_t i = 0; i < PAGES; i++) { + if (flags[i] == _lastFlags[i]) continue; + if (nowMs - _lastSent[i] < EDGE_MIN_MS) continue; + periodic = false; + return (int8_t)i; + } + return -1; +} + +void TelemSchedule::sent(uint8_t slot, uint8_t flags, uint32_t nowMs) { + if (slot >= PAGES) return; + _lastSent[slot] = nowMs; + _lastFlags[slot] = flags; +} + +} // namespace Pdu +} // namespace OpenSkyhawk diff --git a/Firmware/Libraries/PowerMonitor/TelemSchedule.h b/Firmware/Libraries/PowerMonitor/TelemSchedule.h new file mode 100644 index 00000000..c8188962 --- /dev/null +++ b/Firmware/Libraries/PowerMonitor/TelemSchedule.h @@ -0,0 +1,53 @@ +/** + * @file TelemSchedule.h + * @brief When to send which TELEM_n page — periodic rotation plus rate-limited edge sends (#216). + * + * - Periodic: one frame per TELEM_PERIOD_MS (the HEALTH_n rate), rotating through the pages, so + * each page refreshes every PAGES × period. A periodic send is the one that restarts that page's + * statistics window. + * - Edge: when a page's flag byte differs from what was last sent for it, send that page now — + * at most once per EDGE_MIN_MS per page; a change inside the hold-off goes out when it ends. + * An edge send does not restart the window. Every page starts from an all-clear baseline (0), + * so a condition present at boot goes out promptly instead of waiting for its periodic slot. + * + * Hardware-free — the caller supplies the clock and the current flag bytes. + * + * @copyright GPL-2.0-only — see Firmware/LICENSE + */ + +#pragma once +#include + +namespace OpenSkyhawk { +namespace Pdu { + +class TelemSchedule { +public: + static constexpr uint8_t PAGES = 3; ///< PDU_RAIL_12V, PDU_RAIL_5V, PDU_STATUS + static constexpr uint32_t TELEM_PERIOD_MS = 1000; ///< one frame per second per node + static constexpr uint32_t EDGE_MIN_MS = 100; ///< per-page edge-send hold-off + + /** @brief Start the clock; the first periodic frame is due one period later. */ + void start(uint32_t nowMs); + + /** + * @brief Which page to send now, if any. + * @param nowMs millis(). + * @param flags Current flag byte of each page (index = page slot). + * @param periodic Out: true for the periodic (window-restarting) send. + * @return Page slot 0..PAGES-1, or -1 when nothing is due. + */ + int8_t next(uint32_t nowMs, const uint8_t flags[PAGES], bool& periodic); + + /** @brief Record that page `slot` went out carrying `flags` at `nowMs`. */ + void sent(uint8_t slot, uint8_t flags, uint32_t nowMs); + +private: + uint32_t _lastPeriodic = 0; + uint8_t _rr = 0; + uint32_t _lastSent[PAGES] = {}; + uint8_t _lastFlags[PAGES] = {}; +}; + +} // namespace Pdu +} // namespace OpenSkyhawk diff --git a/Firmware/Libraries/PowerMonitor/library.json b/Firmware/Libraries/PowerMonitor/library.json new file mode 100644 index 00000000..df708db2 --- /dev/null +++ b/Firmware/Libraries/PowerMonitor/library.json @@ -0,0 +1,28 @@ +{ + "name": "PowerMonitor", + "version": "0.1.0", + "description": "PDU monitoring node for OpenSkyhawk — rail voltage/current and board temperature, TELEM_n telemetry, per-board calibration", + "keywords": [ + "CAN", + "STM32", + "OpenSkyhawk", + "PDU" + ], + "frameworks": [ + "arduino" + ], + "platforms": [ + "ststm32" + ], + "dependencies": [ + { + "name": "STM32Board" + }, + { + "name": "CANProtocol" + }, + { + "name": "NodeStatus" + } + ] +} diff --git a/Firmware/NODE_IDS.md b/Firmware/NODE_IDS.md index 462afe6d..80196e09 100644 --- a/Firmware/NODE_IDS.md +++ b/Firmware/NODE_IDS.md @@ -12,11 +12,14 @@ claim the next available NODE_ID here. | 0 | PanelBridge | — | Reserved — CAN master, never transmitted on bus | | 1 | Center_Armament | Center | Active | | 2 | Right_Navigation | Right | Active | +| 3 | PDU_Left | Left | Active — power distribution + monitoring (#216) | +| 4 | PDU_Right | Right | Active — power distribution + monitoring (#216) | +| 5 | PDU_Center | Center | Active — power distribution + monitoring (#216) | ## Rules - NODE_ID 0 is reserved for PanelBridge. -- Assign incrementally: next available is 2. +- Assign incrementally: next available is 6. - One NODE_ID per physical STM32 board. - Add your assignment here in the PR that creates the new panel sketch. diff --git a/Firmware/Panels/PDU_Center/platformio.ini b/Firmware/Panels/PDU_Center/platformio.ini new file mode 100644 index 00000000..f1ba3df4 --- /dev/null +++ b/Firmware/Panels/PDU_Center/platformio.ini @@ -0,0 +1,31 @@ +[env:PDU_Center] +; Major version held on purpose: ^20 takes 20.x updates but blocks the next +; major. Unconstrained, this jumped to 20.0.0 (STM32 Arduino core 3.0.0) and +; broke the weekly build for two weeks (#276). +platform = ststm32@^20.0.0 +board = genericSTM32F103C8 +framework = arduino +board_build.f_cpu = 72000000L + +; The last 1 KB flash page (0x0800FC00) holds this board's calibration (PowerMonitor +; PduCalStore). Capping the image at 63 KB makes the build fail before code can grow into it. +board_upload.maximum_size = 64512 + +; NODE_ID must be set here — not in src/main.cpp. +; A #define in main.cpp is invisible to library translation units (CANProtocol, PowerMonitor). +; The three PDUs run identical code; only NODE_ID differs. Center console PDU = 5 (Firmware/NODE_IDS.md). +build_flags = + -DNODE_ID=5 + -DHAL_CAN_MODULE_ENABLED + -DUSB_NONE + -DHSE_VALUE=8000000 + +lib_extra_dirs = ${PROJECT_DIR}/../../Libraries +lib_deps = + STM32Board + CANProtocol + NodeStatus + PowerMonitor + +upload_protocol = stlink +monitor_speed = 115200 diff --git a/Firmware/Panels/PDU_Center/src/main.cpp b/Firmware/Panels/PDU_Center/src/main.cpp new file mode 100644 index 00000000..402c5117 --- /dev/null +++ b/Firmware/Panels/PDU_Center/src/main.cpp @@ -0,0 +1,14 @@ +// PDU_Center — power distribution + monitoring node for the Center console (#202 hardware, #216 firmware). +// Identical on every PDU; NODE_ID comes from platformio.ini. Everything else is in the +// PowerMonitor library — see PduNode.h for what runs and the DiagSerial calibration commands. + +#include + +void setup() { + STM32Board::setDebug(true); // DiagSerial log + the CAL commands' replies + PduNode::setup(); +} + +void loop() { + PduNode::loop(); +} diff --git a/Firmware/Panels/PDU_Left/platformio.ini b/Firmware/Panels/PDU_Left/platformio.ini new file mode 100644 index 00000000..3211927b --- /dev/null +++ b/Firmware/Panels/PDU_Left/platformio.ini @@ -0,0 +1,31 @@ +[env:PDU_Left] +; Major version held on purpose: ^20 takes 20.x updates but blocks the next +; major. Unconstrained, this jumped to 20.0.0 (STM32 Arduino core 3.0.0) and +; broke the weekly build for two weeks (#276). +platform = ststm32@^20.0.0 +board = genericSTM32F103C8 +framework = arduino +board_build.f_cpu = 72000000L + +; The last 1 KB flash page (0x0800FC00) holds this board's calibration (PowerMonitor +; PduCalStore). Capping the image at 63 KB makes the build fail before code can grow into it. +board_upload.maximum_size = 64512 + +; NODE_ID must be set here — not in src/main.cpp. +; A #define in main.cpp is invisible to library translation units (CANProtocol, PowerMonitor). +; The three PDUs run identical code; only NODE_ID differs. Left console PDU = 3 (Firmware/NODE_IDS.md). +build_flags = + -DNODE_ID=3 + -DHAL_CAN_MODULE_ENABLED + -DUSB_NONE + -DHSE_VALUE=8000000 + +lib_extra_dirs = ${PROJECT_DIR}/../../Libraries +lib_deps = + STM32Board + CANProtocol + NodeStatus + PowerMonitor + +upload_protocol = stlink +monitor_speed = 115200 diff --git a/Firmware/Panels/PDU_Left/src/main.cpp b/Firmware/Panels/PDU_Left/src/main.cpp new file mode 100644 index 00000000..cd12211e --- /dev/null +++ b/Firmware/Panels/PDU_Left/src/main.cpp @@ -0,0 +1,14 @@ +// PDU_Left — power distribution + monitoring node for the Left console (#202 hardware, #216 firmware). +// Identical on every PDU; NODE_ID comes from platformio.ini. Everything else is in the +// PowerMonitor library — see PduNode.h for what runs and the DiagSerial calibration commands. + +#include + +void setup() { + STM32Board::setDebug(true); // DiagSerial log + the CAL commands' replies + PduNode::setup(); +} + +void loop() { + PduNode::loop(); +} diff --git a/Firmware/Panels/PDU_Right/platformio.ini b/Firmware/Panels/PDU_Right/platformio.ini new file mode 100644 index 00000000..484f7c24 --- /dev/null +++ b/Firmware/Panels/PDU_Right/platformio.ini @@ -0,0 +1,31 @@ +[env:PDU_Right] +; Major version held on purpose: ^20 takes 20.x updates but blocks the next +; major. Unconstrained, this jumped to 20.0.0 (STM32 Arduino core 3.0.0) and +; broke the weekly build for two weeks (#276). +platform = ststm32@^20.0.0 +board = genericSTM32F103C8 +framework = arduino +board_build.f_cpu = 72000000L + +; The last 1 KB flash page (0x0800FC00) holds this board's calibration (PowerMonitor +; PduCalStore). Capping the image at 63 KB makes the build fail before code can grow into it. +board_upload.maximum_size = 64512 + +; NODE_ID must be set here — not in src/main.cpp. +; A #define in main.cpp is invisible to library translation units (CANProtocol, PowerMonitor). +; The three PDUs run identical code; only NODE_ID differs. Right console PDU = 4 (Firmware/NODE_IDS.md). +build_flags = + -DNODE_ID=4 + -DHAL_CAN_MODULE_ENABLED + -DUSB_NONE + -DHSE_VALUE=8000000 + +lib_extra_dirs = ${PROJECT_DIR}/../../Libraries +lib_deps = + STM32Board + CANProtocol + NodeStatus + PowerMonitor + +upload_protocol = stlink +monitor_speed = 115200 diff --git a/Firmware/Panels/PDU_Right/src/main.cpp b/Firmware/Panels/PDU_Right/src/main.cpp new file mode 100644 index 00000000..577e48c3 --- /dev/null +++ b/Firmware/Panels/PDU_Right/src/main.cpp @@ -0,0 +1,14 @@ +// PDU_Right — power distribution + monitoring node for the Right console (#202 hardware, #216 firmware). +// Identical on every PDU; NODE_ID comes from platformio.ini. Everything else is in the +// PowerMonitor library — see PduNode.h for what runs and the DiagSerial calibration commands. + +#include + +void setup() { + STM32Board::setDebug(true); // DiagSerial log + the CAL commands' replies + PduNode::setup(); +} + +void loop() { + PduNode::loop(); +} diff --git a/Firmware/ScratchPad/FirmwarePlan/00-decisions.md b/Firmware/ScratchPad/FirmwarePlan/00-decisions.md index 6009a27c..b26db6a7 100644 --- a/Firmware/ScratchPad/FirmwarePlan/00-decisions.md +++ b/Firmware/ScratchPad/FirmwarePlan/00-decisions.md @@ -240,7 +240,7 @@ CAN/DCS traffic. ch17 internally to reference the reading to Vdd) and reports `dieTempC` on a **new dedicated `HEALTH_n` frame** (`0x140+n`, 1000 ms, default-on). It is **not** folded into `HB_n`. Vdd is measured but **not transmitted** — per-node rail voltage is weak signal on a regulated bus and is -covered properly by the PDU's INA226 power telemetry (#202). The overheat flag / status-LED +covered properly by the PDU's power telemetry (`TELEM_n`, #216; INA180A2 current sense, #202). The overheat flag / status-LED WARNING is computed only when a build defines `NODE_OVERHEAT_C`; the default build ships pure telemetry with no trip point. The frame is the shared node-health contract (#221): temperature (#213) and degraded-state (#163) each own their own fields within the fixed 8 bytes. @@ -416,3 +416,36 @@ PanelGroup and PanelBridge no longer carry their own copies. **Affects:** `02-can-protocol.md`, `06-panelbridge-api.md`, `08-hardware-firmware-contracts.md`, `09-startup-resync-diagnostics.md`, `11-open-issues.md`, TechSpec (`CANProtocol`, `PanelGroup`, `PanelBridge`, `STM32Board`), `docs/architecture/can-bus.md`. + +--- + +## D18 — PDU telemetry on a paged TELEM_n frame, forwarded unread (#216) + +**Decision:** node telemetry beyond generic health travels on one new per-node frame family, +`TELEM_n = 0x180 + n`, whose byte 0 is a global, append-only `TelemPage` naming the layout of the +other seven bytes. PanelBridge forwards every `TELEM_n` frame to the host **unread** as +`_NODE_TELEM <8 bytes>`, versioned on its own (`NODE_TELEM_PROTO_VERSION`) so `_NODE_STATUS` +stays at v2. The first pages belong to the PDU monitoring node: `PDU_RAIL_12V`, `PDU_RAIL_5V`, +`PDU_STATUS`, sent one frame per second in rotation plus rate-limited edges on any flag change. + +**Rationale:** +- *Layer on HEALTH_n, don't duplicate it.* `HEALTH_n` already is the generic all-node health frame + (die temp, degraded, faultId). PDU rail faults reach it through the FaultSource registry (D14) like + any fault; only the rail values and their detail flags need a new frame. +- *A page byte, not fixed IDs.* Per-node ID blocks cost 64 IDs each, and the original proposal's + `0x700 + n` had meanwhile gone to `EVT_ACTION_n` (#116). One family with a page byte keeps the ID + map small and lets the bridge stay ignorant of layouts — a new page needs no bridge change. +- *Engineering units on the node.* The PDU holds its own per-board calibration, so it sends mV / mA / + 0.1 °C; the bridge forwards bytes and the client needs no per-board knowledge. Raw ADC counts stay + on DiagSerial for bring-up. +- *One frame per second.* Matching the `HEALTH_n` rate keeps `_NODE_TELEM` traffic to the host — and + the DCS relay, which receives every device line — low (about 3 messages/s for three PDUs), and the + CAN load negligible (~0.08 %). Edge sends make faults immediate anyway, where the 5 s roster poll + would delay them. +- *VDDA: nominal until anchored.* The F103's Vrefint is untrimmed (1.16–1.24 V, ±~3 %), a worse + reference than the AMS1117's ±1–2 % nominal, so an uncalibrated PDU assumes 3300 mV; a one-time + `CAL VDDA ` (DMM on the 3V3 rail) stores this chip's Vrefint, after which VDDA tracks it. + +**Affects:** `02-can-protocol.md`, `04-dcs-bios-integration.md`, `06-panelbridge-api.md`, +`08-hardware-firmware-contracts.md`, `10-implementation-plan.md`, TechSpec (`CANProtocol`, +`NodeStatus`, `PanelBridge`, `PowerMonitor`), `docs/architecture/can-bus.md`, `docs/architecture/power.md`. diff --git a/Firmware/ScratchPad/FirmwarePlan/02-can-protocol.md b/Firmware/ScratchPad/FirmwarePlan/02-can-protocol.md index bb20398e..230d7319 100644 --- a/Firmware/ScratchPad/FirmwarePlan/02-can-protocol.md +++ b/Firmware/ScratchPad/FirmwarePlan/02-can-protocol.md @@ -70,6 +70,7 @@ replaced by inline functions: ```cpp constexpr uint32_t canIdHb(uint8_t n) { return 0x100 + n; } // 0x100–0x13F (0 = PanelBridge) constexpr uint32_t canIdHealth(uint8_t n) { return 0x140 + n; } // 0x140–0x17F (node health/thermal) +constexpr uint32_t canIdTelem(uint8_t n) { return 0x180 + n; } // 0x181–0x1BF (paged node telemetry, #216) constexpr uint32_t canIdEvt(uint8_t n) { return 0x200 + n; } // 0x201–0x23F constexpr uint32_t canIdEcho(uint8_t n) { return 0x300 + n; } // 0x301–0x33F constexpr uint32_t canIdReady(uint8_t n) { return 0x400 + n; } // 0x401–0x43F @@ -87,6 +88,7 @@ constexpr uint32_t canIdReady(uint8_t n) { return 0x400 + n; } // 0x401–0x43F | `HB_0` | `0x100` | PanelBridge → All | 8 | Master liveness beacon every 500 ms; every other node runs a master-loss watchdog on it (#93) | | `HB_n` | `0x100+n` | Node_n → PanelBridge | 8 | Node heartbeat every 500 ms for n=1-63 — see HB payload below | | `HEALTH_n` | `0x140+n` | Node_n → PanelBridge | 8 | Internal die-temp + node-health telemetry every 1000 ms (n=0-63, incl. PanelBridge itself) — see HEALTH payload below (#213/#221) | +| `TELEM_n` | `0x180+n` | Node_n → PanelBridge | 8 | Paged node telemetry (`TelemPayload`) — one frame per second plus flag-change edges; first user the PDU (#216) — see TELEM payload below | | `EVT_n` | `0x200+n` | Node_n → PanelBridge | 8 | Absolute input events (switches, selectors, pots) — `ControlPacketPair`, value `%u` → `set_state` | | `ECHO_n` | `0x300+n` | Node_n → PanelBridge | 8 | TEST_SEQ echo — 8-byte payload mirrored unchanged | | `READY_n` | `0x400+n` | Node_n → PanelBridge | 0 | One-time boot signal after initial input poll and EVT burst are complete | @@ -166,7 +168,7 @@ the degraded-state feature (#163) owns `flags` bit1 / `faultId`, populated per n order). A healthy node ships all-zero; a faulted node ships `flags` DEGRADED + a nonzero `faultId`, but `faultMask` **stays 0** — it is reserved for future fault-domain bits. `faultId` is a `NodeFaultCode` (NodeStatus.h) the client maps to a label — no fault strings on the wire. **Rail voltage/current is deliberately absent** — that is the PDU's dedicated power -telemetry (#202, real INA226 sensors per console), not generic per-node health; the reserved +telemetry (`TELEM_n` below, #216; INA180A2 current sense per console, #202), not generic per-node health; the reserved bytes are for future *generic* health fields (reset cause, free RAM, I2C error count). **Default-on**, compile out with `-DNODE_HEALTH_TELEM=0`. PanelBridge caches each node's fields @@ -192,6 +194,55 @@ overheat flagging — accurate hot-spot sensing (e.g. PDU shunt/fuse zones) stil --- +## Node Telemetry Payload — TELEM_n (#216) + +`TELEM_n` frames carry **paged node telemetry**: byte 0 is a `TelemPage` (NodeStatus.h) naming the +layout of bytes 1–7. Page ids are **global and append-only** — a page means the same layout whichever +node sends it, so the host decodes by page, never by node type — and `0x00` is invalid. Multi-byte +fields are little-endian. + +```cpp +struct __attribute__((packed)) TelemPayload { + uint8_t page; // 0: TelemPage (NodeStatus.h); 0x00 invalid + uint8_t body[7]; // 1-7: page-defined +}; +``` + +**Why one frame family with a page byte**, not one ID block per value group: each per-node ID block +costs 64 IDs, and with a page byte PanelBridge can forward frames **unread** (`_NODE_TELEM`, see +`04-dcs-bios-integration.md`) — a new page type needs no bridge change. `0x180+n` sits with `HB_n` +(`0x100`) and `HEALTH_n` (`0x140`) in the node-status block; it outranks `EVT_n` in arbitration +exactly as `HEALTH_n` does, which at ≤ 1 frame/s per node costs an input event at most one frame time +(~0.27 ms). Generic node health (die temp, fault id) stays in `HEALTH_n` and is never repeated here. + +**PDU pages** (the PDU monitoring node, `PowerMonitor` library): + +| Page | Byte 1 | Bytes 2–3 | Bytes 4–5 | Bytes 6–7 | +|---|---|---|---|---| +| `0x01 PDU_RAIL_12V` / `0x02 PDU_RAIL_5V` | `PduRailFlag` bits | `vAvgMv` u16 | `vMinMv` u16 (window min) | `iAvgMa` **int16** | +| `0x03 PDU_STATUS` | `PduStatusFlag` bits | `ntcTempDC` int16, 0.1 °C (`INT16_MIN` = unavailable) | `i12PeakMa` u16 | `i5PeakMa` u16 | + +- `PduRailFlag`: bit0 UNDER_V · bit1 OVER_V · bit2 OVER_CURRENT · bit3 FUSE_OPEN · bit4 SENSOR_FAULT. +- `PduStatusFlag`: bit0 UNCAL_VDDA · bit1 UNCAL_ZERO · bit2 TEMP_WARN · bit3 TEMP_CRIT · bit4 NTC_FAULT. + All flags are fault-sense — all-zero is healthy and calibrated. +- Units are engineering units computed on the node, which holds its own calibration; the host needs no + per-board knowledge. `iAvgMa` is signed so residual zero-offset stays visible. +- Peak current is after the board's ~100 µs RC filter (1 kΩ / 100 nF on each INA180 output). + +**Send policy (PDU):** one frame per second — the `HEALTH_n` rate — rotating `PDU_RAIL_12V` → +`PDU_RAIL_5V` → `PDU_STATUS`, so each page refreshes every 3 s and its averages / minimum / peaks cover +those 3 s. A page whose flag byte changes is sent at once, at most once per 100 ms per page, without +restarting its window; every page starts from an all-clear baseline, so a condition present at boot +goes out promptly. `SYNC_REQ` sends all three pages immediately. The node's rail and thermal monitors +are `FaultSource`s, so their worst condition also reaches `HEALTH_n.faultId` (`FUSE_OPEN`, +`SHORT_CIRCUIT`, `OVER_VOLTAGE`, `UNDER_VOLTAGE`, `OVER_TEMPERATURE`, `SENSOR_FAULT`). + +**Bus load at 500 kbit/s** (8-byte frame ≈ 130 bits worst-case stuffing ≈ 0.27 ms): 3 PDUs steady = +3 frames/s ≈ **0.08 %**; worst-case fault storm (every page edging at the 100 ms limit) = 90 frames/s +≈ 2.4 %. For scale, `HB_n` + `HEALTH_n` from 20 nodes is 60 frames/s ≈ 1.6 %. + +--- + ## ControlPacketPair Frame Payload `CTRL_BCAST` and `EVT_n` frames carry exactly **8 bytes** — a `ControlPacketPair`. @@ -252,11 +303,12 @@ unpacked C++ struct, because natural alignment can pad this layout beyond 8 byte ## PanelBridge Receive Filter The heartbeat address family is `canIdHb(n) = 0x100 + n`, so the full HB range is -`0x100`-`0x13F` with `HB_0` PanelBridge's own master beacon. For child-node liveness, PanelBridge -accepts PanelGroup frames across five inbound ranges: HB_1-HB_63 (`0x101`-`0x13F`), -HEALTH_1-HEALTH_63 (`0x141`-`0x17F`), EVT_1-EVT_63 (`0x201`-`0x23F`), ECHO_1-ECHO_63 -(`0x301`-`0x33F`), and READY_1-READY_63 (`0x401`-`0x43F`). These cannot be expressed as a -single CAN mask filter. +`0x100`-`0x13F` with `HB_0` PanelBridge's own master beacon. PanelBridge accepts node frames across +nine inbound ranges: HB_1-HB_63 (`0x101`-`0x13F`), HEALTH_1-HEALTH_63 (`0x141`-`0x17F`), +TELEM_1-TELEM_63 (`0x181`-`0x1BF`), EVT_1-EVT_63 (`0x201`-`0x23F`), ECHO_1-ECHO_63 +(`0x301`-`0x33F`), READY_1-READY_63 (`0x401`-`0x43F`), EVT_REL (`0x501`-`0x53F`), EVT_DIR +(`0x601`-`0x63F`) and EVT_ACTION (`0x701`-`0x73F`). These cannot be expressed as a single CAN mask +filter. **Strategy:** use a **pass-all mask filter** (accepts every ID) with software-side validation inside the CAN RX interrupt handler. Frames whose ID does not fall in one of the accepted ranges diff --git a/Firmware/ScratchPad/FirmwarePlan/04-dcs-bios-integration.md b/Firmware/ScratchPad/FirmwarePlan/04-dcs-bios-integration.md index b938990c..e10b1678 100644 --- a/Firmware/ScratchPad/FirmwarePlan/04-dcs-bios-integration.md +++ b/Firmware/ScratchPad/FirmwarePlan/04-dcs-bios-integration.md @@ -67,6 +67,29 @@ serial in Monitor/Replay). Reserved constants + the wire format live in `NodeSta canonical contract source the client syncs against; `NODE_STATUS_PROTO_VERSION` bumps on any wire change). The whole feature is gated behind `-DPANELBRIDGE_NODE_STATUS` (default off). +### Node telemetry — `_NODE_TELEM` (#216) + +Same transport, separate contract (`NODE_TELEM_*` in `NodeStatus.h`, `NODE_TELEM_PROTO_VERSION` 1 — +independent of `_NODE_STATUS`, which stays v2). PanelBridge forwards **every** `TELEM_n` CAN frame +(`0x181`–`0x1BF`, see `02-can-protocol.md`) to the host **unread**, one DCS-BIOS command message per +frame: + +``` +_NODE_TELEM 18 hex chars: nodeId(2) + the 8 TELEM frame bytes (16), in wire order +``` + +- **Pushed as frames arrive** — no request, no cache, no terminator. The host keeps the latest value + per `(nodeId, page)` and treats a page as stale after a few periods (PDU: one frame per second, + rotating three pages, so each page every 3 s). +- **Byte 2 of the hex payload is the `TelemPage`;** the host decodes by page. Unlike `_NODE_STATUS`, + the 8 bytes are raw frame bytes, so multi-byte page fields are **little-endian**. +- PanelBridge drops a frame whose page byte is `0x00` or whose length is not 8; a `TELEM_n` frame + never changes liveness (`HB_n` owns that). +- Bridge mode only, gated by the same `-DPANELBRIDGE_NODE_STATUS` flag. Like `_NODE_STATUS`, a copy + that reaches DCS through the client's byte-for-byte relay is ignored as an unknown command. + +Client side: OpenSkyhawk/SkyHawkClient#61. + --- ## DCSIN_* — Compact Transport Aliases diff --git a/Firmware/ScratchPad/FirmwarePlan/06-panelbridge-api.md b/Firmware/ScratchPad/FirmwarePlan/06-panelbridge-api.md index 64ac48a0..685a5235 100644 --- a/Firmware/ScratchPad/FirmwarePlan/06-panelbridge-api.md +++ b/Firmware/ScratchPad/FirmwarePlan/06-panelbridge-api.md @@ -65,6 +65,11 @@ in the `HEALTH_n` handler, #221) and emits a `_NODE_STATUS ` DCS-BIOS comma SimGateway is unchanged — the ASCII messages and the binary request pass through transparently. +**Node telemetry to the host (#216, same flag):** every `TELEM_n` frame (`0x181`–`0x1BF`) is +forwarded unread as `_NODE_TELEM <8 frame bytes>` as soon as it arrives — no cache, no +decode, so a new `TelemPage` needs no bridge change. Page-`0x00` and short frames are dropped; a +`TELEM_n` frame never marks a node alive. Contract: `04-dcs-bios-integration.md`. + --- ## ExportStreamListener diff --git a/Firmware/ScratchPad/FirmwarePlan/08-hardware-firmware-contracts.md b/Firmware/ScratchPad/FirmwarePlan/08-hardware-firmware-contracts.md index df8cd1e1..b059c320 100644 --- a/Firmware/ScratchPad/FirmwarePlan/08-hardware-firmware-contracts.md +++ b/Firmware/ScratchPad/FirmwarePlan/08-hardware-firmware-contracts.md @@ -149,6 +149,43 @@ output. Production builds use `setDebug(false)` (default) — pins remain availa --- +## PDU Monitoring Node — Analog Front End & Calibration Page (#202 / #216) + +The PDU (`PCB/Base/Power/PDU`, Rev 0.2.0) is an STM32F103C8 node running the `PowerMonitor` library +(`Firmware/Panels/PDU_Left|Right|Center`, NODE_IDs 3/4/5). Pin map — verified against the Rev 0.2.0 +netlist; overridable as `PDU_PIN_*`: + +| Pin | Net | Front end | Firmware conversion | +|---|---|---|---| +| `PA0` | `12V_READ_ADC` | 33k / 10k divider (0.1 %) → 1k → 100 nF | rail = pin × 43 / 10 | +| `PA1` | `5V_READ_ADC` | 10k / 13k divider (0.1 %) → 1k → 100 nF | rail = pin × 23 / 13 | +| `PA2` | `I_12V_ADC` | 10 mΩ shunt → INA180A2 (×50) → 1k → 100 nF | mA = 2 × pin mV | +| `PA3` | `I_5V_ADC` | 10 mΩ shunt → INA180A2 (×50) → 1k → 100 nF | mA = 2 × pin mV | +| `PA6` | `NTC_ADC` | 10k from +3V3, NTC 10k B3450 ±1 % to GND → 1k → 100 nF | ratiometric Beta equation | +| — | Vrefint (ch17) | internal | VDDA correction once calibrated | + +- **VDDA.** The ADC reference is VDDA (the AMS1117 3V3 through FB1). Uncalibrated, the firmware assumes + **3300 mV**; the F103 Vrefint is untrimmed (±~3 %) and worse than that nominal until anchored. A + one-time `CAL VDDA ` (DMM reading of the 3V3 rail) stores this chip's Vrefint; VDDA then + tracks it. The NTC divider runs from the same rail, so temperature is ratiometric and needs no VDDA. +- **Current zero.** `CAL ZERO` (no load connected) stores each rail's INA180 offset. The PDU's own + regulator, CAN transceiver and LED sit **after the 5 V shunt** (~30–50 mA), so the 5 V zero + subtracts the node's own draw and the 5 V reading is *downstream load only*; the refusal guard is + therefore 120 mA on 5 V vs 75 mA on 12 V. +- **Filtering.** The 1 kΩ / 100 nF on each channel (τ ≈ 100 µs) is the anti-alias filter for the + 1 kHz rail scan; reported peak current is after it. +- **A blown 5 V fuse powers the node down** (the AMS1117 is fed from the fused 5 V), so only the 12 V + rail has a fuse-open check; the host sees a dead 5 V fuse as the node going offline. +- **Calibration flash page.** The last 1 KB page, `0x0800FC00`, holds an append-only log of 32-byte + CRC-checked records (newest valid wins; erased only when full). The PDU builds set + `board_upload.maximum_size = 64512`, so the build fails before code can grow into the page, and an + OpenOCD upload erases only the sectors it writes — a reflash keeps the calibration. A full-chip + erase (e.g. STM32CubeProgrammer) wipes it; re-run the calibration afterwards. +- **Status LED** behaves as on every node (CAN / link + master-loss WARNING); rail faults show on the + host and the board's D3/D4 rail LEDs, not the MCU LED. + +--- + ## Status LED — Bi-Color (Red/Green) Each custom OpenSkyhawk STM32 board has a bi-color red/green status LED driven by two dedicated diff --git a/Firmware/ScratchPad/FirmwarePlan/10-implementation-plan.md b/Firmware/ScratchPad/FirmwarePlan/10-implementation-plan.md index 5ababd4e..ccbb9e1d 100644 --- a/Firmware/ScratchPad/FirmwarePlan/10-implementation-plan.md +++ b/Firmware/ScratchPad/FirmwarePlan/10-implementation-plan.md @@ -217,8 +217,14 @@ covered; the only uncovered outputs were the five light-intensity values (`LIGHT ## Phase 5b — Infrastructure Nodes: PDU *(Firmware v0.2.0)* - [ ] PDU boards (#202) assembled and brought up. -- [ ] PDU monitoring firmware + its new CAN telemetry frame (#216) — reuse the `HEALTH_n` pattern - (D13). +- [x] Node heartbeat / HEALTH / master watchdog moved into `CANProtocol::serviceNodeStatus()` so + the PDU needs no PanelGroup dependency (#340, D17). +- [x] PDU monitoring firmware + its `TELEM_n` telemetry frame (#216, D18) — `PowerMonitor` library, + `Panels/PDU_Left|Right|Center` (NODE_IDs 3/4/5), `_NODE_TELEM` forwarding in PanelBridge. + Logic + flash tests on hardware; real-bus smoke on a non-PDU board. +- [ ] On the PDU itself (with #202 bring-up): 1 kHz scan, `CAL VDDA` / `CAL ZERO` per board, + thresholds tuned against the real load, calibration survives a reflash. +- [ ] Client: `_NODE_TELEM` decode + per-rail display (OpenSkyHawk/SkyHawkClient#61). --- diff --git a/Firmware/ScratchPad/TechSpec/CANProtocol.md b/Firmware/ScratchPad/TechSpec/CANProtocol.md index 5a1a0e5e..b7adb7c9 100644 --- a/Firmware/ScratchPad/TechSpec/CANProtocol.md +++ b/Firmware/ScratchPad/TechSpec/CANProtocol.md @@ -258,6 +258,19 @@ struct __attribute__((packed)) HeartbeatPayload { uint16_t esr; ///< (CAN1->ESR >> 16): low byte=TEC, high byte=REC }; +/** + * @brief 8-byte payload carried by TELEM_n frames — paged node telemetry (#216). + * + * Byte 0 is a TelemPage (NodeStatus.h) naming the layout of bytes 1–7; page ids are global and + * append-only. Multi-byte page fields are little-endian. Layouts: FirmwarePlan/02-can-protocol.md + * (Node Telemetry Payload). + */ +struct __attribute__((packed)) TelemPayload { + uint8_t page; ///< 0: TelemPage; 0x00 invalid + uint8_t body[7]; ///< 1-7: page-defined +}; +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, ///< Peripheral configured, not yet started @@ -280,6 +293,10 @@ constexpr uint32_t canIdHb(uint8_t n) { return 0x100 + n; } /** @brief Node-health/thermal telemetry frame ID for node n. Range 0x140–0x17F; n=0 is PanelBridge. */ constexpr uint32_t canIdHealth(uint8_t n) { return 0x140 + n; } +/** @brief Paged node-telemetry frame ID for node n (TelemPayload, #216). Range 0x181–0x1BF. + * 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; } diff --git a/Firmware/ScratchPad/TechSpec/NodeStatus.md b/Firmware/ScratchPad/TechSpec/NodeStatus.md index fe0ac207..88f79744 100644 --- a/Firmware/ScratchPad/TechSpec/NodeStatus.md +++ b/Firmware/ScratchPad/TechSpec/NodeStatus.md @@ -22,6 +22,11 @@ Owns: - **`NodeFaultCode`** — the compact `faultId` dictionary (cross-node: I2C_PERIPHERAL, OVER_VOLTAGE, UNDER_VOLTAGE, SHORT_CIRCUIT, HOST_LINK_LOST, …). Client maps id → label (SkyHawkClient#40). - **`FaultSource`** — the interface a fault-producing object implements + a self-registering list. +- **`NODE_TELEM_*` + the TELEM_n page vocabulary (#216)** — `NODE_TELEM_MSG_NAME` (`_NODE_TELEM`) and + `NODE_TELEM_PROTO_VERSION` (1, independent of `_NODE_STATUS`); `TelemPage` (global, append-only + page ids — PDU_RAIL_12V / PDU_RAIL_5V / PDU_STATUS); `PduRailFlag` and `PduStatusFlag` (the page + flag bytes). Here, not in a PDU header, so the client's `sync-a4ec.ts` generates them from the same + contract file (SkyHawkClient#61). `NodeHealthPayload` (the CAN HEALTH_n frame struct) stays in `CANProtocol.h`. Fault **detail strings** stay local (DiagSerial) and never go on the wire or into any shared/HID header. @@ -46,12 +51,15 @@ Firmware/Libraries/NodeStatus/ #define NODE_STATUS_REQ_ADDR 0x86FE #define NODE_STATUS_MSG_NAME "_NODE_STATUS" #define NODE_STATUS_END_MSG_NAME "_NODE_STATUS_END" +#define NODE_TELEM_PROTO_VERSION 1 // bump on a _NODE_TELEM framing / existing-page layout change +#define NODE_TELEM_MSG_NAME "_NODE_TELEM" enum class NodeHealthFlag : uint8_t { OVERHEAT = 0x01, DEGRADED = 0x02 }; enum class NodeFaultCode : uint8_t { NONE = 0, I2C_PERIPHERAL = 1, OVER_VOLTAGE = 2, UNDER_VOLTAGE = 3, - SHORT_CIRCUIT = 4, HOST_LINK_LOST = 5, // 0x06+ reserved + SHORT_CIRCUIT = 4, HOST_LINK_LOST = 5, + FUSE_OPEN = 6, OVER_TEMPERATURE = 7, SENSOR_FAULT = 8, // PDU (#216); 0x09+ reserved }; namespace OpenSkyhawk { @@ -93,6 +101,9 @@ DiagSerial only, on change). **Degraded drives no status-LED** — frame + DiagS are others. Implementers report **cached** state only (cheap/const, no blocking I/O) — the aggregator runs on the periodic health path. **Adding a fault code:** append a `NodeFaultCode` value + mirror the client label. **Changing the `_NODE_STATUS` wire shape:** bump `NODE_STATUS_PROTO_VERSION`. +**Adding a TELEM page:** append a `TelemPage` value (never renumber) and document its layout in +FirmwarePlan 02 — additive, no bump; changing an existing page's layout bumps +`NODE_TELEM_PROTO_VERSION`. --- @@ -104,7 +115,9 @@ its detail, null `faultDetail()` normalized to `""`, `nullptr` detailOut accepte indirectly: `DrumDisplay` breaker test asserts `faultCode()`/`faultDetail()` via the `FaultSource` override; `CANProtocol` `health_payload`/`health_flags` tests assert the faultId/DEGRADED packing (incl. overheat+degraded coexistence); `PanelBridge` node_status test exercises the `_NODE_STATUS` -emission; the client `reference.test.ts` asserts the proto-version contract. +emission; `PanelBridge` node_telem test exercises `_NODE_TELEM` forwarding; `PowerMonitor` tests +cover the PDU fault sources and page flags; the client `reference.test.ts` asserts the proto-version +contract. --- diff --git a/Firmware/ScratchPad/TechSpec/PanelBridge.md b/Firmware/ScratchPad/TechSpec/PanelBridge.md index 7adfcfbe..a73e601b 100644 --- a/Firmware/ScratchPad/TechSpec/PanelBridge.md +++ b/Firmware/ScratchPad/TechSpec/PanelBridge.md @@ -361,6 +361,8 @@ are child-node liveness frames. The RX handler validates IDs in software: | Range | Accepted by PanelBridge | Action | |-------|--------------------------|--------| | `0x101`-`0x13F` | `HB_1`-`HB_63` | Update node heartbeat/liveness | +| `0x141`-`0x17F` | `HEALTH_1`-`HEALTH_63` | Cache die temp + health/fault for `_NODE_STATUS` (not liveness) | +| `0x181`-`0x1BF` | `TELEM_1`-`TELEM_63` | Forward unread as `_NODE_TELEM` (#216; not liveness; page 0 / short dropped) | | `0x201`-`0x23F` | `EVT_1`-`EVT_63` | Dispatch each non-null ControlPacket slot | | `0x301`-`0x33F` | `ECHO_1`-`ECHO_63` | Consume TEST_SEQ RTT diagnostic response | | `0x401`-`0x43F` | `READY_1`-`READY_63` | Mark node ready and broadcast `SYNC_REQ` | diff --git a/Firmware/ScratchPad/TechSpec/PowerMonitor.md b/Firmware/ScratchPad/TechSpec/PowerMonitor.md new file mode 100644 index 00000000..649fbca4 --- /dev/null +++ b/Firmware/ScratchPad/TechSpec/PowerMonitor.md @@ -0,0 +1,165 @@ +# PowerMonitor — Technical Specification + +**Status:** Done — firmware + tests (hardware bench on the PDU itself pending #202 bring-up) +**FirmwarePlan ref:** `02-can-protocol.md` (Node Telemetry Payload — TELEM_n), `04-dcs-bios-integration.md` +(`_NODE_TELEM`), `08-hardware-firmware-contracts.md` (PDU analog front end & calibration page), +`00-decisions.md` D17 (node status service) + D18 (TELEM_n) +**Depends on:** CANProtocol, STM32Board, NodeStatus + +--- + +## Responsibility + +The firmware of the **PDU monitoring node** (#202 hardware, #216 firmware) — one per console, +NODE_IDs 3/4/5. It samples the 12 V and 5 V rails (voltage + current) and the board NTC, turns +them into engineering units with the board's own calibration, raises rail / thermal conditions, +publishes them as `TELEM_n` pages, and feeds its faults into `HEALTH_n` through the FaultSource +registry. Node plumbing (HB / HEALTH / master watchdog) comes from `CANProtocol::serviceNodeStatus()`; +there is **no PanelGroup dependency** (the PDU has no inputs or outputs). + +Does **not** own: the TELEM wire contract (`TelemPayload` in CANProtocol, page vocabulary in +NodeStatus), host forwarding (PanelBridge), or the status LED (STM32Board / CANProtocol — rail +faults never touch it, D14). + +Reads the analog pins directly (`analogRead`) rather than through `PinRef`: `PinRef` lives in the +PanelGroup library, which this node deliberately does not link. The pins are fixed by the PDU board +and overridable as `PDU_PIN_*`. + +--- + +## File Layout + +``` +Firmware/Libraries/PowerMonitor/ +├── library.json ← deps: STM32Board, CANProtocol, NodeStatus +├── PduConfig.h ← pin map, front-end constants, PDU_* threshold macros (defaults) +├── PduMath.h/.cpp ← pure conversions: VDDA, rail mV, current mA, NTC 0.1 °C, CRC-32 +├── RailMonitor.h/.cpp ← one rail: flags (Latch persistence + hysteresis), window stats, page +├── BoardThermal.h/.cpp ← NTC warn / critical / fault +├── TelemSchedule.h/.cpp← 1 frame/s rotation + rate-limited edge sends +├── PduCalStore.h/.cpp ← calibration records in the reserved flash page +└── PduNode.h/.cpp ← the node: setup()/loop(), sampling, telemetry, DiagSerial CAL commands + +Firmware/Panels/PDU_Left|PDU_Right|PDU_Center/ ← identical thin sketches, NODE_ID 3/4/5 +Firmware/Tests/PowerMonitor/ ← 5 test envs (below) +``` + +--- + +## Public API + +```cpp +namespace PduNode { + void setup(); // STM32Board::begin, load cal, CAN start, READY_n + void loop(); // tick, drain, serviceNodeStatus, scan, telemetry, DiagSerial + uint16_t scanRateHz(); // rail scans in the last second (target 1000) + const OpenSkyhawk::RailMonitor& rail12(); + const OpenSkyhawk::RailMonitor& rail5(); + const OpenSkyhawk::BoardThermal& thermal(); +} + +class OpenSkyhawk::RailMonitor : public FaultSource { + struct Config { TelemPage page; const char* name; uint16_t uvMv, ovMv, ocMa; bool fuseOpenCheck; }; + void sample(uint16_t vMv, int32_t iMa, bool vRailed, uint32_t nowMs, bool armed); + uint8_t flags() const; // PduRailFlag bits + TelemPayload buildPage(bool resetWindow); // PDU_RAIL_* page; periodic send restarts the window + uint16_t peakMa() const; void resetPeak(); + NodeFaultCode faultCode() const override; // FUSE_OPEN > SHORT_CIRCUIT > OVER_V > UNDER_V > SENSOR +}; + +class OpenSkyhawk::BoardThermal : public FaultSource { + void sample(int16_t tempDC, bool ntcFault, bool armed); + uint8_t flags() const; // TEMP_WARN | TEMP_CRIT | NTC_FAULT + int16_t tempDC() const; // INT16_MIN = unavailable + NodeFaultCode faultCode() const override; // OVER_TEMPERATURE > SENSOR_FAULT +}; + +class OpenSkyhawk::Pdu::TelemSchedule { // PAGES = 3, TELEM_PERIOD_MS = 1000, EDGE_MIN_MS = 100 + void start(uint32_t nowMs); + int8_t next(uint32_t nowMs, const uint8_t flags[PAGES], bool& periodic); // slot or -1 + void sent(uint8_t slot, uint8_t flags, uint32_t nowMs); +}; + +class OpenSkyhawk::Pdu::CalStore { // page = PDU_CAL_PAGE_ADDR (0x0800FC00) + bool load(CalRecord& out) const; // newest valid record; false → calDefaults() + bool save(CalRecord& rec); // append (seq+1); erase first when full + bool erase(); + uint8_t usedSlots() const; +}; +``` + +--- + +## Key Data Structures + +**`CalRecord`** — 32 bytes, CRC-32 over bytes 0–27, up to 32 per page (append-only, newest valid +wins, erase only when full): + +| Off | Size | Field | +|---|---|---| +| 0x00 | u32 | `magic` `'PCAL'` | +| 0x04 | u8 | `version` (1) | +| 0x05 | u8 | `calFlags` (`CAL_VDDA` / `CAL_ZERO` / `CAL_GAIN` / `CAL_NTC`) | +| 0x06 | u16 | `vrefintMv` | +| 0x08 / 0x0A | i16 ×2 | 12 V / 5 V current zero, 0.1 mA | +| 0x0C / 0x0E | u16 ×2 | current gain trims (10000 = ×1) | +| 0x10 / 0x12 | u16 ×2 | voltage gain trims | +| 0x14 | i16 | NTC offset, 0.1 °C | +| 0x16 | u16 | reserved | +| 0x18 | u32 | `seq` | +| 0x1C | u32 | CRC-32 | + +**`Pdu::Latch`** — a condition that must hold `setMs` to raise, and its clear condition hold +`clearMs` to drop; hysteresis is expressed through the separate clear condition. + +--- + +## Implementation Notes + +- **Sampling.** Rails every `PDU_SAMPLE_US` (1000 µs; measured 1000 scans/s on an F103C8), four + `analogRead`s per scan; NTC + Vrefint every 100 ms. `analogReadResolution(16)` comes from + STM32Board, shared with `readDieTempC()` — same thread, no contention. +- **Conversions** (PduMath, integer except the NTC): pin mV = counts × VDDA / 65536; 12 V = pin × 43/10; + 5 V = pin × 23/13; mA = 2 × pin mV − zero, × gain; NTC ratiometric Beta (B 3450, 10 k / 10 k). + VDDA = 3300 mV until `CAL VDDA` stores a Vrefint, then Vrefint-tracked. +- **Flags** (defaults in `PduConfig.h`, all `-D` overridable): UV 11.40 / 4.75 V, OV 13.2 / 5.5 V, + OC 4.5 / 1.8 A (90 % of fuse), fuse-open < 1.0 V (12 V only); voltage set 50 ms, OC set 100 ms, + clear 500 ms, 100 mV hysteresis; NTC warn 60 °C / crit 75 °C, 3 °C hysteresis; NTC fault outside + 2–98 % FS; voltage channel ≥ 99.5 % FS → SENSOR_FAULT. Everything held clear for + `PDU_BOOT_BLANK_MS` (500 ms) after boot. +- **Telemetry.** `TelemSchedule` drives one `TELEM_n` frame per second, rotating the three pages; + a flag change sends that page at once (≤ 1 / 100 ms / page) without restarting its window; + `SYNC_REQ` sends all three. The status page's peaks reset on its periodic send. +- **DiagSerial commands** (115200, one per line, replies always printed): `CAL SHOW`, + `CAL VDDA ` (3000–3600; refuses a derived Vrefint outside 1100–1300 mV), `CAL ZERO` (256-sample + average; refuses above 75 mA on 12 V / 120 mA on 5 V), `CAL CLEAR`, `RAW` (live values + scan rate). +- **Flash budget.** `board_upload.maximum_size = 64512` keeps the image out of the calibration page; + the PDU build is ~31.5 KB. + +--- + +## Test + +`Firmware/Tests/PowerMonitor/` — results on DiagSerial, any STM32F103 board: + +| Env | Covers | +|---|---| +| `test_pdu_math` | VDDA, rail / current conversions incl. trims and signed offset, NTC curve (25 / 50 / 0 °C), NTC open / short, railed, CRC-32 check value | +| `test_rail_monitor` | boot blanking, UV set 50 ms / clear 500 ms, hysteresis band, OC 100 ms + 90 % clear, fuse-open priority, sensor railed, 5 V has no fuse check, page bytes, window + peak reset | +| `test_board_thermal` | warn / crit thresholds + hysteresis, NTC fault keeps last state, fault codes | +| `test_telem_schedule` | 1 Hz rotation, edge send, 100 ms hold-off, rotation undisturbed, no burst after a stall, all-clear baseline | +| `test_cal_store` | real flash page: save / load, newest wins, corrupt record skipped, wrap-around erase, left erased | + +Plus `Tests/PanelBridge` `test_node_telem` (`_NODE_TELEM` forwarding, needs the UART2 adapter) and +`Tests/CANProtocol` `protocol_layout` (`canIdTelem` range, `TelemPayload` size). + +--- + +## Dependencies + +| Dependency | Use | +|---|---| +| CANProtocol | `send`, `drain`, `start`, `serviceNodeStatus`, `canIdTelem`, `canIdReady`, `TelemPayload` | +| STM32Board | `begin`, `tick`, `diagSerial`, `onCanStatus`, ADC resolution | +| NodeStatus | `FaultSource`, `NodeFaultCode`, `TelemPage`, `PduRailFlag`, `PduStatusFlag` | +| STM32duino core | `analogRead` (incl. `AVREF`), `micros`, `millis`, HAL flash (`HAL_FLASHEx_Erase`, `HAL_FLASH_Program`) | diff --git a/Firmware/ScratchPad/TechSpec/README.md b/Firmware/ScratchPad/TechSpec/README.md index 19b013a6..a001eef1 100644 --- a/Firmware/ScratchPad/TechSpec/README.md +++ b/Firmware/ScratchPad/TechSpec/README.md @@ -163,6 +163,8 @@ STM32Board (depends on CANProtocol for the CanStatus event type) ↑ implement HIDControls first; then A4EC and CANProtocol (either order); then STM32Board PanelBridge (depends on CANProtocol, STM32Board, A4EC) SimGateway (depends on HIDControls — CTRL_* constants used in sketch declarations) +PowerMonitor (depends on CANProtocol, STM32Board, NodeStatus — the PDU node, #216; + no PanelGroup dependency) ↑ build the bridge/gateway backbone before the large PanelGroup surface └── PanelGroup/ ├── PinRef (no dependencies) diff --git a/Firmware/Tests/CANProtocol/tests/protocol_layout/protocol_layout.cpp b/Firmware/Tests/CANProtocol/tests/protocol_layout/protocol_layout.cpp index a9b09ceb..5aab1dae 100644 --- a/Firmware/Tests/CANProtocol/tests/protocol_layout/protocol_layout.cpp +++ b/Firmware/Tests/CANProtocol/tests/protocol_layout/protocol_layout.cpp @@ -12,6 +12,7 @@ static_assert(sizeof(ControlPacket) == 4, "ControlPacket must be 4 bytes"); static_assert(sizeof(ControlPacketPair) == 8, "ControlPacketPair must be 8 bytes"); static_assert(sizeof(HeartbeatPayload) == 8, "HeartbeatPayload must be 8 bytes"); +static_assert(sizeof(TelemPayload) == 8, "TelemPayload must be 8 bytes"); static_assert(canIdHb(0) == 0x100U, "canIdHb(0) reserved for PanelBridge"); static_assert(canIdHb(1) == 0x101U, "canIdHb(1) must be 0x101"); @@ -24,6 +25,9 @@ static_assert(canIdEvtDir(1) == 0x601U, "canIdEvtDir(1) must be 0x601"); static_assert(canIdEvtDir(63) == 0x63FU, "canIdEvtDir(63) must be 0x63F"); static_assert(canIdEvtAction(1) == 0x701U, "canIdEvtAction(1) must be 0x701"); static_assert(canIdEvtAction(63) == 0x73FU, "canIdEvtAction(63) must be 0x73F"); +static_assert(canIdTelem(1) == 0x181U, "canIdTelem(1) must be 0x181"); +static_assert(canIdTelem(63) == 0x1BFU, "canIdTelem(63) must be 0x1BF"); +static_assert(canIdTelem(63) < canIdEvt(1), "TELEM range must stay below EVT"); static_assert(canIdEcho(1) == 0x301U, "canIdEcho(1) must be 0x301"); static_assert(canIdReady(1) == 0x401U, "canIdReady(1) must be 0x401"); diff --git a/Firmware/Tests/PanelBridge/platformio.ini b/Firmware/Tests/PanelBridge/platformio.ini index af52edf9..f76e82ec 100644 --- a/Firmware/Tests/PanelBridge/platformio.ini +++ b/Firmware/Tests/PanelBridge/platformio.ini @@ -112,6 +112,21 @@ build_flags = -DPANELBRIDGE_NODE_STATUS build_src_filter = -<*> + +; TELEM forwarding (#216): TELEM_n frames → `_NODE_TELEM` DCS-BIOS messages, unread; page-0 / short +; frames dropped; TELEM is not liveness. Single board, PANELBRIDGE_TEST hooks — no CAN bus required. +; Serial (UART2 PA2/PA3) → USB-UART @ 250000 for _NODE_TELEM lines; DiagSerial (USART1) @ 115200. +[env:test_node_telem] +extends = env_base +build_flags = + -DNODE_ID=0 + -DHAL_CAN_MODULE_ENABLED + -DUSB_NONE + -DHSE_VALUE=8000000 + -DSERIAL_RX_BUFFER_SIZE=256 + -DPANELBRIDGE_TEST + -DPANELBRIDGE_NODE_STATUS +build_src_filter = -<*> + + ; Node-timeout underflow guard (#225): testCheckTimeouts with now < lastSeenMs must NOT false-dead, ; while a genuinely overdue node still times out. Single board, no CAN bus. DiagSerial @115200. [env:test_timeout_underflow] diff --git a/Firmware/Tests/PanelBridge/tests/node_telem/node_telem.cpp b/Firmware/Tests/PanelBridge/tests/node_telem/node_telem.cpp new file mode 100644 index 00000000..416b4c97 --- /dev/null +++ b/Firmware/Tests/PanelBridge/tests/node_telem/node_telem.cpp @@ -0,0 +1,60 @@ +// PanelBridge — node_telem test (#216) +// +// Purpose: verify TELEM_n forwarding. PanelBridge passes every TELEM_n CAN frame (0x181–0x1BF) +// to the host unread, as `_NODE_TELEM <8 frame bytes>`; it drops a page-0 or short +// frame, and a TELEM frame never marks a node alive (HB_n owns liveness). Frames are injected +// with the PANELBRIDGE_TEST hook — no CAN bus or second board required. +// +// Hardware: single STM32F103CB board. +// - Serial (UART2 PA2/PA3) → USB-UART adapter → PC terminal @ 250000 baud +// - DiagSerial (USART1 PA9/PA10) @ 115200 baud (phase log) +// +// Expected on USB-UART (250000), each cycle: +// _NODE_TELEM 030100E02E182EBC02 ← node 3 PDU_RAIL_12V: 12000 mV avg, 11800 min, 700 mA +// _NODE_TELEM 050303FA0010273412 ← node 5 PDU_STATUS: flags 03, 25.0 °C, peaks 10000 / 4660 mA +// (nothing for the page-0 frame, the 4-byte frame, or canIdTelem(0)) +// _NODE_STATUS_END 0 ← roster request: TELEM made no node alive +// +// hex = nodeId(2) then the 8 frame bytes in wire order (multi-byte page fields little-endian). + +#define DCSBIOS_DEFAULT_SERIAL +#include +#include +#include +#include + +static uint32_t _lastMs = 0; + +void setup() { + STM32Board::diagSerial().begin(115200); + STM32Board::diagSerial().println(F("=== PanelBridge: node_telem (#216) ===")); + STM32Board::setDebug(true); + PanelBridge::setup(); // starts Serial @ 250000 + CAN + DcsBios::setup(); + STM32Board::diagSerial().println(F("[TEST] watch USB-UART (250000) for _NODE_TELEM lines")); +} + +void loop() { + DcsBios::loop(); + PanelBridge::loop(); + + const uint32_t now = millis(); + if (now - _lastMs < 3000) return; + _lastMs = now; + auto& d = STM32Board::diagSerial(); + + const uint8_t rail12[8] = {0x01, 0x00, 0xE0, 0x2E, 0x18, 0x2E, 0xBC, 0x02}; + const uint8_t status[8] = {0x03, 0x03, 0xFA, 0x00, 0x10, 0x27, 0x34, 0x12}; + const uint8_t page0[8] = {0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07}; + + d.println(F("[TEST] feed node 3 rail page + node 5 status page -> expect 2x _NODE_TELEM")); + PanelBridge::testFeedCanFrame(canIdTelem(3), rail12, 8); + PanelBridge::testFeedCanFrame(canIdTelem(5), status, 8); + d.println(F("[TEST] feed page-0, 4-byte and TELEM_0 frames -> expect nothing")); + PanelBridge::testFeedCanFrame(canIdTelem(3), page0, 8); + PanelBridge::testFeedCanFrame(canIdTelem(3), rail12, 4); + PanelBridge::testFeedCanFrame(canIdTelem(0), rail12, 8); + d.println(F("[TEST] roster request -> expect _NODE_STATUS_END 0 (TELEM is not liveness)")); + PanelBridge::testRequestNodeStatus(); + d.println(F("[TEST] --- cycle ---")); +} diff --git a/Firmware/Tests/PowerMonitor/platformio.ini b/Firmware/Tests/PowerMonitor/platformio.ini new file mode 100644 index 00000000..fa9b557f --- /dev/null +++ b/Firmware/Tests/PowerMonitor/platformio.ini @@ -0,0 +1,51 @@ +[platformio] +src_dir = tests + +; PowerMonitor (PDU node, #216) tests. pdu_math / rail_monitor / board_thermal / telem_schedule are +; pure logic on injected values; cal_store writes the reserved calibration flash page (0x0800FC00) +; and erases it when done. Any STM32F103 board — no PDU hardware needed. Results on DiagSerial +; (USART1 PA9/PA10, 115200). +[env_base] +; Major version held on purpose: ^20 takes 20.x updates but blocks the next +; major. Unconstrained, this jumped to 20.0.0 (STM32 Arduino core 3.0.0) and +; broke the weekly build for two weeks (#276). +platform = ststm32@^20.0.0 +board = genericSTM32F103C8 +framework = arduino +build_flags = + -DNODE_ID=3 + -DHAL_CAN_MODULE_ENABLED + -DUSB_NONE + -DHSE_VALUE=8000000 +lib_extra_dirs = ${PROJECT_DIR}/../../Libraries +lib_deps = + STM32Board + CANProtocol + NodeStatus + PowerMonitor +upload_protocol = stlink +upload_flags = + -c + set CPUTAPID 0 +monitor_speed = 115200 + +[env:test_pdu_math] +extends = env_base +build_src_filter = -<*> + + +[env:test_rail_monitor] +extends = env_base +build_src_filter = -<*> + + +[env:test_board_thermal] +extends = env_base +build_src_filter = -<*> + + +[env:test_telem_schedule] +extends = env_base +build_src_filter = -<*> + + +; Writes and erases the real calibration page — leaves the board uncalibrated. +[env:test_cal_store] +extends = env_base +build_src_filter = -<*> + diff --git a/Firmware/Tests/PowerMonitor/tests/board_thermal/board_thermal.cpp b/Firmware/Tests/PowerMonitor/tests/board_thermal/board_thermal.cpp new file mode 100644 index 00000000..6cba94e5 --- /dev/null +++ b/Firmware/Tests/PowerMonitor/tests/board_thermal/board_thermal.cpp @@ -0,0 +1,51 @@ +// PowerMonitor — BoardThermal thresholds, hysteresis and NTC fault (injected values). +// +// Defaults: warn 60.0 °C, critical 75.0 °C, hysteresis 3.0 °C. Ends with +// "=== board_thermal ALL PASS (n) ===". + +#include "../common/check.h" +#include + +using namespace OpenSkyhawk; + +BoardThermal th; // FaultSource: static lifetime + +static bool has(PduStatusFlag f) { return th.flags() & (uint8_t)f; } + +void setup() { + begin("board_thermal"); + + th.sample(900, false, /*armed=*/false); + checkEq("blanked: no flags even at 90 C", th.flags(), 0); + checkEq("blanked: temperature still reported", th.tempDC(), 900); + + th.sample(450, false, true); + checkEq("45 C: healthy", th.flags(), 0); + th.sample(600, false, true); + check("60 C: warn", has(PduStatusFlag::TEMP_WARN)); + check("warn is not a node fault", th.faultCode() == NodeFaultCode::NONE); + th.sample(575, false, true); + check("57.5 C: warn held (hysteresis)", has(PduStatusFlag::TEMP_WARN)); + th.sample(570, false, true); + check("57.0 C: warn cleared", !has(PduStatusFlag::TEMP_WARN)); + + th.sample(750, false, true); + check("75 C: critical", has(PduStatusFlag::TEMP_CRIT)); + check("critical -> OVER_TEMPERATURE", th.faultCode() == NodeFaultCode::OVER_TEMPERATURE); + th.sample(725, false, true); + check("72.5 C: critical held", has(PduStatusFlag::TEMP_CRIT)); + th.sample(700, false, true); + check("70 C: critical cleared, warn kept", !has(PduStatusFlag::TEMP_CRIT) && has(PduStatusFlag::TEMP_WARN)); + + th.sample(0, true, true); + check("NTC fault flagged", has(PduStatusFlag::NTC_FAULT)); + check("NTC fault -> SENSOR_FAULT", th.faultCode() == NodeFaultCode::SENSOR_FAULT); + checkEq("NTC fault -> temperature unavailable", th.tempDC(), INT16_MIN); + check("NTC fault keeps last warn state", has(PduStatusFlag::TEMP_WARN)); + th.sample(400, false, true); + checkEq("NTC back, 40 C: healthy", th.flags(), 0); + + summary("board_thermal"); +} + +void loop() {} diff --git a/Firmware/Tests/PowerMonitor/tests/cal_store/cal_store.cpp b/Firmware/Tests/PowerMonitor/tests/cal_store/cal_store.cpp new file mode 100644 index 00000000..d32d0765 --- /dev/null +++ b/Firmware/Tests/PowerMonitor/tests/cal_store/cal_store.cpp @@ -0,0 +1,78 @@ +// PowerMonitor — PduCalStore on the real reserved flash page (0x0800FC00). +// +// Erases the page, saves / reloads records, plants a corrupt record (bad CRC) that load() must +// skip, fills the page to force the wrap-around erase, and erases again at the end — the board is +// left uncalibrated. Ends with "=== cal_store ALL PASS (n) ===". + +#include "../common/check.h" +#include +#include + +using namespace OpenSkyhawk::Pdu; + +static CalStore store; + +// Program a 32-byte record verbatim (no sealing) into slot `i` — to plant a corrupt one. +static void programRaw(uint8_t i, const CalRecord& r) { + uint32_t w[sizeof(CalRecord) / 4]; + memcpy(w, &r, sizeof(w)); + HAL_FLASH_Unlock(); + for (size_t k = 0; k < sizeof(w) / 4; k++) + HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, PDU_CAL_PAGE_ADDR + i * sizeof(CalRecord) + k * 4, w[k]); + HAL_FLASH_Lock(); +} + +void setup() { + begin("cal_store"); + CalRecord r; + + check("erase", store.erase()); + checkEq("empty page: 0 slots used", store.usedSlots(), 0); + check("empty page: load() false", !store.load(r)); + checkEq("empty page: defaults unity gain", r.iGain12, GAIN_UNITY); + check("defaults are valid records", calValid(r)); + + CalRecord a = calDefaults(); + a.vrefintMv = 1210; a.zero12DmA = 123; a.calFlags = CAL_VDDA; + check("save #1", store.save(a)); + checkEq("1 slot used", store.usedSlots(), 1); + check("load #1", store.load(r)); + checkEq("vrefint round-trip", r.vrefintMv, 1210); + checkEq("zero12 round-trip", r.zero12DmA, 123); + checkEq("seq 1", (long)r.seq, 1); + checkEq("calValues: VDDA flag -> vrefint used", calValues(r).vrefintMv, 1210); + + CalRecord b = r; + b.zero5DmA = -45; b.calFlags |= CAL_ZERO; + check("save #2", store.save(b)); + store.load(r); + checkEq("newest wins: zero5", r.zero5DmA, -45); + checkEq("seq 2", (long)r.seq, 2); + + // A corrupt record after the good ones must be skipped, not trusted. + CalRecord bad = r; + bad.vrefintMv = 999; // CRC no longer matches + programRaw(2, bad); + checkEq("corrupt slot counted as used", store.usedSlots(), 3); + store.load(r); + checkEq("corrupt record skipped -> previous good", r.vrefintMv, 1210); + checkEq("still seq 2", (long)r.seq, 2); + + // Fill to 32 slots, then one more save wraps: page erased, record lands in slot 0. + while (store.usedSlots() < CAL_SLOTS) store.save(b); + checkEq("page full", store.usedSlots(), CAL_SLOTS); + CalRecord before; + store.load(before); + check("save on a full page", store.save(b)); + checkEq("wrapped: 1 slot used", store.usedSlots(), 1); + store.load(r); + checkEq("wrapped: seq continues", (long)r.seq, (long)before.seq + 1); + checkEq("wrapped: data kept", r.zero5DmA, -45); + + check("final erase", store.erase()); + check("left uncalibrated", !store.load(r)); + + summary("cal_store"); +} + +void loop() {} diff --git a/Firmware/Tests/PowerMonitor/tests/common/check.h b/Firmware/Tests/PowerMonitor/tests/common/check.h new file mode 100644 index 00000000..6c0ead5e --- /dev/null +++ b/Firmware/Tests/PowerMonitor/tests/common/check.h @@ -0,0 +1,46 @@ +// Minimal PASS/FAIL reporter for the PowerMonitor tests — prints one line per check on DiagSerial +// and a final ALL PASS / FAIL count. +#pragma once +#include + +static int _checks = 0, _fails = 0; + +static void check(const char* label, bool ok) { + auto& d = STM32Board::diagSerial(); + _checks++; + if (!ok) _fails++; + d.print(label); d.println(ok ? F(": PASS") : F(": FAIL")); +} + +static void checkEq(const char* label, long got, long want) { + if (got != want) { + auto& d = STM32Board::diagSerial(); + d.print(F(" got ")); d.print(got); d.print(F(" want ")); d.println(want); + } + check(label, got == want); +} + +static void checkNear(const char* label, long got, long want, long tol) { + const bool ok = got >= want - tol && got <= want + tol; + if (!ok) { + auto& d = STM32Board::diagSerial(); + d.print(F(" got ")); d.print(got); d.print(F(" want ")); d.print(want); + d.print(F(" ±")); d.println(tol); + } + check(label, ok); +} + +static void summary(const char* name) { + auto& d = STM32Board::diagSerial(); + d.print(F("=== ")); d.print(name); + if (_fails) { d.print(F(" FAIL ")); d.print(_fails); d.print('/'); d.print(_checks); d.println(F(" ===")); } + else { d.print(F(" ALL PASS (")); d.print(_checks); d.println(F(") ===")); } +} + +static void begin(const char* name) { + STM32Board::setDebug(true); + STM32Board::begin(); + delay(200); + auto& d = STM32Board::diagSerial(); + d.print(F("=== ")); d.print(name); d.println(F(" ===")); +} diff --git a/Firmware/Tests/PowerMonitor/tests/pdu_math/pdu_math.cpp b/Firmware/Tests/PowerMonitor/tests/pdu_math/pdu_math.cpp new file mode 100644 index 00000000..712bd202 --- /dev/null +++ b/Firmware/Tests/PowerMonitor/tests/pdu_math/pdu_math.cpp @@ -0,0 +1,56 @@ +// PowerMonitor — PduMath conversions (pure logic, injected values). +// +// Expected values worked by hand from the Rev 0.2.0 front end: 12 V divider 43/10, 5 V divider +// 23/13, INA180A2 ×50 on 10 mΩ (2 mA per mV), NTC 10k B3450 under a 10k bias. Ends with +// "=== pdu_math ALL PASS (n) ===". + +#include "../common/check.h" +#include +#include +#include + +using namespace OpenSkyhawk::Pdu; + +void setup() { + begin("pdu_math"); + + // VDDA: nominal until Vrefint is anchored, then Vrefint-tracked. + checkEq("vdda uncalibrated -> nominal 3300", vddaMv(23831, 0), 3300); + checkEq("vdda calibrated 1200 mV @ raw 23831", vddaMv(23831, 1200), 3300); + checkEq("vdda calibrated, droop raw 24550", vddaMv(24550, 1200), 3203); + checkEq("vdda unreadable reference -> nominal", vddaMv(0, 1200), 3300); + + checkEq("counts 32768 @ 3300 -> 1650 mV", countsToMv(32768, 3300), 1650); + checkEq("12 V rail: 2790 mV pin -> 11997 mV", rail12Mv(2790, 10000), 11997); + checkEq("5 V rail: 2826 mV pin -> 4999 mV", rail5Mv(2826, 10000), 4999); + checkEq("12 V rail gain trim x1.01", rail12Mv(2790, 10100), 12116); + + checkEq("current 1500 mV -> 3000 mA", currentMa(1500, 0, 10000), 3000); + checkEq("current with 50 mA zero -> 2950", currentMa(1500, 500, 10000), 2950); + checkEq("current zero + gain x1.01 -> 2980", currentMa(1500, 500, 10100), 2980); + checkEq("residual offset stays negative", currentMa(10, 500, 10000), -30); + checkEq("raw current 25 mV -> 500 dmA", rawCurrentDmA(25), 500); + + bool fault = true; + checkEq("NTC mid-scale -> 25.0 C", ntcTempDC(32768, 0, fault), 250); + check("NTC mid-scale not a fault", !fault); + checkNear("NTC 4085 ohm -> 50.0 C", ntcTempDC(19007, 0, fault), 500, 2); + checkNear("NTC 28835 ohm -> 0.0 C", ntcTempDC(48661, 0, fault), 0, 2); + checkEq("NTC offset applied", ntcTempDC(32768, -15, fault), 235); + checkEq("NTC shorted -> unavailable", ntcTempDC(500, 0, fault), INT16_MIN); + check("NTC shorted flagged", fault); + ntcTempDC(65000, 0, fault); + check("NTC open flagged", fault); + + check("railed at 65300", railed(65300)); + check("not railed at 60000", !railed(60000)); + + const char* v = "123456789"; + checkEq("crc32 check value", (long)crc32(reinterpret_cast(v), strlen(v)), (long)0xCBF43926u); + checkEq("clampI16 high", clampI16(40000), 32767); + checkEq("clampU16 negative", clampU16(-5), 0); + + summary("pdu_math"); +} + +void loop() {} diff --git a/Firmware/Tests/PowerMonitor/tests/rail_monitor/rail_monitor.cpp b/Firmware/Tests/PowerMonitor/tests/rail_monitor/rail_monitor.cpp new file mode 100644 index 00000000..0b4ffbbb --- /dev/null +++ b/Firmware/Tests/PowerMonitor/tests/rail_monitor/rail_monitor.cpp @@ -0,0 +1,106 @@ +// PowerMonitor — RailMonitor flags, timing, fault priority and page packing (injected values). +// +// A simulated clock steps in 10 ms scans. Default thresholds (PduConfig.h): 12 V UV 11400 / +// OV 13200 / OC 4500, hysteresis 100 mV, set 50 ms (OC 100 ms), clear 500 ms, fuse-open < 1000 mV. +// Ends with "=== rail_monitor ALL PASS (n) ===". + +#include "../common/check.h" +#include +#include + +using namespace OpenSkyhawk; + +// Globals: FaultSource objects must have static lifetime. +RailMonitor m12({TelemPage::PDU_RAIL_12V, "12V", PDU_12V_UV_MV, PDU_12V_OV_MV, PDU_12V_OC_MA, true}); +RailMonitor m5 ({TelemPage::PDU_RAIL_5V, "5V", PDU_5V_UV_MV, PDU_5V_OV_MV, PDU_5V_OC_MA, false}); +RailMonitor mp ({TelemPage::PDU_RAIL_12V, "12V", PDU_12V_UV_MV, PDU_12V_OV_MV, PDU_12V_OC_MA, true}); + +static uint32_t T = 0; // simulated millis() + +// Feed scans every 10 ms up to and including `until`. +static void run(RailMonitor& m, uint16_t mv, int32_t ma, uint32_t until, bool railedV = false, bool armed = true) { + while (T <= until) { m.sample(mv, ma, railedV, T, armed); T += 10; } +} + +static bool has(const RailMonitor& m, PduRailFlag f) { return m.flags() & (uint8_t)f; } + +void setup() { + begin("rail_monitor"); + + // Boot blanking: a dead rail + huge current while unarmed raises nothing. + run(m12, 500, 9000, 400, false, /*armed=*/false); + checkEq("blanked: no flags", m12.flags(), 0); + + run(m12, 12000, 1000, 990); + checkEq("healthy: no flags", m12.flags(), 0); + check("healthy: fault NONE", m12.faultCode() == NodeFaultCode::NONE); + + // Under-voltage needs 50 ms to set. + run(m12, 11000, 1000, 1040); + check("UV not set at 40 ms", !has(m12, PduRailFlag::UNDER_V)); + run(m12, 11000, 1000, 1050); + check("UV set at 50 ms", has(m12, PduRailFlag::UNDER_V)); + check("UV -> UNDER_VOLTAGE", m12.faultCode() == NodeFaultCode::UNDER_VOLTAGE); + + // Inside the hysteresis band (UV + < 100 mV) it never clears. + run(m12, 11450, 1000, 2100); + check("UV held inside hysteresis", has(m12, PduRailFlag::UNDER_V)); + // Clear needs > UV + 100 mV for 500 ms. + const uint32_t c0 = T; + run(m12, 12000, 1000, c0 + 490); + check("UV still set at 490 ms good", has(m12, PduRailFlag::UNDER_V)); + run(m12, 12000, 1000, c0 + 500); + check("UV cleared at 500 ms good", !has(m12, PduRailFlag::UNDER_V)); + + // Over-current needs 100 ms; clears below 90 % for 500 ms. + const uint32_t o0 = T; + run(m12, 12000, 5000, o0 + 90); + check("OC not set at 90 ms", !has(m12, PduRailFlag::OVER_CURRENT)); + run(m12, 12000, 5000, o0 + 100); + check("OC set at 100 ms", has(m12, PduRailFlag::OVER_CURRENT)); + check("OC -> SHORT_CIRCUIT", m12.faultCode() == NodeFaultCode::SHORT_CIRCUIT); + run(m12, 12000, 4200, T + 800); + check("OC held above 90 %", has(m12, PduRailFlag::OVER_CURRENT)); + run(m12, 12000, 3000, T + 500); + check("OC cleared", !has(m12, PduRailFlag::OVER_CURRENT)); + + // Fuse open: rail ~0 V — UV and FUSE_OPEN both set; FUSE_OPEN wins the fault code. + run(m12, 500, 0, T + 60); + check("fuse open set", has(m12, PduRailFlag::FUSE_OPEN)); + check("UV also set", has(m12, PduRailFlag::UNDER_V)); + check("FUSE_OPEN outranks UV", m12.faultCode() == NodeFaultCode::FUSE_OPEN); + run(m12, 12000, 1000, T + 600); + checkEq("recovered: no flags", m12.flags(), 0); + + // Railed voltage channel -> SENSOR_FAULT (OV too, which outranks it). + run(m12, 14190, 1000, T + 60, /*railedV=*/true); + check("sensor fault set", has(m12, PduRailFlag::SENSOR_FAULT)); + check("OV outranks sensor fault", m12.faultCode() == NodeFaultCode::OVER_VOLTAGE); + + // 5 V rail has no fuse-open check: a dead 5 V rail powers the node down instead. + T = 0; + run(m5, 500, 0, 100); + check("5V dead: UV set", has(m5, PduRailFlag::UNDER_V)); + check("5V dead: no FUSE_OPEN", !has(m5, PduRailFlag::FUSE_OPEN)); + + // Page packing + window: avg 12000, min 11800, avg I 700, peak 1200. + mp.sample(12000, 1000, false, 0, true); + mp.sample(11800, 1200, false, 10, true); + mp.sample(12200, -100, false, 20, true); + TelemPayload p = mp.buildPage(/*resetWindow=*/true); + const uint8_t want[8] = {0x01, 0x00, 0xE0, 0x2E, 0x18, 0x2E, 0xBC, 0x02}; + bool same = true; + for (int i = 0; i < 8; i++) same &= reinterpret_cast(&p)[i] == want[i]; + check("page bytes 01 00 E02E 182E BC02", same); + checkEq("peak current 1200", mp.peakMa(), 1200); + // After the periodic reset, an empty window reports the last sample. + p = mp.buildPage(false); + checkEq("empty window -> last vMv", p.body[1] | (p.body[2] << 8), 12200); + checkEq("empty window -> last iMa", (int16_t)(p.body[5] | (p.body[6] << 8)), -100); + mp.resetPeak(); + checkEq("peak reset", mp.peakMa(), 0); + + summary("rail_monitor"); +} + +void loop() {} diff --git a/Firmware/Tests/PowerMonitor/tests/telem_schedule/telem_schedule.cpp b/Firmware/Tests/PowerMonitor/tests/telem_schedule/telem_schedule.cpp new file mode 100644 index 00000000..994858f0 --- /dev/null +++ b/Firmware/Tests/PowerMonitor/tests/telem_schedule/telem_schedule.cpp @@ -0,0 +1,76 @@ +// PowerMonitor — TelemSchedule: 1 frame/s rotation, edge sends, hold-off, no burst (injected clock). +// Ends with "=== telem_schedule ALL PASS (n) ===". + +#include "../common/check.h" +#include + +using namespace OpenSkyhawk::Pdu; + +static TelemSchedule s; +static uint8_t flags[TelemSchedule::PAGES] = {0, 0, 0}; + +// Advance 1 ms at a time from `from` to `to`; record every send (slot, periodic, t). +struct Send { int8_t slot; bool periodic; uint32_t t; }; +static Send sends[64]; +static int nSends = 0; + +static void run(uint32_t from, uint32_t to) { + for (uint32_t t = from; t <= to; t++) { + bool periodic = false; + const int8_t slot = s.next(t, flags, periodic); + if (slot < 0) continue; + if (nSends < 64) sends[nSends++] = {slot, periodic, t}; + s.sent((uint8_t)slot, flags[slot], t); + } +} + +void setup() { + begin("telem_schedule"); + + s.start(0); + run(0, 3500); + checkEq("3 periodic frames in 3.5 s", nSends, 3); + check("rotation 12V -> 5V -> STATUS", sends[0].slot == 0 && sends[1].slot == 1 && sends[2].slot == 2); + check("all periodic", sends[0].periodic && sends[1].periodic && sends[2].periodic); + check("one per second", sends[0].t == 1000 && sends[1].t == 2000 && sends[2].t == 3000); + + // A flag change goes out at once as a non-periodic send. + nSends = 0; + flags[1] = 0x01; // 5 V under-voltage appears at 3600 + run(3600, 3600); + check("edge sent immediately", nSends == 1 && sends[0].slot == 1 && !sends[0].periodic); + + // Within the 100 ms hold-off a further change waits, then goes out. + nSends = 0; + flags[1] = 0x03; + run(3601, 3699); + checkEq("change inside hold-off waits", nSends, 0); + run(3700, 3700); + check("sent when hold-off ends", nSends == 1 && sends[0].slot == 1); + + // Edges don't disturb the rotation: next periodic is still at 4000, slot 0. + nSends = 0; + run(3701, 4000); + check("rotation unaffected by edges", nSends == 1 && sends[0].slot == 0 && sends[0].periodic && sends[0].t == 4000); + + // After a long stall the schedule resumes without bursting. + nSends = 0; + run(9000, 9000); + checkEq("stall: one frame, no burst", nSends, 1); + run(9001, 9999); + checkEq("stall: next frame a full period later", nSends, 1); + run(10000, 10000); + checkEq("stall: then back on cadence", nSends, 2); + + // Baseline is all-clear: a page that starts non-zero is edge-sent right after start(). + TelemSchedule s2; + s2.start(0); + const uint8_t f2[TelemSchedule::PAGES] = {0, 0, 0x03}; + bool periodic = true; + const int8_t slot = s2.next(0, f2, periodic); + check("non-zero at boot -> edge at once", slot == 2 && !periodic); + + summary("telem_schedule"); +} + +void loop() {} diff --git a/PCB/Base/Power/PDU/DESIGN-NOTES.md b/PCB/Base/Power/PDU/DESIGN-NOTES.md index 274d8742..ee68bbce 100644 --- a/PCB/Base/Power/PDU/DESIGN-NOTES.md +++ b/PCB/Base/Power/PDU/DESIGN-NOTES.md @@ -38,6 +38,10 @@ Note: **NO buck on the PDU.** Servo 12V→5/6V buck (AP63205WU) lives on each *p - ADC ref = VDDA (3.3V rail, AMS1117 ±1–2% + drift) → all readings scale with it. - Correction = **STM32F103 internal Vrefint** (ADC ch17, ~1.20V bandgap): FW reads it → computes true VDDA → corrects. **0 parts, $0.** - F103 Vrefint not per-chip trimmed (±~3% absolute) but tracks load/temp drift — enough for ballpark. +- **Correction (2026-10-15, #216):** untrimmed, Vrefint (±~3 %) is a *worse* absolute reference than + the AMS1117 nominal (±1–2 %), so it only helps once anchored. Firmware assumes **VDDA = 3300 mV** + until a one-time `CAL VDDA ` (DMM reading of the 3V3 rail) stores this chip's Vrefint; after + that VDDA tracks it. So "no probes" is not quite true: one DMM reading per board. - **LM4040 dropped** — ~$0.60 (not $0.15) precision ref; overkill vs Vrefint for ballpark goal. Revisit only if absolute-V accuracy becomes a requirement. ### B2. Shunt resistor ×2 — 10 mΩ 2512 [CURRENT SKU: C2903468 — see 2026-09-30 note below] diff --git a/docs/_source/base-boards.md b/docs/_source/base-boards.md index 4164593a..2a172c1c 100644 --- a/docs/_source/base-boards.md +++ b/docs/_source/base-boards.md @@ -136,10 +136,10 @@ scale or isolate: redesign. - **+12V gets the full pass-through treatment even on pass-through-only boards** (e.g. Gateway/Bridge draws no local 12V but still carries the cumulative downstream). -- Contingency: a **power-distribution + monitoring CAN node** (#202) — STM32 + per-zone INA226 + - eFuse breakers, telemetry → PanelBridge — measures the (unknown) consumption *and* provides the - injection points. Its eFuse breaking sits **on top of** the baseline per-console fuse (telemetry + - active trip), not instead of it. +- **Power-distribution + monitoring CAN node (PDU)** (#202 hardware, #216 firmware) — STM32 + + per-rail INA180A2 current sense + voltage dividers + NTC, telemetry → PanelBridge (`TELEM_n`) — + measures the (unknown) consumption *and* provides the injection points. It monitors **on top of** + the baseline per-console fuse; it does not break the circuit (no eFuse on Rev 0.2.0). ### PSU / source supply diff --git a/docs/architecture/can-bus.md b/docs/architecture/can-bus.md index bb162fc4..7e4d5305 100644 --- a/docs/architecture/can-bus.md +++ b/docs/architecture/can-bus.md @@ -32,6 +32,7 @@ helper functions — **never hard-code these values**, always use the helper. | `SYNC_REQ` | `0x012` | `CAN_ID_SYNC_REQ` | PanelBridge → All | 0 | | `HB_n` | `0x100 + n` | `canIdHb(n)` | Node *n* → PanelBridge | 8 | | `HEALTH_n` | `0x140 + n` | `canIdHealth(n)` | Node *n* → PanelBridge | 8 | +| `TELEM_n` | `0x180 + n` | `canIdTelem(n)` | Node *n* → PanelBridge | 8 | | `EVT_n` | `0x200 + n` | `canIdEvt(n)` | Node *n* → PanelBridge | 8 | | `ECHO_n` | `0x300 + n` | `canIdEcho(n)` | Node *n* → PanelBridge | 8 | | `READY_n` | `0x400 + n` | `canIdReady(n)` | Node *n* → PanelBridge | 0 | @@ -46,6 +47,10 @@ helper functions — **never hard-code these values**, always use the helper. CANProtocol routine, which also runs each node's watch on the master heartbeat. - `HEALTH_n` carries `NodeHealthPayload` — internal die temperature, and node degraded/fault state as that lands. +- `TELEM_n` carries paged telemetry: byte 0 says which page the other seven bytes are. The + power-distribution node uses it for per-rail voltage and current and its board temperature, one + frame a second plus an immediate frame whenever a rail condition changes. PanelBridge passes every + `TELEM_n` frame to the host app unread, so a new kind of page needs no bridge change. - `TEST_SEQ` / `ECHO_n` are a round-trip latency test: PanelBridge sends `TEST_SEQ`, a node echoes on `ECHO_n`. - `SYNC_REQ` / `READY_n` are the boot/resync handshake (below). diff --git a/docs/architecture/power.md b/docs/architecture/power.md index ba2f767f..3633accc 100644 --- a/docs/architecture/power.md +++ b/docs/architecture/power.md @@ -82,13 +82,38 @@ the rest of the cockpit and its harness. - **The PSU's own OCP protects the PSU, not your harness** — a single-rail ATX only trips at tens of amps, long after a thin lead would cook. The per-zone fuse is what actually protects the wiring. -- A planned **power-distribution CAN node** adds live per-console current telemetry and active - eFuse breaking **on top of** — not instead of — these baseline fuses. +- The **power-distribution node (PDU)** sits at each console's injection point, holds that + console's fuses, and reports live telemetry **on top of** them — it monitors, it doesn't break + the circuit. See *Monitoring — the PDU node* below. !!! note "Exact values live in the hardware source" Fuse ratings, trace widths, and injection copper are specified in the base-boards hardware source; this page is the overview. +## Monitoring — the PDU node + +Each console's PDU measures what it feeds: the 12 V and 5 V rail voltage, the current through each +fuse, and the board temperature. It reports them over CAN about once a second, and at once when +something changes — a rail sagging below or rising above its limits, a current near the fuse +rating, a blown 12 V fuse, or the board running hot. Those faults also turn the node amber in the +host app's node list. Its first job is measurement: logging the real cockpit load so the final +fuse ratings are chosen from data. + +Each PDU is calibrated once at bring-up, over its debug serial port, with a multimeter reading of +its 3.3 V rail and the load disconnected. The calibration is stored on the board and survives +firmware updates. + +!!! warning "Wiring assumptions the PDU relies on" + - **Power never chains past a PDU.** A console's downstream harness must not feed another + PDU's console. A blown fuse then leaves that console's rail dead, which is how the PDU + detects it — back-feed from a neighbour would hide the blown fuse and split the current + readings. + - **A blown 5 V fuse takes its own PDU offline**, because the PDU runs from the fused 5 V. The + host app shows that node going offline, not a fuse alert. + - **PanelBridge is powered from one of the PDUs.** If its PDU loses 5 V, the bridge goes down + with it and every node appears offline at once. When the whole cockpit drops off, check the + PDU fuses first. + ## Cross-tier notes - **CAN transceiver** (SN65HVD230) runs directly from the 3.3 V rail — no level shifter. diff --git a/docs/firmware/build-flags.md b/docs/firmware/build-flags.md index 0241a997..20048a39 100644 --- a/docs/firmware/build-flags.md +++ b/docs/firmware/build-flags.md @@ -129,6 +129,30 @@ them: things.) The client half is already released, which is why the default flipped in the template rather than in the library. +## PDU node thresholds (`PDU_*`) + +The power-distribution node (`PowerMonitor` library, `Panels/PDU_*`) takes every limit as a +`PDU_*` macro with a default in `Firmware/Libraries/PowerMonitor/PduConfig.h`. The defaults are +starting points to tune on the bench against the real load; override one per build, e.g. +`-DPDU_12V_UV_MV=11200`, with no code change. + +| Flag | Default | What it sets | +|---|---|---| +| `PDU_12V_UV_MV` / `PDU_12V_OV_MV` | 11400 / 13200 | 12 V under / over-voltage | +| `PDU_5V_UV_MV` / `PDU_5V_OV_MV` | 4750 / 5500 | 5 V under / over-voltage | +| `PDU_12V_OC_MA` / `PDU_5V_OC_MA` | 4500 / 1800 | over-current, 90 % of the 5 A / 2 A fuse | +| `PDU_FUSE_OPEN_MV` | 1000 | 12 V below this while the node runs → fuse open | +| `PDU_V_HYST_MV` | 100 | voltage hysteresis | +| `PDU_V_SET_MS` / `PDU_OC_SET_MS` / `PDU_CLEAR_MS` | 50 / 100 / 500 | how long a condition must hold to set / clear | +| `PDU_TEMP_WARN_DC` / `PDU_TEMP_CRIT_DC` / `PDU_TEMP_HYST_DC` | 600 / 750 / 30 | board temperature, tenths of °C | +| `PDU_BOOT_BLANK_MS` | 500 | faults ignored after power-up (rail ramp, inrush) | +| `PDU_SAMPLE_US` | 1000 | rail scan period (1 kHz) | +| `PDU_ZERO_GUARD_12V_MA` / `PDU_ZERO_GUARD_5V_MA` | 75 / 120 | `CAL ZERO` refuses above this — load still connected | +| `PDU_CAL_PAGE_ADDR` | `0x0800FC00` | calibration flash page; the PDU builds cap the image at 63 KB (`board_upload.maximum_size`) to keep code out of it | + +`PANELBRIDGE_NODE_STATUS` (above) also gates the bridge's `_NODE_TELEM` forwarding of these +readings to the host. + ## Third-party, set by us (7) These belong to **STM32duino**, **arduino-pico**, and the **DCS-BIOS** library. We only set them — diff --git a/docs/firmware/index.md b/docs/firmware/index.md index 7facfb25..1b7a326f 100644 --- a/docs/firmware/index.md +++ b/docs/firmware/index.md @@ -22,6 +22,7 @@ source** for any constant or API. When a doc and a header disagree, the header w | `HIDControls` | shared | HID `controlId` allocations (`CTRL_*`), shared between SimGateway and CANProtocol | | `PanelGroup` | STM32 | CAN sub-node: `PinRef`, MCP23017 management, input and output control classes | | `PanelBridge` | STM32 | CAN master + DCS-BIOS processor | +| `PowerMonitor` | STM32 | The power-distribution (PDU) node: rail voltage/current + board temperature, `TELEM_n` telemetry, per-board calibration | | `SimGateway` | RP2040 | USB byte relay + HID (`HIDAxis`, `HIDButton`, `HIDHatSwitch`) | | `A4EC` | shared | Generated A-4E-C DCS-BIOS headers (`DCSIN_*` command IDs, `A_4E_C_*` output addresses) | @@ -49,6 +50,9 @@ classes are hardware-verified. The summary below is a pointer, not a second sour `RotaryEncoder` (REL/DIR), `RotaryAcceleratedEncoder`, `ActionButton` - **Five output classes** — `LED`, `DrumDisplay`, `NeedleGauge`, `Dimmer` (PWM backlight) and `IntegerOutput` (your own callback), the last two over the `AnalogOutput` family base + - **Power-distribution node** (`PowerMonitor`, one per console) — per-rail voltage and current, + board temperature, fault flags and calibration, reported on the `TELEM_n` frame; awaiting + bench tuning on the PDU boards !!! warning "Not yet implemented" - **After v1.0** — the `ServoMotor` gauge backend. diff --git a/docs/firmware/node-id.md b/docs/firmware/node-id.md index e39c3a94..a02e9a34 100644 --- a/docs/firmware/node-id.md +++ b/docs/firmware/node-id.md @@ -33,7 +33,7 @@ The authoritative record lives in `Firmware/NODE_IDS.md` at the repo root. Curre IDs are permanent — **never reuse one**, even if a panel is retired (mark it Retired instead). When you start a new panel group: -1. Open `Firmware/NODE_IDS.md` and take the **next available** number (currently **2**). +1. Open `Firmware/NODE_IDS.md` and take the **next available** number (currently **6**). 2. Add a row with your panel group, console, and status. 3. Set `build_flags = -DNODE_ID=N` in your board's `platformio.ini`. 4. Commit the registry change **in the same PR** that creates the panel sketch. From f3bdc25b66ea501df21c5c727c45a5d6ec1dbbb8 Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Thu, 15 Oct 2026 18:04:34 -0400 Subject: [PATCH 2/6] ci(firmware): regenerate the PlatformIO impact map for PowerMonitor and the PDU sketches (#216) --- .github/firmware-platformio-impact.json | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/.github/firmware-platformio-impact.json b/.github/firmware-platformio-impact.json index c4482b42..9b5b5433 100644 --- a/.github/firmware-platformio-impact.json +++ b/.github/firmware-platformio-impact.json @@ -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", @@ -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", @@ -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", @@ -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", @@ -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", @@ -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", @@ -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", @@ -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", From 49f72ed8d0254a24eda4ac7936420c1467fb9669 Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Thu, 15 Oct 2026 18:10:33 -0400 Subject: [PATCH 3/6] =?UTF-8?q?docs(hardware):=20Power=20Distribution=20Un?= =?UTF-8?q?it=20page=20=E2=80=94=20what=20it=20measures,=20fuses,=20power-?= =?UTF-8?q?in=20pinout,=20bring-up=20and=20calibration=20(#216)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/architecture/power.md | 13 +++--- docs/hardware/base-boards.md | 1 + docs/hardware/pdu.md | 86 ++++++++++++++++++++++++++++++++++++ docs/mkdocs.yml | 1 + 4 files changed, 95 insertions(+), 6 deletions(-) create mode 100644 docs/hardware/pdu.md diff --git a/docs/architecture/power.md b/docs/architecture/power.md index 3633accc..590f2a1f 100644 --- a/docs/architecture/power.md +++ b/docs/architecture/power.md @@ -96,12 +96,13 @@ Each console's PDU measures what it feeds: the 12 V and 5 V rail voltage, the cu fuse, and the board temperature. It reports them over CAN about once a second, and at once when something changes — a rail sagging below or rising above its limits, a current near the fuse rating, a blown 12 V fuse, or the board running hot. Those faults also turn the node amber in the -host app's node list. Its first job is measurement: logging the real cockpit load so the final -fuse ratings are chosen from data. +host app's node list. Logging a console through idle, startup, full lighting and its peaks shows +its real load, so fuse ratings can be sized from data rather than estimates. Each PDU is calibrated once at bring-up, over its debug serial port, with a multimeter reading of its 3.3 V rail and the load disconnected. The calibration is stored on the board and survives -firmware updates. +firmware updates. The board itself, its fuses and the bring-up steps are on +[Power Distribution Unit](../hardware/pdu.md). !!! warning "Wiring assumptions the PDU relies on" - **Power never chains past a PDU.** A console's downstream harness must not feed another @@ -110,9 +111,9 @@ firmware updates. readings. - **A blown 5 V fuse takes its own PDU offline**, because the PDU runs from the fused 5 V. The host app shows that node going offline, not a fuse alert. - - **PanelBridge is powered from one of the PDUs.** If its PDU loses 5 V, the bridge goes down - with it and every node appears offline at once. When the whole cockpit drops off, check the - PDU fuses first. + - **The Gateway/Bridge head node is powered from one of the PDUs.** If its PDU loses 5 V, the + head node goes down with it and every node appears offline at once. When the whole cockpit + drops off, check the PDU fuses first. ## Cross-tier notes diff --git a/docs/hardware/base-boards.md b/docs/hardware/base-boards.md index 3a46e58b..39e03a4b 100644 --- a/docs/hardware/base-boards.md +++ b/docs/hardware/base-boards.md @@ -104,3 +104,4 @@ bus. [Connector & Harness Guide](connectors.md), [PCB Design Rules](pcb-design-rules.md) - **How the head node fits the system:** [Architecture overview](../architecture/index.md), [SimGateway](../architecture/sim-gateway.md) +- **The per-console power board** built on the same core: [Power Distribution Unit](pdu.md) diff --git a/docs/hardware/pdu.md b/docs/hardware/pdu.md new file mode 100644 index 00000000..7ef1d2a1 --- /dev/null +++ b/docs/hardware/pdu.md @@ -0,0 +1,86 @@ +# Power Distribution Unit (PDU) + +Each console gets one **PDU**: the board where that console's power comes in. It takes 12 V and +5 V from the supply, passes each rail through its own fuse, and feeds the console's bus — and it +measures everything it feeds. Voltage and current on both rails, plus the board's temperature, go +out over CAN to the host app about once a second, and at once when something goes wrong. + +| | | +|---|---| +| **Role** | Per-console power injection, fusing and monitoring — a CAN node like any other | +| **Node IDs** | 3 (Left), 4 (Right), 5 (Center) | +| **Microcontroller** | STM32F103 — same core, CAN transceiver and status LED as the [base boards](base-boards.md) | +| **Measures** | 12 V and 5 V rail voltage and current, board temperature | +| **Fuses** | 12 V **5 A**, 5 V **2 A** — standard MINI blade | +| **Revision** | 0.2.0 | + +## What it does + +- **Fuses each rail at the injection point** — a fault in one console opens only that console's + fuse. Fuses are swappable automotive MINI blades, so a rating can change without touching the + board. +- **Measures what it feeds** — rail voltage (average and lowest dip), current (average and peak), + and board temperature, sampled a thousand times a second. Logging a console through idle, + startup, full lighting and its peaks shows its real load, so its fuses can be sized from data. +- **Raises the alarm** — a rail sagging below or rising above its limits, a current near the fuse + rating, a blown 12 V fuse, or the board running hot. A fault turns the node amber in the host + app's node list and is reported straight away, not on the next poll. +- **Shows rail state without the microcontroller** — a green LED per rail lights whenever that rail + is present, even if the firmware isn't running. + +!!! note "Status LED" + The red/green status LED works exactly as on every other board — CAN link and bus health (see + [Debugging](../firmware/debugging.md)). Rail faults are reported to the host app and shown on + the rail LEDs, not on the status LED. + +## Where it sits + +``` +PSU ──► PDU (fuse + measure) ──► console bus ──► that console's boards +``` + +Ground and CAN run continuously through every PDU; only 12 V and 5 V are split per console. + +!!! warning "Power never chains past a PDU" + A console's downstream harness must not feed another PDU's console. A blown fuse then leaves + that console's rail dead — which is exactly how the PDU detects it. See the wiring + assumptions on [Power](../architecture/power.md). + +!!! warning "Power-in pinout — crimp the harness to the board" + The 4-pin Mini-Fit Jr power input is **pin 1 = +12 V · pin 2 = +5 V · pin 3 = GND · + pin 4 = GND**. +12 V and +5 V sit side by side — check the harness against this before + first power-up. + +A blown **5 V** fuse powers the PDU itself down, so the host app shows that node going offline +rather than a fuse alert. The Gateway/Bridge head node also takes its power from one of the PDUs — +if the whole cockpit drops off at once, check the PDU fuses first. + +## Bring-up and calibration + +Each PDU is calibrated once, at bring-up. The values are stored on the board and survive firmware +updates. + +1. **Flash** the PDU for its console, e.g. `pio run -d Firmware/Panels/PDU_Left -t upload` + (`PDU_Right` / `PDU_Center` for the others — the code is identical, only the node ID differs). +2. **Open the debug serial console** at 115200 baud. The PDU prints its node ID and calibration + state at boot; `CAL SHOW` prints it again. +3. **Supply reference:** measure the board's 3.3 V rail with a multimeter and enter it, in + millivolts — `CAL VDDA 3297`. Until this is done the PDU assumes exactly 3.3 V. +4. **Current zero:** disconnect the console load, then run `CAL ZERO`. The PDU refuses if it still + sees current flowing. The 5 V reading includes the PDU's own small draw, so afterwards it + shows the console's load only. + +`RAW` prints the live readings at any time, and `CAL CLEAR` returns the board to uncalibrated. + +!!! note "Thresholds are starting points" + The limits — under / over-voltage, over-current (90 % of each fuse), temperature — are + defaults to tune against your console's measured load. Each can be changed per build without a + code change; the list is on [Build Flags](../firmware/build-flags.md). + +## Where it lives + +- **KiCad project:** `PCB/Base/Power/PDU/` — schematic, layout, BOM, and `DESIGN-NOTES.md` with the + full parts rationale +- **Firmware:** the `PowerMonitor` library and the `Firmware/Panels/PDU_*` sketches +- **How it fits the power system:** [Power](../architecture/power.md); the telemetry frame on + [CAN Bus](../architecture/can-bus.md) diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 708ef33e..7d0b97f4 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -79,6 +79,7 @@ nav: - 3D Printing Guide: hardware/3d-printing.md - Connector & Harness Guide: hardware/connectors.md - Base Boards: hardware/base-boards.md + - Power Distribution Unit: hardware/pdu.md - Component Library: hardware/components.md - Printed Parts Library: hardware/printed-parts.md From bcd8b23c365da9609ba2291d95903886022b878c Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Thu, 15 Oct 2026 18:14:08 -0400 Subject: [PATCH 4/6] fix(firmware): CAL CLEAR reports a failed flash erase instead of claiming success (#216) --- Firmware/Libraries/PowerMonitor/PduNode.cpp | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/Firmware/Libraries/PowerMonitor/PduNode.cpp b/Firmware/Libraries/PowerMonitor/PduNode.cpp index 536bf480..26e0431e 100644 --- a/Firmware/Libraries/PowerMonitor/PduNode.cpp +++ b/Firmware/Libraries/PowerMonitor/PduNode.cpp @@ -152,6 +152,18 @@ void cmdCalZero() { saveCal(rec); } +void cmdCalClear() { + // Report "cleared" only when the page really erased — otherwise the old calibration is still + // what the node is using, and saying otherwise would mislead whoever is calibrating. + if (!gStore.erase()) { + out().println(F("[PDU] ERROR: flash erase failed — calibration unchanged")); + return; + } + loadCal(); + out().print(F("[PDU] cleared. ")); + printCal(); +} + void cmdRaw() { auto& d = out(); d.print(F("[PDU] vdda=")); d.print(gVdda); @@ -167,7 +179,7 @@ void runCommand(char* line) { if (!strcmp(line, "CAL SHOW")) printCal(); else if (!strncmp(line, "CAL VDDA ", 9)) cmdCalVdda(line + 9); else if (!strcmp(line, "CAL ZERO")) cmdCalZero(); - else if (!strcmp(line, "CAL CLEAR")) { gStore.erase(); loadCal(); out().print(F("[PDU] cleared. ")); printCal(); } + else if (!strcmp(line, "CAL CLEAR")) cmdCalClear(); else if (!strcmp(line, "RAW")) cmdRaw(); else if (line[0]) out().println(F("[PDU] commands: CAL SHOW | CAL VDDA | CAL ZERO | CAL CLEAR | RAW")); } From db26ab0fe31ac656e8b197c75471a72adb87316d Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Thu, 15 Oct 2026 18:15:49 -0400 Subject: [PATCH 5/6] docs(firmware): PDU power LEDs show rail presence only, not faults (#216) --- Firmware/Libraries/PowerMonitor/PduNode.h | 6 ++++-- .../08-hardware-firmware-contracts.md | 7 +++++-- docs/hardware/pdu.md | 17 ++++++++++------- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/Firmware/Libraries/PowerMonitor/PduNode.h b/Firmware/Libraries/PowerMonitor/PduNode.h index 754a1300..6e434be5 100644 --- a/Firmware/Libraries/PowerMonitor/PduNode.h +++ b/Firmware/Libraries/PowerMonitor/PduNode.h @@ -21,8 +21,10 @@ * connected) · `CAL CLEAR` · `RAW` (live readings + scan rate). * Stored per board in the reserved flash page (PduCalStore). * - * The status LED follows every other node: CAN / link state plus the master-loss WARNING; rail - * faults show on the client and the board's D3/D4 rail LEDs, not the MCU LED (FirmwarePlan D14). + * Rail faults are reported to the host only (TELEM_n flags + HEALTH_n.faultId). The status LEDs + * follow every other node — CAN / link state plus the master-loss WARNING — and don't change for + * rail faults (FirmwarePlan D14). The board's power LEDs (D3 12 V / D4 5 V / D5 3V3) are passive, + * wired to their rails, and show rail presence only — this firmware never drives them. * * @copyright GPL-2.0-only — see Firmware/LICENSE */ diff --git a/Firmware/ScratchPad/FirmwarePlan/08-hardware-firmware-contracts.md b/Firmware/ScratchPad/FirmwarePlan/08-hardware-firmware-contracts.md index b059c320..f9fd4221 100644 --- a/Firmware/ScratchPad/FirmwarePlan/08-hardware-firmware-contracts.md +++ b/Firmware/ScratchPad/FirmwarePlan/08-hardware-firmware-contracts.md @@ -181,8 +181,11 @@ netlist; overridable as `PDU_PIN_*`: `board_upload.maximum_size = 64512`, so the build fails before code can grow into the page, and an OpenOCD upload erases only the sectors it writes — a reflash keeps the calibration. A full-chip erase (e.g. STM32CubeProgrammer) wipes it; re-run the calibration afterwards. -- **Status LED** behaves as on every node (CAN / link + master-loss WARNING); rail faults show on the - host and the board's D3/D4 rail LEDs, not the MCU LED. +- **LEDs.** Rail faults are reported to the host only (`TELEM_n` flags + `HEALTH_n.faultId`). The + MCU status LEDs (D6 red / D7 green on PB14 / PB15) behave as on every node — CAN / link + the + master-loss WARNING — and don't change for rail faults (D14). The power LEDs D3 (+12 V), D4 (+5 V) + and D5 (+3V3) are passive, resistor-fed from their rails and not firmware-driven: they show rail + presence only — dark on a dead rail or blown fuse, lit through OV / OC / overheat. --- diff --git a/docs/hardware/pdu.md b/docs/hardware/pdu.md index 7ef1d2a1..49a7a4f9 100644 --- a/docs/hardware/pdu.md +++ b/docs/hardware/pdu.md @@ -25,13 +25,16 @@ out over CAN to the host app about once a second, and at once when something goe - **Raises the alarm** — a rail sagging below or rising above its limits, a current near the fuse rating, a blown 12 V fuse, or the board running hot. A fault turns the node amber in the host app's node list and is reported straight away, not on the next poll. -- **Shows rail state without the microcontroller** — a green LED per rail lights whenever that rail - is present, even if the firmware isn't running. - -!!! note "Status LED" - The red/green status LED works exactly as on every other board — CAN link and bus health (see - [Debugging](../firmware/debugging.md)). Rail faults are reported to the host app and shown on - the rail LEDs, not on the status LED. +- **Shows its rails without the microcontroller** — three green power LEDs, one each for 12 V, 5 V + and 3.3 V, are wired straight to their rails and light whenever that rail is present, even if + the firmware isn't running. + +!!! note "What the LEDs do and don't show" + Rail faults are reported to the host app only. The red/green **status LEDs** work exactly as on + every other board — CAN link and bus health (see [Debugging](../firmware/debugging.md)) — and + don't change for a rail fault. The green **power LEDs** only show that a rail is present: one + goes out on a dead rail or a blown fuse, but all stay lit through an over-voltage, + over-current or overheat. ## Where it sits From 89c73e7d3b3c49cc61e1bd3f83bb3723f6c11507 Mon Sep 17 00:00:00 2001 From: Motti Horesh Date: Thu, 15 Oct 2026 18:20:10 -0400 Subject: [PATCH 6/6] =?UTF-8?q?docs(firmware):=20TX=20queue=20spec=20?= =?UTF-8?q?=E2=80=94=20periodic=20status=20frames=20use=20bounded=20retry,?= =?UTF-8?q?=20only=20CTRL=5FBCAST=20coalesces=20(#216)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Firmware/ScratchPad/FirmwarePlan/00-decisions.md | 2 +- Firmware/ScratchPad/FirmwarePlan/02-can-protocol.md | 2 +- Firmware/ScratchPad/FirmwarePlan/10-implementation-plan.md | 2 +- Firmware/ScratchPad/TechSpec/CANProtocol.md | 7 ++++--- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/Firmware/ScratchPad/FirmwarePlan/00-decisions.md b/Firmware/ScratchPad/FirmwarePlan/00-decisions.md index b26db6a7..3e9ebf3c 100644 --- a/Firmware/ScratchPad/FirmwarePlan/00-decisions.md +++ b/Firmware/ScratchPad/FirmwarePlan/00-decisions.md @@ -128,7 +128,7 @@ impossible. No parser state machine needs to be maintained for the common case |------------|--------| | `CTRL_BCAST` DCS output broadcasts | Drop stale queued state and retain the newest frame. Coalesce by `controlId` where practical. | | `EVT_n` input events | Bounded retry: keep the event for up to 3 send attempts, then drop it and increment the `DIAG_ERR` TX drop counter. Never build an unbounded backlog. | -| `HB_n` heartbeat | Drop stale heartbeat; the next heartbeat replaces it. | +| `HB_n` heartbeat | Drop stale heartbeat; the next heartbeat replaces it. *(As built, heartbeats — and the later `HEALTH_n` / `TELEM_n` — take the same bounded retry as event frames; only `CTRL_BCAST` coalesces. See `02-can-protocol.md`.)* | | `READY_n`, `SYNC_REQ`, `TEST_SEQ`, `ECHO_n` | Bounded retry up to 3 attempts, then drop and increment the `DIAG_ERR` TX drop counter. | **Rationale:** output broadcasts are state snapshots; the newest value is always more useful diff --git a/Firmware/ScratchPad/FirmwarePlan/02-can-protocol.md b/Firmware/ScratchPad/FirmwarePlan/02-can-protocol.md index 230d7319..66a4fdba 100644 --- a/Firmware/ScratchPad/FirmwarePlan/02-can-protocol.md +++ b/Firmware/ScratchPad/FirmwarePlan/02-can-protocol.md @@ -329,7 +329,7 @@ via the TX-complete interrupt — no explicit drain call is required from `loop( |------------|--------| | `CTRL_BCAST` DCS output broadcasts | Drop stale queued state and retain the newest frame. Coalesce by `controlId` where practical. | | `EVT_n` input events | Bounded retry: keep the event for up to 3 send attempts, then drop it and increment the `DIAG_ERR` TX drop counter. No unbounded backlog. | -| `HB_n` heartbeat | Drop stale heartbeat; the next heartbeat replaces it. | +| `HB_n`, `HEALTH_n`, `TELEM_n` periodic status | Same bounded retry as the event frames — up to 3 send attempts, then drop and increment the `DIAG_ERR` TX drop counter. Not coalesced: the next period's frame carries fresh state anyway. | | `READY_n`, `SYNC_REQ`, `TEST_SEQ`, `ECHO_n` | Bounded retry up to 3 attempts, then drop and increment the `DIAG_ERR` TX drop counter. | This prevents silent frame drops during DCS-BIOS export bursts without stalling the firmware, diff --git a/Firmware/ScratchPad/FirmwarePlan/10-implementation-plan.md b/Firmware/ScratchPad/FirmwarePlan/10-implementation-plan.md index ccbb9e1d..45e8a310 100644 --- a/Firmware/ScratchPad/FirmwarePlan/10-implementation-plan.md +++ b/Firmware/ScratchPad/FirmwarePlan/10-implementation-plan.md @@ -54,7 +54,7 @@ are occupied, enqueue frames in a small ring buffer (~16 entries). Use frame-type dependent overflow: `CTRL_BCAST` may drop/coalesce stale state; `EVT_n`, `READY_n`, `SYNC_REQ`, `TEST_SEQ`, and `ECHO_n` use bounded retry up to 3 attempts, then drop and - increment the `DIAG_ERR` TX drop counter; `HB_n` may drop stale heartbeat frames. + increment the `DIAG_ERR` TX drop counter; `HB_n` takes the same bounded retry. - [x] Build A-4E-C input map generator: parse A-4E-C DCS-BIOS JSON → assign sequential `DCSIN_*` IDs from 0x8001; emit **three headers plus gap report**: - `A4EC_CmdIds.h` — `#define DCSIN_*` constants, included by PanelGroup sketches diff --git a/Firmware/ScratchPad/TechSpec/CANProtocol.md b/Firmware/ScratchPad/TechSpec/CANProtocol.md index b7adb7c9..fc4c1de3 100644 --- a/Firmware/ScratchPad/TechSpec/CANProtocol.md +++ b/Firmware/ScratchPad/TechSpec/CANProtocol.md @@ -436,8 +436,8 @@ namespace CANProtocol { * occupied, the frame is placed in the TX software ring buffer (~16 entries). * The buffer is drained automatically when mailboxes free up via TX-complete * interrupt. Overflow is frame-type dependent: CTRL_BCAST may drop/coalesce - * stale state; EVT/control frames retry up to 3 send attempts before drop; - * heartbeat frames may drop stale heartbeat state. CTRL_BCAST and EVT_n payloads are + * stale state; every other frame (EVT/control and the periodic HB/HEALTH/TELEM) retries up + * to 3 send attempts before drop. CTRL_BCAST and EVT_n payloads are * ControlPacketPair batches; a single input/output packet uses slot B controlId == 0x0000 * as the null sentinel. Special/control/diagnostic frames use their explicit DLC and * payload. All drops increment the DIAG_ERR TX drop counter. @@ -720,7 +720,8 @@ for up to 3 send attempts, then drop it and increment the `DIAG_ERR` TX drop cou avoids losing user actions during transient mailbox contention without allowing an unhealthy bus to create an unbounded backlog. -`HB_n` heartbeat frames may drop stale queued heartbeat state; the next heartbeat replaces it. +The periodic status frames — `HB_n`, `HEALTH_n`, `TELEM_n` — take the same bounded retry; they are +not coalesced, since the next period's frame carries fresh state anyway. ### RX Queue