Fade the lights in and out at the scheduled transitions (#21) #119

Merged
claude-bot merged 1 commit from feat/21-brightness-ramp into main 2026-09-04 20:24:32 +00:00
Contributor

Closes #21.

RAMP_ON_MINUTES / RAMP_OFF_MINUTES, also in Settings, both 0 (the previous instant behaviour) by default. The fade-in starts at the on time and reaches the scheme's brightness after the window; the fade-out ends at the off time, so it begins early and the lights are dark on schedule. Colours and effect still land immediately — only the level moves.

The measurement that chose the design

The issue offered two mechanisms: scheduler interval pushes, or "offload the ramp to the device". I measured before choosing, and the second turned out to be impossible in a way worth knowing about.

WLED's transition is documented in 100 ms units but stored as a uint16 of milliseconds. Past 65535 ms it wraps modulo 65536 rather than clamping:

sent = ms stored actual fade
655 65500 655 65.5 s — the maximum
656 65600 0 instant snap
700 70000 44 4.4 s
1200 120000 544 54.4 s

70000 − 65536 = 4464 → 44. Every row is exact modulo arithmetic, with no error and no clamp — the request succeeds and the light does something else entirely.

I had told you earlier that the field width allowed ~109 minutes. That was wrong: I'd reasoned from 100 ms units × uint16 without checking what the firmware actually stores. Only measuring caught it.

But the fade itself is excellent. state.bri reports the transition target immediately and can't observe a ramp; info.leds.pwr (estimated draw, derived from rendered output) can. A 30 s transition traced a near-perfect line, ~95 mA per 2 s, finishing exactly on time.

So: neither of the two options, but a combination — chained scheduler steps, each carrying a device transition equal to the step interval. The scheduler sets a coarse target twice a minute; the device fades between them.

Verified on the roofline, not just in tests

I drove the real ramp code against your controller and sampled actual draw:

start: bri=1  pwr=161 mA
step 1/5 -> bri  52     256 → 341 → 436 mA
step 2/5 -> bri 103     521 → 616 → 701 mA
step 3/5 -> bri 153     786 → 870 → 966 mA
step 4/5 -> bri 204    1050 → 1146 → 1230 mA
step 5/5 -> bri 255    1326 → 1410 → 1506 mA

The point is the step boundaries: draw climbs at a constant ~90 mA per 2 s straight through them, with no discontinuity where the scheduler stepped. That's the whole design claim — continuous, not a staircase — and it holds on real hardware. It lands exactly on 255.

Interactions

The late-night dim cancels an in-flight ramp rather than racing it: a ramp still stepping toward a ceiling the dim just lowered would keep overwriting it, and whichever pushed last would win. Same reasoning as the existing trigger_scheduler_revert.

When an on transition itself falls inside the late-night window — a fixed on-time after 23:00, or a far-northern sunset — the ramp ceiling is the dimmed level, so a ramp can't undo a dim that already happened. That's the issue's "respect the late-night dim target as the ramp ceiling" criterion.

off_job still fires at the scheduled time as a backstop, in case a ramp was cancelled or the controller vanished mid-fade. A failed ramp step logs and keeps going rather than aborting — a ramp is cosmetic, and stranding the lights mid-fade is worse than a missed step.

transition_ms had no backend validation. The scheme editor caps its input at 65535 ms, which divides to 655 and is coincidentally safe — but the API and preset import accepted any integer, and anything larger hits the overflow above. Now bounded, with a test asserting the bound's quotient still fits the uint16 after scheme_to_wled_state multiplies it back up.

Verification

  • 415 backend tests pass (36 new), ruff, mypy clean
  • Frontend in node:22: tsc -b, eslint, vitest (76) all clean
  • uvx pre-commit run --all-files passes
  • Hardware: ramp driven end-to-end as above; controller restored byte-exact (on:false, bri:8, fx 115), presets untouched

🤖 Generated with Claude Code

Closes #21. `RAMP_ON_MINUTES` / `RAMP_OFF_MINUTES`, also in Settings, both `0` (the previous instant behaviour) by default. The fade-in *starts* at the on time and reaches the scheme's brightness after the window; the fade-out *ends* at the off time, so it begins early and the lights are dark on schedule. Colours and effect still land immediately — only the level moves. ### The measurement that chose the design The issue offered two mechanisms: scheduler interval pushes, or "offload the ramp to the device". I measured before choosing, and the second turned out to be impossible in a way worth knowing about. **WLED's `transition` is documented in 100 ms units but stored as a uint16 of *milliseconds*.** Past 65535 ms it wraps modulo 65536 rather than clamping: | sent | = ms | stored | actual fade | |---|---|---|---| | 655 | 65500 | 655 | 65.5 s — the maximum | | 656 | 65600 | **0** | instant snap | | 700 | 70000 | 44 | 4.4 s | | 1200 | 120000 | 544 | 54.4 s | `70000 − 65536 = 4464 → 44`. Every row is exact modulo arithmetic, with no error and no clamp — the request succeeds and the light does something else entirely. I had told you earlier that the field width allowed ~109 minutes. That was wrong: I'd reasoned from 100 ms units × uint16 without checking what the firmware actually stores. Only measuring caught it. **But the fade itself is excellent.** `state.bri` reports the transition *target* immediately and can't observe a ramp; `info.leds.pwr` (estimated draw, derived from rendered output) can. A 30 s transition traced a near-perfect line, ~95 mA per 2 s, finishing exactly on time. So: neither of the two options, but a combination — **chained scheduler steps, each carrying a device transition equal to the step interval.** The scheduler sets a coarse target twice a minute; the device fades between them. ### Verified on the roofline, not just in tests I drove the real ramp code against your controller and sampled actual draw: ``` start: bri=1 pwr=161 mA step 1/5 -> bri 52 256 → 341 → 436 mA step 2/5 -> bri 103 521 → 616 → 701 mA step 3/5 -> bri 153 786 → 870 → 966 mA step 4/5 -> bri 204 1050 → 1146 → 1230 mA step 5/5 -> bri 255 1326 → 1410 → 1506 mA ``` The point is the **step boundaries**: draw climbs at a constant ~90 mA per 2 s straight through them, with no discontinuity where the scheduler stepped. That's the whole design claim — continuous, not a staircase — and it holds on real hardware. It lands exactly on 255. ### Interactions **The late-night dim cancels an in-flight ramp** rather than racing it: a ramp still stepping toward a ceiling the dim just lowered would keep overwriting it, and whichever pushed last would win. Same reasoning as the existing `trigger_scheduler_revert`. **When an on transition itself falls inside the late-night window** — a fixed on-time after 23:00, or a far-northern sunset — the ramp ceiling is the dimmed level, so a ramp can't undo a dim that already happened. That's the issue's "respect the late-night dim target as the ramp ceiling" criterion. `off_job` still fires at the scheduled time as a backstop, in case a ramp was cancelled or the controller vanished mid-fade. A failed ramp step logs and keeps going rather than aborting — a ramp is cosmetic, and stranding the lights mid-fade is worse than a missed step. ### Related bug fixed `transition_ms` had **no backend validation**. The scheme editor caps its input at 65535 ms, which divides to 655 and is coincidentally safe — but the API and preset import accepted any integer, and anything larger hits the overflow above. Now bounded, with a test asserting the bound's quotient still fits the uint16 after `scheme_to_wled_state` multiplies it back up. ### Verification - 415 backend tests pass (36 new), `ruff`, `mypy` clean - Frontend in `node:22`: `tsc -b`, `eslint`, `vitest` (76) all clean - `uvx pre-commit run --all-files` passes - Hardware: ramp driven end-to-end as above; controller restored byte-exact (`on:false, bri:8, fx 115`), presets untouched 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Fade the lights in and out at the scheduled transitions (#21)
All checks were successful
CI / Dockerfile lint (pull_request) Successful in 9s
CI / Alembic migration check (pull_request) Successful in 46s
CI / Pre-commit hooks (pull_request) Successful in 1m1s
CI / Python lint & type-check (pull_request) Successful in 1m35s
CI / Frontend lint, test & build (pull_request) Successful in 1m35s
CI / Python tests (pull_request) Successful in 2m33s
CI / Docker build, health smoke & E2E (pull_request) Successful in 3m19s
8406e487bb
RAMP_ON_MINUTES / RAMP_OFF_MINUTES, also in Settings, both 0 (the
previous instant behaviour) by default. The fade-in starts at the on time
and reaches the scheme's brightness after the window; the fade-out ends at
the off time, so it begins early and the lights are dark on schedule. The
scheme's colours and effect still land immediately -- only the level moves.

The issue proposed either scheduler interval pushes or offloading the fade
to the device. Measuring the controller ruled the second out and produced
a better third option.

WLED's `transition` is documented in 100 ms units but stored as a uint16
of MILLISECONDS, so past 65535 ms it wraps modulo 65536 instead of
clamping. On 16.0.0: 655 gives the full 65.5 s, 656 gives an instant snap,
1200 (nominally two minutes) gives 54.4 s. Every value is exact modulo
arithmetic. So a multi-minute device-side fade is not merely unavailable
-- asking for one silently produces a short, arbitrary one.

What the device does well is the fade itself. Sampling info.leds.pwr
(estimated draw from rendered output; state.bri reports the transition
target and cannot observe a ramp) across a 30 s transition traces a
near-perfect line, ~95 mA per 2 s, finishing exactly on time.

So a ramp is a chain of scheduler steps, each carrying a device transition
equal to the step interval: the scheduler sets a coarse target twice a
minute and the device fades between them. Verified on the roofline by
driving the real ramp code and sampling draw -- power climbed uniformly
ACROSS the step boundaries (~90 mA per 2 s throughout, no discontinuity
where the scheduler stepped), so the result is continuous rather than a
staircase, and it landed exactly on the target.

The late-night dim cancels an in-flight ramp rather than racing it -- the
ramp would keep stepping toward a ceiling the dim just lowered and
whichever pushed last would win. When an on transition itself falls inside
the late-night window, the ramp ceiling is the dimmed level, so a ramp
cannot undo a dim that already happened.

Also bounds transition_ms, which had no backend validation at all. The
scheme editor capped its input at 65535 ms, which divides to a safe value,
but the API and preset import accepted anything -- the same overflow, one
layer up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
claude-bot deleted branch feat/21-brightness-ramp 2026-09-04 20:24:32 +00:00
Sign in to join this conversation.
No description provided.