From 9232766e604888e9b8ccda403249a3eeb2c7776e Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Thu, 23 Apr 2026 13:06:38 -0700 Subject: [PATCH] feat(firmware): add NVS-backed event log ring buffer Persistent 32-slot ring buffer of tagged diagnostic events (boot, wifi up/down, http ok/fail, heartbeat miss, reboot). Used to diagnose field failures post-hoc via the heartbeat payload, without needing serial access. Native-native stub lets policy be unit-tested. --- firmware/lib/event_log/event_log.cpp | 109 ++++++++++++++++++ firmware/lib/event_log/event_log.h | 39 +++++++ .../test/test_event_log/test_event_log.cpp | 56 +++++++++ 3 files changed, 204 insertions(+) create mode 100644 firmware/lib/event_log/event_log.cpp create mode 100644 firmware/lib/event_log/event_log.h create mode 100644 firmware/test/test_event_log/test_event_log.cpp diff --git a/firmware/lib/event_log/event_log.cpp b/firmware/lib/event_log/event_log.cpp new file mode 100644 index 0000000..f5c19a8 --- /dev/null +++ b/firmware/lib/event_log/event_log.cpp @@ -0,0 +1,109 @@ +// firmware/lib/event_log/event_log.cpp +#include "event_log.h" +#include +#include + +#ifdef ARDUINO + #include + #include + #include + static Preferences s_prefs; + static const char* NVS_NS = "evlog"; + static const char* NVS_HEAD = "head"; // next write slot (0..31) + static const char* NVS_CNT = "cnt"; // total writes (for seq) +#else + // Native build: in-memory stub + #include + static uint8_t g_slots[32 * 32]; + static uint32_t g_head = 0; + static uint32_t g_cnt = 0; + extern "C" void event_log_test_reset() { + memset(g_slots, 0, sizeof(g_slots)); + g_head = 0; + g_cnt = 0; + } +#endif + +static const size_t SLOTS = 32; +static const size_t SLOT_SIZE = sizeof(EventLogEntry); + +uint16_t event_log_path_hash(const char* path) { + // fnv1a-16 (fold 32-bit fnv1a down to 16 bits) + uint32_t h = 0x811c9dc5u; + while (*path) { h ^= (uint8_t)*path++; h *= 0x01000193u; } + return (uint16_t)((h >> 16) ^ (h & 0xFFFF)); +} + +static void slot_write(size_t idx, const EventLogEntry& e) { +#ifdef ARDUINO + char key[8]; snprintf(key, sizeof(key), "s%u", (unsigned)idx); + s_prefs.putBytes(key, &e, SLOT_SIZE); +#else + memcpy(&g_slots[idx * SLOT_SIZE], &e, SLOT_SIZE); +#endif +} + +static bool slot_read(size_t idx, EventLogEntry& e) { +#ifdef ARDUINO + char key[8]; snprintf(key, sizeof(key), "s%u", (unsigned)idx); + size_t n = s_prefs.getBytes(key, &e, SLOT_SIZE); + return n == SLOT_SIZE; +#else + memcpy(&e, &g_slots[idx * SLOT_SIZE], SLOT_SIZE); + return true; +#endif +} + +void event_log_init() { +#ifdef ARDUINO + s_prefs.begin(NVS_NS, /*readOnly=*/false); +#else + // nothing +#endif +} + +void event_log_write(EventLogTag tag, uint16_t data0, uint16_t data1) { + EventLogEntry e = {}; +#ifdef ARDUINO + time_t now = time(nullptr); + e.ts_unix = (now > 1700000000) ? (uint32_t)now : 0; + e.uptime_s = (uint32_t)(millis() / 1000); + uint32_t head = s_prefs.getUInt(NVS_HEAD, 0); + uint32_t cnt = s_prefs.getUInt(NVS_CNT, 0); +#else + e.ts_unix = 0; + e.uptime_s = g_cnt; // stand-in for uptime in native tests + uint32_t head = g_head; + uint32_t cnt = g_cnt; +#endif + e.tag = (uint8_t)tag; + e.data0 = data0; + e.data1 = data1; + e.seq = (uint8_t)(cnt & 0xFF); + slot_write(head % SLOTS, e); +#ifdef ARDUINO + s_prefs.putUInt(NVS_HEAD, (head + 1) % SLOTS); + s_prefs.putUInt(NVS_CNT, cnt + 1); +#else + g_head = (head + 1) % SLOTS; + g_cnt = cnt + 1; +#endif +} + +size_t event_log_read_recent(EventLogEntry* out, size_t max_entries) { +#ifdef ARDUINO + uint32_t head = s_prefs.getUInt(NVS_HEAD, 0); + uint32_t cnt = s_prefs.getUInt(NVS_CNT, 0); +#else + uint32_t head = g_head; + uint32_t cnt = g_cnt; +#endif + size_t available = (cnt < SLOTS) ? (size_t)cnt : SLOTS; + size_t n = (max_entries < available) ? max_entries : available; + for (size_t i = 0; i < n; i++) { + // newest is at (head - 1), then (head - 2), ... modulo SLOTS + size_t idx = (head + SLOTS - 1 - i) % SLOTS; + slot_read(idx, out[i]); + } + return n; +} diff --git a/firmware/lib/event_log/event_log.h b/firmware/lib/event_log/event_log.h new file mode 100644 index 0000000..de210ed --- /dev/null +++ b/firmware/lib/event_log/event_log.h @@ -0,0 +1,39 @@ +// firmware/lib/event_log/event_log.h +#pragma once +#include +#include + +enum EventLogTag : uint8_t { + EVT_BOOT = 1, // data0 = esp_reset_reason() value + EVT_WIFI_UP = 2, // data0 = rssi (signed, cast) + EVT_WIFI_DOWN = 3, // data0 = disconnect reason code + EVT_HTTP_OK = 4, // data0 = path hash (fnv1a16), data1 = elapsed_ms + EVT_HTTP_FAIL = 5, // data0 = path hash, data1 = (http_code or negative errno) + EVT_HEARTBEAT_MISS = 6, // data0 = consecutive miss count + EVT_NTP_SYNC = 7, // data0 = seconds since boot + EVT_REBOOT = 8, // data0 = reason enum (defined below) +}; + +enum RebootReason : uint8_t { + REBOOT_HEARTBEAT_MISS = 1, + REBOOT_FACTORY_RESET = 2, + REBOOT_OTA = 3, + REBOOT_WIFI_REPROV = 4, +}; + +struct EventLogEntry { + uint32_t ts_unix; // 0 if NTP not synced yet; fall back to millis/1000 + uint32_t uptime_s; // millis()/1000 at log time + uint16_t data0; + uint16_t data1; + uint8_t tag; // EventLogTag + uint8_t seq; // rolling sequence, wraps + uint8_t _pad[18]; // pad to 32 bytes for fixed slot size +} __attribute__((packed)); +static_assert(sizeof(EventLogEntry) == 32, "EventLogEntry must be 32 bytes"); + +// NVS-backed 32-slot ring buffer. Safe to call before NTP sync. +void event_log_init(); +void event_log_write(EventLogTag tag, uint16_t data0 = 0, uint16_t data1 = 0); +size_t event_log_read_recent(EventLogEntry* out, size_t max_entries); +uint16_t event_log_path_hash(const char* path); // fnv1a16 — exposed for tests diff --git a/firmware/test/test_event_log/test_event_log.cpp b/firmware/test/test_event_log/test_event_log.cpp new file mode 100644 index 0000000..1b2562f --- /dev/null +++ b/firmware/test/test_event_log/test_event_log.cpp @@ -0,0 +1,56 @@ +// firmware/test/test_native/test_event_log.cpp +#include +#include +#include "event_log.h" + +// --- Native NVS stub (declared in event_log.cpp for native builds) --- +extern "C" void event_log_test_reset(); + +void setUp() { event_log_test_reset(); } +void tearDown() {} + +void test_entry_is_32_bytes() { + TEST_ASSERT_EQUAL(32, sizeof(EventLogEntry)); +} + +void test_path_hash_is_stable_and_differs() { + uint16_t a = event_log_path_hash("/api/v1/heartbeat"); + uint16_t b = event_log_path_hash("/api/v1/heartbeat"); + uint16_t c = event_log_path_hash("/api/v1/camera/events/batch"); + TEST_ASSERT_EQUAL(a, b); + TEST_ASSERT_NOT_EQUAL(a, c); +} + +void test_write_then_read_recent_returns_newest_first() { + event_log_init(); + event_log_write(EVT_BOOT, 1, 0); + event_log_write(EVT_WIFI_UP, 2, 0); + event_log_write(EVT_HTTP_FAIL, 3, 500); + EventLogEntry buf[8]; + size_t n = event_log_read_recent(buf, 8); + TEST_ASSERT_EQUAL(3, n); + TEST_ASSERT_EQUAL(EVT_HTTP_FAIL, buf[0].tag); + TEST_ASSERT_EQUAL(500, buf[0].data1); + TEST_ASSERT_EQUAL(EVT_WIFI_UP, buf[1].tag); + TEST_ASSERT_EQUAL(EVT_BOOT, buf[2].tag); +} + +void test_ring_buffer_wraps_after_32_entries() { + event_log_init(); + for (int i = 0; i < 40; i++) event_log_write(EVT_HTTP_OK, (uint16_t)i, 0); + EventLogEntry buf[32]; + size_t n = event_log_read_recent(buf, 32); + TEST_ASSERT_EQUAL(32, n); + // Newest first: data0 should be 39, 38, 37, ... down to 8 + TEST_ASSERT_EQUAL(39, buf[0].data0); + TEST_ASSERT_EQUAL(8, buf[31].data0); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_entry_is_32_bytes); + RUN_TEST(test_path_hash_is_stable_and_differs); + RUN_TEST(test_write_then_read_recent_returns_newest_first); + RUN_TEST(test_ring_buffer_wraps_after_32_entries); + return UNITY_END(); +}