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
+ ? `Clear `
+ : '';
+ 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) {