Skip to content

lights,web: per-light override with last-command-wins arbitration - #462

Open
bennotk wants to merge 2 commits into
mainfrom
claude/issue-457-override
Open

lights,web: per-light override with last-command-wins arbitration#462
bennotk wants to merge 2 commits into
mainfrom
claude/issue-457-override

Conversation

@bennotk

@bennotk bennotk commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • New src/lights/LightOverrides.h: volatile per-light override slots — a direct command stores a full LightConfig snapshot (replace, never merge) plus the group's seq at capture time (baseSeq), the light's group membership, and an optional durationMs deadline. RAM-only by design: a reboot always comes back rendering the group
  • Arbitration ("the newest command owns the light") is centralized in a new applyEffectiveLight() funnel in main.cpp that every apply path goes through — group edits (web/MQTT/buttons), mesh LightConfig relay, GroupSync adoption, group reassignment, group deletion:
    • group re-broadcasts with seq == baseSeq (periodic self-heal) keep the override
    • any group update with seq > baseSeq, or moving the light to another group, drops it — the light rejoins its group
    • a newer direct command simply replaces the slot; durationMs expiry reverts receiver-side via the 50 ms slow tick
  • The standing brightness adjustment (brightnessOverrideEnabled, config: allow brightness override per light, not just per group #299) is bypassed while an override is active and applies again on rejoin; the hardware clamp (config,led: per-light hardware brightness limit and scale #456) applies to overrides like everything else. Phase-sync snaps skip overridden lights
  • REST on the owning device: POST /api/lights/override ({index, durationMs?, <LightConfig fields>}, unspecified fields fall back to the current group state) and POST /api/lights/override/clear. Override state exposed as overridden in GET /api/lights and in self.lights of the peers payload, so it rides the existing WebSocket peers push on set/clear/displacement/expiry
  • Web UI: per-light "Following group / Overridden" indicator with a Clear control on the dashboard (own lights only); local dev refreshes over REST after clearing since the mock never opens the dashboard WebSocket
  • Mock parity: same routes and displacement behavior (/api/groups/update now bumps seq on light-field edits), Patio starts overridden so the indicator/clear flow is exercisable immediately; 8 new mock-server tests

Closes #457

Test plan

  • npm run lint and npm test (44/44) pass
  • clang-format --dry-run --Werror clean on all changed src/ files
  • /validate-ui on the dashboard flow: seeded "Overridden" indicator + Clear button on exactly one light, Clear click flips the row to "Following group", a REST-set override shows the indicator, a real group change displaces it
  • On a pr- OTA build: set an override via REST → exactly that light changes while group siblings keep the scene; edit the group → the light rejoins; wait out a durationMs override → it reverts on its own; reboot mid-override → the light comes back on the group

🤖 Generated with Claude Code

https://claude.ai/code/session_01QCqsYkCrhRHLoWZFuDTWjh


Generated by Claude Code

@bennotk bennotk added the enhancement New feature or request label Jul 24, 2026 — with Claude

bennotk commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

CI is red, but not because of this diff: every job in all three workflows (including gitleaks and setup, which don't touch the changed files) fails within ~3 seconds with no runner assigned (runner_name empty), no logs, and no check output — on both the original run and a re-run. That pattern means GitHub never provisioned runners: most likely the repo's Actions minutes/spending limit (private repo), or a GitHub Actions incident. The same checks passed on #461 about four hours ago, and lint, tests (44/44), and clang-format are green locally.

Nothing to fix in the branch — please check the account's Actions billing/spending limit if this persists. I'll re-run the workflows on my next check-in and report back once they actually execute.


Generated by Claude Code

Per-light override slot (full LightConfig snapshot, replace never
merge) with baseSeq arbitration: group re-broadcasts with an unchanged
seq keep the override, any real group change (seq bump or reassignment)
takes the light back, an optional durationMs auto-reverts receiver-side,
and a reboot always comes back on the group (volatile slot). The
standing brightness adjustment is bypassed while an override is active;
the hardware clamp applies to everything. REST set/clear on the owning
device, override state in /api/lights and the peers push, dashboard
indicator (following group / overridden) with a clear control, mock
parity incl. a seeded override and displacement on group edits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QCqsYkCrhRHLoWZFuDTWjh
@bennotk
bennotk force-pushed the claude/issue-457-override branch from 1334ece to 7e4aeca Compare July 25, 2026 10:08
@github-actions

Copy link
Copy Markdown

Preview

URL: https://batterylight-pr-462.blackpond-ef672c92.germanywestcentral.azurecontainerapps.io

Hosted on Azure Container Apps — accessible from the internet.

Two firmware-build failures the CI outage had hidden. GCC 8.4 rejects an
in-class array initializer whose element type carries default member
initializers, and four static Slots — each holding a full LightConfig —
overflowed esp32dev's DRAM segment by 136 bytes at link time.

Hold the slots in a function-local static pointer table (the idiom
SceneManager already uses) and allocate a Slot only while an override is
actually set, freeing it on clear. Static cost drops to the pointer
table; an override costs its snapshot on the heap for as long as it
lives, and a failed allocation leaves the light following its group.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QCqsYkCrhRHLoWZFuDTWjh
@bennotk
bennotk force-pushed the claude/issue-457-override branch from cccbaca to 54fe618 Compare July 25, 2026 10:19

bennotk commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

CI is green after rebasing onto the renamed project's main — all 11 checks pass, including both firmware builds and the published pr-462 build.

The firmware build had never actually run during yesterday's runner outage, and it surfaced two real failures in this branch, both now fixed in 54fe618:

  • Compile: the ESP32 toolchain (GCC 8.4) rejects an in-class array initializer whose element type carries default member initializers, so inline static Slot _slots[MAX_LIGHTS] = {} did not build.
  • Link: four static slots, each holding a full LightConfig snapshot (~136 bytes), overflowed dram0_0_seg by 136 bytes.

The slots now live in a function-local static pointer table (the idiom SceneManager::_tombstones() already uses) and a slot is allocated only while an override is actually set, freed on clear. Static cost is the pointer table; an override costs its snapshot on the heap for as long as it lives, and a failed allocation leaves the light following its group rather than half-applying an override. The full-snapshot semantics are unchanged.

Worth noting separately: the DRAM segment had only ~400 bytes of headroom before this change, which is unrelated to this PR but likely to bite the next feature that adds static state.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

config,web: per-light override with last-command-wins arbitration

2 participants