Sync presets by uploading the file WLED actually reads (#115) #118

Merged
claude-bot merged 1 commit from feat/115-preset-sync-upload into main 2026-09-04 19:34:03 +00:00
Contributor

Closes #115.

POST /api/v1/wled/sync-presets targeted GET/POST /json/presets, which no WLED firmware implements — a real 16.0.0 device answers 501 {"error":4}. It had never once succeeded outside the test suite.

The actual mechanism

Presets are a file on the controller's flash filesystem, not a JSON API resource:

Read GET /presets.json
Write POST /upload, multipart — the part's filename is the destination path
Delete GET /edit?func=delete&path=/… — a GET, not an HTTP DELETE

All three verified against your roofline controller. I proved the upload path on a throwaway filename (/iris-probe.json) before going anywhere near presets.json, then deleted it.

An uploaded preset file takes effect with no reboot — that was the open question, and it's a yes: applying ps=100 immediately after an upload ran exactly the fx, palette and colours that had been written.

Verification on hardware

Snapshotted presets.json, /json/state and /json/info first; everything below was restored afterwards.

  • Round-trip through the real WLEDController: read-back byte-identical to what was sent, pre-existing slots 1–3 untouched
  • Full end-to-end sync_presets against the live device with a seeded DB — wrote 2 approved schemes to slots 100–101, correctly named and date-ordered (Halloween 2026 (peak), Christmas Day 2026 (mid)), pre-existing presets intact, read-back verification passed
  • Applied a written preset, confirmed it rendered, restored the prior state
  • Controller left byte-identical to the pre-session snapshot (2240 bytes), no stray files on the filesystem, lights back to on:false, bri:8, fx 115

Read-back verification

The write replaces the whole file, so sync merges into what it read — and now reads the file back to confirm what landed. A 200 from /upload says the request was accepted, not that a complete file reached a flash chip. Without this, a truncated write would surface in December as a preset that does nothing. Both failure paths (mismatch, unreadable read-back) return a clear error and synced: 0.

I confirmed those two tests actually fail when the verification block is removed, rather than trusting that they would.

Compact serialisation, and how it was caught

My first restore reported success but left the file byte-different: json.dumps' default ", " / ": " separators inflated it 20% (2240 → 2682 bytes) in pure whitespace. On a device with ~983KB of filesystem that has to hold up to 366 presets, that margin is worth having. Compact output also matches WLED's own serialisation exactly — which is why the restored file is now byte-identical to what the controller originally wrote.

The test bug that hid all of this

get_presets.return_value = {} hands back the same dict object on every call. sync_presets mutates what it reads, so the read-back compared a dict against itself and passed no matter what was written. I checked this explicitly rather than inferring it.

The mock now models the preset file — a fresh copy per read, writes replacing the store — so these tests assert what the controller would hold rather than what the code happened to pass along. Mocks agreeing with the code rather than with the device is precisely what let the original /json/presets bug sit behind green tests for the entire v1.0.0 cycle, which felt worth fixing at the root while I was here.

Two hazards documented for anyone reusing this path

  • An upload named cfg.json reboots the device. post_presets is deliberately not a general-purpose upload helper.
  • {"psave": n} on /json/state saves whatever is currently showing. Syncing a year that way would light the house up once per preset — hence the file upload.

Also noted in docs/wled-compatibility.md: a controller with a settings PIN will reject /edit and /upload, and Iris does not currently send one. Yours has no PIN set. I have not implemented PIN support — say the word if you want it and I'll open an issue.

Verification

  • 379 backend tests pass (9 new), ruff check, ruff format --check, mypy app clean
  • uvx pre-commit run --all-files passes
  • Frontend untouched

🤖 Generated with Claude Code

Closes #115. `POST /api/v1/wled/sync-presets` targeted `GET`/`POST /json/presets`, which no WLED firmware implements — a real 16.0.0 device answers `501 {"error":4}`. It had never once succeeded outside the test suite. ### The actual mechanism Presets are a file on the controller's flash filesystem, not a JSON API resource: | | | |---|---| | Read | `GET /presets.json` | | Write | `POST /upload`, multipart — the part's **filename** is the destination path | | Delete | `GET /edit?func=delete&path=/…` — a GET, not an HTTP DELETE | All three verified against your roofline controller. I proved the upload path on a throwaway filename (`/iris-probe.json`) before going anywhere near `presets.json`, then deleted it. **An uploaded preset file takes effect with no reboot** — that was the open question, and it's a yes: applying `ps=100` immediately after an upload ran exactly the fx, palette and colours that had been written. ### Verification on hardware Snapshotted `presets.json`, `/json/state` and `/json/info` first; everything below was restored afterwards. - Round-trip through the real `WLEDController`: read-back byte-identical to what was sent, pre-existing slots 1–3 untouched - **Full end-to-end `sync_presets`** against the live device with a seeded DB — wrote 2 approved schemes to slots 100–101, correctly named and date-ordered (`Halloween 2026 (peak)`, `Christmas Day 2026 (mid)`), pre-existing presets intact, read-back verification passed - Applied a written preset, confirmed it rendered, restored the prior state - **Controller left byte-identical** to the pre-session snapshot (2240 bytes), no stray files on the filesystem, lights back to `on:false, bri:8, fx 115` ### Read-back verification The write replaces the whole file, so sync merges into what it read — and now reads the file back to confirm what landed. A 200 from `/upload` says the request was accepted, not that a complete file reached a flash chip. Without this, a truncated write would surface in December as a preset that does nothing. Both failure paths (mismatch, unreadable read-back) return a clear error and `synced: 0`. I confirmed those two tests actually fail when the verification block is removed, rather than trusting that they would. ### Compact serialisation, and how it was caught My first restore reported success but left the file byte-*different*: `json.dumps`' default `", "` / `": "` separators inflated it 20% (2240 → 2682 bytes) in pure whitespace. On a device with ~983KB of filesystem that has to hold up to 366 presets, that margin is worth having. Compact output also matches WLED's own serialisation exactly — which is why the restored file is now byte-identical to what the controller originally wrote. ### The test bug that hid all of this `get_presets.return_value = {}` hands back the *same dict object* on every call. `sync_presets` mutates what it reads, so the read-back compared a dict against itself and passed no matter what was written. I checked this explicitly rather than inferring it. The mock now models the preset file — a fresh copy per read, writes replacing the store — so these tests assert what the controller would hold rather than what the code happened to pass along. Mocks agreeing with the code rather than with the device is precisely what let the original `/json/presets` bug sit behind green tests for the entire v1.0.0 cycle, which felt worth fixing at the root while I was here. ### Two hazards documented for anyone reusing this path - **An upload named `cfg.json` reboots the device.** `post_presets` is deliberately not a general-purpose upload helper. - `{"psave": n}` on `/json/state` saves whatever is *currently showing*. Syncing a year that way would light the house up once per preset — hence the file upload. Also noted in `docs/wled-compatibility.md`: a controller with a settings PIN will reject `/edit` and `/upload`, and Iris does not currently send one. Yours has no PIN set. I have not implemented PIN support — say the word if you want it and I'll open an issue. ### Verification - 379 backend tests pass (9 new), `ruff check`, `ruff format --check`, `mypy app` clean - `uvx pre-commit run --all-files` passes - Frontend untouched 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sync presets by uploading the file WLED actually reads (#115)
All checks were successful
CI / Dockerfile lint (pull_request) Successful in 9s
CI / Frontend lint, test & build (pull_request) Successful in 1m12s
CI / Alembic migration check (pull_request) Successful in 1m36s
CI / Pre-commit hooks (pull_request) Successful in 1m54s
CI / Python lint & type-check (pull_request) Successful in 2m16s
CI / Python tests (pull_request) Successful in 4m24s
CI / Docker build, health smoke & E2E (pull_request) Successful in 3m59s
469aa8615c
Preset sync targeted GET/POST /json/presets, which no WLED firmware
implements -- a real 16.0.0 device answers 501 {"error":4} -- so
POST /api/v1/wled/sync-presets had never once succeeded outside the test
suite.

Presets are a file on the controller's flash filesystem, not a JSON API
resource:

  read    GET  /presets.json
  write   POST /upload        multipart; the part's filename IS the path
  delete  GET  /edit?func=delete&path=/...   (a GET, not an HTTP DELETE)

All verified against the roofline controller, including that an uploaded
preset file takes effect with no reboot: applying ps=100 straight after
an upload ran the fx, palette and colours that were written.

The write replaces the whole file, so sync merges into what it read and
now reads the file back to confirm what landed. A 200 from /upload means
the request was accepted, not that a complete file reached a flash chip;
without the read-back a truncated write would surface in December as a
preset that does nothing.

The file is serialised compactly. json.dumps' defaults cost ~20% of the
payload in whitespace (2240 -> 2682 bytes for four presets) on a device
with ~983KB of filesystem that has to hold up to 366 of them. Compact
output also matches WLED's own, so a round-trip through Iris leaves the
file byte-identical to what the controller would have written itself --
which is how the inflation was noticed.

Not used: {"psave": n} on /json/state, which saves whatever is currently
showing. Syncing a year that way would light the house up once per
preset.

Also fixes the tests that hid this. `get_presets.return_value = {}` hands
back the same dict on every call, so sync mutating it made the read-back
compare a dict against itself and pass regardless. The mock now models the
preset file, returning a fresh copy per read. Mocks agreeing with the code
rather than with the device is precisely what let the original bug sit
behind green tests for the whole v1.0.0 cycle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
claude-bot deleted branch feat/115-preset-sync-upload 2026-09-04 19:34:03 +00:00
Sign in to join this conversation.
No description provided.