The first version of my living room "TV mode" sent a command to every light, finished with no error, and changed nothing. Not dim. Not wrong. Nothing.
It had sent brightness: 0 to every bulb. In Home Assistant, light.turn_on at brightness 0 is a no-op, not an error. So the run was green, the log was clean, and the lights stayed exactly as they were.
That bug is a good summary of the whole project. Almost every failure in my house lighting has been a piece of logic that assumed the state of the world instead of reading it.
Where the logic actually lives
I should be honest about the split, because it isn't "no Home Assistant".
The garden room is six WLED strips, and they are driven by a Python script I own. A cron entry fires every 10 minutes and does nothing clever: it's a dumb tick. The script reads the wall clock, picks a time-of-day phase from a schedule.json, and talks straight to each strip's WLED JSON API. Home Assistant isn't in that path at all for the strips, because its WLED integration pins a host address and showed these strips unavailable for long stretches while they were demonstrably up.
The living room runs as Home Assistant scripts and automations, on purpose: no API token, no external cron to drift, survives restarts. But I drive and test it from a shell script that reads state, calls services and injects fake state changes, so I can prove a branch fires without a TV or a sunset.
Keeping the clock inside the script also sidesteps a UTC-versus-London scheduling trap that has bitten me elsewhere.
The brightness-0 bug
TV mode reads its levels from two sliders, input_number.tv_bias_brightness and tv_ambient_brightness, so tuning is a drag and not a config edit. The template was defensive, or so I thought:
brightness: "{{ states('input_number.tv_ambient_brightness') | int(70) }}"
The int(70) is a default for when the state isn't a number. Here's the trap: an input_number with no initial: doesn't come up as unknown. It falls back to its min. Both sliders read 0.0. "0.0" parses perfectly well, so the default never fired, and every light got 0.
| int(default) guards a non-numeric state. An input_number is never non-numeric. The guard could not trigger.
The fix treats 0 as "unset", which is safe because a light at brightness 0 is meaningless:
brightness: "{% set v = states('input_number.tv_ambient_brightness') | int(0) %}{{ v if v > 0 else 70 }}"
Then I set the sliders to 70 and 40 with input_number.set_value, which persists. Adding initial: looks like the obvious fix and is the wrong one: it re-applies on every restart and wipes whatever level you'd dialled in.
The TV that is never "off"
The automation was meant to fire when the LG goes from off to on, after sunset. I measured it: this TV sits at unavailable when powered down, not off. The first version would never have fired, ever.
from: [off, unavailable, unknown, standby]
Naming from: at all matters too. With only to:, a playing to paused change re-runs the scene mid-film and undoes any dimming done by hand. The off branch waits for: "00:00:30", because the TV flaps through unavailable while it wakes.
Bias light: D65, not mood lighting
The Govee H6199 behind the screen is a bias light, and bias light has a standard: D65 / 6500 K at roughly 10% of screen peak luminance (SMPTE RP 166, ITU-R BT.2035). A warm surround drags perceived on-screen whites toward blue, which is the exact artefact bias lighting exists to remove. So the strip's DreamView and music mode are switched off first. An animated strip defeats a reference surround.
The bulbs go the other way: 2700 K at about 16%, low and warm but not off. A bright screen in a black room is what causes eye strain, because the pupil keeps re-adapting.
Which is why TV mode can't reuse my whole-room script. That drives an area to one colour, and here the two groups need opposite colour temperatures.
Verified live, not assumed: the Govee came up at brightness 69, 6500 K, in color_temp mode (it genuinely honours 6500 K, not a silent remap), and the bulbs at 41 / 2700 K. The automation fired on an injected playing and correctly did nothing because the sun was up. The one link I haven't watched happen is "sun below the horizon, scene applies". The scene works by direct call and the trigger works by injection, but I haven't sat through the real thing yet.
There's also a separate sport mode, because sport is social and three hours long: ambient 85, bias 72, 4000 K. My first pass at 115 / 95 was too bright to sit in.
The hardware can't do what the plan wanted
Two constraints I found by reading capabilities rather than product pages.
The five living room bulbs are tunable white, not RGB. There is no colour in the room ambient at all. Four of them bottom out at 2700 K, so candlelight at around 2200 K is unreachable. Every call is clamped per light against its own reported min and max Kelvin.
The garden room strips have no white channel. All six report rgbw:false. A Kelvin target is rendered to RGB, with poor colour rendering and a green cast. Two of them advertise a CCT-capable bus definition and still have no white LEDs. The flag lies about the hardware.
500 LEDs on an 850 mA budget
The garden room never looked matched, and the main reason is WLED's automatic brightness limiter:
gr2 500 LEDs vs 850 mA budget ~3% ceiling
gr4/5/6 300 x 55 mA = 16,500 mA vs 3,000 mA ~18% ceiling
gr1/gr3 ledma unset not capped
Four of six are capped, so they sit visibly dimmer at the same requested brightness. The tempting fix is to raise the budget. Don't. ABL is a calculation, not a measurement, and setting it above what the power supply can deliver browns the supply out rather than protecting anything. This one is blocked on me reading the PSU labels.
Colour that silently became white
Two strips ignored every colour and rendered pure white. WLED accepted the POST, echoed it back, and on the next read had clamped the colour to [255,255,255]. No error anywhere.
The tell was info.leds.lc == 0, the light-capability bitmask: the bus had registered no capability, so colour meant nothing to it. Both were on 0.15.4. The fix was to re-POST the LED bus config unchanged, which forces a rebuild, and lc goes to 1. I first blamed the bus type; I was wrong. The script now checks lc on every status run and prints the exact fix. One of them has since dropped back to lc=0 without a reboot, so the re-save isn't durable on that firmware.
I changed the kitchen by accident
One garden strip had UDP sync send switched on, and every WLED unit in the house receives on that group, including the kitchen and the 3D printer's lights. Every schedule change I wrote to that strip was broadcast, and both ended up at brightness 255 in my garden-room white.
Sync groups are house-wide, not per-room. A room script silently became a house script. Send is now off, verified by driving the garden room and watching the kitchen not move, and the script flags any unit with send enabled.
Bulbs that are online and unreachable
The WiZ bulbs taught me to distrust every "offline" I see. Across the house:
- Some were associated to WiFi but never got an IP. They showed as connected, passed frames, and never sent a single DHCP discover. At one point that was 24 of 36 missing devices. No router change can reach a wedged IP stack; the fix is cutting power at the wall. And zero were online at a different address, so re-adding the integration would have fixed nothing.
- Two config entries once claimed the same address. The lease belonged to a live bulb, so commands for the dead one would have driven a different, working light. Wrong is worse than missing. That entry was removed.
- A living room lamp read
unavailablewhile physically lit and answering the WiZ protocol directly. The Home Assistant log said the config entry had been loaded twice in memory and the second copy's light discarded. Reloading the entry made it worse; a full restart fixed it.
And the probe that settles arguments: a raw unicast getPilot on UDP 38899, checking the returned MAC. Broadcast discovery is lossy, and three bulbs that answered unicast never answered the broadcast. Separately, all six garden strips once failed a curl and answered five minutes later. Poll twice before declaring anything down.
A heartbeat instead of hope
Since bulbs drop and return on their own, TV mode can't assume the room it started with is the room it has. So there's a sensor that reads 3 of 5 with a missing: attribute, and an automation that fires when any bulb comes back from unavailable or unknown (a waking bulb can land on unknown first), notifies me, and, if TV mode is running, brings the late-joiner into line. A flag set only by the TV scripts gates that, so it can never light a room I've left.
Tested by state injection: knock a bulb out with TV mode on, bring it back, and it rejoined at 41 / 2700 K to match the others. Today it reads 3 of 5. The other two are waiting on someone to flip a wall switch.
Takeaways
- Read the state, don't assume it. The TV isn't
off, the slider isn'tunknown, the strip isn't white-capable because its flag says so. - A guard that cannot fail is decoration.
int(70)on a value that's always numeric never did anything. - An unreachable device tells you nothing about why. Identity is the MAC; the router's lease table is the authority.
- Some numbers you must not "just raise". A power budget is a promise about a supply you haven't read yet.
- Same rule I use for my agents: facts come from scripts that go and look.
🤖 Drafted with AI assistance from my own homelab notes, logs and repos, then reviewed and edited before publishing.
Top comments (0)