Gradual brightness ramp at on/off transitions #21

Closed
opened 2026-07-07 17:37:56 +00:00 by claude-bot · 2 comments
Contributor

Goal

Fade the lights in over a configurable window at the on-transition (sunset) and fade out at the off-transition (sunrise), instead of snapping to full/zero brightness.

Why it's valuable

A small change with an outsized polish payoff — a slow ramp at dusk reads as intentional and premium. It's a pure scheduler/push refinement with no schema impact.

Sketch

  • New settings: RAMP_ON_MINUTES / RAMP_OFF_MINUTES (0 = current snap behavior).
  • Scheduler interpolates global brightness across the window via short interval pushes, or uses WLED's transition/nightlight features where possible to offload the ramp to the device.
  • Respect the late-night dim target as the ramp ceiling after dimming.

Acceptance criteria

  • Configurable fade-in at on-time and fade-out at off-time
  • 0 preserves the current instant behavior
  • Interacts correctly with late-night dimming
  • Tests for ramp endpoints/edge times

Proposed enhancement (brainstorm follow-up).

## Goal Fade the lights in over a configurable window at the on-transition (sunset) and fade out at the off-transition (sunrise), instead of snapping to full/zero brightness. ## Why it's valuable A small change with an outsized polish payoff — a slow ramp at dusk reads as intentional and premium. It's a pure scheduler/push refinement with no schema impact. ## Sketch - New settings: `RAMP_ON_MINUTES` / `RAMP_OFF_MINUTES` (0 = current snap behavior). - Scheduler interpolates global brightness across the window via short interval pushes, or uses WLED's transition/nightlight features where possible to offload the ramp to the device. - Respect the late-night dim target as the ramp ceiling after dimming. ## Acceptance criteria - [ ] Configurable fade-in at on-time and fade-out at off-time - [ ] `0` preserves the current instant behavior - [ ] Interacts correctly with late-night dimming - [ ] Tests for ramp endpoints/edge times Proposed enhancement (brainstorm follow-up).
claude-bot added this to the v1.1.0 milestone 2026-07-15 15:32:57 +00:00
Author
Contributor

Picking this up. The sketch offers two mechanisms — scheduler interval pushes, or "uses WLED's transition/nightlight features where possible to offload the ramp to the device" — so I measured the device before choosing. The answer is decisive, and not what I expected.

WLED's transition caps at 65.5 seconds, and overflows silently past it

transition is in 100 ms units, but the firmware stores it as a uint16 of milliseconds. Past 65535 ms it wraps modulo 65536 rather than clamping. Measured on the 16.0.0 controller:

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
6000 600000 101 10.1 s

70000 − 65536 = 4464 ms → 44. Every row is exact modulo arithmetic.

So offloading a multi-minute ramp to the device is impossible — and worse, asking for one silently produces a short, arbitrary fade instead. A 2-minute request becomes 54 seconds; a 65.6-second request becomes an instant snap. I had assumed from the field width that ~109 minutes was available; that was wrong, and only measuring caught it.

But the device's fade itself is smooth and linear

state.bri reports the transition target immediately, so it cannot observe a ramp. info.leds.pwr — estimated draw, computed from actual rendered output — can. A 30-second fade from bri 1 to 255:

t= 0.1s  pwr= 161 mA      t=16.5s  pwr= 891 mA
t= 4.2s  pwr= 341 mA      t=22.6s  pwr=1167 mA
t= 8.3s  pwr= 531 mA      t=28.8s  pwr=1442 mA
t=12.4s  pwr= 711 mA      t=30.8s  pwr=1506 mA  (flat thereafter)

Near-perfectly linear, ~95 mA per 2 s, completing in exactly the requested 30 s. No stepping at 2-second sampling, and WLED interpolates per frame so the true resolution is far finer.

Design that falls out of this

Neither of the sketch's options alone: chained scheduler steps, each carrying a WLED transition equal to the step interval. The scheduler sets a coarse target every N seconds; the device fades smoothly between them. The result is continuous rather than stepped, at ~2 pushes a minute instead of one per brightness level.

The step interval must stay under 65 s or it hits the overflow above. I'll use 30 s.

transition_ms has no backend validation (app/schemas/schemes.py:66). The scheme editor's input caps at 65535 ms, which divides to 655 and is coincidentally safe — but the API and preset import accept any integer, and anything above 65535 ms wraps on the device. Adding a bound as part of this, since it is the same failure and I am in the same code.

Interaction with late-night dimming

Per the acceptance criteria. The ramp ceiling comes from the active baseline reduced by the late-night factor when the ramp runs inside that window, and the dim job cancels any in-flight ramp so the two cannot fight over brightness — the same reason trigger_scheduler_revert exists for quick push.

Timing: the on-ramp starts at on-time and reaches full after ramp_on_minutes; the off-ramp ends at off-time, so it starts ramp_off_minutes early. Asymmetric, but it matches what each time means — on-time is when the lights start appearing, off-time is when they are gone.

Lights were snapshotted before this and restored after; the controller is back to on:false, bri:8, fx 115.

Picking this up. The sketch offers two mechanisms — scheduler interval pushes, or "uses WLED's transition/nightlight features where possible to offload the ramp to the device" — so I measured the device before choosing. The answer is decisive, and not what I expected. ### WLED's `transition` caps at 65.5 seconds, and overflows silently past it `transition` is in 100 ms units, but the firmware stores it as a **uint16 of milliseconds**. Past 65535 ms it wraps modulo 65536 rather than clamping. Measured on the 16.0.0 controller: | 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 | | 6000 | 600000 | 101 | 10.1 s | `70000 − 65536 = 4464 ms → 44`. Every row is exact modulo arithmetic. So **offloading a multi-minute ramp to the device is impossible** — and worse, asking for one silently produces a short, arbitrary fade instead. A 2-minute request becomes 54 seconds; a 65.6-second request becomes an instant snap. I had assumed from the field width that ~109 minutes was available; that was wrong, and only measuring caught it. ### But the device's fade itself is smooth and linear `state.bri` reports the transition *target* immediately, so it cannot observe a ramp. `info.leds.pwr` — estimated draw, computed from actual rendered output — can. A 30-second fade from bri 1 to 255: ``` t= 0.1s pwr= 161 mA t=16.5s pwr= 891 mA t= 4.2s pwr= 341 mA t=22.6s pwr=1167 mA t= 8.3s pwr= 531 mA t=28.8s pwr=1442 mA t=12.4s pwr= 711 mA t=30.8s pwr=1506 mA (flat thereafter) ``` Near-perfectly linear, ~95 mA per 2 s, completing in exactly the requested 30 s. No stepping at 2-second sampling, and WLED interpolates per frame so the true resolution is far finer. ### Design that falls out of this Neither of the sketch's options alone: **chained scheduler steps, each carrying a WLED `transition` equal to the step interval.** The scheduler sets a coarse target every N seconds; the device fades smoothly between them. The result is continuous rather than stepped, at ~2 pushes a minute instead of one per brightness level. The step interval must stay under 65 s or it hits the overflow above. I'll use 30 s. ### Related latent bug `transition_ms` has **no backend validation** (`app/schemas/schemes.py:66`). The scheme editor's input caps at 65535 ms, which divides to 655 and is coincidentally safe — but the API and preset import accept any integer, and anything above 65535 ms wraps on the device. Adding a bound as part of this, since it is the same failure and I am in the same code. ### Interaction with late-night dimming Per the acceptance criteria. The ramp ceiling comes from the active baseline reduced by the late-night factor when the ramp runs inside that window, and the dim job cancels any in-flight ramp so the two cannot fight over brightness — the same reason `trigger_scheduler_revert` exists for quick push. Timing: the on-ramp *starts* at on-time and reaches full after `ramp_on_minutes`; the off-ramp *ends* at off-time, so it starts `ramp_off_minutes` early. Asymmetric, but it matches what each time means — on-time is when the lights start appearing, off-time is when they are gone. Lights were snapshotted before this and restored after; the controller is back to `on:false, bri:8, fx 115`.
Author
Contributor

Done — #119 merged, all seven CI jobs green.

Acceptance criteria

  • Configurable fade-in at on-time and fade-out at off-timeRAMP_ON_MINUTES / RAMP_OFF_MINUTES, also in Settings. The fade-in starts at the on time; the fade-out ends at the off time, so it begins early via a new off_ramp_job and the lights are dark on schedule.
  • 0 preserves the current instant behaviour — the default, and the only path when unset.
  • Interacts correctly with late-night dimming — the dim cancels an in-flight ramp rather than racing it, and an on transition falling inside the late-night window ramps to the dimmed ceiling rather than to full.
  • Tests for ramp endpoints/edge times — 36 new, covering interpolation endpoints (lands exactly on target, never hits 0 early, monotonic both directions), step scheduling and cancellation, the ceiling across the window boundaries, and the on-job integration.

The measurement that decided the mechanism

The sketch offered scheduler stepping or offloading to the device. The second is not available, and fails in a way worth recording:

transition is documented in 100 ms units but stored as a uint16 of milliseconds. Past 65535 ms it wraps modulo 65536 rather than clamping — 656 (65.6 s) gives an instant snap, 1200 (nominally 2 min) gives 54.4 s. No error; the request succeeds and the light does something else.

What the device is good at is the fade itself — sampling info.leds.pwr across a 30 s transition gives a near-perfect line finishing exactly on time. So the implementation is neither option alone but a combination: scheduler steps each carrying a device transition equal to the step interval, so the device fades between coarse targets.

Verified on hardware, which is the part that matters

Driving the real ramp code against the controller and sampling 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 claim under test is continuity across step boundaries, and draw climbs at a constant ~90 mA per 2 s straight through them — no discontinuity where the scheduler stepped. Lands exactly on 255.

transition_ms had no backend validation. The scheme editor's input caps at 65535 ms, which divides to 655 and is coincidentally safe; the API and preset import accepted any integer and could reach 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.

What is left, and it is not code

The mechanism is verified; the remaining question is taste — whether 20 minutes or 45 feels right at your roofline. That is a Settings change on some evening, not a code change. Both values default to 0, so nothing changes until you set them.

The overflow and the linear-fade measurement are both recorded in docs/wled-compatibility.md.

Controller snapshotted and restored throughout — back to on:false, bri:8, fx 115, presets untouched.

Done — #119 merged, all seven CI jobs green. ### Acceptance criteria - [x] **Configurable fade-in at on-time and fade-out at off-time** — `RAMP_ON_MINUTES` / `RAMP_OFF_MINUTES`, also in Settings. The fade-in *starts* at the on time; the fade-out *ends* at the off time, so it begins early via a new `off_ramp_job` and the lights are dark on schedule. - [x] **`0` preserves the current instant behaviour** — the default, and the only path when unset. - [x] **Interacts correctly with late-night dimming** — the dim cancels an in-flight ramp rather than racing it, and an on transition falling inside the late-night window ramps to the dimmed ceiling rather than to full. - [x] **Tests for ramp endpoints/edge times** — 36 new, covering interpolation endpoints (lands exactly on target, never hits 0 early, monotonic both directions), step scheduling and cancellation, the ceiling across the window boundaries, and the on-job integration. ### The measurement that decided the mechanism The sketch offered scheduler stepping *or* offloading to the device. The second is not available, and fails in a way worth recording: **`transition` is documented in 100 ms units but stored as a uint16 of milliseconds.** Past 65535 ms it wraps modulo 65536 rather than clamping — 656 (65.6 s) gives an instant snap, 1200 (nominally 2 min) gives 54.4 s. No error; the request succeeds and the light does something else. What the device *is* good at is the fade itself — sampling `info.leds.pwr` across a 30 s transition gives a near-perfect line finishing exactly on time. So the implementation is neither option alone but a combination: **scheduler steps each carrying a device transition equal to the step interval**, so the device fades between coarse targets. ### Verified on hardware, which is the part that matters Driving the real ramp code against the controller and sampling 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 claim under test is continuity **across step boundaries**, and draw climbs at a constant ~90 mA per 2 s straight through them — no discontinuity where the scheduler stepped. Lands exactly on 255. ### Related bug fixed `transition_ms` had no backend validation. The scheme editor's input caps at 65535 ms, which divides to 655 and is coincidentally safe; the API and preset import accepted any integer and could reach 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. ### What is left, and it is not code The mechanism is verified; the remaining question is taste — whether 20 minutes or 45 feels right at your roofline. That is a Settings change on some evening, not a code change. Both values default to 0, so nothing changes until you set them. The overflow and the linear-fade measurement are both recorded in `docs/wled-compatibility.md`. Controller snapshotted and restored throughout — back to `on:false, bri:8, fx 115`, presets untouched.
Sign in to join this conversation.
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#21
No description provided.