Skip to content

Commit 0a9330e

Browse files
committed
Feed every column from the floor, and move the maths into the YAML
Two changes that turn out to be one. The data line now enters at the BOTTOM of each column and runs upward, so the thirteen feeds sit in a line along the floor of the rack where a skirting board hides them instead of thirteen wires coming over the top. That inverts the coordinate map: pixel 0 is a column's lowest bin - row M on rack 1, row G on rack 2 - and the row letters count backwards up the strand. Anchoring on `first + bins - 1` rather than on `first` is what keeps the U's short columns right: columns 5-9 carry rows I-M, and their row M is pixel 0 exactly like every other column's, because every column reaches the same floor. The `row < first` guard earns its place here in a way it did not before. Counting down from the bottom, a negative row makes the offset LARGER, so without it an out-of-range request returns a plausible index off the end of the strand rather than -1. And the headers are gone. rack_geometry.h and the two per-rack headers were three files a user had to copy beside a config and could forget; the same C++ now lives in `substitutions:` in the node that uses it, so flashing a rack is copying one file. Each strand's lambda is its column number and three ${...} references - thirteen copies of an integer and one copy of the arithmetic, which is what the header was for. That would normally cost the test suite, since an inline YAML lambda is only testable by flashing a board. It does not: tests/cpp/yaml_lambda.py lifts those blocks straight out of the configuration and test_yaml_lambdas.cpp #includes them inside wrappers that supply the names a lambda has in scope. The three #includes in `paint()` are the strand's lambda, in the order the YAML has them, so the text under test is the text that ships and a test cannot pass against a copy that has drifted. FakeLight indexes through vector::at, which turns the bug the whole arrangement exists to catch - a short column indexed as a tall one - into a thrown exception rather than a flickering column elsewhere on the rack. Written test-first: the suite went red on ten cases, all direction (`CHECK( 54 == 0 )` - row G sitting at the far end of its strand instead of at the feed), and green on the one-line change to bin_first_led. Verified: 490 Python tests, 35 C++ cases / 1223 assertions, ruff clean, and both nodes compile to firmware - RAM unchanged at 18.1% and 17.0%, so the geometry is still entirely in flash. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011au3uaZneMFTbnssPSwpLt
1 parent 56f7a0e commit 0a9330e

15 files changed

Lines changed: 1289 additions & 964 deletions

.github/workflows/ci.yml

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,14 +43,19 @@ jobs:
4343
- run: python -m pytest -q
4444

4545
cpp:
46-
name: C++ geometry tests
46+
name: C++ (the maths inside the ESPHome YAML)
4747
runs-on: ubuntu-latest
4848
steps:
4949
- uses: actions/checkout@v4
5050

51-
# examples/esphome/rack_geometry.h is compiled twice: once by ESPHome
52-
# into the firmware, and once here by doctest. The second is the only
53-
# one that can fail fast, so it is the one CI runs.
51+
- uses: actions/setup-python@v5
52+
with:
53+
python-version: "3.13"
54+
55+
# The build's first step lifts the C++ out of examples/esphome/*.yaml,
56+
# so this needs a YAML parser before it needs a compiler.
57+
- run: pip install "PyYAML>=6.0"
58+
5459
- name: Configure
5560
run: cmake -S tests/cpp -B build/cpp -DCMAKE_BUILD_TYPE=Debug
5661

README.md

Lines changed: 63 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -482,10 +482,7 @@ in the room rather than on a screen. `examples/` holds a working set of configur
482482

483483
| File | What it is |
484484
|---|---|
485-
| `examples/esphome/rack_geometry.h` | The bin-to-pixel arithmetic, once — shared by both nodes and by the C++ tests |
486-
| `examples/esphome/winerack1_geometry.h` | Rack 1's shape: the column table that makes it a U |
487-
| `examples/esphome/winerack2_geometry.h` | Rack 2's shape: four numbers, because it is a rectangle |
488-
| `examples/esphome/winerack1led.yaml` | The U-shaped rack — 129 bins in a 13 × 13 envelope, one WS2815 strand per bin column |
485+
| `examples/esphome/winerack1led.yaml` | The U-shaped rack — 129 bins in a 13 × 13 envelope, one WS2815 strand per bin column. One file, nothing beside it |
489486
| `examples/esphome/winerack2led.yaml` | The second rack — a full 7 × 7, its own ESP32, same design at a smaller size |
490487
| `examples/home_assistant/wine_rack_leds.yaml` | The Home Assistant package that turns this integration's inventory into what both nodes paint |
491488

@@ -552,48 +549,85 @@ rebuild does not quietly swallow bottles.
552549

553550
### Changing the shape of a rack
554551

555-
Neither node's YAML knows how big its rack is. The shape lives in one header per rack, and the
556-
arithmetic that turns a bin into a run of pixels lives in
557-
[`rack_geometry.h`](examples/esphome/rack_geometry.h), which both nodes include:
552+
Neither node's YAML knows how big its rack is up front, and neither needs a file
553+
beside it. The rack's shape and the arithmetic that turns a bin into a run of
554+
pixels are both `substitutions:` at the top of the node's own configuration:
558555

559-
```cpp
560-
constexpr rack::Column kColumns[] = {
561-
{0, 13}, // column 1 rows A-M the U's left arm
562-
// ...
563-
{8, 5}, // column 5 rows I-M the opening: no bins above row I
564-
};
565-
constexpr rack::Geometry kRack{kColumns, kEnvelopeColumns, kLedsPerBin, kBinPitch};
556+
```yaml
557+
# {first row, bins} per bin column. Row A is 0, row M is 12.
558+
rack_table: >-
559+
{0, 13}, {0, 13}, {0, 13}, {0, 13},
560+
{8, 5}, {8, 5}, {8, 5}, {8, 5}, {8, 5},
561+
{0, 13}, {0, 13}, {0, 13}, {0, 13}
562+
563+
bin_first_led: |-
564+
auto bin_first_led = [](int row, int first, int bins) -> int {
565+
if (bins <= 0 || row < first || row >= first + bins) return -1;
566+
return (first + bins - 1 - row) * ${bin_pitch};
567+
};
566568
```
567569

568-
A column is a first row and a count, so a rack with an opening in it is a table rather than a
569-
special case, and a short column's first bin is that strand's first pixel — column 5 starts at
570-
row I and at pixel 0 at the same time. To rebuild a rack, edit its table and its `num_leds:`; the
571-
`static_assert`s in the header stop the build if the two stop agreeing, and
572-
`tests/test_rack_leds_example.py` fails if the YAML drifts from the header.
570+
A column is a first row and a count, so a rack with an opening in it is a table
571+
rather than a special case. Each strand's lambda is its column number and three
572+
`${...}` references — thirteen copies of an integer and one copy of the maths.
573+
To rebuild a rack, edit `rack_table` and the matching `num_leds:`;
574+
`tests/test_rack_leds_example.py` fails if the two stop agreeing.
575+
576+
**Data enters at the floor.** Pixel 0 of every column is that column's *lowest*
577+
bin — row M on rack 1, row G on rack 2 — and the row letters count backwards up
578+
the strand. That puts all thirteen feeds in a line along the bottom of the rack
579+
where a skirting board hides them, instead of thirteen wires coming over the
580+
top. To wire from the top instead, change `(first + bins - 1 - row)` back to
581+
`(row - first)`; it is one line, in one place, and the tests will tell you what
582+
it did.
583+
584+
### Testing C++ that lives inside a YAML string
585+
586+
An `addressable_lambda` body is ordinary C++ that happens to be written inside a
587+
string, which normally makes it testable only by flashing a board.
588+
[`tests/cpp`](tests/cpp) does not flash anything and does not keep a copy:
589+
`yaml_lambda.py` lifts those blocks straight out of the configuration, and
590+
[`test_yaml_lambdas.cpp`](tests/cpp/test_yaml_lambdas.cpp) `#include`s them
591+
inside wrappers that supply the same names a lambda has in scope:
573592

574-
`Geometry` is `constexpr` throughout, so a rack's shape is computed by the compiler and stored in
575-
flash. Moving all of it out of the twenty inline lambdas it used to live in cost **no RAM at all**
576-
— 18.1% and 17.0% before and after, 1.7 KB more flash — which is the answer to whether geometry
577-
belongs in compile-time substitutions or in runtime Home Assistant entities: at this size,
578-
compile-time is free and cannot drift.
593+
```cpp
594+
void paint(FakeLight &it, int kCol) {
595+
#include "rack1/column_extent.inc"
596+
#include "rack1/bin_first_led.inc"
597+
#include "rack1/paint_column.inc"
598+
}
599+
```
579600

580-
The header is plain C++ with no ESPHome in it, which is what lets
581-
[`tests/cpp`](tests/cpp) compile it under doctest and check the arithmetic on a workstation
582-
instead of on a cellar wall:
601+
Those three lines are the strand's lambda, in the order the YAML has them. The
602+
text under test is the text that ships, so a test cannot pass against a copy
603+
that has drifted. `FakeLight::operator[]` goes through `std::vector::at`, which
604+
turns the bug this whole arrangement exists to catch — a short column indexed as
605+
a tall one, writing past the end of a 42-pixel strand — into a thrown exception
606+
a test can assert on rather than a flickering column somewhere else on the rack.
583607

584608
```console
585609
$ cmake -S tests/cpp -B build/cpp && cmake --build build/cpp && ctest --test-dir build/cpp
586610
```
587611

588612
CI runs the same three commands.
589613

614+
**Why the geometry is compile-time rather than Home Assistant entities.** Every
615+
number above is folded into flash by the compiler, so a rack's shape costs zero
616+
bytes of RAM and is readable before WiFi comes up. Measured on both nodes,
617+
moving all of it out of the twenty hand-written lambdas it used to live in cost
618+
no RAM at all. Runtime entities would buy re-shaping a rack without reflashing —
619+
which happens roughly never — at the price of heap and a new failure mode where
620+
an unavailable entity leaves the rack dark.
621+
590622
### Before you build it
591623

592624
- **The endpoint is authenticated**, so Home Assistant needs a long-lived access token to read its
593625
own API. The package expects it in `secrets.yaml`.
594626
- **Power.** Rack 1 draws 3.9 A for a fully green overview and 6.3 A worst case; rack 2 adds
595-
2.4 A. One 12 V 12.5 A supply runs both if they can share a bus, and a single feed at the top of
596-
each column is enough at 0.6 A per strand. Halve it all by dropping `bin_leds` from 6 to 3.
627+
2.4 A. One 12 V 12.5 A supply runs both if they can share a bus, and a single feed at the foot of
628+
each column is enough at 0.6 A per strand — the same end the data line enters, so power and
629+
signal share one run along the bottom of the rack. Halve it all by dropping `bin_leds` from 6
630+
to 3.
597631
- **WS2815, not WS2812B.** The backup data line means one dead pixel is one dark bin rather than a
598632
dark column, which matters behind a rack you cannot get at. It costs a second rail: 12 V for the
599633
LEDs, 5 V from a buck for the ESP32 and the level shifters.

examples/README.md

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,6 @@ loaded by Home Assistant on its own — copy what you want.
55

66
| File | What it is |
77
|---|---|
8-
| [`esphome/rack_geometry.h`](esphome/rack_geometry.h) | Bin → pixels, once. No ESPHome in it, so the tests can compile it too |
9-
| [`esphome/winerack1_geometry.h`](esphome/winerack1_geometry.h) | Rack 1's shape — the column table that makes it a U |
10-
| [`esphome/winerack2_geometry.h`](esphome/winerack2_geometry.h) | Rack 2's shape — four numbers, because it is a rectangle |
118
| [`esphome/winerack1led.yaml`](esphome/winerack1led.yaml) | An ESPHome node lighting the U-shaped rack — 129 bins, one WS2815 strand per bin column |
129
| [`esphome/winerack2led.yaml`](esphome/winerack2led.yaml) | The same design for the second rack, a full 7 × 7 on its own ESP32 |
1310
| [`home_assistant/wine_rack_leds.yaml`](home_assistant/wine_rack_leds.yaml) | The Home Assistant package that turns the cellar's inventory into what both nodes paint |
@@ -17,7 +14,8 @@ package owns the wine and knows nothing about pixels. Each file's header says wh
1714
before it will work; [Lighting the rack](../README.md#lighting-the-rack) in the main README covers
1815
what it does and what to know before building the hardware.
1916

20-
The two `.h` files next to each YAML are how a node learns its rack's shape — copy them alongside
21-
the configuration, because ESPHome reads them from the same directory. Both are shape only; the
22-
arithmetic is in `rack_geometry.h`, which [`tests/cpp`](../tests/cpp) compiles under doctest so a
23-
rack can be re-cut without flashing anything to find out whether the numbers still add up.
17+
Each node is one file. Its rack's shape and the arithmetic that turns a bin into a run of pixels
18+
are `substitutions:` at the top of the configuration, so copying a node into your ESPHome
19+
directory is copying one file and there is nothing beside it to forget.
20+
[`tests/cpp`](../tests/cpp) lifts that C++ back out and compiles it under doctest, which is how a
21+
rack gets re-cut without flashing anything to find out whether the numbers still add up.

0 commit comments

Comments
 (0)