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 @@
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 = `
+ | # | Name | Event type | Payload |
+ ${_events.map(e => `| ${e.order} | ${esc(e.name)} | ${esc(e.eventType)} | ${e.payload} |
`).join('')}
+
`;
+}
+
+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"