feat: per-screensaver Art-Net lighting, and restore the room afterwards - #293
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
<name>— hold that scene while this screensaver is up<name>— run that effect, overriding the global targetRows 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-sendwas POST-only and returned no body. On activation it now takes aGET /statussnapshot (per-strip colours, plus any effect already running); on release it writes that back — one/allwhere 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 aneffect:— so an effect can be running while the mode readsreactive, 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
effectRunningboolean with the running effect's name. A boolean can't notice the effect changing: a per-savereffect:aurorareplacing a globaleffect:spotwould be sent a/field/paramsnudge 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
/stopon rotation, a brightness-only difference wrongly called uniform, repeatedactivatenot covered, and the guard bug above.if (false)was left inrotate()and the next run measured against that corrupted baseline. Caught it via lint. The harness now usestry/finallyand verifies restoration by hash — the results above are all from a clean baseline.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:
/statusreportseffect: nullright now, so the shape it takes when an effect is running is inferred.summariseStatehandles both a bare string and{name}, and falls back to colour restore if it's neither.🤖 Generated with Claude Code