Repository navigation
feat(devices): TC001 as an awtrix-ng registry device with a config facade - #360
Conversation
…g facade Phase 2 of per-device apps (#230): both displays are now addressed as /v1/devices/{id}, and the clock's presentation settings read and write as one config without moving any storage yet (#232 does that). The clock record (id clock-<last6 of NG uid>, no token) is created by the first good probe of the effective clock URL or a discovery swap and keyed by the uid GET /api/v1/device already returns; later probes set last_seen on the checkin persistence rule. URL tiers stay in clock_url.go. Device auth, rotate, checkin and the OTA loops skip the kind; DELETE drops the record until the next good probe re-creates it, which is how an old clock's record goes away after a swap. Doctor judges the clock by last_seen instead of a checkin. GET/PUT /v1/devices/{clock}/config composes apps.{agents, focus, weather, calendar} and rotation from usage_json, settings_json colours, weather_json, meetings_json, display_hidden_apps and the clock's own app list, and splits a PUT back through each slice's settingSpec in one tryUpdateConfig, so validation and persistence match the old endpoints byte for byte. Same merge rules as the knob. The rotation patch goes to the clock first, after validation, so a failed clock write changes nothing. config_version is a hash of the composed config, stored on the record and resynced after every config, hidden-tool or app-list change, so the devices epoch moves whichever endpoint made the change. Boot and reload reapply with the resync paused to move it at most once. Old endpoints, the coordinator and the knob goldens are unchanged; PUT /v1/device/apps makes no extra clock request (it forgets the cached app list instead of re-reading it). Spec: Specs/ember/2026-10-04-per-device-apps-design.md
ARCHITECTURE gets the record lifecycle, the facade field table, merge and version rules; API, openapi, RUNBOOK and DEVICE-PROTOCOL note the new kind, the 400 on rotate and that a clock change moves the shared epoch.
A LAN host answering GET /api/v1/device with a fresh uid on every probe appended a persisted awtrix-ng record each time, growing devices_json without bound and bumping the epoch, which woke every knob. Phase 2 has one clock, so a different uid at the effective clock URL now rewrites the existing record in place: new id and hw_id, last_seen reset, created_at and a user-set name kept, one epoch bump. Records are only created from a probe of effectiveClockURL() or the discovery candidate a swap just made effective; the probe body is already read through the clock client's 1 MiB cap. The uid pattern is tightened to 32 characters.
Codex reviewNo deadlock was found between the registry mutex and Blocker
Should-fix
Test gaps: partial persist failure, concurrent PUTs, a stale probe after rediscovery, a failed rotation readback, and the hash/store race. |
Opus reviewIndependent read-only review of Should-fix1. A façade PUT reverts concurrent slice writes (lost update). 2. 3. A uid that changes on every probe still bumps the epoch every probe. Nits4. 5. The PUT is atomic in memory only. 6. Reordering through the old endpoint moves the epoch twice. 7. 8. A probe and a rediscover swap can race. Checked, OK
|
PutSettings upserts a set of settings rows in one SQLite transaction and rolls back on any failure, so a multi-slice settings change is stored whole or not at all. The clock config facade needs it (#360 review).
Review findings on #360 (Codex and Opus): - Persistence: every changed slice and the hidden tools are stored in one SQLite transaction before the new config goes live; a failed store is a 500 and leaves memory and the store unchanged. - Lost update: the patch is merged again inside tryUpdateConfig onto the config and hidden tools of that moment, so a write that landed while the clock call ran is kept. /v1/apps toggles take cfgMu too. - Version: compute and store of config_version are serialised, and the composed config (GET body and hash) comes from one snapshot. - Identity: a probe counts only if its URL is still the effective one; a new uid replaces the record only after two sightings in a row, so a flapping or fresh uid never flips the record or moves the epoch. - Rotation: GET's order now means what PUT's does (enabled apps; disabled listed apart), pushed Ember tiles are left out of it and of the hash, an unchanged rotation is not written to the clock, the cache is dropped after a write and a failed readback gives null. PUT /v1/device/apps no longer bumps the epoch twice: the version moves once, at the next read of the list. - hidden_tools takes what /v1/apps takes, so a GET body can be PUT back.
ARCHITECTURE and openapi described the facade PUT as atomic while each slice was a separate write, and the uid rewrite as ending epoch churn; they now match the code after the #360 review fixes.
Review replies (fixed in fb3cbc7, 13020cb, 1f49188)Codex
Opus
All the new tests fail against 0aebdc4 except the race test, which is probabilistic. |
Re-checkRead-only re-check of should-fixAn unchanged-rotation skip based on a stale cache drops a real reorder and still returns 200.
The same thing happens after any change the server hasn't read: a reorder in the clock's web UI, or an app re-read that failed after the old-route PUT (this is likely given the known ~44 % loss on the clock link). The client is told the clock matches when it does not. Possible fixes, either one:
The docs paragraph ("equals the last list read") describes the code accurately but hides this consequence. Please add a test that runs these three steps. nit
Verified, clean
|
… write Re-check on #360: the unchanged-rotation skip compared the patch with the cached list, so after a reorder the server hadn't read (old route, the clock's web UI, a failed read) a resent rotation returned 200 without reaching the clock. The facade now reads the list during the request and skips only when that read succeeds and matches; a failed read writes. PUT /v1/device/apps clears the cache again, without a resync, so the version still moves once, at the next read. TestClockConfigVersionSyncRace is dropped: compute and store of the version have no point a test can hold one goroutine at without adding a seam to production code, so it never failed on the unfixed code.
Re-check replies (fixed in 70902ae and ec1fd4e)
|
Re-check 2Read-only re-check of No blockers or should-fix findings. The stale-rotation skip is fixed. I found three nits. nit
Verified, clean
|
Closes #230
Phase 2 of per-device apps (spec
Specs/ember/2026-10-04-per-device-apps-design.md). Server only; the app binding is #359.What changed
Clock record (
clock_device.go)GET /api/v1/devicereturningboardType: awtrixngplus auid, through the shared clock-health probe. A discovery swap also creates it.clock-<last6 of uid>(full uid on a collision);hw_id= uidClock <SUFFIX>; no token and no storedconfiglast_seen{seen_at,fw,ip,rssi,uptime_s}and persist on the checkin rule (10 min).clock_url.go.Kind gates
rotateand registrycheckinreturn 400 forawtrix-ng.findKnob).POST /v1/devicesstill mints knobs and clients only.PATCHrenames the record, and a probe never resets the name.DELETEreturns 204, and the next good probe re-creates the record with its default name. This is how a swapped-out clock's record goes away.last_seen: warns past 5 min, saysclock disabledunderEMBER_CLOCK=off.Façade
GET/PUT /v1/devices/{clock}/config(clock_config.go)apps.agents:usage_cards,usage_per_model(usage_json);hidden_tools(display_hidden_apps)apps.focus:focus_color,break_color(settings_json)apps.weather(weather_json):on(rotate_in_apps),native_icon,forecast,forecast_hours,air,moon,overlaypopups.{on_change, sun, severe, native_icons, interval_minutes, duration_seconds},icon_idsapps.calendar:on(meetingsenabled),tile_lead_minutes,popup_lead_minutes(meetings_json)rotation.{order, disabled}: the clock's own app list (NG/api/v1/apps)producer.env)config.json)PUT
icon_idsreplace whole{}writes nothingsettingSpec(setting.stage, the same apply and validation as the old endpoint):tryUpdateConfigundercfgMuafterhooks run afterrotationis sent to the clock (thePUT /v1/device/appsbody) after validation and before the slices. A failed clock write returns 502 and changes nothing.Version and epoch
config_versionis 31 bits of the SHA-256 of the composed config, stored on the record.PUT /v1/device/appsforgets it, so that route makes no extra clock request./admin/reloadpause the resync while reapplying, so the epoch moves at most once.Compatibility
GET /v1/device/apps, which also caches the list it proxies.testdata/devicesis unchanged.testdata/clock/, so cinder's fixture sync doesn't pick them up.Evidence
gofmtclean,go vet ./...clean, andgo test ./... -race -shuffle=onpasses.{}no-ophttptestawtrix-ng stub, a non-NG board, a discovery swap, an id collisionEMBER_MDNS_ADVERTISE=0, local Python stub withauto_rediscover:false. The real clock, server and knob were not contacted.GET /v1/devices→{"id":"clock-112233","kind":"awtrix-ng","hw_id":"aabbcc112233","name":"Clock 112233","config_version":120194409,...,"last_checkin":null,"last_seen":{"fw":"1.0.13","ip":"127.0.0.1","rssi":-55,"uptime_s":100}}GET /v1/devices/clock-112233/config→ 200 withX-Ember-Config-VersionPOST …/rotate→ 400rotate applies to kind "cinder-knob" onlydevices→registered=1 clock-112233 seen=6s agoLeft for #232