DEV Community

prodbymarcu
prodbymarcu

Posted on

Your smart bulb ate the color and lied about it

cover

Two automations in my house went dead on the same night, one on the lamps, one on the TV light trigger, and every layer between me and the fixture answered success. The bulb took a color write, answered 200, then read back [255,163,87], the warm white it had been sitting in for a week. The light trigger never armed because the state I actually arrived from, paused, was never in the list I wrote.

The claim those two failures back reaches past Home Assistant. A status code measures the request you sent, an event filter measures your guess about what the device does, and neither one measures what the device did. Both bugs stayed invisible until I read the state back and dumped the transition trace, and both fixes were one line each.

The write that went nowhere

The two lamps are Leedarson RGBTW bulbs behind a Matter pairing, and on 2026-10-04 around 00:16Z they got re-paired, the old entities died at 00:16:58Z and two new ones were born at 00:15:27Z. Same fixtures, new identity, and the scene file kept pointing at the old ones.

Then every rgb_color write and every hs_color write got eaten by the bulbs in total silence, readback stayed warm white at [255,163,87] after each attempt, while brightness passed through untouched. Only xy_color landed. So the scene entries went from rgb to xy coordinates, left lamp [0.0305, 0.1364], right lamp [0.25, 0.1], and the readback after the change came back [37,157,255] at brightness 128 on the left, [112,0,255] at 128 on the right.

The half-landed write narrows the diagnosis, brightness moved and color didn't, so the integration was fine, the wiring was fine, the bulb understood the request, and one field of that request went out the window. A 200 response cannot separate those cases and nothing else in the chain can either.

The gamut, probed

I didn't pick those coordinates once. [0.33, 0.13] came back too magenta, [0.2, 0.08] came back too blue, and [0.25, 0.1] is the best purple that bulb's gamut can do. Three probes, each one read back before the next, and the color that shipped is a color the device can physically produce.

That's the same rule as the write above, applied one layer down. You can ask a Leedarson for a violet it has no diode for, the ask succeeds, the result is whatever the gamut edge happens to be, and the only way to know which edge you landed on is to look.

The trigger that never armed

The automation fires when the player reaches playing, with for: 5s and a from-list of [off, idle, unavailable, unknown, buffering]. On the broken night the launch path walked off to idle to paused to playing inside about 130 milliseconds, so the transition into playing came from paused, and paused was never in the list. The trigger never armed, the scene never ran, the room stayed warm white.

Bars showing 0.13 seconds of state crossing against the 5 seconds of held state the trigger wants

The night before, 2026-10-04 at 00:11:53Z, it fired, because that session went idle to playing. Same device, same trigger, one path that matched and one that didn't, and the difference between them only shows up in a trace nobody prints by default.

The fix was the word paused, added to the from-list, backed up as automations.yaml.bak-moviemode-20261005-2009. The live test was media_pause then media_play on the player, which moved last_triggered to 2026-10-06T00:13:31.992930+00:00, end to end pass, and I applied the scene manually that night since the TV was already playing by the time I found the cause.

What the plumbing around it reports

The reload calls, POST /api/services/automation/reload and POST /api/services/scene/reload, both returned an empty-body 200, which means the config reloaded and says nothing about whether any lamp changed. Two more traps sit in that same setup and both are cheap to state.

for: 5s needs five seconds of held playing, so a player flapping between idle and paused keeps resetting the timer and nothing arms. And the TV carries two entities, the cast-backed one reports playing, paused and content type, the native-app one only ever reports on and off, so a trigger written against the second entity is dead forever and quiet forever. There's also no movie-mode-off automation in that setup, by original design, the lights keep the movie colors until the next all-lights event.

The guards that make this class of bug visible

After any device write, read the state back and compare it against the requested value, a bulb answering 200 while reporting the previous color is one line of comparison and that line turns a silent no-op into a named failure. Build the from-list from a recorded transition trace, the docs list what a spec allows and the app's real path is what the device sends, those two sets are not the same size. Keep the hold window in mind while you write for:, because a state that crosses in 130 milliseconds can never satisfy a 5 second hold, and a trigger that needs both a fast crossing and a long hold is asking for something the device won't do.

Both fixes cost one line each, and getting there cost reading back every write and dumping one trace. The device layer and the automation layer failed in different ways, the bulb dropped a field and the trigger dropped an event, and the one thing they share is that neither reported a problem.

So for anyone with automations wired to real devices, when did you last read back a color write, and does your trigger list contain every state your player actually emits?

Top comments (0)