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.
This commit is contained in:
109
firmware/lib/event_log/event_log.cpp
Normal file
109
firmware/lib/event_log/event_log.cpp
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
// firmware/lib/event_log/event_log.cpp
|
||||||
|
#include "event_log.h"
|
||||||
|
#include <string.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
|
||||||
|
#ifdef ARDUINO
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <Preferences.h>
|
||||||
|
#include <time.h>
|
||||||
|
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 <cstdint>
|
||||||
|
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;
|
||||||
|
}
|
||||||
39
firmware/lib/event_log/event_log.h
Normal file
39
firmware/lib/event_log/event_log.h
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
// firmware/lib/event_log/event_log.h
|
||||||
|
#pragma once
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stddef.h>
|
||||||
|
|
||||||
|
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
|
||||||
56
firmware/test/test_event_log/test_event_log.cpp
Normal file
56
firmware/test/test_event_log/test_event_log.cpp
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
// firmware/test/test_native/test_event_log.cpp
|
||||||
|
#include <unity.h>
|
||||||
|
#include <string.h>
|
||||||
|
#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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user