This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

configs.gg23.audio: routing matrix in Home Assistant instead of one room select

audio-matrix (./matrix.py) watches PipeWire (pw-dump --monitor) and
publishes per source and room an MQTT discovery switch
switch.audio_route_<source>_<room> (attributes source, speaker, kind),
grouped as one HA device per source. Sources are AirPlay (shairport's
"Shairport Sync" stream), LAN (roc-lan) and every connected Bluetooth
phone (bluez_input.*, keyed by MAC, named by its alias); a phone's switches
are removed when it disconnects. The daemon links the source node directly
to roc-<room> with pw-link and re-applies the links whenever nodes come
back (phone reconnects, PipeWire restarts).

The routes are kept per source key in /var/lib/audio-matrix/routes.json, so
a reconnecting phone gets its previous routing. A source never seen before
plays on all rooms: a freshly paired phone should just make sound.

The hub null sink, the "Audio room" select, its buttons, the audio-room
service and command are gone. WirePlumber must not link the sources itself
any more: node.autoconnect = false for bluez_input.* (monitor.bluez.rules
run after name-node.lua sets it true) and roc-lan (source.props), and
PIPEWIRE_AUTOCONNECT=false for shairport-sync, whose pipewire backend
always connects with PW_STREAM_FLAG_AUTOCONNECT. Groups without the null
sink are driven by the phone's node or PipeWire's Dummy-Driver.

The old discovery configs stay retained on mosquitto; clear them once on
acheron:

for t in homeassistant/select/audio_room/config \
homeassistant/button/audio_room_{shodan,icarus,both,off}/config \
gg23/audio-room/set gg23/audio-room/state; do
mosquitto_pub -h localhost -u gg23 -P gg23-mqtt -r -n -t "$t"
done

author
Hermes
committer
lassulus
date (Oct 17, 2026, 11:25 AM CEST) commit 65c2c7cf parent 489d1786
+319 -109
+53 -109
configs/gg23/audio/hub.nix
··· 1 - # Low-latency audio hub on acheron: everything received plays into the 2 - # virtual "hub" sink, whose monitor is linked to the ROC sinks of the rooms 3 - # selected with `audio-room` or from Home Assistant (MQTT). Senders: any LAN 4 - # machine via ROC (while `gg23-audio` from tools/gg23-audio runs), barnacle 5 - # via AirPlay 2 (shairport-sync + nqptp), phones via Bluetooth A2DP (paired 6 - # during the window opened by the "Bluetooth pairing" button). Rooms run 7 - # ./room.nix. 1 + # Low-latency audio hub on acheron: audio-matrix (./matrix.py) links each 2 + # received source to the ROC sinks of the rooms switched on for it in Home 3 + # Assistant (MQTT). Senders: any LAN machine via ROC (while `gg23-audio` from 4 + # tools/gg23-audio runs), barnacle via AirPlay 2 (shairport-sync + nqptp), 5 + # phones via Bluetooth A2DP (paired during the window opened by the 6 + # "Bluetooth pairing" button). Rooms run ./room.nix. 8 7 { 9 8 self, 10 9 config, ··· 19 18 kartei = import (self.inputs.kartei + "/modules/retiolum/hosts.nix") { inherit lib; }; 20 19 ip4 = n: kartei.own.${n}.ip4; 21 20 22 - rooms = [ 23 - "shodan" 24 - "icarus" 25 - ]; 26 - choices = rooms ++ [ 27 - "both" 28 - "off" 29 - ]; 21 + # room machine -> speaker name in Home Assistant 22 + rooms = { 23 + shodan = "Kitchen"; 24 + icarus = "Bath"; 25 + }; 30 26 31 27 mqtt = "-h localhost -u gg23 -P gg23-mqtt"; 32 28 33 - # Home Assistant picks these up via MQTT discovery: a select showing the 34 - # current room plus one button per choice (dashboard / phone shortcuts). 35 - # Commands are retained, so the bridge restores the last choice on start. 36 - discovery = [ 37 - { 38 - topic = "homeassistant/select/audio_room/config"; 39 - config = { 40 - name = "Audio room"; 41 - unique_id = "acheron_audio_room"; 42 - command_topic = "gg23/audio-room/set"; 43 - state_topic = "gg23/audio-room/state"; 44 - options = choices; 45 - retain = true; 46 - icon = "mdi:speaker-multiple"; 29 + # Home Assistant shows a switch per source and room (./matrix.py); the 30 + # fixed sources by node name, Bluetooth phones come and go 31 + matrixConfig = pkgs.writeText "audio-matrix.json" ( 32 + builtins.toJSON { 33 + mqtt = { 34 + host = "localhost"; 35 + user = "gg23"; 36 + password = "gg23-mqtt"; 47 37 }; 38 + speakers = lib.mapAttrs (r: label: { 39 + node = "roc-${r}"; 40 + inherit label; 41 + }) rooms; 42 + sources = { 43 + airplay = { 44 + node = "Shairport Sync"; 45 + label = "AirPlay"; 46 + }; 47 + lan = { 48 + node = "roc-lan"; 49 + label = "LAN"; 50 + }; 51 + }; 52 + state = "/var/lib/audio-matrix/routes.json"; 48 53 } 49 - ] 50 - ++ map (c: { 51 - topic = "homeassistant/button/audio_room_${c}/config"; 52 - config = { 53 - name = "Audio ${c}"; 54 - unique_id = "acheron_audio_room_${c}"; 55 - command_topic = "gg23/audio-room/set"; 56 - payload_press = c; 57 - retain = true; 58 - icon = "mdi:speaker-multiple"; 59 - }; 60 - }) choices; 54 + ); 61 55 62 56 # seconds acheron stays discoverable and pairable after the button press 63 57 pairingTimeout = 120; ··· 90 84 audio.enable = true; 91 85 extraConfig.pipewire."10-audio-hub" = { 92 86 "context.properties"."default.clock.quantum" = 256; 93 - "context.objects" = [ 94 - { 95 - factory = "adapter"; 96 - args = { 97 - "factory.name" = "support.null-audio-sink"; 98 - "node.name" = "hub"; 99 - "node.description" = "Audio hub"; 100 - "media.class" = "Audio/Sink"; 101 - "audio.position" = [ 102 - "FL" 103 - "FR" 104 - ]; 105 - }; 106 - } 107 - ]; 108 87 "context.modules" = [ 109 88 { 110 89 # from any LAN sender (`gg23-audio`); roc mixes concurrent ··· 120 99 "source.name" = "roc-lan"; 121 100 "source.props" = { 122 101 "node.description" = "LAN (ROC)"; 123 - "target.object" = "hub"; 102 + "node.autoconnect" = false; 124 103 }; 125 104 }; 126 105 } ··· 136 115 "sink.name" = "roc-${r}"; 137 116 "sink.props"."node.description" = "${r} (ROC)"; 138 117 }; 139 - }) rooms; 118 + }) (lib.attrNames rooms); 140 119 }; 141 - # phones play into the hub: acheron is only an A2DP sink (no headset or 142 - # LE audio roles), and the received stream targets "hub" 120 + # acheron is only an A2DP sink (no headset or LE audio roles); like the 121 + # other sources, phones are linked only by audio-matrix 143 122 wireplumber.extraConfig."10-bluetooth-hub" = { 144 123 "monitor.bluez.properties" = { 145 124 "bluez5.roles" = [ "a2dp_sink" ]; ··· 153 132 "monitor.bluez.rules" = [ 154 133 { 155 134 matches = [ { "node.name" = "~bluez_input.*"; } ]; 156 - actions.update-props."target.object" = "hub"; 135 + actions.update-props."node.autoconnect" = false; 157 136 } 158 137 ]; 159 138 }; ··· 201 180 general = { 202 181 name = "gg23"; 203 182 output_backend = "pipewire"; 204 - # play early by about the hub -> room ROC hop, so AirPlay video 183 + # play early by about the acheron -> room ROC hop, so AirPlay video 205 184 # stays in sync in the rooms 206 185 audio_backend_latency_offset_in_seconds = -0.04; 207 186 }; 208 - pipewire.sink_target = "hub"; 209 187 }; 210 188 }; 211 189 users.users.shairport.extraGroups = [ "pipewire" ]; ··· 217 195 systemd.services.shairport-sync = { 218 196 wants = [ "nqptp.service" ]; 219 197 after = [ "nqptp.service" ]; 198 + # linked only by audio-matrix 199 + environment.PIPEWIRE_AUTOCONNECT = "false"; 220 200 }; 221 201 222 202 # mDNS for AirPlay on the LAN only; int0 is trusted, et0 must stay closed ··· 226 206 }; 227 207 228 208 # MQTT -> pipewire links, so Home Assistant (in podman) needs no access to 229 - # pipewire. Restarted with pipewire, the retained command restores the room. 230 - systemd.services.audio-room = { 209 + # pipewire. Restarted with pipewire, the state file restores the routes. 210 + systemd.services.audio-matrix = { 231 211 wantedBy = [ 232 212 "multi-user.target" 233 213 "pipewire.service" ··· 237 217 "pipewire.service" 238 218 "mosquitto.service" 239 219 ]; 240 - path = [ 241 - config.services.pipewire.package 242 - pkgs.mosquitto 243 - ]; 244 - script = '' 245 - ${lib.concatMapStrings (d: '' 246 - mosquitto_pub ${mqtt} -r -t ${d.topic} -m ${lib.escapeShellArg (builtins.toJSON d.config)} 247 - '') discovery} 248 - route() { 249 - for r in ${toString rooms}; do 250 - pw-link -d hub "roc-$r" 2>/dev/null 251 - case "$1" in "$r" | both) 252 - i=0 253 - until pw-link hub "roc-$r"; do 254 - i=$((i + 1)) 255 - [ "$i" -lt 10 ] || return 1 256 - sleep 1 257 - done 258 - ;; 259 - esac 260 - done 261 - } 262 - mosquitto_sub ${mqtt} -t gg23/audio-room/set | while read -r room; do 263 - case "$room" in 264 - ${lib.concatStringsSep " | " choices}) ;; 265 - *) echo "ignoring $room" >&2; continue ;; 266 - esac 267 - route "$room" && mosquitto_pub ${mqtt} -r -t gg23/audio-room/state -m "$room" 268 - done 269 - ''; 220 + path = [ config.services.pipewire.package ]; 270 221 serviceConfig = { 222 + ExecStart = "${ 223 + pkgs.writers.writePython3 "audio-matrix" { 224 + libraries = [ pkgs.python3Packages.paho-mqtt ]; 225 + flakeIgnore = [ "E501" ]; 226 + } ./matrix.py 227 + } ${matrixConfig}"; 271 228 DynamicUser = true; 272 229 SupplementaryGroups = [ "pipewire" ]; 230 + StateDirectory = "audio-matrix"; 273 231 Restart = "always"; 274 232 RestartSec = 2; 275 233 }; ··· 331 289 RestartSec = 2; 332 290 }; 333 291 }; 334 - 335 - environment.systemPackages = [ 336 - (pkgs.writers.writeDashBin "audio-room" '' 337 - case "''${1-}" in 338 - ${lib.concatStringsSep " | " choices}) 339 - exec ${pkgs.mosquitto}/bin/mosquitto_pub ${mqtt} -r -t gg23/audio-room/set -m "$1" 340 - ;; 341 - *) 342 - echo "usage: audio-room ${lib.concatStringsSep "|" choices}" >&2 343 - exit 1 344 - ;; 345 - esac 346 - '') 347 - ]; 348 292 }
+266
configs/gg23/audio/matrix.py
··· 1 + # Audio routing matrix: one Home Assistant switch (MQTT discovery) per 2 + # source and speaker. While a switch is on, the source's PipeWire node is 3 + # linked to the speaker's node. Sources are the fixed nodes from the config 4 + # plus every connected Bluetooth A2DP source (bluez_input.*). Usage: 5 + # audio-matrix CONFIG.json [--dry-run], --dry-run prints the pw-link calls 6 + # without running them. 7 + import json 8 + import os 9 + import queue 10 + import subprocess 11 + import sys 12 + import threading 13 + 14 + import paho.mqtt.client as mqtt 15 + 16 + with open(sys.argv[1]) as f: 17 + config = json.load(f) 18 + dry_run = "--dry-run" in sys.argv[2:] 19 + speakers = config["speakers"] 20 + 21 + events = queue.Queue() 22 + 23 + # PipeWire objects by id: nodes, ports and links 24 + graph = {} 25 + interesting = { 26 + "PipeWire:Interface:Node", 27 + "PipeWire:Interface:Port", 28 + "PipeWire:Interface:Link", 29 + } 30 + 31 + 32 + def read_graph(proc): 33 + # every update is a JSON array whose closing bracket starts a line 34 + chunk = [] 35 + try: 36 + for line in proc.stdout: 37 + chunk.append(line) 38 + if line.startswith("]"): 39 + events.put(("graph", json.loads("".join(chunk)))) 40 + chunk = [] 41 + finally: 42 + proc.kill() 43 + events.put(("exit", proc.wait())) 44 + 45 + 46 + def apply(update): 47 + for o in update: 48 + if "type" not in o: 49 + graph.pop(o["id"], None) 50 + elif o["type"] in interesting: 51 + graph[o["id"]] = o 52 + 53 + 54 + def objects(kind): 55 + for o in graph.values(): 56 + if o["type"] == "PipeWire:Interface:" + kind: 57 + yield o["id"], o["info"] 58 + 59 + 60 + def nodes_named(name): 61 + return [ 62 + i for i, info in objects("Node") 63 + if info["props"].get("node.name") == name 64 + ] 65 + 66 + 67 + def sources(): 68 + found = { 69 + key: {"label": s["label"], "kind": key, "nodes": nodes_named(s["node"])} 70 + for key, s in config["sources"].items() 71 + } 72 + for i, info in objects("Node"): 73 + props = info["props"] 74 + address = props.get("api.bluez5.address") 75 + if not props.get("node.name", "").startswith("bluez_input.") or not address: 76 + continue 77 + key = address.lower().replace(":", "_") 78 + src = found.setdefault( 79 + key, 80 + { 81 + "label": props.get("node.description", address), 82 + "kind": "bluetooth", 83 + "nodes": [], 84 + }, 85 + ) 86 + src["nodes"].append(i) 87 + return found 88 + 89 + 90 + # whether both nodes have their ports to link yet 91 + def ports(out_node, in_node): 92 + have = { 93 + (int(info["props"]["node.id"]), info["direction"]) 94 + for _, info in objects("Port") 95 + } 96 + return (out_node, "output") in have and (in_node, "input") in have 97 + 98 + 99 + def linked(out_node, in_node): 100 + return any( 101 + info["output-node-id"] == out_node and info["input-node-id"] == in_node 102 + for _, info in objects("Link") 103 + ) 104 + 105 + 106 + def pw_link(*args): 107 + cmd = ["pw-link", *map(str, args)] 108 + print(" ".join(cmd), flush=True) 109 + if not dry_run: 110 + subprocess.run(cmd) 111 + 112 + 113 + # speakers each source plays on, by source key 114 + try: 115 + with open(config["state"]) as f: 116 + routes = json.load(f) 117 + except FileNotFoundError: 118 + routes = {} 119 + 120 + 121 + def save(): 122 + with open(config["state"] + ".tmp", "w") as f: 123 + json.dump(routes, f) 124 + os.replace(config["state"] + ".tmp", config["state"]) 125 + 126 + 127 + client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2) 128 + client.username_pw_set(config["mqtt"]["user"], config["mqtt"]["password"]) 129 + 130 + 131 + def entity(key, speaker): 132 + return f"audio_route_{key}_{speaker}" 133 + 134 + 135 + def topic(key, speaker): 136 + return f"gg23/audio-route/{key}/{speaker}" 137 + 138 + 139 + def publish_state(key, speaker): 140 + state = "ON" if speaker in routes[key] else "OFF" 141 + client.publish(topic(key, speaker) + "/state", state, retain=True) 142 + 143 + 144 + def publish(key, src): 145 + for speaker, sp in speakers.items(): 146 + base = topic(key, speaker) 147 + attributes = { 148 + "source": src["label"], 149 + "speaker": sp["label"], 150 + "kind": src["kind"], 151 + } 152 + client.publish(base + "/attributes", json.dumps(attributes), retain=True) 153 + publish_state(key, speaker) 154 + discovery = { 155 + "name": sp["label"], 156 + "unique_id": "acheron_" + entity(key, speaker), 157 + "default_entity_id": "switch." + entity(key, speaker), 158 + "command_topic": base + "/set", 159 + "state_topic": base + "/state", 160 + "json_attributes_topic": base + "/attributes", 161 + "icon": "mdi:speaker", 162 + "device": { 163 + "identifiers": ["acheron_audio_" + key], 164 + "name": src["label"], 165 + }, 166 + } 167 + client.publish( 168 + f"homeassistant/switch/{entity(key, speaker)}/config", 169 + json.dumps(discovery), 170 + retain=True, 171 + ) 172 + 173 + 174 + def unpublish(key): 175 + for speaker in speakers: 176 + client.publish( 177 + f"homeassistant/switch/{entity(key, speaker)}/config", "", retain=True 178 + ) 179 + for t in ["state", "attributes"]: 180 + client.publish(topic(key, speaker) + "/" + t, "", retain=True) 181 + 182 + 183 + # label of each source whose switches are published, None if unknown 184 + published = {} 185 + 186 + 187 + def sync(): 188 + found = sources() 189 + for key in [k for k in published if k not in found]: 190 + unpublish(key) 191 + del published[key] 192 + for key, src in found.items(): 193 + if key not in routes: 194 + routes[key] = list(speakers) 195 + save() 196 + if key not in published or published[key] != src["label"]: 197 + publish(key, src) 198 + published[key] = src["label"] 199 + for speaker, sp in speakers.items(): 200 + want = speaker in routes[key] 201 + for out_node in src["nodes"]: 202 + for in_node in nodes_named(sp["node"]): 203 + if linked(out_node, in_node): 204 + if not want: 205 + pw_link("-d", out_node, in_node) 206 + elif want and ports(out_node, in_node): 207 + pw_link(out_node, in_node) 208 + 209 + 210 + def command(t, payload): 211 + _, _, key, speaker, _ = t.split("/") 212 + if key not in published or speaker not in speakers: 213 + return 214 + if payload == "ON" and speaker not in routes[key]: 215 + routes[key].append(speaker) 216 + elif payload == "OFF" and speaker in routes[key]: 217 + routes[key].remove(speaker) 218 + save() 219 + publish_state(key, speaker) 220 + 221 + 222 + def on_connect(client, userdata, flags, reason_code, properties): 223 + client.subscribe("gg23/audio-route/+/+/set") 224 + events.put(("connect",)) 225 + 226 + 227 + def on_message(client, userdata, msg): 228 + events.put(("command", msg.topic, msg.payload.decode())) 229 + 230 + 231 + client.on_connect = on_connect 232 + client.on_message = on_message 233 + client.connect_async(config["mqtt"]["host"]) 234 + client.loop_start() 235 + pw_dump = subprocess.Popen( 236 + ["pw-dump", "--monitor", "--no-colors"], 237 + stdout=subprocess.PIPE, 238 + text=True, 239 + ) 240 + threading.Thread(target=read_graph, args=(pw_dump,), daemon=True).start() 241 + 242 + graph_ready = False 243 + 244 + 245 + def handle(event): 246 + global graph_ready, published 247 + if event[0] == "graph": 248 + apply(event[1]) 249 + graph_ready = True 250 + elif event[0] == "connect": 251 + # republish everything, and clear the switches of sources that 252 + # vanished while nobody was connected 253 + published = dict.fromkeys(routes) 254 + elif event[0] == "command": 255 + command(*event[1:]) 256 + elif event[0] == "exit": 257 + sys.exit(f"pw-dump exited with {event[1]}") 258 + 259 + 260 + while True: 261 + handle(events.get()) 262 + # sync once per burst, so links just created are seen before it 263 + while not events.empty(): 264 + handle(events.get()) 265 + if graph_ready: 266 + sync()