Skip to content
Open
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
53 changes: 48 additions & 5 deletions data/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1999,6 +1999,44 @@ <h3>Options</h3>
</div>`;
}

// 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
? '<span style="font-size:.72rem;color:#fa4">Overridden</span>'
: '<span style="font-size:.72rem;color:#555">Following group</span>';
const clearBtn = overridden
? `<button class="btn-ghost btn-sm" data-action="clearLightOverride" data-light-index="${light.index}">Clear</button>`
: '';
return `<div class="lov-row" style="display:flex;align-items:center;gap:.35rem;margin-top:.15rem" title="${esc(tip)}">${state}${clearBtn}</div>`;
}

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');
Expand Down Expand Up @@ -2194,8 +2232,10 @@ <h3>Options</h3>
const lightLabel = l.name ? esc(l.name) : `L${l.index}`;
const label = `<span style="font-size:.72rem;color:#888;margin-right:.25rem">${lightLabel}</span>`;
const override = p.isSelf ? _renderLightBrightnessOverride(l) : '';
const overrideState = p.isSelf ? _renderLightOverrideState(l) : '';
return `<div style="margin-bottom:.2rem">
<div style="display:flex;align-items:center;gap:.25rem">${label}${_renderLightGroupSelect(mac, l)}</div>
${overrideState}
${override}
</div>`;
}).join('')) + soundHtml;
Expand All @@ -2205,11 +2245,13 @@ <h3>Options</h3>
: 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 `<div class="light-device-light">
<div class="light-device-light-head">
<span class="light-device-light-name">${lightLabel}</span>
</div>
${_renderLightGroupSelect(mac, l)}
${overrideState}
${override}
</div>`;
}).join('')) + soundHtml;
Expand Down Expand Up @@ -2602,11 +2644,12 @@ <h3>Options</h3>
// 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)),
Expand Down
99 changes: 97 additions & 2 deletions server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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' });
Expand All @@ -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
Expand All @@ -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?, <LightConfig
// fields>} — 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 || {};
Expand Down Expand Up @@ -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) => {
Expand All @@ -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 }));
Expand Down
74 changes: 74 additions & 0 deletions server/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down
Loading