← Return to field notes
embedded / home-automation build log

A lighting engine is a state machine, not a remote control

Tying Hue, Govee, audio gear, and projectors into one coherent scene system means treating the living room as a publish-subscribe device graph with a recovery contract.

field-note maintained created 2026-07-28 updated 2026-07-28 6 min 4 sections 1 figures
subscribe via RSS report a correction sec intro ~6 min left read 0%
opening contract field-note · maintained
problem
A pile of smart bulbs, LED strips, projectors, and audio gear controlled through vendor apps becomes unmaintainable: scenes drift, devices go offline silently, and "movie mode" stops working when one bulb updates its firmware.
scope
A home lighting engine built on Home Assistant: device graph, event-driven scenes, availability-aware recovery, and an offline-safe control surface that does not hide controls when a device is unreachable.
environment
Home Assistant on a Raspberry Pi 4, with Philips Hue, Govee, Wiz, an XGIMI projector, Samsung soundbar, and Argon fan controllers exposed over LAN and Zigbee.

Assumptions

  • Devices expose a local API or a Zigbee profile; cloud-only devices are excluded from the engine.
  • The hub runs continuously and can store scene state outside individual device memory.
  • Scene transitions are idempotent: re-applying a scene should not flicker or stack transitions.

Limitations

  • Vendor firmware updates can break local APIs without warning; the engine treats API failure as a recoverable event, not a permanent fix.
  • This is a personal build log, not a packaged integration; device specifics (IPs, entity IDs) are omitted.
  • Color-temperature and gamut mapping across vendors is approximate; perceptual matching is not guaranteed.
Table of contents 4 sections
  1. 1 Model the room as a device graph
  2. 2 Keep controls visible during outages
  3. 3 Wire audio reactivity without a dedicated controller
  4. 4 Make scenes idempotent and declarative

Model the room as a device graph

The first mistake is treating each light as an independent endpoint. A living room is a graph: the projector affects perceived brightness, the soundbar affects whether lights should dim, and an Argon fan controller ties thermal state to the scene. The scene engine does not set bulbs; it publishes a target state to the graph and lets each node resolve it.

Each device is a node with a capability profile (on/off, brightness, color, color-temperature) and an availability state. A scene is a target vector across the graph, not a script of sequential API calls. This means "movie mode" can include the projector power state and the soundbar input, and a single device failure does not abort the whole scene.

Keep controls visible during outages

A common UX failure is hiding a light control when the entity is unavailable. The operator should see the control, an outage warning, and the last known target. Hiding the control removes the ability to retry or override, which is exactly when the operator needs it most.

The engine stores the last commanded target per device. When a device returns from unavailable, a state listener compares the device current state against the stored target and re-applies if they differ. This turns a 30-second Wi-Fi blip into a self-healing event instead of a manual scene re-trigger.

Wire audio reactivity without a dedicated controller

A DDJ-FLX10 controller maps MIDI to Home Assistant actions. A fader raises brightness, a pad triggers a scene, and a knob sweeps color temperature. The mapping is a thin bridge: MIDI input event to HA service call. The interesting part is the feedback: the controller LEDs reflect the actual device state, not the commanded state, so the physical surface stays honest.

This avoids a dedicated lighting controller box. The DJ controller is already on the desk, already USB-connected, and already has enough physical controls for a room. The bridge is a small script that subscribes to MIDI and to HA state changes, keeping both directions in sync.

MIDI-to-HA bridge sketch python
# fader 7 -> living room brightness (0-127 -> 0-255)def on_midi(msg):    if msg.type == 'pitchwheel' and msg.channel == 0:        brightness = int(msg.pitch / 8191 * 255)        ha.call_service('light', 'turn_on',            entity_id='light.living_room',            brightness=brightness) # HA state -> controller LED feedbackdef on_ha_state(entity, new_state):    if entity == 'light.living_room':        bri = new_state.attributes.get('brightness', 0)        send_midi('pitchwheel', channel=0,                  value=int(bri / 255 * 8191))

Make scenes idempotent and declarative

A scene is a declarative target, not an imperative script. "Set living room to 40 percent warm white with projector on" is a contract: the engine resolves each device to the target, applies transitions, and reports which devices reached the target, which are pending, and which failed. Re-running the same scene is a no-op if devices already match.

This is what separates a lighting engine from a remote control. The remote fires commands and hopes. The engine publishes a target, measures convergence, and exposes the delta. When the projector takes 8 seconds to warm up, the scene is not "done" when the command returns; it is done when the projector reports the target input and the bulbs report target brightness.

evidence ledger 2 claims
  1. implemented
    Multi-vendor scene engine

    Hue, Govee, Wiz, projector, and soundbar states are composed into named scenes with availability checks and per-device fallback targets.

  2. field-observed
    Recovery under partial outage

    When a bulb drops offline, the scene engine marks it unavailable, keeps the remaining devices in scene, and re-applies target state when the bulb returns instead of requiring a manual re-trigger.

linked artifacts 1 attached
  • reference
    Home Assistant documentation

    Reference for automations, scenes, template sensors, and device availability.

    open ↗