An ESPHome light: platform with no pin. Your browser is the strip.
Point your effects at it instead of neopixelbus or esp32_rmt_led_strip, open
http://<ip>:8083/, and the strip appears in a tab — with a panel that names
whatever goes wrong. ESP8266 and ESP32. No LEDs, no data pin, no solder.
external_components:
- source: github://kaboom748/virtual_led_strip
components: [virtual_led_strip]
refresh: 1d
light:
- platform: virtual_led_strip
id: strip
name: "Shelf"
num_leds: 188
port: 8083
max_refresh_rate: 32ms
effects:
- addressable_rainbow:
- addressable_scan:num_leds, port, is_rgbw. There is no chipset:, no variant:, no
rgb_order: — those describe a wire we do not have. ESPColorView hands out
pointers into our own buffer, so the memory layout is ours; an effect writes
Color(255, 0, 0) and the view puts it where it likes. Byte order on the wire is
a property of the bus.
is_rgbw earns its place because it changes LightTraits, and therefore what
effects can express. Rehearsing an SK6812 RGBW is not rehearsing a WS2812 RGB.
Replace the light: block. Effects, automations, lambdas, id, name and
num_leds all carry over:
light:
- platform: neopixelbus # ESP8266
variant: WS2812
pin: GPIO2
num_leds: 188Wiring advice worth more than any option: use GPIO1, GPIO2 or GPIO3 on
ESP8266. neopixelbus picks its method from the pin — DMA on GPIO3, UART on
GPIO1/GPIO2, and bit-bang on anything else. Bit-bang means 188 × 30 µs =
5.6 ms of loop with interrupts off, every frame. The other three cost nothing.
On ESP32 it is always I2S or RMT — never blocking.
Same semantics as esp32_rmt_led_strip, and the same trap. Writes leave from
LightState::loop(), one per pass, so the loop interval quantizes it:
| you write | you actually get |
|---|---|
16ms |
62.5 fps |
32ms |
31.2 fps |
33ms |
20.9 fps — 30 % below what you asked |
48ms |
20.9 fps |
Only multiples of the 16 ms loop land where you meant. dump_config() prints the
resolved rate so you are not left guessing.
example/bench-mire-100-esp8266.yaml and -esp32.yaml carry a test pattern
worth more than its size.
With 10 LEDs you are forced to code in time: each LED gets its rhythm and you wait for the sequence to unfold. With 100 you code in space — ten decades, ten diagnostics running in parallel. One image is a complete verdict, and there is nothing to wait for.
| decade | LEDs | what it proves |
|---|---|---|
| d0-d2 | 00-29 | R, G, B ramps 25..255 — per-channel linearity, missing steps |
| d3 | 30-39 | white ramp — do the three channels track together |
| d4 | 40-49 | levels 1..10 of 255 — the quantization floor |
| d5 | 50-59 | checkerboard at fs/2 — signal integrity, dropped frames |
| d6 | 60-69 | pure R G B — channel order |
| d7 | 70-79 | hue wheel — continuity, banding |
| d8 | 80-89 | pure Y C M — two-channel mixing |
| d9 | 90-99 | full white — reference, end of strip |
You read a fault as decade + rank: "third LED of the blue ramp is dark" is LED 22, blue channel. You get the index without counting the strip.
A white cursor walks one LED per frame, preceded by a black hole that shows the direction. A dead LED is a fixed gap. One pass is 100 frames — time it and you have your real frame rate without touching the log. 3.2 s is 31.25 fps; 4.8 s is 20.8 fps, which means you landed on the 16 ms quantum described above.
gamma_correct: 0 is not optional on a bench: with the 2.8 default, d4 is
entirely black and your ramps stop being ramps. Set it back to 2.8 to see the
crushing — decade d4 vanishing is the same finding as the cliff below effect 28.
Decoded off the wire, d0 reads 25, 51, 76, 102, 127, 153, 178, 204, 229, 255
and d4 reads exactly 1..10. The encoder picks DELTA and spends 12 B/frame:
only the cursor moves.
The one fault this pattern cannot see is voltage drop — it needs all 100 LEDs at
full simultaneously, which is incompatible with a test pattern. That is what
the second effect, MUR, is for: a white ramp to 100 %. Watch the far end.
White turning yellow then orange is the 5 V collapsing; blue (Vf ~3.2 V) dies
before red (Vf ~2.0 V). The hue drift is the voltmeter. Note the percentage
where it starts: that is your supply limit, in a number.
MUR only means anything on real hardware. This platform has no wire and no
supply, and cannot simulate it.
The heartbeat (10/s, always, playing or not) is the metronome. Every beat is stamped on the ESP, before the network can touch it, which lets the panel separate the only two things that can go wrong:
| Line | Means |
|---|---|
Beat rate / target |
Must read 10.0 / 10.0. Any sag is real. |
ESP loop worst |
Largest gap between beats as the ESP lived it. |
Link jitter |
Arrival spread minus the ESP's own period. The network, and nothing else. |
Dropped frames |
A frame was superseded before it left. Your effect outruns the link. |
Encoding / RAW / RLE / DELTA |
The three candidates and which won, per frame. |
Late frames |
Play-out shorter than the link jitter. Raise the slider; Margin min says by how much. |
Measured on ESPHome's own effects, on the wire:
| effect | 60 LEDs | 300 LEDs | 5000 LEDs |
|---|---|---|---|
| Scan | 12 B | 12 B | 12 B |
| Twinkle | 10 B | 13 B | 12 B |
| Fireworks | 6 B | 5 B | 5 B |
| Rainbow | 180 B | 900 B | 15000 B |
Sparse effects are O(1) — delta sends only what moved, and what moves does not grow with the strip. Only a full-length gradient is O(N). At 188 LEDs a rainbow is 564 B/frame: 33 kB/s at 60 fps (over an ESP8266's practical ~32 kB/s), 16.5 kB/s at 32ms (half the budget).
The encoder picks the shortest per frame — and that is not a flourish. On a rainbow, RLE (1199 B) and delta (955 B) both cost more than raw (900 B); on a twinkle, delta collapses to 13 B. No single encoding wins.
gamma_correct defaults to 2.8 on every ESPHome light, and
ESPColorView::set_red() applies it into our buffer. So what we stream is not
colour — it is PWM duty, which is linear light. Your screen wants sRGB.
| effect writes | buffer carries | LED emits | rgb(buffer) emits |
|---|---|---|---|
| 128 | 37 | 14.5 % | 1.43 % — 10× too dark |
| 64 | 5 | 2.0 % | 0.02 % — 112× |
| 32 | 1 | 0.4 % | 0.00 % — 772× |
A naive render erases the bottom half of your range. This page converts duty → linear → sRGB with the real piecewise curve (not a 2.2 power, whose linear segment matters exactly where your fade lives).
Which means the page shows you something you would otherwise only find on the shelf: below effect 28, the LED is simply off. Gamma 2.8 quantizes 0–27 to buffer 0, and the last visible step is a jump of 21 sRGB levels. Your fade to black does not fade — it snaps.
- A wrong
rgb_order. No wire, no order. Your rainbow will be right here and blue-green on the shelf. - Interrupt glitches on a bit-banged WS2812. No wire, no malformed bit.
- Above 60 Hz the strip fuses — that is your monitor, and it is also your eye.
- Rows, elbows and serpentine are assertions about your soldering. The ESP
knows
num_ledsand nothing else. The browser lets you toggle them because picking the one where your comet snakes correctly is how you decide what to solder.
setup_priority::HARDWARE, notAFTER_WIFI.LightStatesits atHARDWARE - 1.0f, so it sets up after the output and immediately callssetup_state(),init_internal()on every effect, then restores saved state with acall.perform()— all of which reach into our buffer. AtAFTER_WIFI(200) that buffer was still empty 599 priority levels earlier: a pointer into nothing, a crash, a boot loop, safe mode. Both real drivers are atHARDWAREfor exactly this reason. Thehostbench cannot see it — it has no flash to restore from, so nothing touches the buffer early.- The listen socket opens on the first
loop(), not insetup(). The buffer must exist atHARDWARE; the socket wants wifi'ssetup()done (apiis atAFTER_WIFIfor that reason). You cannot be in both places, so the memory goes whereLightStateneeds it and the socket waits forloop(), which follows everysetup(). - No undefined behaviour.
AddressableLightis a public API and this component owns its buffer. Nothing is reached by casting to a class the object is not an instance of. - One socket path. ESPHome's
socketcovers ESP8266 throughlwip_tcp. Note thatListenSocketandSocketare distinct types there:socket_ip()cannotlisten(). - Drain the request, do not read once.
socket.hstates the contract, and the cost of ignoring it is not a slow read: closing a socket with received bytes still unread makes TCP send an RST instead of a FIN, and the RST discards the send buffer. The page arrived truncated mid-<script>, nothing ran, and the panel showed its hard-coded HTML defaults as if all were well. Invisible on loopback — everything is delivered before theclose(). It takes a real browser. read() == 0is EOF.-1/EWOULDBLOCKis "wait". They are not the same, andif (len <= 0) return;conflates them. Chrome opens speculative sockets it never uses; treated as "would block", the first one holds the pending slot forever,accept()stops accepting,fetch('/events')never lands, and the strip stays black until reboot.lwip_raw_tcp_impl.cppis explicit about which is which.- The panel's HTML defaults declare failure, in red, and the script contradicts them on its first line. A panel that shows a plausible state while nothing runs is worse than an empty one.
- A short write is not a failure. At 564 B every 32 ms an ESP8266's send buffer fills in normal service. The frame is kept and retried; nothing is thrown away mid-stream.
prev_only advances on a frame committed to the wire. Otherwise the client would rebuild deltas against a frame it never saw.- The heartbeat advances its phase (
last_beat_ += 100, never= now). A metronome that cannot reach its own target measures nothing. - The heartbeat runs before the frame. Put
promote_()first and the frame refills the send buffer every pass; the beat never finds it free, andBeat ratecollapses precisely when you are reading the panel to find out why. The data starves the instrument. - No
on/offbranch in the renderer. Ambient and emission add; zero is not a special case. Two separate formulas do not meet at zero, and a comet's tail rendered darker than an unlit strip — a darkness invented by the drawing, present nowhere in the buffer. It is checked, not intended: monotone across all 256 levels. Displaycounts requestAnimationFrame;Rendercounts frames painted. Conflating them made the panel blame the ESP for judder the renderer could have caused. Two gradients per lit dome is not free.ESP loop worstis a 30 s window, with the lifetime max on its own line. A max that never decays welds a boot-time stall to the panel forever, and you cannot tell an old freeze from a live one.- The browser integrates over the display frame, in linear. Sampling the newest frame drops one at every beat between 16 ms and 16.7 ms — a 2.5 Hz stutter we would be adding. And averaging sRGB bytes would darken a 50/50 strobe from 188 to 128.
GPLv3 for the C++, MIT for the Python — ESPHome's own model. GitHub will show
NOASSERTION; so does esphome/esphome.