diff --git a/data/index.html b/data/index.html index 7252c1c1..ec90399e 100644 --- a/data/index.html +++ b/data/index.html @@ -1093,6 +1093,19 @@

Automations

+ +
+

Event log

+

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

+
+ + + +
+
+ +
+

Updates

@@ -2578,7 +2591,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 @@ -2654,6 +2667,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; @@ -3079,9 +3093,12 @@

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(() => {}); + loadEvents().catch(() => {}); await loadAudioGroups().catch(() => {}); loadSounds().catch(() => {}); loadStorage().catch(() => {}); @@ -3181,6 +3198,8 @@

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); + if (d.t === 'eventsCleared') { _events = []; renderEventsList(); } } catch { /* ignore malformed/unexpected WS messages */ } }; } @@ -6561,6 +6580,70 @@

${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 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. +let _events = []; +let _eventsFilter = ''; +let _eventsLimit = 10; + +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 > _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 // rendered elements wire their own listeners via delegation near their render diff --git a/server/index.js b/server/index.js index b8fe3938..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, @@ -162,6 +163,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' }, @@ -224,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. // @@ -436,6 +456,17 @@ 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 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 || {}; // Mirrors WebServer.h::_setRemoteGroup: only self-mac assignments are @@ -1122,6 +1153,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/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 new file mode 100644 index 00000000..842ab736 --- /dev/null +++ b/src/events/EventLog.h @@ -0,0 +1,84 @@ +#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; } + + 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)); + 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); + } + + // 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, 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 (!matches(e)) continue; + if (seen++ < skip) continue; + fn(e); + } + } + + private: + EventLogEntry _entries[CAPACITY]; + uint16_t _head = 0; + uint16_t _count = 0; + uint32_t _nextOrder = 0; + AppendCb _onAppend; + ClearCb _onClear; +}; diff --git a/src/main.cpp b/src/main.cpp index 565dbc56..7aa07c7d 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,10 @@ void setup() { }); sceneSync.setOnSceneSaved(notifySceneUpdated); + 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 // 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..03b3bc9f 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,9 @@ 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, @@ -769,6 +773,9 @@ 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); } + void pushEventsCleared() { _pushEventsCleared(); } private: AsyncWebServer _server{80}; @@ -777,6 +784,7 @@ class BatteryWebServer { PeerRegistry* _peers = nullptr; ChannelManager* _channelMgr = nullptr; SdCardManager* _sdCard = nullptr; + EventLog* _eventLog = nullptr; GroupChangeCb _onGroupChange; GroupLightCb _onGroupLight; @@ -886,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; @@ -980,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()) { @@ -1120,6 +1132,31 @@ 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(), 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; @@ -1476,6 +1513,24 @@ 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 _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"