Keep strobes under 3 flashes a second (#150) #167

Merged
claude-bot merged 1 commit from feat/150-photosensitivity-guard into main 2026-09-26 09:26:58 +00:00
Collaborator

Closes #150. The only acceptance item left open is the on-hardware filming check (see below).

Why

Nothing stopped a strobe reaching the house. Three paths could put one there: AI output (JSON-shape validation only), quick push / /remote (effect_id: ge=0), and the editor. WCAG 2.3.1 sets the limit at no more than 3 general flashes per second.

What

The flash table is derived from firmware source (backend/app/data/flash_safety.json), using wled00/FX.cpp at v16.0.0 (commit 4374f01). Each row cites the function and formula it came from, and uses the worst-case frame time.

  • Speed-limited, clamped to sx 238: Blink, Strobe, Strobe Rainbow and Blink Rainbow (cycle = 20·(255−sx) + 2·FRAMETIME ms). Only the top 17 speed steps are over the line.
  • Speed-limited, clamped to sx 247: Dynamic.
  • Unsafe at every speed, so substituted:
    • Strobe Mega → Strobe (then clamped)
    • Lightning → Twinklefox (not Twinkle: Twinkle's reset clears the whole segment at once, which is a flash in its own right)
    • Chase Flash → Chase, and Chase Flash Rnd → Chase Random
    • Sparkle+ → Sparkle Dark
  • Judgement calls, documented in docs/photosensitivity.md: single-pixel sparkles aren't general flashes, and Fire Flicker is allowed. Fire Flicker is the first row to film.
  • Pinned by tests: every id is checked by name against the shipped 16.0.0 catalogue (the lesson from #132), and the limits are re-derived from their formulas (the limit is under 3/s, the limit + 1 is over).

One chokepoint. The translator runs guard_state on every payload, and the default is on, so a caller that forgets to pass the setting still gets the guard. The paths it covers:

  • the on job, via #149's day_plan, which now carries flash_substitutions
  • the no-event default and the unapproved-default path
  • Push now
  • quick push and /remote
  • preview
  • preset sync and export
  • aurora

Planning 60 nights logs at DEBUG; a real push logs once at INFO.

Entry points explain instead of silently changing:

  • AI: an unsafe effect fails validation, and the retry prompt says why. Unsafe effects are removed from the catalogue offered to the model. If the retry is still unsafe, the scheme is repaired, never saved as-is.
  • Editor: a per-segment warning, and the preview draws the guarded version.
  • /remote: replaced effects are hidden from the picker.
  • effect_id bounds: checked against the controller's fxcount, with the shipped catalogue as a fallback. Out of range is a 422.
  • The frontend reads one source of truth, GET /api/v1/wled/flash-safety. There is no copy of the table in TypeScript.

Setting: photosensitive_safe (default on), with its own card on the Settings page explaining what turning it off means.

Fixed along the way:

  • On /remote, choosing an effect pushed the previous choice (a stale closure).
  • The simulator's strobe family flashed at up to about 5.9/s on screen. It now follows WLED's blink() timing and is capped at 3/s.

Verification (after rebasing onto #149)

  • Backend: pytest 1014 passed; ruff and strict mypy are clean.
  • Frontend: lint (0 warnings), tsc, vitest (163 passed) and build.
  • Playwright E2E, run the CI way (read-only, cap-drop ALL, uid 1000): 8/8 passed.

Still open

  • Filming the flash-family effects on the roofline controller (phone at 240 fps). The method is in docs/photosensitivity.md, and the table carries verified_on_hardware: false until then.
  • Found, not fixed:
    • "Push now" doesn't go through plan_for_date, so it could drift from the on job (the #93 risk).
    • The AI routes never pass the live effect list.
    • SchemeSegmentUpdate has no bounds on speed, intensity, palette or brightness.
    • Preset and config import don't bound effect_id.

🤖 Generated with Claude Code

Closes #150. The only acceptance item left open is the on-hardware filming check (see below). ## Why Nothing stopped a strobe reaching the house. Three paths could put one there: AI output (JSON-shape validation only), quick push / `/remote` (`effect_id: ge=0`), and the editor. WCAG 2.3.1 sets the limit at **no more than 3 general flashes per second.** ## What **The flash table is derived from firmware source** (`backend/app/data/flash_safety.json`), using `wled00/FX.cpp` at v16.0.0 (commit `4374f01`). Each row cites the function and formula it came from, and uses the worst-case frame time. - **Speed-limited, clamped to `sx` 238:** Blink, Strobe, Strobe Rainbow and Blink Rainbow (cycle = 20·(255−sx) + 2·FRAMETIME ms). Only the top 17 speed steps are over the line. - **Speed-limited, clamped to `sx` 247:** Dynamic. - **Unsafe at every speed, so substituted:** - Strobe Mega → Strobe (then clamped) - Lightning → Twinklefox (not Twinkle: Twinkle's reset clears the whole segment at once, which is a flash in its own right) - Chase Flash → Chase, and Chase Flash Rnd → Chase Random - Sparkle+ → Sparkle Dark - **Judgement calls, documented in `docs/photosensitivity.md`:** single-pixel sparkles aren't general flashes, and Fire Flicker is allowed. Fire Flicker is the first row to film. - **Pinned by tests:** every id is checked by name against the shipped 16.0.0 catalogue (the lesson from #132), and the limits are re-derived from their formulas (the limit is under 3/s, the limit + 1 is over). **One chokepoint.** The translator runs `guard_state` on every payload, and the default is **on**, so a caller that forgets to pass the setting still gets the guard. The paths it covers: - the on job, via #149's `day_plan`, which now carries `flash_substitutions` - the no-event default and the unapproved-default path - Push now - quick push and `/remote` - preview - preset sync and export - aurora Planning 60 nights logs at DEBUG; a real push logs once at INFO. **Entry points explain instead of silently changing:** - **AI:** an unsafe effect fails validation, and the retry prompt says why. Unsafe effects are removed from the catalogue offered to the model. If the retry is still unsafe, the scheme is repaired, never saved as-is. - **Editor:** a per-segment warning, and the preview draws the guarded version. - **`/remote`:** replaced effects are hidden from the picker. - **`effect_id` bounds:** checked against the controller's `fxcount`, with the shipped catalogue as a fallback. Out of range is a 422. - **The frontend reads one source of truth,** `GET /api/v1/wled/flash-safety`. There is no copy of the table in TypeScript. **Setting:** `photosensitive_safe` (default on), with its own card on the Settings page explaining what turning it off means. **Fixed along the way:** - On `/remote`, choosing an effect pushed the *previous* choice (a stale closure). - The simulator's strobe family flashed at up to about 5.9/s on screen. It now follows WLED's `blink()` timing and is capped at 3/s. ## Verification (after rebasing onto #149) - **Backend:** pytest 1014 passed; ruff and strict mypy are clean. - **Frontend:** lint (0 warnings), `tsc`, vitest (163 passed) and build. - **Playwright E2E, run the CI way** (read-only, cap-drop ALL, uid 1000): 8/8 passed. ## Still open - **Filming the flash-family effects on the roofline controller** (phone at 240 fps). The method is in `docs/photosensitivity.md`, and the table carries `verified_on_hardware: false` until then. - **Found, not fixed:** - "Push now" doesn't go through `plan_for_date`, so it could drift from the on job (the #93 risk). - The AI routes never pass the live effect list. - `SchemeSegmentUpdate` has no bounds on speed, intensity, palette or brightness. - Preset and config import don't bound `effect_id`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Keep strobes under 3 flashes a second (#150)
All checks were successful
CI / Pre-commit hooks (pull_request) Successful in 47s
CI / Python lint & type-check (pull_request) Successful in 1m7s
CI / Dockerfile lint (pull_request) Successful in 9s
CI / Alembic migration check (pull_request) Successful in 37s
CI / Frontend lint, test & build (pull_request) Successful in 1m32s
CI / Python tests (pull_request) Successful in 7m50s
CI / Docker build, health smoke & E2E (pull_request) Successful in 5m31s
c8f3c077f3
Every push now holds effects at or under the WCAG 2.3.1 threshold: Strobe,
Blink and their rainbow variants are slowed to speed 238 at most, and
Lightning, Strobe Mega, Chase Flash (Rnd) and Sparkle+, which flash too fast
at every speed, are shown as Twinklefox, Strobe, Chase (Random) and Sparkle
Dark. On by default; a Settings switch (PHOTOSENSITIVE_SAFE) turns it off.

The limits are derived from WLED's v16.0.0 source, not filmed. The table is
backend/app/data/flash_safety.json, derived in docs/photosensitivity.md, and
tests re-derive each limit from its formula and pin every id by name to the
captured 16.0.0 catalogue. Hardware verification is outstanding.

Enforced once, in the scheme translator, so the scheduler, Push now, quick
push, the remote, preview, carousel, preset sync and export are covered by
construction. The day planner (#149) builds its plan through the guard, so
plan and push still agree; the plan carries the changes as
flash_substitutions, logged at DEBUG while planning and at INFO when the on
job pushes. Saved schemes are not rewritten, except AI output, which is
retried with the reason and repaired before saving if still unsafe. The
editor warns, the virtual strip previews what will be pushed (and never
strobes over 3/s on screen), and the remote hides replaced effects, all from
GET /api/v1/wled/flash-safety.

Also: effect_id is bounded by the live fxcount (or the shipped catalogue)
with a 422, and the remote no longer pushes the previously chosen effect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude-bot deleted branch feat/150-photosensitivity-guard 2026-09-26 09:26:59 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rbrooks/Iris-WLED!167
No description provided.