Declare WLED firmware compatibility and pin it with fixtures (#62) #116

Merged
claude-bot merged 1 commit from feat/62-wled-firmware-compat into main 2026-09-04 18:33:53 +00:00
Contributor

Closes #62.

WLED's JSON API drifts across releases and Iris assumed one shape of it. This declares the supported range, records real API responses to test against, and makes untested firmware fail legibly.

I probed the live roofline controller read-only before writing anything, which turned up three real bugs — so this is more than the docs-and-fixtures job the issue reads as.

The dev controller runs WLED 16.0.0

Not 0.16.0. WLED dropped the leading zero for the 2026-05-03 release, so 16.0.0 follows 0.15.x. Versions are parsed into component tuples and compared as such: reading the second component as "the version" — the natural habit from the entire 0.x era — yields 0 on 16.0.0 and would reject the newest firmware as ancient.

Bugs found and fixed

segment_count was always 1. get_info read info.leds.segs, a field no WLED firmware has ever sent — absent from both the API reference and a real device. It silently took the default on every controller. The count comes from leds.seglc, the per-segment capabilities array.

The old test passed only because it invented the same field the code invented. Worth noting the fixture design here: the captured 16.0.0 device genuinely has one segment, so it cannot distinguish the fix from the bug. The derived fixtures carry two segments deliberately, and I verified that by reintroducing the bug — test_segment_count_comes_from_seglc[0.14.0] and [0.15.0] fail, [16.0.0] passes.

GET /api/v1/wled/effects did not exist. The frontend has called it since the effect picker was built (frontend/src/api/index.ts:144). useWledEffects is written to fail silently, so a missing endpoint was indistinguishable from an unreachable controller — the picker always fell back to its built-in 113-effect list against a controller reporting 220. Now implemented as a proxy of /json/eff; it has to be proxied because the controller is usually on a LAN the browser cannot reach and serves no CORS headers.

Effect ids drift, and two entries were already wrong. Ids are positional and get reassigned:

id 0.14 16.0.0
76 Meteor Meteor
77 Meteor Smooth Copy Segment
78 Railway Railway

Neighbours unchanged either side, so it is a repurposed slot rather than an offset. A test now checks the whole offline list against the captured 16.0.0 table.

Graceful failure, without a hard gate

Firmware below 0.14.0 — or a host that is not a WLED device — now produces one clear warning at startup, firmware_supported: false plus an explanation on GET /wled/status, and that explanation appended to any failing push.

It does not refuse to drive the controller, which is a deliberate departure from the issue's fourth bullet. The fields Iris writes have been stable far longer than the supported range, so a 0.13 device will very likely work fine; a hard gate would strand a working setup over a problem that may not exist. The bullet's emphasis is "clear error, not a stack trace", and that is what this delivers. Happy to tighten it to a refusal if you'd rather.

Fixtures

backend/tests/fixtures/wled/, one directory per version, with provenance documented in its README and reflected in the tests:

  • 16.0.0/ — captured from the roofline controller, verbatim apart from redacted device identifiers (mac, ip, deviceId, bootloaderSHA256, wifi.bssid), none of which Iris reads.
  • 0.15.0/, 0.14.0/ — derived from WLED's published API reference and labelled as such. No hardware for those was available, so they carry only documented fields; inventing an uptime would add fabricated detail without adding coverage.

The interesting test is the drift tripwire: Iris only ever writes state, and WLED ignores keys it does not recognise, so a removed field would not raise — the lights would just quietly do the wrong thing. Every key Iris writes is asserted present in each firmware's recorded segment. There is also a check that the preset payload matches a preset the device actually saved.

Split out

#115 — preset sync targets GET/POST /json/presets, which no firmware implements; a real device answers 501 {"error":4}. Live sync has never worked. The rework needs a different mechanism (/presets.json read, /upload write) and hardware to verify, so it is its own issue.

This PR only makes the failure loud. sync_presets swallowed the read error and built its payload on {}, dropping every preset outside the managed range — exactly the ones it documents itself as preserving. Nothing was lost because the write fails too, but that stops being true the moment #115 fixes the write.

Also

Fixed the README's Status section, which still said "Approaching the v1.0.0 release", and gitignored frontend/*.tsbuildinfo (a bare tsc -b writes them, and they turned up staged after running the frontend checks).

Verification

  • Backend: 372 tests pass (63 new), ruff check, ruff format --check, mypy app all clean
  • Frontend in node:22: tsc -b, eslint, vitest (76 tests), npm run build all clean
  • uvx pre-commit run --all-files passes
  • The segs fix verified by reintroducing the bug and confirming the tests catch it

Nothing in this PR drove the lights — every probe was a GET.

🤖 Generated with Claude Code

Closes #62. WLED's JSON API drifts across releases and Iris assumed one shape of it. This declares the supported range, records real API responses to test against, and makes untested firmware fail legibly. I probed the live roofline controller read-only before writing anything, which turned up three real bugs — so this is more than the docs-and-fixtures job the issue reads as. ### The dev controller runs WLED 16.0.0 Not 0.16.0. WLED [dropped the leading zero](https://github.com/wled/WLED/releases/tag/v16.0.0) for the 2026-05-03 release, so `16.0.0` follows `0.15.x`. Versions are parsed into component tuples and compared as such: reading the second component as "the version" — the natural habit from the entire 0.x era — yields `0` on 16.0.0 and would reject the newest firmware as ancient. ### Bugs found and fixed **`segment_count` was always 1.** `get_info` read `info.leds.segs`, a field no WLED firmware has ever sent — absent from both the [API reference](https://kno.wled.ge/interfaces/json-api/) and a real device. It silently took the default on every controller. The count comes from `leds.seglc`, the per-segment capabilities array. The old test passed only because it invented the same field the code invented. Worth noting the fixture design here: the captured 16.0.0 device genuinely has one segment, so it cannot distinguish the fix from the bug. The derived fixtures carry two segments deliberately, and I verified that by reintroducing the bug — `test_segment_count_comes_from_seglc[0.14.0]` and `[0.15.0]` fail, `[16.0.0]` passes. **`GET /api/v1/wled/effects` did not exist.** The frontend has called it since the effect picker was built (`frontend/src/api/index.ts:144`). `useWledEffects` is written to fail silently, so a missing endpoint was indistinguishable from an unreachable controller — the picker always fell back to its built-in 113-effect list against a controller reporting 220. Now implemented as a proxy of `/json/eff`; it has to be proxied because the controller is usually on a LAN the browser cannot reach and serves no CORS headers. **Effect ids drift, and two entries were already wrong.** Ids are positional and get reassigned: | id | 0.14 | 16.0.0 | |----|------|--------| | 76 | Meteor | Meteor | | **77** | **Meteor Smooth** | **Copy Segment** | | 78 | Railway | Railway | Neighbours unchanged either side, so it is a repurposed slot rather than an offset. A test now checks the whole offline list against the captured 16.0.0 table. ### Graceful failure, without a hard gate Firmware below 0.14.0 — or a host that is not a WLED device — now produces one clear warning at startup, `firmware_supported: false` plus an explanation on `GET /wled/status`, and that explanation appended to any failing push. **It does not refuse to drive the controller**, which is a deliberate departure from the issue's fourth bullet. The fields Iris writes have been stable far longer than the supported range, so a 0.13 device will very likely work fine; a hard gate would strand a working setup over a problem that may not exist. The bullet's emphasis is "clear error, not a stack trace", and that is what this delivers. Happy to tighten it to a refusal if you'd rather. ### Fixtures `backend/tests/fixtures/wled/`, one directory per version, with provenance documented in its README and reflected in the tests: - **`16.0.0/`** — captured from the roofline controller, verbatim apart from redacted device identifiers (`mac`, `ip`, `deviceId`, `bootloaderSHA256`, `wifi.bssid`), none of which Iris reads. - **`0.15.0/`, `0.14.0/`** — derived from WLED's published API reference and labelled as such. No hardware for those was available, so they carry only documented fields; inventing an `uptime` would add fabricated detail without adding coverage. The interesting test is the drift tripwire: Iris only ever *writes* state, and WLED ignores keys it does not recognise, so a removed field would not raise — the lights would just quietly do the wrong thing. Every key Iris writes is asserted present in each firmware's recorded segment. There is also a check that the preset payload matches a preset the device actually saved. ### Split out **#115** — preset sync targets `GET`/`POST /json/presets`, which no firmware implements; a real device answers `501 {"error":4}`. Live sync has never worked. The rework needs a different mechanism (`/presets.json` read, `/upload` write) and hardware to verify, so it is its own issue. This PR only makes the failure loud. `sync_presets` swallowed the read error and built its payload on `{}`, dropping every preset outside the managed range — exactly the ones it documents itself as preserving. Nothing was lost because the write fails too, but that stops being true the moment #115 fixes the write. ### Also Fixed the README's Status section, which still said "Approaching the v1.0.0 release", and gitignored `frontend/*.tsbuildinfo` (a bare `tsc -b` writes them, and they turned up staged after running the frontend checks). ### Verification - Backend: 372 tests pass (63 new), `ruff check`, `ruff format --check`, `mypy app` all clean - Frontend in `node:22`: `tsc -b`, `eslint`, `vitest` (76 tests), `npm run build` all clean - `uvx pre-commit run --all-files` passes - The `segs` fix verified by reintroducing the bug and confirming the tests catch it Nothing in this PR drove the lights — every probe was a GET. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Declare WLED firmware compatibility and pin it with fixtures (#62)
All checks were successful
CI / Pre-commit hooks (pull_request) Successful in 46s
CI / Python lint & type-check (pull_request) Successful in 1m3s
CI / Frontend lint, test & build (pull_request) Successful in 1m20s
CI / Alembic migration check (pull_request) Successful in 1m38s
CI / Python tests (pull_request) Successful in 4m40s
CI / Docker build, health smoke & E2E (pull_request) Successful in 3m26s
28ef3451ed
WLED's JSON API drifts across releases and Iris assumed one shape of it.
Declare the supported range, record real API responses to test against,
and fail legibly on firmware we have never tested.

The dev controller runs 16.0.0 — WLED released that directly after 0.15.x
rather than 0.16.0, so version strings are parsed into component tuples
and compared as such. Reading the second component as "the version", the
natural habit from the whole 0.x era, yields 0 on 16.0.0 and would reject
the newest firmware as ancient.

Probing the device turned up three bugs the fixtures then pinned:

- Segment count read `info.leds.segs`, a field no firmware has ever sent,
  so it silently defaulted to 1 everywhere. It comes from `leds.seglc`.
  The old test passed only because it invented the same field the code
  did; the derived fixtures carry two segments so the 16.0.0 capture,
  which genuinely has one, cannot mask a regression.

- `GET /api/v1/wled/effects` did not exist, though the frontend has called
  it since the effect picker was built. `useWledEffects` fails silently by
  design, so its absence looked exactly like an unreachable controller and
  the picker always used its built-in 113-effect list against a controller
  reporting 220. Implemented as a proxy of `/json/eff`.

- Effect ids are positional and get reassigned: id 77 is "Copy Segment" on
  16.0.0 but was "Meteor Smooth" on 0.14, with 76 and 78 unchanged either
  side. Two entries in the offline list were wrong for the firmware we
  support; a test now checks the whole list against the captured table.

Unsupported firmware now produces one warning at startup, a flag and
explanation on `/wled/status`, and that explanation appended to a failing
push — but not a refusal to drive the controller. The fields Iris writes
have been stable far longer than the supported range, so a hard gate would
strand working setups over a problem that may not exist.

Preset sync is split to #115: it targets `/json/presets`, which no
firmware implements (a real device answers 501), so live sync has never
worked. Here the swallowed read failure is only made loud — it used to
fall back to an empty dict and would have wiped every unmanaged preset the
moment the write path started working.

16.0.0 fixtures are captured from the roofline controller; 0.14/0.15 are
derived from WLED's published API reference and labelled as such, since no
hardware for those was available.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
claude-bot deleted branch feat/62-wled-firmware-compat 2026-09-04 18:33:54 +00:00
Sign in to join this conversation.
No description provided.