Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 84 additions & 1 deletion data/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1093,6 +1093,19 @@ <h2>Automations</h2>
<button class="btn-ghost" style="width:100%" id="add-automation-btn">Add automation</button>
</div>

<!-- EVENT LOG -->
<div class="card">
<h2>Event log</h2>
<p style="font-size:.78rem;color:#666;margin:0 0 .5rem">Order this device received mesh events in. Cleared on reboot.</p>
<div class="row" style="margin-bottom:.5rem">
<input type="text" id="events-filter" placeholder="Filter by event type (e.g. buzz.press)" style="margin-bottom:0">
<label style="flex:0 0 auto;margin:0;white-space:nowrap">Max</label>
<input type="number" id="eventLogLimit" min="1" max="128" style="flex:0 0 4.5rem;margin-bottom:0">
</div>
<div id="events-list"></div>
<button class="btn-danger btn-sm" style="width:100%;margin-top:.6rem" id="events-clear-btn">Clear event log</button>
</div>

<!-- UPDATES -->
<div class="card">
<h2>Updates</h2>
Expand Down Expand Up @@ -2578,7 +2591,7 @@ <h3>Options</h3>
'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
Expand Down Expand Up @@ -2654,6 +2667,7 @@ <h3>Options</h3>
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;
Expand Down Expand Up @@ -3079,9 +3093,12 @@ <h3>Options</h3>
$('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(() => {});
Expand Down Expand Up @@ -3181,6 +3198,8 @@ <h3>Options</h3>
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 */ }
};
}
Expand Down Expand Up @@ -6561,6 +6580,70 @@ <h2 style="margin:0">${title}</h2>
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 = '<p style="color:#555;font-size:.85rem">No events recorded yet.</p>';
return;
}
el.innerHTML = `<div class="lights-table-wrap"><table class="lights-table">
<thead><tr><th>#</th><th>Name</th><th>Event type</th><th>Payload</th></tr></thead>
<tbody>${_events.map(e => `<tr><td>${e.order}</td><td>${esc(e.name)}</td><td>${esc(e.eventType)}</td><td>${e.payload}</td></tr>`).join('')}</tbody>
</table></div>`;
}

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
Expand Down
32 changes: 32 additions & 0 deletions server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ const MOCK_CONFIG = {
logLevel: 2, // Info
sceneSyncEnabled: true,
checkUpdateOnStartup: false,
eventLogLimit: 10,
wifiSingleClientMode: false,
batteryHwSupported: true,
batteryMonitoringEnabled: true,
Expand Down Expand Up @@ -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' },
Expand Down Expand Up @@ -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.
//
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions src/config/Config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions src/config/Config.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
84 changes: 84 additions & 0 deletions src/events/EventLog.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
#pragma once
#include <Arduino.h>

#include <functional>

#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(const EventLogEntry&)>;
void setOnAppend(AppendCb cb) { _onAppend = cb; }

using ClearCb = std::function<void()>;
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<void(const EventLogEntry&)>& 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;
};
11 changes: 11 additions & 0 deletions src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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(); });

Expand Down Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions src/mesh/PeerRegistry.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading