diff --git a/data/index.html b/data/index.html index 1987177d..1ce4610b 100644 --- a/data/index.html +++ b/data/index.html @@ -1999,6 +1999,44 @@

Options

`; } +// Per-light command-arbitration state (issue #457): while a direct command +// overrides a light, it stops following its group until the group next +// actually changes, the override's duration elapses, or it's cleared here. +// Like the brightness override above, only ever rendered for this device's +// own lights — the override lives on the owning device. +function _renderLightOverrideState(light) { + const overridden = light.overridden === true; + const tip = overridden + ? 'A direct command currently overrides this light — it rejoins its group on the next group change, or when cleared here' + : 'This light is following its group'; + const state = overridden + ? 'Overridden' + : 'Following group'; + const clearBtn = overridden + ? `` + : ''; + return `
${state}${clearBtn}
`; +} + +async function clearLightOverride(btn, index) { + // Drop focus first: _patchKeyedChildren skips repainting the row that + // holds the focused element, and this row must repaint to "Following + // group" right after the clear. + btn.blur(); + const res = await _fetchOrToast('/api/lights/override/clear', { + method: 'POST', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({index}) + }, "Failed to clear light's override"); + if (!res) return; + // Local dev (mock server) never opens the dashboard WebSocket (see + // isLocalDev), so refresh over REST — on a real device the peers push + // covers it and this is just a harmless double render. + try { + const d = await (await fetch('/api/peers')).json(); + renderLights(d.self, d.peers || [], d.wifiSingleClientMode === true); + } catch { /* the WS push already covers it where connected */ } +} + function onLightBrightnessOverrideToggle(checkboxEl) { const index = Number(checkboxEl.dataset.lightIndex); const slider = checkboxEl.closest('.lbr-row')?.querySelector('.lbr-value'); @@ -2194,8 +2232,10 @@

Options

const lightLabel = l.name ? esc(l.name) : `L${l.index}`; const label = `${lightLabel}`; const override = p.isSelf ? _renderLightBrightnessOverride(l) : ''; + const overrideState = p.isSelf ? _renderLightOverrideState(l) : ''; return `
${label}${_renderLightGroupSelect(mac, l)}
+ ${overrideState} ${override}
`; }).join('')) + soundHtml; @@ -2205,11 +2245,13 @@

Options

: lights.map(l => { const lightLabel = l.name ? esc(l.name) : `Light ${l.index}`; const override = p.isSelf ? _renderLightBrightnessOverride(l) : ''; + const overrideState = p.isSelf ? _renderLightOverrideState(l) : ''; return `
${lightLabel}
${_renderLightGroupSelect(mac, l)} + ${overrideState} ${override}
`; }).join('')) + soundHtml; @@ -2602,11 +2644,12 @@

Options

// containers (see renderLights/_patchKeyedChildren), so both get delegation. for (const rootId of ['lights-body', 'lights-mobile-list']) { _delegate(rootId, 'click', { - openPushModal: el => openPushModal(el.dataset.mac, el.dataset.name), - triggerSelfUpdate: el => triggerSelfUpdate(el, el.dataset.mac), - triggerPeerUpdate: el => triggerPeerUpdate(el, el.dataset.mac), - checkSelfUpdate: el => checkSelfUpdate(el), - checkPeerUpdate: el => checkPeerUpdate(el, el.dataset.mac), + openPushModal: el => openPushModal(el.dataset.mac, el.dataset.name), + triggerSelfUpdate: el => triggerSelfUpdate(el, el.dataset.mac), + triggerPeerUpdate: el => triggerPeerUpdate(el, el.dataset.mac), + checkSelfUpdate: el => checkSelfUpdate(el), + checkPeerUpdate: el => checkPeerUpdate(el, el.dataset.mac), + clearLightOverride: el => clearLightOverride(el, Number(el.dataset.lightIndex)), }); _delegate(rootId, 'change', { assignGroup: el => assignGroup(el.dataset.mac, Number(el.dataset.lightIndex), parseInt(el.value)), diff --git a/server/index.js b/server/index.js index 5166aeea..f8d41303 100644 --- a/server/index.js +++ b/server/index.js @@ -14,12 +14,43 @@ const mockLights = [ { index: 2, name: 'Patio', ledType: 0, colorOrder: 2, dataPin: 27, clockPin: 32, width: 12, height: 1, matrixStart: 0, matrixDir: 0, matrixSerpentine: false, wrapWidth: true, wrapHeight: false, groupId: 2, brightnessOverrideEnabled: false, brightnessOverride: 255, brightnessLimit: 128, brightnessScale: 180 }, ]; +// Per-light direct-command override slots (issue #457) — mirrors the +// firmware's LightOverrides: a full LightConfig snapshot plus the group's seq +// (baseSeq) and the light's group membership at capture time, keyed by light +// index. Volatile on the real device, plain in-memory state here. Patio +// starts overridden (a direct command turned it solid red) so the +// dashboard's "Overridden" indicator + clear flow can be exercised without +// crafting a REST call first. +const mockLightOverrides = new Map([ + [2, { + cfg: { mode: 0, sceneId: '', pattern: 0, r: 255, g: 40, b: 40, brightness: 255, speed: 1, proximityScale: 1.0, morphEnabled: false, gradientStopCount: 0, text: '', textAnimation: 0, time24h: true }, + baseSeq: 0, groupId: 2, timer: null, + }], +]); + +// Mirrors LightOverrides::displaced + main.cpp's applyEffectiveLight: an +// override whose light was reassigned, or whose group's seq advanced past its +// baseSeq (a real change, not a re-broadcast), is dropped — the light rejoins +// its group. Call after anything that could displace one. +function dropDisplacedOverrides() { + let dropped = false; + for (const [index, slot] of mockLightOverrides) { + const light = mockLights.find(l => l.index === index); + const group = light && MOCK_CONFIG.groups.find(g => g.id === light.groupId); + if (light && light.groupId === slot.groupId && !(group && group.seq > slot.baseSeq)) continue; + clearTimeout(slot.timer); + mockLightOverrides.delete(index); + dropped = true; + } + if (dropped) broadcastPeers(); +} + // The real device derives self.lights in /api/peers straight from the live // hardware config on every request (see WebServer.h::_buildPeersJson), so it // can never drift. Mirror that here instead of keeping a second static copy. function mockSelfLights() { return mockLights.map(({ index, name, groupId, ledType, width, height, wrapWidth, brightnessOverrideEnabled, brightnessOverride }) => - ({ index, name, groupId, ledType, width, height, wrapWidth, brightnessOverrideEnabled, brightnessOverride })); + ({ index, name, groupId, ledType, width, height, wrapWidth, brightnessOverrideEnabled, brightnessOverride, overridden: mockLightOverrides.has(index) })); } // Same "derive from the live hardware config, never a second static copy" @@ -488,6 +519,7 @@ app.post('/api/peers/setgroup', (req, res) => { if (mac === MOCK_SELF.mac) { const light = mockLights.find(l => l.index === lightIndex); if (light) light.groupId = groupId; + dropDisplacedOverrides(); broadcastPeers(); } res.json({ ok: true }); @@ -645,7 +677,10 @@ function lightPinConflict(l, excludeIndex) { return null; } -app.get('/api/lights', (_req, res) => res.json({ lights: mockLights, maxLights: 4 })); +app.get('/api/lights', (_req, res) => res.json({ + lights: mockLights.map(l => ({ ...l, overridden: mockLightOverrides.has(l.index) })), + maxLights: 4, +})); app.post('/api/lights/add', (req, res) => { const free = [0,1,2,3].find(i => !mockLights.find(l => l.index === i)); if (free === undefined) return res.status(400).json({ error: 'light limit reached' }); @@ -670,6 +705,9 @@ app.post('/api/lights/update', (req, res) => { } Object.assign(light, fields); res.json({ ok: true }); + // A group reassignment is a newer group-level command — it displaces the + // light's override, same as /api/peers/setgroup (issue #457). + if ('groupId' in fields) dropDisplacedOverrides(); // Mirrors WebServer.h::_updateLight: a brightness override change is a live // update (no reboot) that pushes to the dashboard via WS, unlike other // hardware-config fields on this endpoint (which trigger ESP.restart() on @@ -681,7 +719,54 @@ app.post('/api/lights/delete', (req, res) => { const idx = mockLights.findIndex(l => l.index === index); if (idx === -1) return res.status(404).json({ error: 'not found' }); mockLights.splice(idx, 1); + const slot = mockLightOverrides.get(index); + if (slot) { + clearTimeout(slot.timer); + mockLightOverrides.delete(index); + } + res.json({ ok: true }); +}); + +// Mirrors WebServer.h::_setLightOverride: {index, durationMs?, } — unspecified fields fall back to the light's current group state, +// so the stored override is always a full snapshot (replace, never merge). +// baseSeq captures the group's seq: a later real group change (seq bump) +// displaces the override, a durationMs > 0 expires it on its own. +app.post('/api/lights/override', (req, res) => { + const { index, durationMs = 0, ...fields } = req.body || {}; + const light = mockLights.find(l => l.index === index); + if (!light) return res.status(404).json({ error: 'not found' }); + const group = MOCK_CONFIG.groups.find(g => g.id === light.groupId); + const base = group + ? (({ mode, sceneId, pattern, r, g, b, brightness, speed, proximityScale, morphEnabled, gradientStopCount, text, textAnimation, time24h }) => + ({ mode, sceneId, pattern, r, g, b, brightness, speed, proximityScale, morphEnabled, gradientStopCount, text, textAnimation, time24h }))(group) + : {}; + const prev = mockLightOverrides.get(index); + if (prev) clearTimeout(prev.timer); + const slot = { cfg: { ...base, ...fields }, baseSeq: group ? (group.seq || 0) : 0, groupId: light.groupId, timer: null }; + if (durationMs > 0) { + slot.timer = setTimeout(() => { + mockLightOverrides.delete(index); + broadcastPeers(); + }, durationMs); + } + mockLightOverrides.set(index, slot); + res.json({ ok: true }); + broadcastPeers(); +}); + +// Mirrors WebServer.h::_clearLightOverride: {index} — the light rejoins its group. +app.post('/api/lights/override/clear', (req, res) => { + const { index } = req.body || {}; + const light = mockLights.find(l => l.index === index); + if (!light) return res.status(404).json({ error: 'not found' }); + const slot = mockLightOverrides.get(index); + if (slot) { + clearTimeout(slot.timer); + mockLightOverrides.delete(index); + } res.json({ ok: true }); + broadcastPeers(); }); app.post('/api/lights/test', (req, res) => { const { index } = req.body || {}; @@ -1063,7 +1148,16 @@ app.post('/api/groups/update', (req, res) => { const { id, ...fields } = req.body || {}; const group = MOCK_CONFIG.groups.find(g => g.id === id); if (!group) return res.status(404).json({ error: 'not found' }); + // Mirrors WebServer.h::_updateGroup: any key besides name/syncEnabled is a + // LightConfig field, and a real light change bumps the group's seq — which + // is what displaces per-light overrides (issue #457); name/syncEnabled + // edits don't and leave them alone. + const lightChanged = Object.keys(fields).some(k => k !== 'name' && k !== 'syncEnabled'); Object.assign(group, fields); + if (lightChanged) { + group.seq = (group.seq || 0) + 1; + dropDisplacedOverrides(); + } res.json({ ok: true }); }); app.post('/api/groups/delete', (req, res) => { @@ -1073,6 +1167,7 @@ app.post('/api/groups/delete', (req, res) => { if (idx === -1) return res.status(404).json({ error: 'not found' }); MOCK_CONFIG.groups.splice(idx, 1); for (const l of mockLights) if (l.groupId === id) l.groupId = 0; + dropDisplacedOverrides(); res.json({ ok: true }); }); app.post('/api/reset', (_req, res) => res.json({ ok: true })); diff --git a/server/index.test.js b/server/index.test.js index 9a6ed20d..aac14ddc 100644 --- a/server/index.test.js +++ b/server/index.test.js @@ -184,6 +184,80 @@ describe('POST /api/lights/update', () => { }); }); +describe('POST /api/lights/override', () => { + const postJson = (path, body) => fetch(`${baseUrl}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const overridden = async index => + (await (await fetch(`${baseUrl}/api/lights`)).json()).lights.find(l => l.index === index).overridden; + + test('marks the seeded Patio override in /api/lights and self.lights', async () => { + assert.equal(await overridden(2), true); + assert.equal(await overridden(0), false); + const { self } = await (await fetch(`${baseUrl}/api/peers`)).json(); + assert.equal(self.lights.find(l => l.index === 2).overridden, true); + }); + + test('returns 404 for an unknown light index', async () => { + const res = await postJson('/api/lights/override', { index: 99, r: 0, g: 255, b: 0 }); + assert.equal(res.status, 404); + }); + + test('sets an override on a light', async () => { + const res = await postJson('/api/lights/override', { index: 0, r: 0, g: 255, b: 0 }); + assert.equal(res.status, 200); + assert.equal(await overridden(0), true); + }); + + test('a real group change displaces the override, a name-only edit does not', async () => { + await postJson('/api/lights/override', { index: 0, r: 0, g: 255, b: 0 }); + let res = await postJson('/api/groups/update', { id: 0, name: 'Default' }); + assert.equal(res.status, 200); + assert.equal(await overridden(0), true); + res = await postJson('/api/groups/update', { id: 0, brightness: 123 }); + assert.equal(res.status, 200); + assert.equal(await overridden(0), false); + }); + + test('reassigning the light to another group displaces the override', async () => { + await postJson('/api/lights/override', { index: 0, r: 0, g: 255, b: 0 }); + await postJson('/api/lights/update', { index: 0, groupId: 1 }); + assert.equal(await overridden(0), false); + await postJson('/api/lights/update', { index: 0, groupId: 0 }); + }); + + test('a durationMs override expires on its own', async () => { + await postJson('/api/lights/override', { index: 0, r: 0, g: 255, b: 0, durationMs: 40 }); + assert.equal(await overridden(0), true); + await new Promise(resolve => setTimeout(resolve, 120)); + assert.equal(await overridden(0), false); + }); +}); + +describe('POST /api/lights/override/clear', () => { + test('returns 404 for an unknown light index', async () => { + const res = await fetch(`${baseUrl}/api/lights/override/clear`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ index: 99 }), + }); + assert.equal(res.status, 404); + }); + + test('clears the seeded override — the light follows its group again', async () => { + const res = await fetch(`${baseUrl}/api/lights/override/clear`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ index: 2 }), + }); + assert.equal(res.status, 200); + const { lights } = await (await fetch(`${baseUrl}/api/lights`)).json(); + assert.equal(lights.find(l => l.index === 2).overridden, false); + }); +}); + describe('GET /api/sounds', () => { test('returns the configured sound outputs', async () => { const res = await fetch(`${baseUrl}/api/sounds`); diff --git a/src/lights/LightOverrides.h b/src/lights/LightOverrides.h new file mode 100644 index 00000000..eaf1a196 --- /dev/null +++ b/src/lights/LightOverrides.h @@ -0,0 +1,107 @@ +#pragma once +#include + +#include + +#include "../config/Config.h" +#include "../logging/Logger.h" + +// Per-light direct-command override slots (issue #457, LightWitch M1 command +// arbitration): a direct command aimed at one light stores a full LightConfig +// snapshot here (replace, never merge) and the light renders it instead of its +// group's state — bypassing the standing brightness adjustment +// (LightHardwareConfig::brightnessOverride), which applies again once the +// light rejoins its group. The hardware clamp at the LED driver boundary +// applies to overrides like everything else. +// +// Arbitration is "the newest command owns the light": the slot captures the +// group's LightConfig::seq at set time (baseSeq). Group re-broadcasts with +// seq == baseSeq (periodic self-heal) keep the override; any group update +// with seq > baseSeq — someone actually changed the group — displaces it, as +// does reassigning the light to a different group. A newer direct command +// simply replaces the slot; an optional durationMs auto-expires it +// receiver-side (see LightOverrides::expired and main.cpp's slow tick). +// +// Deliberately volatile: state lives in RAM only, so a reboot always comes +// back rendering the group. Local to this device — the mesh message that +// carries direct commands between devices builds on this (issue #458). +// +// Slots are heap-allocated on demand rather than kept as a static array: a +// Slot carries a whole LightConfig (~136 bytes), and MAX_LIGHTS of them in +// .bss overflow esp32dev's static DRAM segment. Overrides are occasional and +// usually affect one light, so only an active override costs memory; the +// static cost is the pointer table. +class LightOverrides { + public: + struct Slot { + LightConfig cfg; // full snapshot; replace, never merge + uint32_t baseSeq = 0; // group's light.seq when the override was set + uint8_t groupId = 0; // group the light followed when the override was set + bool hasExpiry = false; + uint32_t expiresAtMs = 0; // millis() deadline, only meaningful with hasExpiry + }; + + static bool active(uint8_t i) { return i < MAX_LIGHTS && _slots()[i] != nullptr; } + + // Only meaningful while active(i) — callers check that first. + static const LightConfig& config(uint8_t i) { return _slots()[i]->cfg; } + + // Captures the current group seq + membership as the arbitration baseline. + // durationMs == 0 means no expiry. A failed allocation leaves the light + // following its group rather than rendering a half-applied override. + static void set(uint8_t i, const LightConfig& cfg, uint32_t durationMs) { + if (i >= MAX_LIGHTS) return; + Slot* s = _slots()[i]; + if (!s) { + s = new (std::nothrow) Slot(); + if (!s) { + Logger::e("[light] override on light %u dropped — out of memory", i); + return; + } + _slots()[i] = s; + } + const auto& l = Config::get().lights[i]; + GroupConfig* g = Config::group(l.groupId); + s->cfg = cfg; + s->baseSeq = g ? g->light.seq : 0; + s->groupId = l.groupId; + s->hasExpiry = durationMs > 0; + s->expiresAtMs = millis() + durationMs; + } + + static void clear(uint8_t i) { + if (i >= MAX_LIGHTS) return; + delete _slots()[i]; + _slots()[i] = nullptr; + } + + // True if light i's active override has been displaced by a newer group + // command: the light was reassigned to a different group, or its group's + // seq advanced past baseSeq (a real change, not a seq == baseSeq + // re-broadcast). The caller clears the slot and reapplies the group. + static bool displaced(uint8_t i) { + if (!active(i)) return false; + const Slot& s = *_slots()[i]; + const auto& l = Config::get().lights[i]; + if (l.groupId != s.groupId) return true; + GroupConfig* g = Config::group(l.groupId); + return g && g->light.seq > s.baseSeq; + } + + // True if light i's active override carries a durationMs whose deadline + // has passed (wraparound-safe). + static bool expired(uint8_t i, uint32_t nowMs) { + if (!active(i) || !_slots()[i]->hasExpiry) return false; + return (int32_t)(nowMs - _slots()[i]->expiresAtMs) >= 0; + } + + private: + // Function-local static rather than an `inline static` member: the ESP32 + // toolchain (GCC 8.4) rejects an in-class array initializer whose element + // type carries default member initializers. Same idiom as + // SceneManager::_tombstones(). + static Slot** _slots() { + static Slot* slots[MAX_LIGHTS] = {}; + return slots; + } +}; diff --git a/src/main.cpp b/src/main.cpp index 68ed6f4f..795ee86f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -15,6 +15,7 @@ #include "events/EventLog.h" #include "led/Ws2801Driver.h" #include "led/Ws2812bDriver.h" +#include "lights/LightOverrides.h" #include "logging/Logger.h" #include "mesh/ChannelManager.h" #include "mesh/MeshManager.h" @@ -115,18 +116,39 @@ static LightConfig withBrightnessOverride(const LightConfig& cfg, const LightHar return c; } -// Apply each light's group config to its runner -static void applyAllLights() { - Config::forEachLight([](uint8_t i, LightHardwareConfig& l) { - if (!_leds[i]) return; - auto* g = Config::group(l.groupId); - if (g) _runners[i].applyConfig(withBrightnessOverride(g->light, l)); - }); -} - // Pushes mesh peers over the dashboard WebSocket. static void publishTelemetry() { webServer.pushPeers(); } +// Resolves and applies what light i renders right now — the single funnel +// every path that (re)applies a light's config must go through, so command +// arbitration (issue #457) stays consistent regardless of origin: +// - an override displaced by a newer group command (seq advanced past its +// baseSeq, or the light reassigned) is dropped here, rejoining the group; +// - an active override renders its snapshot, bypassing the standing +// brightness adjustment; +// - otherwise the group's state applies, with the standing adjustment on top. +static void applyEffectiveLight(uint8_t i) { + if (i >= MAX_LIGHTS || !_leds[i]) return; + auto& l = Config::get().lights[i]; + if (!l.exists) return; + if (LightOverrides::displaced(i)) { + LightOverrides::clear(i); + Logger::i("[light] override on light %u displaced by group change — rejoining group", i); + publishTelemetry(); + } + if (LightOverrides::active(i)) { + _runners[i].applyConfig(LightOverrides::config(i)); + return; + } + GroupConfig* g = Config::group(l.groupId); + if (g) _runners[i].applyConfig(withBrightnessOverride(g->light, l)); +} + +// Apply each light's effective config to its runner +static void applyAllLights() { + Config::forEachLight([](uint8_t i, LightHardwareConfig&) { applyEffectiveLight(i); }); +} + // Broadcasts a GroupConfig change over mesh and republishes its MQTT state — // or, for a tombstone (g.exists == false, i.e. this group was just deleted), // clears its retained MQTT topics instead of trying to publish content for @@ -240,10 +262,7 @@ static void triggerStopAudio(uint8_t audioGroupId) { // applyAndPropagateLightConfig/mesh broadcast. static void applyLightBrightnessOverride(uint8_t lightIndex) { if (lightIndex >= MAX_LIGHTS || !_leds[lightIndex]) return; - auto& l = Config::get().lights[lightIndex]; - GroupConfig* g = Config::group(l.groupId); - if (!g) return; - _runners[lightIndex].applyConfig(withBrightnessOverride(g->light, l)); + applyEffectiveLight(lightIndex); mqtt.publishLightOverride(lightIndex); publishTelemetry(); } @@ -275,8 +294,7 @@ static void applyAndPropagateLightConfig(uint8_t groupId, const LightConfig& cfg Config::save(); Config::forEachLight([&](uint8_t i, LightHardwareConfig& l) { - if (l.groupId == groupId && _leds[i]) - _runners[i].applyConfig(withBrightnessOverride(cfg, l)); + if (l.groupId == groupId) applyEffectiveLight(i); }); mesh.broadcastLightConfig(groupId, cfg); @@ -480,8 +498,7 @@ void setup() { _runners[i].setWrap(l.wrapWidth, l.wrapHeight); _runners[i].setPeerRegistry(&mesh.peers); _runners[i].setGroupId(l.groupId); - auto* g = Config::group(l.groupId); - if (g) _runners[i].applyConfig(withBrightnessOverride(g->light, l)); + applyEffectiveLight(i); }); // Bring up the device-wide I2C bus, if configured — shared by the sound @@ -709,10 +726,7 @@ void setup() { Config::save(); if (_leds[lightIndex]) { _runners[lightIndex].setGroupId(groupId); - auto* g = Config::group(groupId); - if (g) - _runners[lightIndex].applyConfig( - withBrightnessOverride(g->light, Config::get().lights[lightIndex])); + applyEffectiveLight(lightIndex); } Logger::i("[mesh] light %u moved to group %u", lightIndex, groupId); } @@ -778,8 +792,7 @@ void setup() { // reapplying is cheap and name/exists/light always move together // now, so there's no cheaper way to tell them apart here. Config::forEachLight([&](uint8_t i, LightHardwareConfig& l) { - if (l.groupId == g.id && _leds[i]) - _runners[i].applyConfig(withBrightnessOverride(applied->light, l)); + if (l.groupId == g.id) applyEffectiveLight(i); }); } if (applied) mqtt.publishGroupState(applied->id); @@ -789,6 +802,10 @@ void setup() { mesh.setOnPhaseSync([](uint8_t groupId, float phase) { Config::forEachLight([&](uint8_t i, LightHardwareConfig& l) { if (l.groupId != groupId) return; + // An overridden light renders its own snapshot, not the group's + // pattern — snapping it to the group's phase would visibly + // disrupt the override without making it any more in sync. + if (LightOverrides::active(i)) return; GroupConfig* g = Config::group(l.groupId); if (!g || !g->syncEnabled) return; _runners[i].snapPhase(phase); @@ -888,6 +905,16 @@ void setup() { } }); webServer.setOnLightBrightnessChange([](uint8_t idx) { applyLightBrightnessOverride(idx); }); + webServer.setOnLightOverrideSet([](uint8_t idx, const LightConfig& cfg, uint32_t durationMs) { + LightOverrides::set(idx, cfg, durationMs); + applyEffectiveLight(idx); + publishTelemetry(); + }); + webServer.setOnLightOverrideClear([](uint8_t idx) { + LightOverrides::clear(idx); + applyEffectiveLight(idx); + publishTelemetry(); + }); webServer.setOnLightClampChange([](uint8_t idx) { if (idx < MAX_LIGHTS && _leds[idx]) { auto& l = Config::get().lights[idx]; @@ -1048,6 +1075,19 @@ void loop() { playlistSync.tick(); } + // Receiver-side expiry of temporary overrides (durationMs, issue #457): + // revert the light to its group and tell the dashboard. 50 ms granularity + // comfortably covers the shortest sensible durations. + if (!_otaActive && slowTick) { + Config::forEachLight([&](uint8_t i, LightHardwareConfig&) { + if (!LightOverrides::expired(i, now)) return; + LightOverrides::clear(i); + Logger::i("[light] override on light %u expired — rejoining group", i); + applyEffectiveLight(i); + publishTelemetry(); + }); + } + if (!_otaActive && now - _lastPatternTickMs >= PATTERN_TICK_INTERVAL_MS) { _lastPatternTickMs = now; for (uint8_t i = 0; i < MAX_LIGHTS; i++) diff --git a/src/web/WebServer.h b/src/web/WebServer.h index 2fe12612..db92f891 100644 --- a/src/web/WebServer.h +++ b/src/web/WebServer.h @@ -8,6 +8,7 @@ #include "../battery/BatteryMonitor.h" #include "../config/Config.h" #include "../events/EventLog.h" +#include "../lights/LightOverrides.h" #include "../logging/Logger.h" #include "../mesh/ChannelManager.h" #include "../mesh/PeerRegistry.h" @@ -52,6 +53,12 @@ using OrientationChangeCb = std::function; using ColorOrderChangeCb = std::function; // Called when a light's own brightnessOverride(Enabled) changes without reboot using LightBrightnessChangeCb = std::function; +// Called when a direct per-light override is set via REST (issue #457): the +// full LightConfig snapshot to render and an optional durationMs (0 = no +// expiry). The receiver captures the arbitration baseline and applies it live. +using LightOverrideSetCb = std::function; +// Called when a light's override is cleared via REST — the light rejoins its group. +using LightOverrideClearCb = std::function; // Called when a light's brightnessLimit/brightnessScale changes without reboot, // so the new clamp can be applied to its LED driver live using LightClampChangeCb = std::function; @@ -283,6 +290,16 @@ class BatteryWebServer { [this](AsyncWebServerRequest* r, uint8_t* d, size_t l, size_t, size_t) { _deleteLight(r, d, l); }); + _server.on( + "/api/lights/override", HTTP_POST, [](AsyncWebServerRequest*) {}, nullptr, + [this](AsyncWebServerRequest* r, uint8_t* d, size_t l, size_t, size_t) { + _setLightOverride(r, d, l); + }); + _server.on( + "/api/lights/override/clear", HTTP_POST, [](AsyncWebServerRequest*) {}, nullptr, + [this](AsyncWebServerRequest* r, uint8_t* d, size_t l, size_t, size_t) { + _clearLightOverride(r, d, l); + }); _server.on( "/api/lights/test", HTTP_POST, [](AsyncWebServerRequest*) {}, nullptr, [this](AsyncWebServerRequest* r, uint8_t* d, size_t l, size_t, size_t) { @@ -750,6 +767,8 @@ class BatteryWebServer { void setOnColorOrderChange(ColorOrderChangeCb cb) { _onColorOrderChange = cb; } void setOnLightBrightnessChange(LightBrightnessChangeCb cb) { _onLightBrightnessChange = cb; } void setOnLightClampChange(LightClampChangeCb cb) { _onLightClampChange = cb; } + void setOnLightOverrideSet(LightOverrideSetCb cb) { _onLightOverrideSet = cb; } + void setOnLightOverrideClear(LightOverrideClearCb cb) { _onLightOverrideClear = cb; } void setOnButtonsChanged(ButtonsChangedCb cb) { _onButtonsChanged = cb; } void setOnGroupsChanged(GroupsChangedCb cb) { _onGroupsChanged = cb; } void setOnSceneListChanged(SceneListChangedCb cb) { _onSceneListChanged = cb; } @@ -814,6 +833,8 @@ class BatteryWebServer { ColorOrderChangeCb _onColorOrderChange; LightBrightnessChangeCb _onLightBrightnessChange; LightClampChangeCb _onLightClampChange; + LightOverrideSetCb _onLightOverrideSet; + LightOverrideClearCb _onLightOverrideClear; ButtonsChangedCb _onButtonsChanged; GroupsChangedCb _onGroupsChanged; SceneListChangedCb _onSceneListChanged; @@ -1211,6 +1232,7 @@ class BatteryWebServer { lo["wrapWidth"] = c.lights[i].wrapWidth; lo["brightnessOverrideEnabled"] = c.lights[i].brightnessOverrideEnabled; lo["brightnessOverride"] = c.lights[i].brightnessOverride; + lo["overridden"] = LightOverrides::active(i); } } { @@ -2032,6 +2054,7 @@ class BatteryWebServer { o["brightnessOverride"] = l.brightnessOverride; o["brightnessLimit"] = l.brightnessLimit; o["brightnessScale"] = l.brightnessScale; + o["overridden"] = LightOverrides::active(i); } _sendJson(r, 200, doc); } @@ -2231,6 +2254,45 @@ class BatteryWebServer { if (colorOrderChanged && _onColorOrderChange) _onColorOrderChange(idx); } + // ── POST /api/lights/override ───────────────────────────────────────────── + // Body: {index, durationMs?, } — sets a direct-command + // override on a local light (issue #457). Unspecified LightConfig fields + // fall back to the light's current group state, so the stored override is + // always a full snapshot; the receiver replaces, never merges. A + // durationMs > 0 auto-reverts the light to its group when it elapses. + void _setLightOverride(AsyncWebServerRequest* r, uint8_t* data, size_t len) { + JsonDocument doc; + if (!_parseJson(r, doc, data, len)) return; + uint8_t idx = doc["index"] | (uint8_t)0xFF; + if (idx >= MAX_LIGHTS || !Config::get().lights[idx].exists) { + auto e = _makeErr("not found"); + _sendJson(r, 404, e); + return; + } + GroupConfig* g = Config::group(Config::get().lights[idx].groupId); + LightConfig cfg = deserializeLightConfig(doc, g ? g->light : LightConfig{}); + uint32_t durationMs = doc["durationMs"] | (uint32_t)0; + if (_onLightOverrideSet) _onLightOverrideSet(idx, cfg, durationMs); + auto ok = _makeOk(); + _sendJson(r, 200, ok); + } + + // ── POST /api/lights/override/clear ─────────────────────────────────────── + // Body: {index} — drops the light's override; it rejoins its group. + void _clearLightOverride(AsyncWebServerRequest* r, uint8_t* data, size_t len) { + JsonDocument doc; + if (!_parseJson(r, doc, data, len)) return; + uint8_t idx = doc["index"] | (uint8_t)0xFF; + if (idx >= MAX_LIGHTS || !Config::get().lights[idx].exists) { + auto e = _makeErr("not found"); + _sendJson(r, 404, e); + return; + } + if (_onLightOverrideClear) _onLightOverrideClear(idx); + auto ok = _makeOk(); + _sendJson(r, 200, ok); + } + // ── POST /api/lights/test ───────────────────────────────────────────────── // Body: {index} void _testLight(AsyncWebServerRequest* r, uint8_t* data, size_t len) {