Reconcile My Living Room: A React for Home Assistant
The lights in my house kept fighting each other. A few rooms of smart bulbs is a tiny distributed system with controllers stomping on shared state, so I stole the two patterns that fix this elsewhere (React render-and-diff, the Kubernetes reconcile loop) and pointed them at Home Assistant.
The lights in my house used to fight. One automation set the living room warm at sunset, another set it cool when the motion sensor tripped, and they'd sit there overwriting each other while the bulb flipped back and forth between two color temperatures. Each automation was correct on its own. Together they were a mess.
The usual fix is more conditions. Add a guard: only do this if that other thing didn't just happen. I did a lot of that. It works until you have fifteen automations and every one of them has to know about the other fourteen. Change one, three others break somewhere you weren't looking.
I do this for a living with Kubernetes, where the whole design leans against writing rules like that over shared state. You declare the state you want, and a controller runs a loop that drags reality toward it. So I rebuilt my home automation the same way. About 5,000 lines of typed Python as I write this, sitting on top of AppDaemon, which already owns the Home Assistant websocket connection, so I didn't have to reinvent that part. I call it ha-operator. The bulbs are resources. My behaviors are the controllers.
If you write React instead of YAML for a living, same shape, different noun. A component takes the current state and returns what the screen should look like; the runtime diffs that against the DOM and applies the minimal change. Point that at a house instead of a browser: a behavior takes a snapshot of the world and returns what the rooms should look like, and the reconciler diffs and applies. ui = f(state), house = f(world). You never write turn-on-the-lamp and then hope it stuck.
The loop
A trigger fires (a sensor changes, a timer lapses) and the runtime snapshots the world, asks the affected behaviors what they want, diffs that against what's actually true, and emits only the difference. A slower full sweep re-renders everything every few minutes to repair whatever the event path missed. If a behavior wants a light at brightness 87 and it's already 87, nothing goes out. That's the whole idea. It's a set-point and a closed loop, the same thing your thermostat does.
Infra people have a name for the difference: level-triggered versus edge-triggered. A classic automation is edge-triggered, when THIS happens do THAT, so a missed event leaves the room wrong until the next edge shows up, which might be tomorrow. And a smart home misses events constantly: Zigbee drops a message, HA restarts at the wrong second, an integration hiccups, a bulb ignores a command it was too busy to hear. A loop that re-derives the entire desired world every tick doesn't care what it missed, because the next tick heals it. That's the Kubernetes posture toward flaky reality, and a smart home is nothing if not flaky reality. Keep re-checking the world against what you meant, instead of reacting to changes and trusting your writes landed.
A behavior is a pure function, with a couple of confessed exceptions. Hand it an immutable snapshot of the world, it returns "these devices should be in these states." Run it a thousand times, same answer. (The exceptions are the stateful ones, like the climate controller; they keep their memory in a named ledger where I can stare at it, instead of scattered across closures.) There's a second primitive, a Reaction, for genuine one-shot events like a button press, where "describe the entire desired world" doesn't make sense. And there's no "mode" concept anywhere. A mode is just a value some Reaction wrote into a Home Assistant helper that a bunch of behaviors happen to read. Composition falls out of shared state, same as it does in the cluster.
Ownership
Two behaviors fighting over a bulb is really an ownership problem, so I made ownership explicit. Every behavior declares the exact device attributes it controls, down to the field. On startup a checker walks all of them, and if two behaviors claim the same (entity, attribute), it refuses to boot and points at the file and line of both. The race I used to debug at night is now a config error I catch before the runtime touches a single device. Kubernetes gets at the same idea with server-side field ownership; mine is blunter about it. I like bugs that can't be represented at all.
Going field-level instead of whole-entity is what makes it compose. One behavior owns a light's brightness, a completely separate one owns its color_temp, and the reconciler merges both into a single turn_on call. If I'd locked ownership to the whole light, every concern that touched a bulb would have to live in one giant function, which is the monolith I was running from.
The light lies to you
A closed loop has to ignore the echoes of its own writes. Right after I send a command, Home Assistant reports a string of intermediate states while the device transitions: an LED fading to the new color, a dimmer ramping, an integration just lagging. A naive diff sees that, decides a human must have grabbed the switch, and politely backs off. Now the framework is fighting itself.
The fix is a 5-second window after each write where I stop trusting divergence. The cost is that a switch flipped inside that window can get missed until the next divergent event shows up. That's a deliberate, bounded false negative, and it's the right call. I wrote a comment saying so, so future-me doesn't "fix" it.
Knowing when to let go
Manual override is the genuinely hard part, and not for the reason you'd guess. When you touch a device by hand, the controller should shut up and stop asserting. Fine. The question nobody warns you about is when to take back over. Timers are the obvious answer and they're bad: too short and you steamroll the person, too long and the room is stuck.
What I use is convergence. The override clears the moment my freshly computed desired state matches whatever you set by hand, because at that point there's nothing left to disagree about. You set the light warm, I also want it warm now, so we're done. There's still a one-hour failsafe, and one class of override that refuses to clear by convergence on purpose: turning a light ON by hand right after the system turned it off is protective, and it holds until something stronger resets it. But convergence does most of the work for free. (It has to be computed per-entity after merging all the assertions, or it goes order-dependent and you lose an afternoon to it.)
The other thing I got wrong was which inputs are allowed to clear an override. I treated every input change as a reset. Then a motion sensor's dead zone reported motion=off, my code read that as "context changed, drop the override," and it turned the lights off on one of my kids mid-play. So now sensors like motion and presence can't clear an override. Only deliberate actions, like flipping a helper, can. The override logic was fine the whole time. I was trusting the wrong signals.
Occupancy without a model
Whether a room is occupied is its own swamp. I skipped machine learning and went with weighted evidence. Every source (a BLE beacon, the camera, an mmWave sensor, a plain motion sensor, a door contact) throws a positive weight with its own decay window, and the room is occupied when the weights sum past a threshold.
Two choices make it work. An idle sensor contributes nothing. Absence isn't a vote for "empty," it's just a signal aging out of its window, so if you sit still and the motion sensor decays to zero, a slower source keeps the room alive and the lights stay on while you read. And I keep only the latest reading per (room, source, person), so a chatty sensor can't pile up runaway weight. The numbers I sketched first (BLE at 0.8 with a ~15-second window, motion at 0.3 with ~30 minutes) are still mostly design targets: the deployed rooms run simpler unit weights with per-room windows, and some of this is wired up while some sits inert waiting for me to care. I'd rather say that than pretend the constants are blessed.
Why it's sane to work on
All of this is testable because behaviors are pure functions of a value, not of some live runtime. A test builds a world snapshot by hand and asserts on the device states that come back. No Home Assistant, no AppDaemon, nothing booted. That's how the repo carries several hundred test functions (and counting) without me dreading every change. It rolls out one room at a time, running next to the old automations until I trust it, then I delete the old one.
Could you run this?
Not yet, and I'm mildly embarrassed by how often I think about it. The split is already real in the repo: ha_operator/ is the framework (reconciler, ownership checker, override machinery, occupancy tracker) and home/ is my house, which is just behaviors and config. But "separable" and "usable by a stranger" are different bars. A public version needs a config story that doesn't assume my entity names, an example house, docs that don't live in my head, and honestly its own runtime: today the framework rides on AppDaemon for the websocket and the scheduler, which was the right call for getting here and the wrong dependency to hand to strangers. Owning that layer is the next real chunk of work.
The additions I'd be excited to build for a public version, in order: plan mode, kubectl-diff for your house ("here's everything I'd change right now, and why," most of which already exists as the explainability attributes each behavior publishes); the test harness as a first-class kit, because "your automations are pure functions, here's how to unit-test your bedroom" might be the single most useful thing in here; and the occupancy tracker as a standalone piece, for people who want the evidence model without the reconciler. If you'd actually run this, say so. I need the excuse.