Lighting the stairs with two lidar sensors, physics and Rust

Disclosure: this post was written with the help of AI — drafts and especially the WASM parts came from GLM 5.3 Flash, Space Bunny Alpha and GPT 6 Luna, and Claude Sonnet 5.5, all inside opencode. AI helped build the firmware itself, too, not just this text. Yannick reviewed and edited the result before publishing.

I wanted nice lights on my stairs that I can control, so I built them: a strip of addressable LEDs along the stairs, driven by ESPHome, with the logic in YAML lambdas. ESPHome, in case you haven’t met it, is a way to get DIY sensors and lights into Home Assistant without writing firmware — you describe everything in YAML, Home Assistant picks it up automatically, and small pieces of logic live as YAML lambdas inside that YAML.

It never worked properly. The occupancy sensors hung, as I noticed while building the setup. About once a day, maybe more often, one would stop answering, and it never came back by itself, so I only found out when I walked up the stairs and the light stayed off. Then I power cycled it by hand. I tried various things in ESPHome to make it work, and nothing did. For a light on a staircase, at night, that was unacceptable.

I had heard of Rust on microcontrollers before; I just hadn’t done anything with it. How the rewrite would happen, I had not decided yet — I am a Rust developer, so Rust was the likely answer anyway, but I had never used it on embedded. Then I went to RustWeek 2026 in Utrecht, with the sensor problem still unsolved. Espressif’s stand had Rust demos running on ESP32 chips and people talking about esp-hal, and that was the push to actually try it. So the rewrite had one hard requirement: I want to be able to recover a hung sensor in software.

So it is now no_std Rust firmware: two ESP32-C6 boards, esp-hal and embassy async actors. One board (unten — downstairs) owns the LEDs and the bottom sensor; the other (oben — upstairs) watches the top landing and reports in over MQTT. The strip is a 12 V WS2815 with 398 LEDs in three physical segments (143, 130 and 125), driven by the C6’s RMT peripheral at WS2812B timing — 398 × 24 bits ≈ 12 ms of wire time per 30 ms frame tick, a number that will matter later.

The piece that made that recovery possible is the sensor driver. I ported a C VL53L0X driver to Rust with AI help — AI helped all over this project, not just there — and owning the driver is what let me write the recovery myself instead of power-cycling by hand. The sensors that used to need a power cycle now recover on their own.

Not PIR: two little lidars

Most stair lights use PIR sensors, which tell you “something moved somewhere nearby”. I used two VL53L0X time-of-flight sensors instead, one per landing: they measure actual distance, so occupancy is simply “anything closer than 750 mm”, polled every 45 ms over I2C.

They can still hang and stop answering. I never found out why. The recovery is a workaround, not a fix, but it works very well. The driver counts missed reads, and after five in a row it declares the sensor hung: it pulls that sensor’s XSHUT line low for 10 ms and back up, which resets just the sensor while the board keeps running, and then reconfigures it over I2C. A failed recovery is retried five seconds later. Home Assistant gets a recover command and a seconds-since-last-recovery reading, so I can see how often any of this actually fires. The recovery is very fast. I rarely notice that a sensor needed it at all, though I can look it up in Home Assistant: by my estimate it now fires around five times a day.

Debouncing lives on the sensing side: each landing reports a settled occupied/unoccupied state — on instantly, clear only after a 2-second deadline without fresh evidence. The upstairs sensor sits on the second board, which publishes its debounced state over MQTT, so both landings are just occupancy topics to the light controller.

Direction is which end triggered first

This is the default logic, and it is deliberately boring. The whole automation is one actor with a small state machine: bottom zone goes occupied while the light is off means someone is walking up; top zone first means someone is walking down. Either way the light plays the stream effect towards the other end — a little train of light running ahead of you. Three transitions carry the logic:

I use a power rail when the LEDs are off (well and to turn them off as well) - 398 of these LEDs draw an awful lot of standby power, so the rail gets cut between shows which saves electricity.

There is also an effect roulette: 90 % of walks play the default directional stream, the rest roll a random effect from a direction-aware list. Random-color mode draws its saturation from a Beta(4, 1) distribution — usually vibrant, occasionally washed out.

And one more piece of the real behaviour: when the same landing triggers again while the light is already on — a second person entering the stairs — the automation simply turns the light on again. In random-color mode that comes with a fresh colour and effect. If it randomly chose the default effect again, the LED task answers a colour change on the running stream by appending a second train. Two little streams can end up chasing each other up the stairs.

The demo below is the real state machine, minus the effect roulette:

Loading interactive demo…

The automation state machine from the firmware, stepped at the same 30 ms tick. Trigger a landing to start a walk and watch the stream run ahead; trigger the same landing again while the light is on to add a second stream; trigger the other end to "arrive" — or watch the 10 s auto-off countdown in the status line.

The effects engine

A transmitter task renders one frame per 30 ms tick and clocks it out through RMT. It runs in an interrupt executor, so its stack locals live on whatever stack the CPU was on when the software interrupt hit — including 2–4 KB driver stacks. The first version of the per-effect scratch state was a stack enum with multi-kilobyte variants; under load it overran exactly those stacks — wild-pointer crashes in the WiFi driver, multi-minute task freezes. The fix was structural: all per-effect state now lives in a single static EffectScratch, resets happen in place, and renders may not build per-LED locals larger than ~512 bytes. Embedded Rust does not so much have a heap as it has opinions about where your bytes live.

Brightness goes through a 256-entry gamma table, because LED PWM steps are perceptually nowhere near linear.

The frame budget is tight from both ends. The RMT transfer eats ~12 ms of the 30 ms tick, and the ESP32-C6 is a RISC-V core without a hardware FPU, so every f32 operation is emulated in software. So the shared math helpers use a Bhaskara I sine approximation (~0.16 % max error, imperceptible on 8-bit LED brightness), Kuramoto folds its phase differences with a single conditional instead of a modulo, and whatever the heavy effects leave of their frame is yielded, so MQTT and WiFi always get a guaranteed slice. I didn’t want my Firmware to become unresponsive.

The physics shelf

Several effects are straight ports of physics toys — try them below:

Loading interactive demo…

The real effect code from the firmware, running on a 398-LED virtual strip. Pick a color, then switch effects: aurora, Kuramoto synchronization, a Nagel-Schreckenberg traffic jam, or confetti.

Aurora borealis is a traveling color curtain: a 1-D wave of hue drifting along the strip, with smoothed random jitter — a new target every ~600 ms, eased towards — so the curtain wanders instead of marching.

Kuramoto synchronization gives every LED a phase oscillator with its own natural frequency, coupled to its ring neighbors. Hue maps the phase; brightness maps local coherence, a sliding-window order parameter over ±8 neighbors. The strip starts as dim, mottled rainbow chaos, grows coherent domains that drift and merge, and finally locks into one sweeping color band — the Kuramoto model, with your staircase as the lattice.

Nagel-Schreckenberg traffic jam is the classic cellular-automaton traffic model on 398 cells: 80 cars by default, max speed 5, and a random braking probability — enough for stop-and-go jams to emerge out of nowhere and crawl along the banister.

Confetti keeps a strict FIFO of lit pixels per segment: random rolls spawn a vibrant pixel in a random dark slot, other rolls switch off the pixel that has glowed the longest, and the palette is derived from your picked color — evenly spaced hues around the wheel.

That is four of the twenty effects the firmware carries; they were all ported into the demo the same way. The rest are a marching-dot and glide-wave pulse, a tide of cyan with ripples, a candle-warm flicker with sparkles, two small creatures scurrying around with fading tails, bricks dropping into a growing wall, and eight bands of ember drift. Two more are worth a second look:

Potts domains puts one of four color states on every LED and flips a quarter of them per frame, accepting a move downhill and uphill with a Boltzmann probability. It starts as speckle and spends minutes coarsening into domains — the same model that shows up in magnetic materials, run at temperature 1.4.

Domain drift is the only effect the firmware does not step on its 30 ms tick: it measures real elapsed time, because twelve soft gradient blobs wander as Ornstein-Uhlenbeck processes and have to agree on how to overlap. Each pair of overlapping blobs negotiates crossfade, shimmer, fusion or pulse from shuffled preference ranks. The demo steps it on the tick like everything else. On the stairs, whether it keeps up depends on how many domains are up — with enough of them a frame can miss its window by a little, which is exactly why it runs on real time. In practice you hardly notice.

Home Assistant

Everything is wired into Home Assistant via MQTT discovery: one light entity with the full effect list, sliders for the tunables (stream speed, traffic speed/density/braking, confetti rates, the roulette’s 90 %), selects for the modes, and each landing’s occupancy as a binary sensor.

The demos above are not re-implementations. They are the firmware’s own effect and automation crates, compiled to WebAssembly, with a shim standing in for the hardware RNG. What the stairs do at night is exactly what you played with here.