Firmware: lp-stand
ESP-IDF in C, following the conventions of esp32-ma-bell-gateway: main/app/,
main/config/, main/hardware/, main/network/, with a Makefile wrapper over
idf.py.
The split that matters
Scene validation, effect rendering, gamma and event formatting are pure C with no ESP-IDF dependency, because that is where a bug is subtle: it shows up only as the light looks wrong, or, for an event that is not valid JSON, as nothing at all. They are tested with a compiler and nothing else:
make test # needs gcc; no ESP-IDF, no hardwareThe ESP-IDF glue is thin by design and needs a device to trust.
Toolchain
# EIM-style install (what this project is developed against)
. ~/.espressif/tools/activate_idf_v6.1.sh
# or the classic layout
. $IDF_PATH/export.shThe ESP-IDF Installation Manager exposes
idf.pyas a shell function, not as something onPATH. Amakerecipe runs in a subshell that cannot see shell functions, so the Makefile resolves$IDF_PATH/tools/idf.pydirectly rather than callingidf.py. If you are wiring up new tooling, do the same.
Build and flash
idf.py set-target esp32
make build
make flash PORT=/dev/cu.usbserial-0001 # macOS
make flash PORT=/dev/ttyUSB0 # Linux
make monitor PORT=/dev/cu.usbserial-0001make menuconfig covers the stand identifier, topic prefix, strip length and
GPIO. It does not cover credentials — see
Provisioning a Stand.
Seeing what a stand is doing
Once it is off the USB cable, a stand reports its key events — boot, network,
scenes, health — to eds/log/<id>, and Deevnet IoTaaS carries them into the
tenant’s log store:
make logs # the last hour
make logs ARGS=-f # follow
make logs ARGS='--since 1d'The same events are on the LP Stand dashboard in the tenant’s Grafana
organization. To add one, call lp_event_log_with() with a dotted name and any
numbers as JSON fields; event_format.c is the pure half and has the tests.
Board requirements
A 4 MB flash part is required, not merely recommended: the partition table’s
two 1.5 MB OTA slots need 3.25 MB, and the ESP-IDF default of 2 MB fails the
build at partition-table generation. sdkconfig.defaults declares
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y for this reason.
Verified on an ESP32-D0WD-V3 rev v3.1, dual core at 240 MHz, 4 MB flash, no PSRAM. Headroom on that part:
| App image | ~933 KB in a 1.5 MB slot — 41% free |
| DRAM | ~39 KB static, ~141 KB remaining |
| IRAM | ~69% used |
The absence of PSRAM is not a constraint here, and that is the architecture paying off: because the stand renders named effects locally and never receives frames, there is no image buffer to hold. A 60-LED frame is 180 bytes and a scene is capped at 2 KB.
ESP-IDF 6.x notes
The project targets IDF 6.0+. Moving from 5.x required three changes, all of which are already in the tree:
mqttandjsonwere removed from core IDF in 6.0 and moved to the component registry. They are now managed dependencies,espressif/mqttandespressif/cjson. Note the component name iscjson, notjson.espressif/led_strip2.5.x does not compile against IDF 6.1 — its SPI backend lost a transitive heap header. The project uses^3.0.3, which is API-compatible withled_strip_out.cas written.- Nothing in the firmware’s own sources needed changing.
Failure behavior to preserve
Everything below is a deliberate choice. If you change one, change it knowingly:
- A malformed or unparseable scene is ignored, and the previous one keeps rendering.
- A scene with a different contract version is refused, not guessed at.
- An unknown effect falls back to
solid. - WiFi never gives up. An unattended stand that stops retrying is one that needs a person to walk over and power-cycle it.
- The strip lights before the network exists — a dim warm white — so an unconfigured or unreachable stand looks deliberate rather than broken.
- A frame is skipped rather than stalled if the render task cannot take the scene lock. A skipped frame is invisible; a stalled strip is not.
- Last will on
status, so the room learns a stand died rather than merely went quiet.
A healthy first boot, unprovisioned, looks like this:
I (88) boot: 5 creds WiFi data 01 02 00320000 00004000
I (587) creds: not provisioned; ssid 'DVNTM-IOT', wifi key empty, ...
I (590) strip: 60 LEDs on GPIO 18
I (593) lp-stand: rendering at 16 ms/frame on 60 LEDs
W (30792) wifi: no address yet; continuing and retrying in the background
I (30815) lp-stand: subscribed; awaiting a retained scene on eds/lightstand/lp-stand-01/sceneThe strip driver initializing and the render task starting before the network is touched is the designed order, not a coincidence.