-
Notifications
You must be signed in to change notification settings - Fork 1
HA MQTT Discovery + CodeRabbit fixes + docs overhaul (ADRs, lessons split) #39
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| # 1. Persist POD module state with memcpy, not JSON | ||
|
|
||
| Status: Accepted | ||
|
|
||
| ## Context | ||
|
|
||
| Plan-09 attempted ~1700 LOC of JSON-based persistence for module state. It was fully abandoned. The premise "persistence is JSON" was assumed without justification: neither human-readability nor manual editability were real requirements. The JSON design spawned ~15 helpers (`rebuildControls`, `LoadAllFn`, `applyNode`, `serializeNode`, `cleanupTmpLeafCb_`, and more), which was the system signalling the design was too elaborate for the job. It forced a Scheduler reorder (3→5 phases) that bred secondary bugs (a duplicate-children bug, a MAC→deviceName guard, multiple "device shows nothing" failures), and needed five defensive null guards that masked an allocate-new-before-free fragmentation invariant. | ||
|
|
||
| ## Decision | ||
|
|
||
| Persist POD module state with a single `memcpy(file, this + sizeof(MoonModule), classSize - sizeof(MoonModule))`, loading it back before any module's `setup()` / `onBuildControls()` by memcpy into member memory directly. Plan-10 took this path and shipped. | ||
|
|
||
| ## Consequences | ||
|
|
||
| - Save and restore are one line each; no serializer/deserializer helper sprawl. | ||
| - Loading before `setup()` means no Scheduler phase reorder and none of its secondary bugs. | ||
| - POD-only: state is a flat memory image, so there is no schema-versioned migration across a struct-layout change (a future need would be its own decision, not a reason to pay for JSON now). | ||
| - The lesson that generalised: question a format premise before building to it; suspicious helper proliferation is a design smell; fix an invariant, do not paper it with per-call-site guards. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| # 2. Adaptive allocation with a degradation cascade | ||
|
|
||
| Status: Accepted | ||
|
|
||
| ## Context | ||
|
|
||
| The light pipeline runs on devices from a no-PSRAM ESP32 (~180 KB free internal heap) to a PSRAM-rich P4. A fixed buffer scheme either wastes memory on small installs or fails to fit large ones. The pipeline has intermediate buffers (mapping LUT, driver output buffer) that a 1:1 unshuffled layout does not need at all. | ||
|
|
||
| ## Decision | ||
|
|
||
| Allocate intermediate buffers on demand, only when the pipeline actually needs them, and degrade under memory pressure rather than fail: | ||
|
|
||
| - The **mapping LUT** is built only when modifiers exist and the layout is not a plain grid and heap remains after reserving `HEAP_RESERVE` (32 KB) for stack/HTTP/WiFi. | ||
| - The **driver output buffer** is built only when a LUT is actually allocated. | ||
| - When memory is insufficient, degrade in order: full pipeline → skip the output buffer (map inline) → skip the LUT (forced 1:1) → reduce layer dimensions (halve to a floor of 8×8). | ||
|
|
||
| Each level is observable (`degraded()`, `lutSkipped()`, `outputBufferSkipped()`). Every allocation is predict-then-measure: predict from dimensions + channels + modifiers, compare the heap delta, and flag >5% variance as a leak. | ||
|
|
||
| ## Consequences | ||
|
|
||
| - A 1:1 unshuffled layout allocates zero intermediate buffers; ArtNet reads the layer buffer directly. Maximum LED count at minimum memory. | ||
| - The device stays running under pressure (degraded is acceptable, crashed is not), instead of failing an allocation outright. | ||
| - The mechanism and buffer-type detail live in [architecture.md § Memory strategy](../architecture.md#memory-strategy); this ADR records the decision to make allocation adaptive rather than fixed. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| # 3. The layer buffer persists frame-to-frame | ||
|
|
||
| Status: Accepted | ||
|
|
||
| ## Context | ||
|
|
||
| An early design cleared the render buffer before every effect frame, on the reasoning "the buffer is the effect's to fill, every time." This silently broke every persistence effect: a scroll reads the prior column and shifts it (reading a wiped buffer, only the fresh pixel survived); a trail calls `fadeToBlackBy` to decay the previous frame (fading zeroes never forms a trail); Game-of-Life reads its prior cell state (gone). The symptom that surfaced it: FreqMatrix lit only one row, and ~13 effects' `fade` controls did nothing. | ||
|
|
||
| ## Decision | ||
|
|
||
| The render buffer is **not** cleared each frame. `Layer::loop()` leaves the previous frame's pixels in place, zeroed once on allocation/resize, then persistent, matching the FastLED / WLED / MoonLight convention (their `leds[]` / segment / VirtualLayer buffers all persist). Each effect owns its background inside its own `loop()`: a full-grid effect overwrites every pixel, a trail calls `fadeToBlackBy`, a sparse effect that wants a clean frame calls `draw::fill` itself. There is no per-effect "persist" flag (a flag would be bespoke). `fadeToBlackBy` is a Layer operation collected once per frame: effects register an amount, the Layer keeps the MIN across them and applies one pass at the next frame's start. | ||
|
|
||
| ## Consequences | ||
|
|
||
| - Persistence effects (scroll, trail, Game-of-Life) work; multiple effects on one layer deliberately interact through the shared persistent buffer. | ||
| - N fading effects cost one buffer pass, not N, and never fade each other's fresh pixels. | ||
| - A state-advancing effect (Game-of-Life) separates when the simulation steps (gated on `bpm`) from when it paints (frame rate); its state lives in the persistent buffer. `unit_Layer_persistence` and `unit_GameOfLifeEffect` pin it. | ||
|
Comment on lines
+1
to
+17
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Rewrite this ADR in present tense. The context text uses retrospective phrasing ("cleared", "broke", "surfaced it"), which conflicts with the repo rule for markdown docs outside As per coding guidelines, 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Rewrite this ADR in present tense.
The context/decision prose reads like a changelog ("attempted", "was fully abandoned", "took this path and shipped"), which violates the repo rule for markdown docs outside
docs/backlog/anddocs/history/. Keep the same decision content, but phrase it as current guidance.As per coding guidelines,
**/*.h,hpp,cpp,cc,mm,m,md: write code, comments, and documentation in the present tense only; avoid changelog-style or absence-narration phrasing, except in docs/backlog/ and docs/history/.🤖 Prompt for AI Agents
Source: Coding guidelines