Repository navigation
feat: display brightness endpoint from clock light level and sun times - #212
Conversation
tarakanof
left a comment
There was a problem hiding this comment.
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_secondsis validated>=10(brightness.go:94), but samples refresh only everyclockProbeTTL=30s. Withstale_seconds:10I measuredt+0s lux 93, thent+15s sun 255, and this flaps every probe window. - Fix: keep the last good
luxSampleinbrightnessTracker, fall back only whennow-lastGood.At > StaleSeconds, and validateStaleSeconds >= 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 "
nightisisNight's call". It issunLevel's own computation over neighbouring dates and can differ fromisNight. - The field comment says "a change must exceed"
hysteresis, but the code changes at>= band. server_test.go:166: the auth table lacksPUT /v1/brightness/config, andTestBrightnessConfigMergePutcalls 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.
|
Rebased on 5d17c3f (conflicts resolved to main's comment style). Review fixes in 2nd commit:
|
c3ad9b8 to
bc08754
Compare
|
Re-review of bc08754: all findings fixed in code (rebased onto 5d17c3f, mergeable; gofmt/vet/ One gap remains: the headline fixes have no regression tests. Each mutation below still passes
The clamp (#7) and polar night (#8) tests do fail without their fix. Suggest adding three small |
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).
|
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. |
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.lightLevelfrom the existing 30s device probe (probeClockHealth; no new poller). Log-mappedlux_dark..lux_bright->floor..ceiling, EMA fed once per distinct probe, hysteresis band (floor/ceiling snap).lightLevel):day_level->night_levelwith a twilight ramp fromsunTimes(weather lat/lon, neighbouring UTC dates included). Resets the filter.day_level.lightLevelkept off that wire).Policy is pure (
decideBrightness,luxToLevel,holdWithinBand,sunLevelincmd/ember/brightness.go), table-tested.Response
{"level":132,"source":"lux","night":false}sourceislux|sun|default;nightisisNight's call whenever a location is set.Config
config.jsonbrightness(defaults):floor10,ceiling255,night_level20,day_level255,lux_dark5,lux_bright300,ema_alpha0.3,hysteresis8,stale_seconds120,twilight_minutes45. Runtime overlayGET/PUT /v1/brightness/config(bearer, merge semantics, 400 on invalid merge).Tests
go vet ./...clean;go test ./... -racegreen. 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
lightLeveland its unit (assumed lux) are from the issue, not verified on a device; the lux thresholds are tunable via config.Closes #211