This repository has no description
0

Configure Feed

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

configs.gg23.audio: living room on styx, radio source, room volumes, declarative Audio dashboard

+285 -29
+1 -1
machines/styx/config.nix
··· 12 12 ../../configs/green-host.nix 13 13 ../../configs/syncthing.nix 14 14 ../../configs/ppp/umts-stick.nix 15 - ../../configs/snapclient.nix 15 + ../../configs/gg23/audio/room.nix 16 16 ../../configs/monitoring/server.nix 17 17 ]; 18 18
+188 -10
configs/gg23/audio/hub.nix
··· 1 1 # Low-latency audio hub on acheron: audio-matrix (./matrix.py) links each 2 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. 3 + # Assistant (MQTT), which also sets a volume per room. Sources: 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) and 7 + # radio.lassul.us. Rooms run ./room.nix. 7 8 { 8 9 self, 9 10 config, ··· 12 13 ... 13 14 }: 14 15 let 15 - # shodan and icarus have no stable LAN address (dynamic DHCP leases), so 16 - # send to their static retiolum IPs; tinc's LocalDiscovery keeps that on the 17 - # LAN. 16 + # Rooms are addressed by their static retiolum IPs (shodan and icarus have 17 + # dynamic DHCP leases); tinc's LocalDiscovery keeps that on the LAN. 18 18 kartei = import (self.inputs.kartei + "/modules/retiolum/hosts.nix") { inherit lib; }; 19 19 ip4 = n: kartei.own.${n}.ip4; 20 20 ··· 22 22 rooms = { 23 23 shodan = "Kitchen"; 24 24 icarus = "Bath"; 25 + styx = "Living room"; 25 26 }; 26 27 27 28 mqtt = "-h localhost -u gg23 -P gg23-mqtt"; 28 29 29 - # Home Assistant shows a switch per source and room (./matrix.py); the 30 - # fixed sources by node name, Bluetooth phones come and go 30 + # Home Assistant shows a switch per source and room and a volume per room 31 + # (./matrix.py); the fixed sources by node name, Bluetooth phones come and 32 + # go. A source starts on all rooms unless it lists `rooms`. 31 33 matrixConfig = pkgs.writeText "audio-matrix.json" ( 32 34 builtins.toJSON { 33 35 mqtt = { ··· 48 50 node = "roc-lan"; 49 51 label = "LAN"; 50 52 }; 53 + # where Snapcast played it before 54 + radio = { 55 + node = "radio"; 56 + label = "Radio"; 57 + rooms = [ 58 + "icarus" 59 + "shodan" 60 + ]; 61 + }; 51 62 }; 52 63 state = "/var/lib/audio-matrix/routes.json"; 64 + volumes = "/var/lib/audio-matrix/volumes.json"; 53 65 } 54 66 ); 55 67 ··· 76 88 }; 77 89 } 78 90 ]; 91 + 92 + # "Audio" dashboard in Home Assistant (configs/hass loads /config/packages): 93 + # one grid of a column per room (volume, all off) and a row per source 94 + # (all on/off), cells toggle the audio_route switches. Rows come from the 95 + # switches of the first room via auto-entities (HACS), so Bluetooth phones 96 + # appear while connected. 97 + roomNames = lib.attrNames rooms; 98 + anchor = builtins.head roomNames; 99 + # button calling `service` on the route switches in Jinja list `targets` 100 + allButton = 101 + name: icon: service: targets: 102 + ''{"type": "button", "name": "${name}", "icon": "${icon}", "show_name": true, "icon_height": "24px", "tap_action": {"action": "perform-action", "perform_action": "${service}", "target": {"entity_id": ${targets}}}, "hold_action": {"action": "none"}}''; 103 + roomHeader = 104 + r: 105 + ''{% set col = states.switch | selectattr('entity_id', 'search', '^switch\\.audio_route_.*_${r}$') | map(attribute='entity_id') | list %}{% set ns.out = ns.out + [{"type": "vertical-stack", "cards": [{"type": "tile", "entity": "number.audio_volume_${r}", "name": "${rooms.${r}}", "icon": "mdi:speaker", "features": [{"type": "numeric-input", "style": "slider"}]}, ${ 106 + allButton "All off" "mdi:volume-off" "switch.turn_off" "col" 107 + }]}] %}''; 108 + routeCell = 109 + r: 110 + ''{"type": "button", "entity": "switch.audio_route_" ~ k ~ "_${r}", "name": "${rooms.${r}}", "show_name": false, "icon": "mdi:speaker", "icon_height": "64px", "tap_action": {"action": "toggle"}, "hold_action": {"action": "none"}}''; 111 + matrix = lib.concatStrings ( 112 + [ 113 + "{% set ns = namespace(out=[]) %}" 114 + ''{% set ns.out = ns.out + [{"type": "markdown", "content": "### Source / Room"}] %}'' 115 + ] 116 + ++ map roomHeader roomNames 117 + ++ [ 118 + "{% for e in states.switch | selectattr('entity_id', 'search', '^switch\\\\.audio_route_.*_${anchor}$') | sort(attribute='attributes.source') | sort(attribute='attributes.kind') %}" 119 + "{% set icon = {'bluetooth': 'mdi:bluetooth', 'airplay': 'mdi:cast-audio', 'lan': 'mdi:lan', 'radio': 'mdi:radio'}.get(e.attributes.kind, 'mdi:music') %}" 120 + # switch.audio_route_<key>_<anchor> 121 + "{% set k = e.entity_id[${toString (builtins.stringLength "switch.audio_route_")}:-${toString (builtins.stringLength "_${anchor}")}] %}" 122 + "{% set row = [${ 123 + lib.concatMapStringsSep ", " (r: ''"switch.audio_route_" ~ k ~ "_${r}"'') roomNames 124 + }] %}" 125 + ''{% set ns.out = ns.out + [{"type": "vertical-stack", "cards": [{"type": "button", "name": e.attributes.source, "icon": icon, "show_state": false, "icon_height": "40px", "tap_action": {"action": "none"}, "hold_action": {"action": "none"}}, {"type": "grid", "columns": 2, "square": false, "cards": [${ 126 + allButton "All on" "mdi:volume-high" "switch.turn_on" "row" 127 + }, ${allButton "All off" "mdi:volume-off" "switch.turn_off" "row"}]}]}] %}'' 128 + "{% set ns.out = ns.out + [${lib.concatMapStringsSep ", " routeCell roomNames}] %}" 129 + "{% endfor %}{{ ns.out }}" 130 + ] 131 + ); 132 + audioDashboard = { 133 + title = "Audio"; 134 + views = [ 135 + { 136 + title = "Audio"; 137 + path = "routing"; 138 + icon = "mdi:speaker-multiple"; 139 + type = "sections"; 140 + max_columns = 3; 141 + sections = [ 142 + { 143 + type = "grid"; 144 + column_span = 3; 145 + cards = [ 146 + { 147 + type = "heading"; 148 + heading = "Where does the audio go?"; 149 + icon = "mdi:speaker-multiple"; 150 + } 151 + { 152 + type = "markdown"; 153 + text_only = true; 154 + content = "Tap a speaker to send that source there. Bluetooth `gg23`, AirPlay `gg23`, LAN via `s gg23-audio` and the radio; Bluetooth devices appear while connected."; 155 + } 156 + { 157 + type = "custom:auto-entities"; 158 + grid_options.columns = "full"; 159 + card = { 160 + type = "grid"; 161 + columns = 1 + builtins.length roomNames; 162 + square = false; 163 + }; 164 + card_param = "cards"; 165 + filter.template = matrix; 166 + show_empty = true; 167 + } 168 + ]; 169 + } 170 + { 171 + type = "grid"; 172 + column_span = 3; 173 + cards = [ 174 + { 175 + type = "heading"; 176 + heading = "Bluetooth"; 177 + icon = "mdi:bluetooth"; 178 + heading_style = "subtitle"; 179 + } 180 + { 181 + type = "button"; 182 + name = "Pair Bluetooth (${toString pairingTimeout} s)"; 183 + icon = "mdi:bluetooth-connect"; 184 + show_state = false; 185 + grid_options.columns = 6; 186 + tap_action = { 187 + action = "perform-action"; 188 + perform_action = "button.press"; 189 + target.entity_id = "button.bluetooth_pairing"; 190 + }; 191 + } 192 + { 193 + type = "tile"; 194 + entity = "binary_sensor.bluetooth_pairing"; 195 + name = "Pairing window"; 196 + grid_options.columns = 6; 197 + } 198 + ]; 199 + } 200 + ]; 201 + } 202 + ]; 203 + }; 204 + json = name: value: pkgs.writeText name (builtins.toJSON value); 79 205 in 80 206 { 207 + virtualisation.oci-containers.containers.homeassistant.volumes = [ 208 + "${ 209 + json "audio.yaml" { 210 + lovelace.dashboards.audio-routing = { 211 + mode = "yaml"; 212 + title = "Audio"; 213 + icon = "mdi:speaker-multiple"; 214 + show_in_sidebar = true; 215 + filename = "dashboards/audio.yaml"; 216 + }; 217 + } 218 + }:/config/packages/audio.yaml:ro" 219 + "${json "audio-dashboard.yaml" audioDashboard}:/config/dashboards/audio.yaml:ro" 220 + ]; 221 + 81 222 services.pipewire = { 82 223 enable = true; 83 224 systemWide = true; ··· 199 340 environment.PIPEWIRE_AUTOCONNECT = "false"; 200 341 }; 201 342 343 + # radio.lassul.us as a hub source, playing all the time; audio-matrix 344 + # decides where it is heard 345 + systemd.services.radio = { 346 + wantedBy = [ 347 + "multi-user.target" 348 + "pipewire.service" 349 + ]; 350 + bindsTo = [ "pipewire.service" ]; 351 + after = [ 352 + "pipewire.service" 353 + "network-online.target" 354 + ]; 355 + wants = [ "network-online.target" ]; 356 + # linked only by audio-matrix 357 + environment.PIPEWIRE_AUTOCONNECT = "false"; 358 + serviceConfig = { 359 + ExecStart = lib.escapeShellArgs [ 360 + (lib.getExe pkgs.mpv) 361 + "--no-video" 362 + "--no-terminal" 363 + "--ao=pipewire" 364 + # node.name, see `sources` in matrixConfig 365 + "--audio-client-name=radio" 366 + # keep going through stream hiccups and server restarts 367 + "--loop-playlist=inf" 368 + "http://radio.lassul.us/radio.ogg" 369 + ]; 370 + DynamicUser = true; 371 + SupplementaryGroups = [ "pipewire" ]; 372 + Restart = "always"; 373 + RestartSec = 5; 374 + }; 375 + }; 376 + 202 377 # mDNS for AirPlay on the LAN only; int0 is trusted, et0 must stay closed 203 378 services.avahi = { 204 379 allowInterfaces = [ "int0" ]; ··· 217 392 "pipewire.service" 218 393 "mosquitto.service" 219 394 ]; 220 - path = [ config.services.pipewire.package ]; 395 + path = [ 396 + config.services.pipewire.package 397 + config.services.pipewire.wireplumber.package # wpctl, for room volumes 398 + ]; 221 399 serviceConfig = { 222 400 ExecStart = "${ 223 401 pkgs.writers.writePython3 "audio-matrix" {
+96 -18
configs/gg23/audio/matrix.py
··· 1 1 # Audio routing matrix: one Home Assistant switch (MQTT discovery) per 2 2 # source and speaker. While a switch is on, the source's PipeWire node is 3 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. 4 + # plus every connected Bluetooth A2DP source (bluez_input.*). A new source 5 + # starts on the config's `rooms` of it, else on all speakers. Each speaker 6 + # also gets a volume (MQTT number), applied to its node with wpctl. Usage: 7 + # audio-matrix CONFIG.json [--dry-run], --dry-run prints the pw-link and 8 + # wpctl calls without running them. 7 9 import json 8 10 import os 9 11 import queue ··· 66 68 67 69 def sources(): 68 70 found = { 69 - key: {"label": s["label"], "kind": key, "nodes": nodes_named(s["node"])} 71 + key: { 72 + "label": s["label"], 73 + "kind": key, 74 + "nodes": nodes_named(s["node"]), 75 + "rooms": s.get("rooms"), 76 + } 70 77 for key, s in config["sources"].items() 71 78 } 72 79 for i, info in objects("Node"): ··· 103 110 ) 104 111 105 112 106 - def pw_link(*args): 107 - cmd = ["pw-link", *map(str, args)] 108 - print(" ".join(cmd), flush=True) 113 + def run(*cmd): 114 + print(" ".join(map(str, cmd)), flush=True) 109 115 if not dry_run: 110 - subprocess.run(cmd) 116 + subprocess.run(list(map(str, cmd))) 117 + 118 + 119 + def pw_link(*args): 120 + run("pw-link", *args) 121 + 122 + 123 + def load(path): 124 + try: 125 + with open(path) as f: 126 + return json.load(f) 127 + except FileNotFoundError: 128 + return {} 129 + 130 + 131 + def store(path, data): 132 + with open(path + ".tmp", "w") as f: 133 + json.dump(data, f) 134 + os.replace(path + ".tmp", path) 111 135 112 136 113 137 # 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 = {} 138 + routes = load(config["state"]) 139 + # volume in percent by speaker, 100 if never set 140 + volumes = load(config["volumes"]) 141 + # volume last set on each speaker node, by node id 142 + applied = {} 119 143 120 144 121 145 def save(): 122 - with open(config["state"] + ".tmp", "w") as f: 123 - json.dump(routes, f) 124 - os.replace(config["state"] + ".tmp", config["state"]) 146 + store(config["state"], routes) 125 147 126 148 127 149 client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2) ··· 139 161 def publish_state(key, speaker): 140 162 state = "ON" if speaker in routes[key] else "OFF" 141 163 client.publish(topic(key, speaker) + "/state", state, retain=True) 164 + 165 + 166 + def volume_topic(speaker): 167 + return f"gg23/audio-volume/{speaker}" 168 + 169 + 170 + def publish_volumes(): 171 + for speaker, sp in speakers.items(): 172 + base = volume_topic(speaker) 173 + client.publish(base + "/state", volumes.get(speaker, 100), retain=True) 174 + discovery = { 175 + "name": sp["label"], 176 + "unique_id": f"acheron_audio_volume_{speaker}", 177 + "default_entity_id": f"number.audio_volume_{speaker}", 178 + "command_topic": base + "/set", 179 + "state_topic": base + "/state", 180 + "min": 0, 181 + "max": 100, 182 + "step": 1, 183 + "mode": "slider", 184 + "unit_of_measurement": "%", 185 + "icon": "mdi:volume-high", 186 + "device": { 187 + "identifiers": ["acheron_audio_volume"], 188 + "name": "Room volume", 189 + }, 190 + } 191 + client.publish( 192 + f"homeassistant/number/audio_volume_{speaker}/config", 193 + json.dumps(discovery), 194 + retain=True, 195 + ) 142 196 143 197 144 198 def publish(key, src): ··· 191 245 del published[key] 192 246 for key, src in found.items(): 193 247 if key not in routes: 194 - routes[key] = list(speakers) 248 + rooms = src.get("rooms") 249 + routes[key] = list(speakers if rooms is None else rooms) 195 250 save() 196 251 if key not in published or published[key] != src["label"]: 197 252 publish(key, src) ··· 205 260 pw_link("-d", out_node, in_node) 206 261 elif want and ports(out_node, in_node): 207 262 pw_link(out_node, in_node) 263 + for speaker, sp in speakers.items(): 264 + volume = volumes.get(speaker, 100) 265 + for node in nodes_named(sp["node"]): 266 + if applied.get(node) != volume: 267 + run("wpctl", "set-volume", node, volume / 100) 268 + applied[node] = volume 208 269 209 270 210 271 def command(t, payload): ··· 219 280 publish_state(key, speaker) 220 281 221 282 283 + def volume_command(t, payload): 284 + speaker = t.split("/")[2] 285 + if speaker not in speakers: 286 + return 287 + try: 288 + volumes[speaker] = max(0, min(100, round(float(payload)))) 289 + except ValueError: 290 + return 291 + store(config["volumes"], volumes) 292 + client.publish(volume_topic(speaker) + "/state", volumes[speaker], retain=True) 293 + 294 + 222 295 def on_connect(client, userdata, flags, reason_code, properties): 223 296 client.subscribe("gg23/audio-route/+/+/set") 297 + client.subscribe("gg23/audio-volume/+/set") 224 298 events.put(("connect",)) 225 299 226 300 ··· 251 325 # republish everything, and clear the switches of sources that 252 326 # vanished while nobody was connected 253 327 published = dict.fromkeys(routes) 328 + publish_volumes() 254 329 elif event[0] == "command": 255 - command(*event[1:]) 330 + if event[1].startswith("gg23/audio-volume/"): 331 + volume_command(*event[1:]) 332 + else: 333 + command(*event[1:]) 256 334 elif event[0] == "exit": 257 335 sys.exit(f"pw-dump exited with {event[1]}") 258 336