Late-night dim compounds across nights: brightness decays 128 → 51 → 20 → 8 on consecutive no-event days #110

Closed
opened 2026-09-04 15:10:32 +00:00 by claude-bot · 1 comment
Contributor

Found while confirming #7's unattended cycles on the dev deployment. The scheduler is working correctly; this is a separate defect it exposed.

Observed

Three consecutive nights on the dev host, with default_scheme unset so every no-event evening pushes off:

Night on_job dim_job 23:00 resulting bri
Tue 09-01 pushed late_summer_moon_2026_mid (global_brightness: 128) 128 × 0.4 51
Wed 09-02 no event → {"on": false} 51 × 0.4 20
Thu 09-03 no event → {"on": false} 20 × 0.4 8

Controller now reads on=false, bri=8. Left alone it continues 8 → 3 → 1.

Cause

run_dim_job reads the live state and dims relative to it, with no check that the lights are on:

current = ctrl.get_state()
payload = dim_state(cfg.late_night_brightness, current)
def dim_state(brightness_pct, current_state):
    current_bri = current_state.get("bri", 255)
    new_bri = max(1, int(current_bri * brightness_pct / 100))
    return {"bri": new_bri}

Two things follow:

  1. The dim is not idempotent. It multiplies whatever is already there, so applying it twice gives 16% and three times 6.4%. late_night_brightness = 40 reads as "dim to 40%", but it means "dim by 60% of current".
  2. It runs while the lights are off. {"on": false} from the no-event path leaves bri untouched, so nothing resets the baseline and each night's dim stacks on the last.

It self-heals the moment a real scheme is pushed, because scheme_to_wled_state sets bri from global_brightness — and likewise for the dim_white default, which sets bri explicitly. The decay window is a run of consecutive no-event days with default_scheme unset, which for a holiday-lighting system is most of the year.

Why it matters

Nothing is visible while the lights are off, which is why this went unnoticed. The failure mode is a user turning the lights on by hand during a quiet stretch — via the WLED app, or Iris's own quick push, which sets segment brightness but not the global bri — and finding them at 3% with no explanation. "My lights come on almost black" is a hard thing to trace back to a dim job that ran correctly on an empty night.

There is also a smaller reporting problem: dim_job logs success every night while dimming an off controller, so the schedule log implies work that had no effect.

Suggested fix

Either would break the compounding; they are not exclusive:

  • Skip the dim when the controller is off. ctrl.get_state() is already fetched — a current.get("on") check is free, and logging skipped is more honest than success.
  • Dim relative to a stable baseline rather than the live value. The scheme's global_brightness (or 255 when no scheme is active) makes late_night_brightness mean what its name says and makes the operation idempotent.

The first is the smaller change and fixes the observed decay. The second fixes the underlying non-idempotence, which would still bite if the dim job ever ran twice in one night — for example after a restart between 23:00 and sunrise re-registering a pending dim_job.

Acceptance criteria

  • Running the dim job repeatedly without an intervening scheme push does not reduce brightness further each time
  • The dim job does not silently report success when the controller is off
  • Regression test: apply the dim twice and assert the second is a no-op (or asserts the same resulting brightness)
  • Decide and document what LATE_NIGHT_BRIGHTNESS means — a target level, or a reduction factor. The env comment and settings label should agree with the code.

Found during #7. Related: #106.

Found while confirming #7's unattended cycles on the dev deployment. The scheduler is working correctly; this is a separate defect it exposed. ## Observed Three consecutive nights on the dev host, with `default_scheme` unset so every no-event evening pushes *off*: | Night | on_job | dim_job 23:00 | resulting `bri` | |---|---|---|---| | Tue 09-01 | pushed `late_summer_moon_2026_mid` (`global_brightness: 128`) | 128 × 0.4 | **51** | | Wed 09-02 | no event → `{"on": false}` | 51 × 0.4 | **20** | | Thu 09-03 | no event → `{"on": false}` | 20 × 0.4 | **8** | Controller now reads `on=false, bri=8`. Left alone it continues 8 → 3 → 1. ## Cause `run_dim_job` reads the live state and dims relative to it, with no check that the lights are on: ```python current = ctrl.get_state() payload = dim_state(cfg.late_night_brightness, current) ``` ```python def dim_state(brightness_pct, current_state): current_bri = current_state.get("bri", 255) new_bri = max(1, int(current_bri * brightness_pct / 100)) return {"bri": new_bri} ``` Two things follow: 1. **The dim is not idempotent.** It multiplies whatever is already there, so applying it twice gives 16% and three times 6.4%. `late_night_brightness = 40` reads as "dim to 40%", but it means "dim *by* 60% of current". 2. **It runs while the lights are off.** `{"on": false}` from the no-event path leaves `bri` untouched, so nothing resets the baseline and each night's dim stacks on the last. It self-heals the moment a real scheme is pushed, because `scheme_to_wled_state` sets `bri` from `global_brightness` — and likewise for the `dim_white` default, which sets `bri` explicitly. **The decay window is a run of consecutive no-event days with `default_scheme` unset**, which for a holiday-lighting system is most of the year. ## Why it matters Nothing is visible while the lights are off, which is why this went unnoticed. The failure mode is a user turning the lights on by hand during a quiet stretch — via the WLED app, or Iris's own quick push, which sets segment brightness but not the global `bri` — and finding them at 3% with no explanation. "My lights come on almost black" is a hard thing to trace back to a dim job that ran correctly on an empty night. There is also a smaller reporting problem: `dim_job` logs `success` every night while dimming an off controller, so the schedule log implies work that had no effect. ## Suggested fix Either would break the compounding; they are not exclusive: - **Skip the dim when the controller is off.** `ctrl.get_state()` is already fetched — a `current.get("on")` check is free, and logging `skipped` is more honest than `success`. - **Dim relative to a stable baseline rather than the live value.** The scheme's `global_brightness` (or 255 when no scheme is active) makes `late_night_brightness` mean what its name says and makes the operation idempotent. The first is the smaller change and fixes the observed decay. The second fixes the underlying non-idempotence, which would still bite if the dim job ever ran twice in one night — for example after a restart between 23:00 and sunrise re-registering a pending `dim_job`. ## Acceptance criteria - [ ] Running the dim job repeatedly without an intervening scheme push does not reduce brightness further each time - [ ] The dim job does not silently report `success` when the controller is off - [ ] Regression test: apply the dim twice and assert the second is a no-op (or asserts the same resulting brightness) - [ ] Decide and document what `LATE_NIGHT_BRIGHTNESS` means — a target level, or a reduction factor. The env comment and settings label should agree with the code. Found during #7. Related: #106.
claude-bot added this to the v1.1.0 milestone 2026-09-04 15:10:40 +00:00
Author
Contributor

Fixed in #111.

  • Running the dim job repeatedly without an intervening scheme push does not reduce brightness further — dim_state now takes an explicit baseline, so it is idempotent
  • The dim job does not silently report success when the controller is off — it logs skipped
  • Regression tests — dim_state applied twice gives the same answer; run_dim_job against an off controller pushes nothing and logs skipped
  • LATE_NIGHT_BRIGHTNESS documented as a percentage of the scheme's brightness, in both .env.example and spec §5.2

Spec §5.2 already specified the reduction as proportional — "overrides per-scheme brightness values proportionally" — so proportional was correct and the baseline was the defect. Worth checking before changing it, since the alternative reading (40% of full scale) would have been a behaviour change disguised as a bug fix.

One extra case the fix covers, not in the original report: with default_scheme = dim_white the compounding happened within a single night. The ambient default comes on at late_night_brightness% of 255 (102), and dim_job then took 40% of that — 40 — a few hours later. With a stable 255 baseline the dim now lands on the level already showing, so it is a no-op rather than a second reduction.

That also explains how the ambiguity survived: dim_white_state reads the setting as a percentage of 255 while dim_state read it as a percentage of current. Two defensible readings of "brightness 40", both live in the same codebase.

Closing.

**Fixed in #111.** - [x] Running the dim job repeatedly without an intervening scheme push does not reduce brightness further — `dim_state` now takes an explicit baseline, so it is idempotent - [x] The dim job does not silently report `success` when the controller is off — it logs `skipped` - [x] Regression tests — `dim_state` applied twice gives the same answer; `run_dim_job` against an off controller pushes nothing and logs `skipped` - [x] `LATE_NIGHT_BRIGHTNESS` documented as a percentage of the *scheme's* brightness, in both `.env.example` and spec §5.2 Spec §5.2 already specified the reduction as proportional — *"overrides per-scheme brightness values proportionally"* — so proportional was correct and the **baseline** was the defect. Worth checking before changing it, since the alternative reading (40% of full scale) would have been a behaviour change disguised as a bug fix. **One extra case the fix covers**, not in the original report: with `default_scheme = dim_white` the compounding happened *within a single night*. The ambient default comes on at `late_night_brightness`% of 255 (102), and `dim_job` then took 40% of that — 40 — a few hours later. With a stable 255 baseline the dim now lands on the level already showing, so it is a no-op rather than a second reduction. That also explains how the ambiguity survived: `dim_white_state` reads the setting as a percentage of 255 while `dim_state` read it as a percentage of current. Two defensible readings of "brightness 40", both live in the same codebase. Closing.
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#110
No description provided.