Skip to content

feat: per-screensaver Art-Net lighting, and restore the room afterwards - #293

Merged
BernardJen merged 1 commit into
mainfrom
feat/artnet-per-saver
Aug 25, 2026
Merged

feat: per-screensaver Art-Net lighting, and restore the room afterwards#293
BernardJen merged 1 commit into
mainfrom
feat/artnet-per-saver

Conversation

@BernardJen

Copy link
Copy Markdown
Contributor

Each screensaver can now drive the room differently, and when the signal comes back the room returns to how it actually was — not to a configured guess.

What you get

In Settings → Art-Net Lighting → Per Screensaver, one row per screensaver:

  • Reactive (default, and what an absent entry means) — drive the lights from the picture
  • Scene: <name> — hold that scene while this screensaver is up
  • Effect: <name> — run that effect, overriding the global target
  • Leave lights alone — don't touch the room for this screensaver

Rows are rendered from listScreensavers(), so a new screensaver appears automatically — no hand-maintained duplicate to drift, which is the mistake the shortcuts table made in #258. Scene names are read from the relay since they're site-specific; with no relay reachable the dropdowns still render and a configured value is preserved rather than silently rewritten.

Restoring the room

This required giving the app a read path, which it has never had — artnet-send was POST-only and returned no body. On activation it now takes a GET /status snapshot (per-strip colours, plus any effect already running); on release it writes that back — one /all where the room was uniform, per-strip where it wasn't.

Worth noting: the room currently sits at (0,0,0). So "restore" genuinely means turning the lights back off, which a release-scene approach could never express.

Rotation deliberately does not re-snapshot. Rotation happens every few minutes while the wall is dark. By then the fixtures show our lighting, so a second read would destroy the only record of the original state and the room could never be returned. activate() snapshots; rotate() only switches mode.

If the read fails there's nothing to restore to, so it falls back to artnetReleaseScene, and otherwise sends nothing — the fixtures keep their last colour. Never a blackout: a room that may have people in it does not go dark because a video signal came back.

Mutation testing found a real bug in my own guard

Rotation stopped a running effect based on the previous mode. But a reactive saver inherits the global artnetTarget, which may itself be an effect: — so an effect can be running while the mode reads reactive, and the guard skipped the stop in exactly the case it existed for. The room would keep animating underneath the next screensaver's scene.

The fix also replaced the effectRunning boolean with the running effect's name. A boolean can't notice the effect changing: a per-saver effect:aurora replacing a global effect:spot would be sent a /field/params nudge for an effect that was never started.

Also fixed in passing: a configured release scene used to return early without stopping a running effect, leaving the effect to overwrite the scene it was handing over to.

Verification

  • 747 tests green (30 new), lint clean.
  • 32 mutations across four passes, all caught. Six escaped on the first attempt and every one was a genuine gap — a spurious /stop on rotation, a brightness-only difference wrongly called uniform, repeated activate not covered, and the guard bug above.
  • One process note: an early mutation run crashed on a bad anchor before its restore line, so a if (false) was left in rotate() and the next run measured against that corrupted baseline. Caught it via lint. The harness now uses try/finally and verifies restoration by hash — the results above are all from a clean baseline.
  • Panel rendered and checked with a populated 29-row list.

The rig is still untouched — every send goes through the injected transport, and nothing has been posted to the live relay (#274). The one thing I could not verify without writing to it: /status reports effect: null right now, so the shape it takes when an effect is running is inferred. summariseState handles both a bare string and {name}, and falls back to colour restore if it's neither.

🤖 Generated with Claude Code

Each screensaver can now drive the room differently -- reactive (the default),
a fixed scene, an effect, or nothing -- and when the signal comes back the room
returns to how it actually was rather than to a configured guess.

Restoring properly means reading the relay, which this app has never done. On
activation it takes a GET /status snapshot: per-strip colours plus any effect
already running. On release it writes that back, one /all where the room was
uniform and per-strip where it was not. Plugging a laptop in now restores the
lighting exactly, including off -- which is what the room actually sits at.

Rotation deliberately does NOT re-snapshot. By then the fixtures are showing our
own lighting, so a second read would destroy the only record of what was there
before and the room could never be returned. activate() snapshots, rotate() only
switches mode. The hooks live inside revealScreensaver/swapScreensaver rather
than at their four call sites, so a future way of starting a saver cannot forget
to report it.

Where there is no snapshot -- a failed read -- it falls back to
artnetReleaseScene, and otherwise sends nothing. Never a blackout: a room that
may have people in it does not go dark because a video signal came back.

Mutation testing found a real bug in my own guard. Rotation stopped a running
effect based on the PREVIOUS MODE, but a reactive saver inherits a global
`effect:` target, so an effect can be running while the mode reads 'reactive' --
exactly the case the guard existed for, skipped. The fix also replaced the
`effectRunning` boolean with the running effect's NAME: a boolean cannot notice
the effect changing, so a per-saver `effect:aurora` replacing a global
`effect:spot` would have been sent a /field/params nudge for an effect that was
never started.

Also fixed: a configured release scene used to return early without stopping a
running effect, which left the effect overwriting the scene it was meant to
hand over to.

746 tests, 30 new. Every mutation caught -- 32 across four passes, six of which
escaped first and each of which was a genuine gap. The rig is untouched; all
sends go through the injected transport.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@BernardJen
BernardJen merged commit c1c2b44 into main Aug 25, 2026
1 check passed
@BernardJen
BernardJen deleted the feat/artnet-per-saver branch August 25, 2026 21:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant