Skip to the content.

Layered architecture

Before you write a single script, decide how the pieces are layered. The most common way a whole-home build turns into spaghetti is tangling two independent questions together: what is playing and where it is playing. Keep them separate and almost everything else gets easier. Tangle them and every new feature fights the last one.

Separate the source layer from the room layer

There are two independent axes in a whole-home system:

Build them so they never call each other directly. Changing the source shouldn’t re-pick your rooms; adding a room shouldn’t restart the source. The two layers communicate only through shared state (below), and a thin dispatcher wires them together at the edges.

Why it holds: this is one source of truth applied to the two things a person actually controls. Once the layers are decoupled, presets, dashboards, voice, and reactive automations all compose the same small primitives instead of each re-implementing the whole flow. It’s the single highest-leverage decision in the build.

Two-axis state: one select for source, one boolean per room

Make the state explicit and durable using Home Assistant helpers, not variables hidden inside scripts:

Scripts write these helpers; dashboards, the now-playing sensor, and automations read them. The “active room set” is simply which booleans are on.

# Illustrative — the two axes as helpers, the single source of truth.
input_select:
  house_source:
    options: ["None", "Spotify", "Turntable"]

input_boolean:
  kitchen_active:
  living_room_active:

Why it holds: because they’re real HA entities, the whole system survives a restart and is introspectable — you can see the exact state in the UI, and anything can read it. State that lives only inside a running script vanishes the moment HA reloads.

A dispatcher joins the layers

A single small entry point takes a source, stops whatever’s currently active, and routes to the matching engine. Callers — a dashboard tile, a voice command, an automation — only ever learn one script and a name.

# Illustrative — one entry point routes to per-engine scripts by name.
play_source:
  fields:
    source:
      selector: { select: { options: ["Spotify", "Turntable"] } }
  sequence:
    - action: script.stop_active_source        # stop-then-start: clear the old source
    - choose:
        - conditions: ""
          sequence: [{ action: script.play_source_spotify }]
        - conditions: ""
          sequence: [{ action: script.play_source_turntable }]

The room layer has its own tiny primitives — an idempotent room_add_<room> and room_remove_<room> per zone — that only touch that room’s player and its boolean. Presets and reactive automations call those primitives; they never reach into the source layer to do it.

The room group is a model, not a side effect

Rooms are not just targets you send commands to. The set of rooms currently participating is itself state, and like any state it needs exactly one owner — the room layer’s booleans, not the streaming engine’s internal group, not whatever a script happened to do last.

Two properties make that model hold up:

Observe is not the same as control

Once you have a status display reading engine state, a subtle trap appears: the engine can be telling the truth while a room is lying. A now-playing card that reads the engine can correctly say “Service X is playing” while a particular room is actually playing a stale, ungrouped source — the report is right about the engine and wrong about the room.

The fix belongs in the distribution layer, not the reporting layer: when you add a room, both join it to the group and stop whatever it was independently playing. Don’t try to patch a reconciliation problem by editing the sensor that merely observes it.

Why it holds: observing state and controlling state are different jobs. A reporting layer that reads one entity will faithfully report that entity — making it “more accurate” can’t fix a room that’s genuinely in the wrong state. Reconcile reality in the layer that owns reality.

The layered restatement: a unified now-playing view mirrors the engine, not the room. What is playing is a property of the source layer; which rooms hear it is a property of the room layer. Conflate them and you ship an interface that lies — showing “playing” in a room that never joined. Render them as two facts, and render the gap between them: a room that was skipped belongs on the screen. That is the visible-degradation pattern in reliability, and it is the difference between an interface people trust and one they stop believing.

Pitfalls

See also