Skip to content

feat: display brightness endpoint from clock light level and sun times - #212

Merged
tarakanof merged 3 commits into
mainfrom
feat/211-display-brightness
Oct 3, 2026
Merged

tarakanof merged 3 commits into
mainfrom
feat/211-display-brightness

Conversation

@tarakanof

Copy link
Copy Markdown
Owner

Summary

GET /v1/display/brightness (open, per-IP rate-limited like /v1/clock/health) gives sensorless displays (cinder knob) one level, no HA token needed.

  • lux: clock lightLevel from the existing 30s device probe (probeClockHealth; no new poller). Log-mapped lux_dark..lux_bright -> floor..ceiling, EMA fed once per distinct probe, hysteresis band (floor/ceiling snap).
  • sun (clock unreachable / reading stale / no lightLevel): day_level -> night_level with a twilight ramp from sunTimes (weather lat/lon, neighbouring UTC dates included). Resets the filter.
  • default: no clock and no location -> day_level.
  • Clock's own brightness untouched. Health golden unchanged (lightLevel kept off that wire).

Policy is pure (decideBrightness, luxToLevel, holdWithinBand, sunLevel in cmd/ember/brightness.go), table-tested.

Response

{"level":132,"source":"lux","night":false}

source is lux|sun|default; night is isNight's call whenever a location is set.

Config

config.json brightness (defaults): floor 10, ceiling 255, night_level 20, day_level 255, lux_dark 5, lux_bright 300, ema_alpha 0.3, hysteresis 8, stale_seconds 120, twilight_minutes 45. Runtime overlay GET/PUT /v1/brightness/config (bearer, merge semantics, 400 on invalid merge).

Tests

go vet ./... clean; go test ./... -race green. New: mapping, hysteresis, EMA/dedupe, stale reset, sun ramp (incl. western UTC-date crossing, polar), source fallback, routes (open GET, config merge/validation). Docs: ARCHITECTURE entry, AGENTS endpoint list, config.example.json.

Note

The awtrix-ng field name lightLevel and its unit (assumed lux) are from the issue, not verified on a device; the lux thresholds are tunable via config.

Closes #211

@tarakanof tarakanof left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review (read-only; go vet + go test ./... -race green on the branch). Auth is consistent: GET /v1/display/brightness is open and per-IP rate-limited like /v1/clock/health, and GET/PUT /v1/brightness/config is on writeMux (bearer). lightLevel exists in awtrix-ng GET /api/v1/device (see the real 1.1.2 capture in macos/Tests/EmberKitTests/ClockDiscoveryTests.swift). Repro snippets were run through a go test -overlay, so nothing was added to the branch.

1. Merge conflict with main (blocking). The branch is based on 9df3139 (v0.33.0); main is 5d17c3f. mergeable: CONFLICTING in cmd/ember/app.go, cmd/ember/clock_health_http.go and docs/ARCHITECTURE.md, all from main's comment strip (2cb309b/6acb38c/543a451). baseRefOid shows current main, which hides this. Rebase. Drop the new trailing struct comments (app.go brightness … // EMA…, clock_health lightLevel comment) to match main and STYLE §9.

2. Medium: one failed probe drops straight to sun and resets the filter, and stale_seconds does not do what it says. probeClockHealth overwrites the cache with Reachable=false on any failure (clock_health_http.go:317-340). currentBrightness (brightness.go:259) then passes sample=nil, so decideBrightness resets state and returns sun (brightness.go:242) for at least 30s.

  • Example: dark room at noon, one lossy-Wi-Fi timeout, and the knob jumps from 10 to 255. Recovery then restarts from the raw sample.
  • The other way round: stale_seconds is validated >=10 (brightness.go:94), but samples refresh only every clockProbeTTL=30s. With stale_seconds:10 I measured t+0s lux 93, then t+15s sun 255, and this flaps every probe window.
  • Fix: keep the last good luxSample in brightnessTracker, fall back only when now-lastGood.At > StaleSeconds, and validate StaleSeconds >= 2*clockProbeTTL/time.Second.

3. Medium: hysteresis:0 and twilight_minutes:0 can't be set. validate() accepts 0, but resolved() maps 0 to its default (brightness.go:66,72). PUT {"hysteresis":0,"twilight_minutes":0} returns 200 with hysteresis:8, twilight_minutes:45 (verified). TestSunLevelZeroTwilightIsAStep tests a state that config can't reach. Fix: raise the minimums to 1 in validate and the docs, or make those two fields *int in BrightnessConfig.

4. Medium (verify on device): default lux curve vs real readings. The real capture shows "lightLevel":3.3,"ldrRaw":135,"brightness":120. With lux_dark:5 that maps to floor 10 (luxToLevel(3.3)=10). If that capture was a normally lit room, the knob sits at floor indoors. Confirm the unit and range on the TC001 before shipping the defaults, as the PR note already says.

5. Low: out-of-order samples re-feed the EMA. The probe runs outside brightness.mu (brightness.go:259 vs 266). Two requests that straddle a TTL refresh can apply the newer sample and then the older one. !s.At.Equal(st.SampleAt) (brightness.go:230) accepts the older one, so it is double-counted and SampleAt goes backwards (verified: EMA 70.9 after 10/300/10). Fix: if s.At.After(st.SampleAt).

6. Low: the EMA never ages out between requests. The filter only resets on a request that sees no sample. After hours with no poller, the first sample blends 0.3 with an hours-old EMA. Fix: reseed when s.At.Sub(st.SampleAt) > StaleSeconds (pairs with #2).

7. Low: the held level can sit outside a new [floor, ceiling]. holdWithinBand returns prev inside the band (brightness.go:170) even after a PUT raises floor. Example: prev 12, floor 15, target 16 returns 12. Fix: clamp prev to [floor, ceiling] first, or reset the tracker in the setting's after hook.

8. Low: polar night reads as day. With no events, last==nil||next==nil returns DayLevel (brightness.go:203), so 255 all polar winter. This matches isNight, but it is worse for brightness. Fix: when sunTimes is !ok, decide by noon solar altitude (sign of lat vs declination).

Nits

  • ARCHITECTURE says "night is isNight's call". It is sunLevel's own computation over neighbouring dates and can differ from isNight.
  • The field comment says "a change must exceed" hysteresis, but the code changes at >= band.
  • server_test.go:166: the auth table lacks PUT /v1/brightness/config, and TestBrightnessConfigMergePut calls the handler directly, so bearer is never exercised.
  • STYLE §9: inline comments at brightness.go:208/212/214 and (#211) in the file header.

DST and UTC-offset handling looked fine (absolute times; ±1 UTC day window). An unreachable clock, a clock with no lightLevel, and no clock plus no location all fall back as documented.

…times

Sensorless displays (the cinder knob) need a brightness without holding a
Home Assistant token. GET /v1/display/brightness answers one level: the
TC001's lightLevel via the existing 30s clock probe (EMA + hysteresis), else
the sun schedule from the weather location with a twilight ramp, else a
default. The policy is pure and table-tested; knobs live in config.json
`brightness` and GET/PUT /v1/brightness/config. The clock's own brightness is
untouched.

Closes #211
Review of #212: one failed 30s probe dropped straight to the sun schedule,
and stale_seconds below the probe interval flapped the source. Keep the last
good sample until stale (stale_seconds >= 2 probes), feed the EMA only with
newer samples and reseed after a gap, clamp the held level to a changed
floor/ceiling, and read polar night from noon sun altitude. hysteresis and
twilight_minutes now start at 1 so validation matches the 0-means-default
resolution. Defaults lux_dark/lux_bright 1/200: the clock reads lightLevel 0
in a dark room (observed 01:35, ldrRaw 0). Docs, auth tables and tests follow.
@tarakanof

Copy link
Copy Markdown
Owner Author

Rebased on 5d17c3f (conflicts resolved to main's comment style). Review fixes in 2nd commit:

  • last good sample kept until stale_seconds; stale_seconds >= 60 (2 probes) so one lost probe no longer flips to sun/resets the EMA
  • EMA only fed newer samples; reseeds after a gap > stale_seconds
  • held level clamped to current floor/ceiling
  • polar night/day via noon sun altitude
  • hysteresis/twilight_minutes minimum 1 (0 = default no longer silently remapped); unreachable test replaced
  • night/hysteresis docs fixed, inline comments and feat: display brightness endpoint from clock light level and sun times #211 refs dropped, PUT /v1/brightness/config in 413 auth table, merge test goes through bearer (401 without)
  • Device check (read-only GET http://192.168.0.66/api/v1/device, 01:35 CEST, dark): lightLevel 0, ldrRaw 0, brightness 10. Unit/range still unconfirmed in daylight (fixtures show 3.3 w/ ldrRaw 135, and 230). Defaults changed to lux_dark 1 / lux_bright 200; needs a daytime recheck.

@tarakanof
tarakanof force-pushed the feat/211-display-brightness branch from c3ad9b8 to bc08754 Compare October 2, 2026 23:38
@tarakanof

Copy link
Copy Markdown
Owner Author

Re-review of bc08754: all findings fixed in code (rebased onto 5d17c3f, mergeable; gofmt/vet/go test ./... -race green locally, CI pending). I checked the behaviour with a scratch test: a lost probe within stale_seconds keeps lux and goes to sun after 120s; an out-of-order sample leaves the EMA unchanged (97 -> 97); a 3h gap reseeds; the clamp, polar night and min-1 validation all work.

One gap remains: the headline fixes have no regression tests. Each mutation below still passes go test ./cmd/ember:

The clamp (#7) and polar night (#8) tests do fail without their fix. Suggest adding three small decideBrightness cases. Otherwise fine to merge. The daytime lux check (#4) is still open, as you noted.

Mutation testing showed these three behaviours had no failing test. Cover the
stale_seconds boundary with a nil probe, an older sample not displacing the
newest one, and the EMA gap boundary (blend at == stale_seconds, reseed past it).
@tarakanof

Copy link
Copy Markdown
Owner Author

Added tests for the three unpinned behaviours (nil probe within/at stale_seconds keeps lux + EMA; older sample can't displace newest; EMA blends at gap == stale_seconds and reseeds past it). Mutated locally: After->!Equal, <=->< on both stale checks, dropping the gap check, dropping the Last guard all now fail; reverted. vet + race green.

@tarakanof
tarakanof merged commit 4531ca2 into main Oct 3, 2026
3 checks passed
@tarakanof
tarakanof deleted the feat/211-display-brightness branch October 3, 2026 06:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: display brightness endpoint from clock light level and sun times

1 participant