From cb965b790e3ce4797c9f132309cec800a8e8cbe9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 09:11:50 +0000 Subject: [PATCH 1/3] mesh,web: add central event log + read endpoint for GenericEvent history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records GenericEvent broadcasts (buzzer presses, resets, ...) in receipt order in a fixed-size RAM ring buffer, with a read endpoint and live WebSocket push for a web UI viewer. #436 (shared ApiRequest/ApiResponse dispatch table for serial reachability) is still an open, unmerged PR, so GET /api/events is registered directly against AsyncWebServerRequest for now instead of the shared dispatch table the issue's design calls for — it should move onto that table once #436 lands. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CMsq87s9e7zRUvfLYKxqvk --- data/index.html | 63 +++++++++++++++++++++++++++++++++++++++++ server/index.js | 20 +++++++++++++ src/events/EventLog.h | 61 +++++++++++++++++++++++++++++++++++++++ src/main.cpp | 10 +++++++ src/mesh/PeerRegistry.h | 8 ++++++ src/web/WebServer.h | 35 +++++++++++++++++++++++ 6 files changed, 197 insertions(+) create mode 100644 src/events/EventLog.h diff --git a/data/index.html b/data/index.html index 7252c1c1..8fb10f2e 100644 --- a/data/index.html +++ b/data/index.html @@ -1093,6 +1093,14 @@

Automations

+ +
+

Event log

+

Order this device received mesh events in. Cleared on reboot.

+ +
+
+

Updates

@@ -3082,6 +3090,7 @@

Options

if (d.lights) renderLightsHw(d.lights); loadButtons().catch(() => {}); loadAutomations().catch(() => {}); + loadEvents().catch(() => {}); await loadAudioGroups().catch(() => {}); loadSounds().catch(() => {}); loadStorage().catch(() => {}); @@ -3181,6 +3190,7 @@

Options

if (d.t === 'peers') { renderLights(d.self, d.peers || [], d.wifiSingleClientMode === true); _refreshSceneOptionViews(); } if (d.t === 'groups') renderGroups(d.list || []); if (d.t === 'audioGroups') renderAudioGroupsList(d.list || []); + if (d.t === 'event') _onEventPush(d); } catch { /* ignore malformed/unexpected WS messages */ } }; } @@ -6561,6 +6571,59 @@

${title}

renderAutomationsList(d.automations || []); } +// ── Event log ───────────────────────────────────────────────────────────────── +// Read-only view of EventLog (issue #442): the order GenericEvent broadcasts +// (e.g. buzzer presses) arrived at this device. Server-side ring buffer is +// the source of truth (see EventLog::CAPACITY) — mirror its cap here so a +// long-open page doesn't grow the table unbounded from live 't':'event' pushes. +const EVENTS_CAPACITY = 128; +let _events = []; +let _eventsFilter = ''; + +async function loadEvents() { + const filterAtRequest = _eventsFilter; + const q = filterAtRequest ? ('?eventType=' + encodeURIComponent(filterAtRequest)) : ''; + const res = await fetch('/api/events' + q); + if (!res.ok) return; + const d = await res.json(); + const fresh = d.events || []; + // A live 't':'event' WS push can land between this request and its + // response; keep any such already-appended entries newer than what this + // snapshot covers (and still matching the current filter) instead of the + // fetch silently dropping them. + const maxOrder = fresh.length ? fresh[fresh.length - 1].order : -1; + const late = _eventsFilter === filterAtRequest + ? _events.filter(e => e.order > maxOrder && (!_eventsFilter || e.eventType === _eventsFilter)) + : []; + _events = fresh.concat(late); + renderEventsList(); +} + +function renderEventsList() { + const el = $('events-list'); + if (!el) return; + if (!_events.length) { + el.innerHTML = '

No events recorded yet.

'; + return; + } + el.innerHTML = `
+ + ${_events.map(e => ``).join('')} +
#NameEvent typePayload
${e.order}${esc(e.name)}${esc(e.eventType)}${e.payload}
`; +} + +function _onEventPush(e) { + if (_eventsFilter && e.eventType !== _eventsFilter) return; + _events.push(e); + if (_events.length > EVENTS_CAPACITY) _events.shift(); + renderEventsList(); +} + +$('events-filter').addEventListener('input', e => { + _eventsFilter = e.target.value.trim(); + loadEvents(); +}); + // ── Static event wiring ─────────────────────────────────────────────────────── // Elements present in the initial DOM (never rebuilt via innerHTML); dynamically // rendered elements wire their own listeners via delegation near their render diff --git a/server/index.js b/server/index.js index b8fe3938..d068fb6f 100644 --- a/server/index.js +++ b/server/index.js @@ -162,6 +162,20 @@ const MOCK_PEERS = [ { name: 'Mock Light 5', mac: '55:66:77:88:99:aa', lights: [{ index: 0, name: 'Garage', groupId: 0 }], sound: null, online: true, rssi: -70, sceneSyncEnabled: true, wifiConnected: false, hasWifiNetworks: true, wifiConnecting: true, version: '2026.01.01.0', fwState: 'idle', batteryPresent: false, batteryPercent: 0, batteryCharging: false }, ]; +// Mirrors EventLog's ring buffer (src/events/EventLog.h, issue #442): the +// order GenericEvent broadcasts (matching mockAutomations' 'buzz.press'/ +// 'buzz.reset' eventTypes) arrived at this device. Static sample data since +// nothing in the mock server generates live mesh traffic. +const mockEvents = [ + { name: 'Mock Light 2', eventType: 'buzz.press', payload: 1, order: 0 }, + { name: 'Mock Light 4', eventType: 'buzz.press', payload: 2, order: 1 }, + { name: 'Mock Device', eventType: 'buzz.reset', payload: 0, order: 2 }, +]; +// Sent as a live 't':'event' WS push on connect (not part of mockEvents/ +// GET /api/events) so a freshly loaded page also demonstrates the live-append +// path, without duplicating a row the initial GET already returned. +const mockLiveEvent = { name: 'Mock Light 3', eventType: 'buzz.press', payload: 3, order: 3 }; + const wifiNetworks = [ { ssid: 'HomeNetwork', password: 'secret1' }, { ssid: 'WorkWifi', password: 'secret2' }, @@ -436,6 +450,11 @@ app.get('/api/peers', (_req, res) => res.json({ peers: MOCK_PEERS, wifiSingleClientMode: MOCK_CONFIG.wifiSingleClientMode, })); +app.get('/api/events', (req, res) => { + const eventType = (req.query.eventType || '').toString(); + const events = eventType ? mockEvents.filter(e => e.eventType === eventType) : mockEvents; + res.json({ events }); +}); app.post('/api/peers/setgroup', (req, res) => { const { mac, lightIndex, groupId } = req.body || {}; // Mirrors WebServer.h::_setRemoteGroup: only self-mac assignments are @@ -1122,6 +1141,7 @@ wss.on('connection', ws => { send({ t: 'peers', self: selfWithLights(), peers: MOCK_PEERS, wifiSingleClientMode: MOCK_CONFIG.wifiSingleClientMode }); send({ t: 'groups', list: MOCK_CONFIG.groups }); send({ t: 'audioGroups', list: mockAudioGroups }); + send({ t: 'event', ...mockLiveEvent }); }); const PORT = process.env.PORT || 8080; diff --git a/src/events/EventLog.h b/src/events/EventLog.h new file mode 100644 index 00000000..444f3965 --- /dev/null +++ b/src/events/EventLog.h @@ -0,0 +1,61 @@ +#pragma once +#include + +#include + +#include "../config/Config.h" + +// Central log of GenericEvent broadcasts (issue #442), recorded in receipt +// order by whichever device this runs on — RAM only, no persistence across +// reboot (confirmed acceptable; ordering only needs to be receipt order at +// this device, no clock sync). Sender identity is resolved by the caller +// (PeerRegistry/self name lookup by mac) before recording, same as +// AutomationManager::onGenericEvent — the mesh layer only identifies senders +// by mac. +struct EventLogEntry { + char peerName[32] = {}; + char eventType[EVENT_TYPE_LEN] = {}; + uint16_t payload = 0; + uint32_t order = 0; +}; + +class EventLog { + public: + // 128 entries (~9.5KB) comfortably covers a multi-round buzzer session + // with 10+ players; negligible against typical ESP32 free heap. + static constexpr uint16_t CAPACITY = 128; + + using AppendCb = std::function; + void setOnAppend(AppendCb cb) { _onAppend = cb; } + + void record(const char* peerName, const char* eventType, uint16_t payload) { + EventLogEntry& e = _entries[_head]; + strlcpy(e.peerName, peerName, sizeof(e.peerName)); + strlcpy(e.eventType, eventType, sizeof(e.eventType)); + e.payload = payload; + e.order = _nextOrder++; + _head = (_head + 1) % CAPACITY; + if (_count < CAPACITY) _count++; + if (_onAppend) _onAppend(e); + } + + // Visits stored entries oldest-first, optionally restricted to a single + // eventType ("" = no filter). + void forEach(const char* eventTypeFilter, + const std::function& fn) const { + uint16_t start = (_count < CAPACITY) ? 0 : _head; + for (uint16_t i = 0; i < _count; i++) { + const EventLogEntry& e = _entries[(start + i) % CAPACITY]; + if (eventTypeFilter && eventTypeFilter[0] && strcmp(e.eventType, eventTypeFilter) != 0) + continue; + fn(e); + } + } + + private: + EventLogEntry _entries[CAPACITY]; + uint16_t _head = 0; + uint16_t _count = 0; + uint32_t _nextOrder = 0; + AppendCb _onAppend; +}; diff --git a/src/main.cpp b/src/main.cpp index 565dbc56..fb09282c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -12,6 +12,7 @@ #include "battery/BatteryMonitor.h" #include "buttons/ButtonManager.h" #include "config/Config.h" +#include "events/EventLog.h" #include "led/Ws2801Driver.h" #include "led/Ws2812bDriver.h" #include "logging/Logger.h" @@ -45,6 +46,7 @@ static PlaylistSyncManager playlistSync; static ActionExecutor actionExecutor; static ButtonManager buttonManager; static AutomationManager automationManager; +static EventLog eventLog; static BatteryMonitor battery; static bool _otaActive = false; @@ -555,6 +557,11 @@ void setup() { mesh.setOnMeshSearch([]() { channelMgr.beginSearch(); }); mesh.setOnGenericEvent([](const uint8_t* mac, const char* eventType, uint16_t payload) { automationManager.onGenericEvent(mac, eventType, payload); + + // ESP-NOW broadcasts aren't echoed back to their own sender (see + // applyPlayAudioLocally above), so mac here always identifies a peer. + const char* name = mesh.peers.nameFor(mac); + eventLog.record(name ? name : "unknown", eventType, payload); }); mesh.setBatteryStatusProvider([]() { return battery.status(); }); @@ -909,6 +916,9 @@ void setup() { }); sceneSync.setOnSceneSaved(notifySceneUpdated); + webServer.setEventLog(&eventLog); + eventLog.setOnAppend([](const EventLogEntry& e) { webServer.pushEvent(e); }); + webServer.setPlaylistSync(&playlistSync); // idx/volume args no longer directly meaningful (volume now only applies // when that sound's volumeOverrideEnabled is set) — just re-resolve. diff --git a/src/mesh/PeerRegistry.h b/src/mesh/PeerRegistry.h index c07a144f..a7abf42b 100644 --- a/src/mesh/PeerRegistry.h +++ b/src/mesh/PeerRegistry.h @@ -168,6 +168,14 @@ class PeerRegistry { PeerInfo* begin() { return _peers; } PeerInfo* end() { return _peers + MAX_PEERS; } + // Resolves an active peer's name by mac, or nullptr if not (or no longer) + // known. Used by EventLog (issue #442) to label GenericEvent senders. + const char* nameFor(const uint8_t* mac) const { + for (auto& p : _peers) + if (p.active && memcmp(p.mac, mac, 6) == 0) return p.name; + return nullptr; + } + private: PeerInfo _peers[MAX_PEERS]; ChangeCb _onChange; diff --git a/src/web/WebServer.h b/src/web/WebServer.h index 345d7694..ce38894d 100644 --- a/src/web/WebServer.h +++ b/src/web/WebServer.h @@ -7,6 +7,7 @@ #include "../battery/BatteryMonitor.h" #include "../config/Config.h" +#include "../events/EventLog.h" #include "../logging/Logger.h" #include "../mesh/ChannelManager.h" #include "../mesh/PeerRegistry.h" @@ -227,6 +228,7 @@ class BatteryWebServer { }); _server.on("/api/peers", HTTP_GET, [this](AsyncWebServerRequest* r) { _getPeers(r); }); + _server.on("/api/events", HTTP_GET, [this](AsyncWebServerRequest* r) { _getEvents(r); }); _server.on( "/api/groups/create", HTTP_POST, [](AsyncWebServerRequest*) {}, nullptr, @@ -769,6 +771,8 @@ class BatteryWebServer { void setOnSetRemoteAudioGroup(SetRemoteAudioGroupCb cb) { _onSetRemoteAudioGroup = cb; } void setOnSetRemoteVolume(SetRemoteVolumeCb cb) { _onSetRemoteVolume = cb; } void pushAudioGroups() { _pushAudioGroups(); } + void setEventLog(EventLog* log) { _eventLog = log; } + void pushEvent(const EventLogEntry& e) { _pushEvent(e); } private: AsyncWebServer _server{80}; @@ -777,6 +781,7 @@ class BatteryWebServer { PeerRegistry* _peers = nullptr; ChannelManager* _channelMgr = nullptr; SdCardManager* _sdCard = nullptr; + EventLog* _eventLog = nullptr; GroupChangeCb _onGroupChange; GroupLightCb _onGroupLight; @@ -1120,6 +1125,23 @@ class BatteryWebServer { _sendJson(r, 200, ok); } + // ── GET /api/events ────────────────────────────────────────────────────── + void _getEvents(AsyncWebServerRequest* r) { + String filter = r->hasParam("eventType") ? r->getParam("eventType")->value() : ""; + JsonDocument doc; + JsonArray arr = doc["events"].to(); + if (_eventLog) { + _eventLog->forEach(filter.c_str(), [&](const EventLogEntry& e) { + JsonObject o = arr.add(); + o["name"] = e.peerName; + o["eventType"] = e.eventType; + o["payload"] = e.payload; + o["order"] = e.order; + }); + } + _sendJson(r, 200, doc); + } + // ── GET /api/peers ─────────────────────────────────────────────────────── void _getPeers(AsyncWebServerRequest* r) { JsonDocument doc; @@ -1476,6 +1498,19 @@ class BatteryWebServer { _ws->textAll(s); } + void _pushEvent(const EventLogEntry& e) { + if (!_ws || _ws->count() == 0) return; + JsonDocument doc; + doc["t"] = "event"; + doc["name"] = e.peerName; + doc["eventType"] = e.eventType; + doc["payload"] = e.payload; + doc["order"] = e.order; + String s; + serializeJson(doc, s); + _ws->textAll(s); + } + void _pushLog(LogLevel level, const char* msg) { if (!_ws || _ws->count() == 0) return; const char* lv = level == LogLevel::ERROR ? "E" From 1246646aa7620b2c0da85b2dba2508ac2f024523 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 09:31:03 +0000 Subject: [PATCH 2/3] mesh,web: make event log display limit configurable, add device-side clear Adds a persisted eventLogLimit device setting (default 10) capping how many of the most recent matching entries GET /api/events returns, and a POST /api/events/clear endpoint that wipes the device's ring buffer and restarts arrival order at 0, broadcasting to connected WebSocket clients so other open viewers stay in sync. Adds matching UI: a "Max displayed" field in the Settings save flow and a confirm-gated "Clear event log" button. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CMsq87s9e7zRUvfLYKxqvk --- data/index.html | 26 ++++++++++++++++++++++---- server/index.js | 16 ++++++++++++++-- src/config/Config.cpp | 2 ++ src/config/Config.h | 4 ++++ src/events/EventLog.h | 31 +++++++++++++++++++++++++++---- src/main.cpp | 1 + src/web/WebServer.h | 34 +++++++++++++++++++++++++++------- 7 files changed, 97 insertions(+), 17 deletions(-) diff --git a/data/index.html b/data/index.html index 8fb10f2e..62d03e8a 100644 --- a/data/index.html +++ b/data/index.html @@ -1097,8 +1097,11 @@

Automations

Event log

Order this device received mesh events in. Cleared on reboot.

+ +
+
@@ -2586,7 +2589,7 @@

Options

'deviceName', 'logLevel', 'timezone', 'apPassword', 'otaPort', 'otaEnabled', 'mqttHost', 'mqttPort', 'mqttUser', 'mqttPassword', 'githubRepo', 'githubToken', 'checkUpdateOnStartup', 'prOtaEnabled', 'batteryMonitoringEnabled', - 'hasI2cBus', 'i2cSdaPin', 'i2cSclPin', 'hasExpander', 'expanderAddress', + 'hasI2cBus', 'i2cSdaPin', 'i2cSclPin', 'hasExpander', 'expanderAddress', 'eventLogLimit', ]; // Subset of SETTINGS_FIELD_IDS that actually triggers a device reboot (see // _postConfig in WebServer.h) — the button label reflects whether any of @@ -2662,6 +2665,7 @@

Options

if (mqttPw) body.mqttPassword = mqttPw; body.githubRepo = $('githubRepo').value; body.checkUpdateOnStartup = $('checkUpdateOnStartup').checked; + body.eventLogLimit = parseInt($('eventLogLimit').value) || 10; body.prOtaEnabled = $('prOtaEnabled').checked; if ($('battery-section').style.display !== 'none') body.batteryMonitoringEnabled = $('batteryMonitoringEnabled').checked; @@ -3087,6 +3091,8 @@

Options

$('otaPort').value = d.otaPort || 3232; $('otaEnabled').checked = d.otaEnabled !== false; if (d.logLevel !== undefined) $('logLevel').value = d.logLevel; + _eventsLimit = d.eventLogLimit || 10; + $('eventLogLimit').value = _eventsLimit; if (d.lights) renderLightsHw(d.lights); loadButtons().catch(() => {}); loadAutomations().catch(() => {}); @@ -3191,6 +3197,7 @@

Options

if (d.t === 'groups') renderGroups(d.list || []); if (d.t === 'audioGroups') renderAudioGroupsList(d.list || []); if (d.t === 'event') _onEventPush(d); + if (d.t === 'eventsCleared') { _events = []; renderEventsList(); } } catch { /* ignore malformed/unexpected WS messages */ } }; } @@ -6574,11 +6581,12 @@

${title}

// ── Event log ───────────────────────────────────────────────────────────────── // Read-only view of EventLog (issue #442): the order GenericEvent broadcasts // (e.g. buzzer presses) arrived at this device. Server-side ring buffer is -// the source of truth (see EventLog::CAPACITY) — mirror its cap here so a +// the source of truth for what GET /api/events returns (already capped to +// eventLogLimit) — _eventsLimit mirrors that same cap client-side so a // long-open page doesn't grow the table unbounded from live 't':'event' pushes. -const EVENTS_CAPACITY = 128; let _events = []; let _eventsFilter = ''; +let _eventsLimit = 10; async function loadEvents() { const filterAtRequest = _eventsFilter; @@ -6615,14 +6623,24 @@

${title}

function _onEventPush(e) { if (_eventsFilter && e.eventType !== _eventsFilter) return; _events.push(e); - if (_events.length > EVENTS_CAPACITY) _events.shift(); + if (_events.length > _eventsLimit) _events.shift(); renderEventsList(); } +async function clearEvents() { + await _confirmAction('Clear the recorded event history on this device?', async () => { + const res = await _fetchOrAlert('/api/events/clear', {method: 'POST'}); + if (!res) return; + _events = []; + renderEventsList(); + }); +} + $('events-filter').addEventListener('input', e => { _eventsFilter = e.target.value.trim(); loadEvents(); }); +$('events-clear-btn').addEventListener('click', clearEvents); // ── Static event wiring ─────────────────────────────────────────────────────── // Elements present in the initial DOM (never rebuilt via innerHTML); dynamically diff --git a/server/index.js b/server/index.js index d068fb6f..c49773fe 100644 --- a/server/index.js +++ b/server/index.js @@ -45,6 +45,7 @@ const MOCK_CONFIG = { logLevel: 2, // Info sceneSyncEnabled: true, checkUpdateOnStartup: false, + eventLogLimit: 10, wifiSingleClientMode: false, batteryHwSupported: true, batteryMonitoringEnabled: true, @@ -238,6 +239,11 @@ function broadcastAudioGroups() { wss.clients.forEach(c => { if (c.readyState === 1) c.send(msg); }); } +function broadcastEventsCleared() { + const msg = JSON.stringify({ t: 'eventsCleared' }); + wss.clients.forEach(c => { if (c.readyState === 1) c.send(msg); }); +} + // Simulate a device reboot: drop WS connections, block API for ~4 s, then // come back online with the new version. // @@ -452,8 +458,14 @@ app.get('/api/peers', (_req, res) => res.json({ })); app.get('/api/events', (req, res) => { const eventType = (req.query.eventType || '').toString(); - const events = eventType ? mockEvents.filter(e => e.eventType === eventType) : mockEvents; - res.json({ events }); + const filtered = eventType ? mockEvents.filter(e => e.eventType === eventType) : mockEvents; + const limit = MOCK_CONFIG.eventLogLimit || 10; + res.json({ events: filtered.slice(-limit) }); +}); +app.post('/api/events/clear', (_req, res) => { + mockEvents.length = 0; + res.json({ ok: true }); + broadcastEventsCleared(); }); app.post('/api/peers/setgroup', (req, res) => { const { mac, lightIndex, groupId } = req.body || {}; diff --git a/src/config/Config.cpp b/src/config/Config.cpp index 7b8dee41..35149fe2 100644 --- a/src/config/Config.cpp +++ b/src/config/Config.cpp @@ -232,6 +232,7 @@ static void applyDoc(JsonDocument& doc) { Config::get().sceneSyncEnabled = doc["sceneSyncEnabled"] | true; Config::get().playlistSyncEnabled = doc["playlistSyncEnabled"] | true; Config::get().checkUpdateOnStartup = doc["checkUpdateOnStartup"] | false; + Config::get().eventLogLimit = doc["eventLogLimit"] | (uint8_t)10; Config::get().wifiSingleClientMode = doc["wifiSingleClientMode"] | false; Config::get().batteryMonitoringEnabled = doc["batteryMonitoringEnabled"] | false; Config::get().prOtaEnabled = doc["prOtaEnabled"] | false; @@ -394,6 +395,7 @@ bool Config::save() { doc["sceneSyncEnabled"] = _cfg.sceneSyncEnabled; doc["playlistSyncEnabled"] = _cfg.playlistSyncEnabled; doc["checkUpdateOnStartup"] = _cfg.checkUpdateOnStartup; + doc["eventLogLimit"] = _cfg.eventLogLimit; doc["wifiSingleClientMode"] = _cfg.wifiSingleClientMode; doc["batteryMonitoringEnabled"] = _cfg.batteryMonitoringEnabled; doc["prOtaEnabled"] = _cfg.prOtaEnabled; diff --git a/src/config/Config.h b/src/config/Config.h index a6add72b..476af8b7 100644 --- a/src/config/Config.h +++ b/src/config/Config.h @@ -501,6 +501,10 @@ struct DeviceConfig { bool sceneSyncEnabled = true; bool playlistSyncEnabled = true; bool checkUpdateOnStartup = false; + // Max entries the web UI's Event log view shows/fetches at once (see + // EventLog.h, issue #442) — a display preference, not a mesh-pushable + // setting (not part of applyConfigSync). Clamped to [1, EventLog::CAPACITY]. + uint8_t eventLogLimit = 10; // Mesh-wide policy (see WifiElection.h): when true, only one candidate device // actually joins the configured WiFi network at a time; the rest stay AP-only. // This is synchronized as mesh state, not a one-shot event: revision + origin diff --git a/src/events/EventLog.h b/src/events/EventLog.h index 444f3965..842ab736 100644 --- a/src/events/EventLog.h +++ b/src/events/EventLog.h @@ -28,6 +28,9 @@ class EventLog { using AppendCb = std::function; void setOnAppend(AppendCb cb) { _onAppend = cb; } + using ClearCb = std::function; + void setOnClear(ClearCb cb) { _onClear = cb; } + void record(const char* peerName, const char* eventType, uint16_t payload) { EventLogEntry& e = _entries[_head]; strlcpy(e.peerName, peerName, sizeof(e.peerName)); @@ -39,15 +42,34 @@ class EventLog { if (_onAppend) _onAppend(e); } - // Visits stored entries oldest-first, optionally restricted to a single + // Wipes all stored entries (device-side "clear events" action) and + // restarts arrival order at 0 — a fresh start, same as after a reboot. + void clear() { + _head = 0; + _count = 0; + _nextOrder = 0; + if (_onClear) _onClear(); + } + + // Visits at most `limit` (0 = unlimited) of the most-recently recorded + // matching entries, oldest-first, optionally restricted to a single // eventType ("" = no filter). - void forEach(const char* eventTypeFilter, + void forEach(const char* eventTypeFilter, uint16_t limit, const std::function& fn) const { uint16_t start = (_count < CAPACITY) ? 0 : _head; + auto matches = [&](const EventLogEntry& e) { + return !eventTypeFilter || !eventTypeFilter[0] || + strcmp(e.eventType, eventTypeFilter) == 0; + }; + uint16_t total = 0; + for (uint16_t i = 0; i < _count; i++) + if (matches(_entries[(start + i) % CAPACITY])) total++; + uint16_t skip = (limit > 0 && total > limit) ? total - limit : 0; + uint16_t seen = 0; for (uint16_t i = 0; i < _count; i++) { const EventLogEntry& e = _entries[(start + i) % CAPACITY]; - if (eventTypeFilter && eventTypeFilter[0] && strcmp(e.eventType, eventTypeFilter) != 0) - continue; + if (!matches(e)) continue; + if (seen++ < skip) continue; fn(e); } } @@ -58,4 +80,5 @@ class EventLog { uint16_t _count = 0; uint32_t _nextOrder = 0; AppendCb _onAppend; + ClearCb _onClear; }; diff --git a/src/main.cpp b/src/main.cpp index fb09282c..7aa07c7d 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -918,6 +918,7 @@ void setup() { webServer.setEventLog(&eventLog); eventLog.setOnAppend([](const EventLogEntry& e) { webServer.pushEvent(e); }); + eventLog.setOnClear([]() { webServer.pushEventsCleared(); }); webServer.setPlaylistSync(&playlistSync); // idx/volume args no longer directly meaningful (volume now only applies diff --git a/src/web/WebServer.h b/src/web/WebServer.h index ce38894d..03b3bc9f 100644 --- a/src/web/WebServer.h +++ b/src/web/WebServer.h @@ -229,6 +229,8 @@ class BatteryWebServer { _server.on("/api/peers", HTTP_GET, [this](AsyncWebServerRequest* r) { _getPeers(r); }); _server.on("/api/events", HTTP_GET, [this](AsyncWebServerRequest* r) { _getEvents(r); }); + _server.on("/api/events/clear", HTTP_POST, + [this](AsyncWebServerRequest* r) { _clearEvents(r); }); _server.on( "/api/groups/create", HTTP_POST, [](AsyncWebServerRequest*) {}, nullptr, @@ -773,6 +775,7 @@ class BatteryWebServer { void pushAudioGroups() { _pushAudioGroups(); } void setEventLog(EventLog* log) { _eventLog = log; } void pushEvent(const EventLogEntry& e) { _pushEvent(e); } + void pushEventsCleared() { _pushEventsCleared(); } private: AsyncWebServer _server{80}; @@ -891,6 +894,7 @@ class BatteryWebServer { doc["logLevel"] = c.logLevel; doc["sceneSyncEnabled"] = c.sceneSyncEnabled; doc["checkUpdateOnStartup"] = c.checkUpdateOnStartup; + doc["eventLogLimit"] = c.eventLogLimit; doc["wifiSingleClientMode"] = c.wifiSingleClientMode; doc["batteryHwSupported"] = BatteryMonitor::kHwSupported; doc["batteryMonitoringEnabled"] = c.batteryMonitoringEnabled; @@ -985,6 +989,9 @@ class BatteryWebServer { } if (!doc["checkUpdateOnStartup"].isNull()) c.checkUpdateOnStartup = (bool)doc["checkUpdateOnStartup"]; + if (!doc["eventLogLimit"].isNull()) + c.eventLogLimit = + (uint8_t)constrain((int)doc["eventLogLimit"], 1, (int)EventLog::CAPACITY); bool batteryChanged = false; if (!doc["batteryMonitoringEnabled"].isNull()) { @@ -1131,17 +1138,25 @@ class BatteryWebServer { JsonDocument doc; JsonArray arr = doc["events"].to(); if (_eventLog) { - _eventLog->forEach(filter.c_str(), [&](const EventLogEntry& e) { - JsonObject o = arr.add(); - o["name"] = e.peerName; - o["eventType"] = e.eventType; - o["payload"] = e.payload; - o["order"] = e.order; - }); + _eventLog->forEach(filter.c_str(), Config::get().eventLogLimit, + [&](const EventLogEntry& e) { + JsonObject o = arr.add(); + o["name"] = e.peerName; + o["eventType"] = e.eventType; + o["payload"] = e.payload; + o["order"] = e.order; + }); } _sendJson(r, 200, doc); } + // ── POST /api/events/clear ─────────────────────────────────────────────── + void _clearEvents(AsyncWebServerRequest* r) { + if (_eventLog) _eventLog->clear(); + auto ok = _makeOk(); + _sendJson(r, 200, ok); + } + // ── GET /api/peers ─────────────────────────────────────────────────────── void _getPeers(AsyncWebServerRequest* r) { JsonDocument doc; @@ -1511,6 +1526,11 @@ class BatteryWebServer { _ws->textAll(s); } + void _pushEventsCleared() { + if (!_ws || _ws->count() == 0) return; + _ws->textAll("{\"t\":\"eventsCleared\"}"); + } + void _pushLog(LogLevel level, const char* msg) { if (!_ws || _ws->count() == 0) return; const char* lv = level == LogLevel::ERROR ? "E" From 0bc1abe05de2d02c9bd7d285a12ac5342d3868e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 10:19:18 +0000 Subject: [PATCH 3/3] web: put event log's max-displayed and filter fields on one row Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CMsq87s9e7zRUvfLYKxqvk --- data/index.html | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/data/index.html b/data/index.html index 62d03e8a..ec90399e 100644 --- a/data/index.html +++ b/data/index.html @@ -1097,9 +1097,11 @@

Automations

Event log

Order this device received mesh events in. Cleared on reboot.

- - - +
+ + + +