This repository has no description
0

Configure Feed

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

superconfig / configs / gg23 / audio / hub.nix
17 kB 474 lines
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), 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. 8{ 9 self, 10 config, 11 lib, 12 pkgs, 13 ... 14}: 15let 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 kartei = import (self.inputs.kartei + "/modules/retiolum/hosts.nix") { inherit lib; }; 19 ip4 = n: kartei.own.${n}.ip4; 20 21 # room machine -> speaker name in Home Assistant 22 rooms = { 23 shodan = "Kitchen"; 24 icarus = "Bath"; 25 styx = "Living room"; 26 }; 27 28 mqtt = "-h localhost -u gg23 -P gg23-mqtt"; 29 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`. 33 matrixConfig = pkgs.writeText "audio-matrix.json" ( 34 builtins.toJSON { 35 mqtt = { 36 host = "localhost"; 37 user = "gg23"; 38 password = "gg23-mqtt"; 39 }; 40 speakers = lib.mapAttrs (r: label: { 41 node = "roc-${r}"; 42 inherit label; 43 }) rooms; 44 sources = { 45 airplay = { 46 node = "Shairport Sync"; 47 label = "AirPlay"; 48 }; 49 lan = { 50 node = "roc-lan"; 51 label = "LAN"; 52 }; 53 # where Snapcast played it before 54 radio = { 55 node = "radio"; 56 label = "Radio"; 57 rooms = [ 58 "icarus" 59 "shodan" 60 ]; 61 }; 62 }; 63 state = "/var/lib/audio-matrix/routes.json"; 64 volumes = "/var/lib/audio-matrix/volumes.json"; 65 } 66 ); 67 68 # seconds acheron stays discoverable and pairable after the button press 69 pairingTimeout = 120; 70 71 bluetoothDiscovery = [ 72 { 73 topic = "homeassistant/button/bluetooth_pairing/config"; 74 config = { 75 name = "Bluetooth pairing"; 76 unique_id = "acheron_bluetooth_pairing"; 77 command_topic = "gg23/bluetooth-pairing/set"; 78 icon = "mdi:bluetooth-connect"; 79 }; 80 } 81 { 82 topic = "homeassistant/binary_sensor/bluetooth_pairing/config"; 83 config = { 84 name = "Bluetooth pairing"; 85 unique_id = "acheron_bluetooth_pairing_state"; 86 state_topic = "gg23/bluetooth-pairing/state"; 87 icon = "mdi:bluetooth-connect"; 88 }; 89 } 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 # crossed out speaker when off, so on/off differ in shape too 109 routeCell = 110 r: 111 let 112 id = ''"switch.audio_route_" ~ k ~ "_${r}"''; 113 in 114 ''{"type": "button", "entity": ${id}, "name": "${rooms.${r}}", "show_name": false, "icon": ("mdi:speaker" if is_state(${id}, "on") else "mdi:speaker-off"), "icon_height": "64px", "tap_action": {"action": "toggle"}, "hold_action": {"action": "none"}}''; 115 matrix = lib.concatStrings ( 116 [ 117 "{% set ns = namespace(out=[]) %}" 118 ''{% set ns.out = ns.out + [{"type": "markdown", "content": "### Source / Room"}] %}'' 119 ] 120 ++ map roomHeader roomNames 121 ++ [ 122 "{% for e in states.switch | selectattr('entity_id', 'search', '^switch\\\\.audio_route_.*_${anchor}$') | sort(attribute='attributes.source') | sort(attribute='attributes.kind') %}" 123 "{% set icon = {'bluetooth': 'mdi:bluetooth', 'airplay': 'mdi:cast-audio', 'lan': 'mdi:lan', 'radio': 'mdi:radio'}.get(e.attributes.kind, 'mdi:music') %}" 124 # switch.audio_route_<key>_<anchor> 125 "{% set k = e.entity_id[${toString (builtins.stringLength "switch.audio_route_")}:-${toString (builtins.stringLength "_${anchor}")}] %}" 126 "{% set row = [${ 127 lib.concatMapStringsSep ", " (r: ''"switch.audio_route_" ~ k ~ "_${r}"'') roomNames 128 }] %}" 129 ''{% 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": [${ 130 allButton "All on" "mdi:volume-high" "switch.turn_on" "row" 131 }, ${allButton "All off" "mdi:volume-off" "switch.turn_off" "row"}]}]}] %}'' 132 "{% set ns.out = ns.out + [${lib.concatMapStringsSep ", " routeCell roomNames}] %}" 133 "{% endfor %}{{ ns.out }}" 134 ] 135 ); 136 audioDashboard = { 137 title = "Audio"; 138 views = [ 139 { 140 title = "Audio"; 141 path = "routing"; 142 icon = "mdi:speaker-multiple"; 143 type = "sections"; 144 max_columns = 3; 145 sections = [ 146 { 147 type = "grid"; 148 column_span = 3; 149 cards = [ 150 { 151 type = "heading"; 152 heading = "Where does the audio go?"; 153 icon = "mdi:speaker-multiple"; 154 } 155 { 156 type = "markdown"; 157 text_only = true; 158 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."; 159 } 160 { 161 type = "custom:auto-entities"; 162 grid_options.columns = "full"; 163 card = { 164 type = "grid"; 165 columns = 1 + builtins.length roomNames; 166 square = false; 167 }; 168 card_param = "cards"; 169 filter.template = matrix; 170 show_empty = true; 171 } 172 ]; 173 } 174 { 175 type = "grid"; 176 column_span = 3; 177 cards = [ 178 { 179 type = "heading"; 180 heading = "Bluetooth"; 181 icon = "mdi:bluetooth"; 182 heading_style = "subtitle"; 183 } 184 { 185 type = "button"; 186 name = "Pair Bluetooth (${toString pairingTimeout} s)"; 187 icon = "mdi:bluetooth-connect"; 188 show_state = false; 189 grid_options.columns = 6; 190 tap_action = { 191 action = "perform-action"; 192 perform_action = "button.press"; 193 target.entity_id = "button.bluetooth_pairing"; 194 }; 195 } 196 { 197 type = "tile"; 198 entity = "binary_sensor.bluetooth_pairing"; 199 name = "Pairing window"; 200 grid_options.columns = 6; 201 } 202 ]; 203 } 204 ]; 205 } 206 ]; 207 }; 208 json = name: value: pkgs.writeText name (builtins.toJSON value); 209in 210{ 211 virtualisation.oci-containers.containers.homeassistant.volumes = [ 212 "${ 213 json "audio.yaml" { 214 lovelace.dashboards.audio-routing = { 215 mode = "yaml"; 216 title = "Audio"; 217 icon = "mdi:speaker-multiple"; 218 show_in_sidebar = true; 219 filename = "dashboards/audio.yaml"; 220 }; 221 } 222 }:/config/packages/audio.yaml:ro" 223 "${json "audio-dashboard.yaml" audioDashboard}:/config/dashboards/audio.yaml:ro" 224 ]; 225 226 services.pipewire = { 227 enable = true; 228 systemWide = true; 229 audio.enable = true; 230 extraConfig.pipewire."10-audio-hub" = { 231 "context.properties"."default.clock.quantum" = 256; 232 "context.modules" = [ 233 { 234 # from any LAN sender (`gg23-audio`); roc mixes concurrent 235 # sender sessions. Only reachable via int0, et0 is closed. 236 name = "libpipewire-module-roc-source"; 237 args = { 238 "local.ip" = "0.0.0.0"; 239 "local.source.port" = 10001; 240 "local.repair.port" = 10002; 241 "local.control.port" = 10003; 242 "fec.code" = "disable"; 243 "sess.latency.msec" = 30; 244 "source.name" = "roc-lan"; 245 "source.props" = { 246 "node.description" = "LAN (ROC)"; 247 "node.autoconnect" = false; 248 }; 249 }; 250 } 251 ] 252 ++ map (r: { 253 name = "libpipewire-module-roc-sink"; 254 args = { 255 "remote.ip" = ip4 r; 256 "remote.source.port" = 10001; 257 "remote.repair.port" = 10002; 258 "remote.control.port" = 10003; 259 "fec.code" = "disable"; 260 "sink.name" = "roc-${r}"; 261 "sink.props"."node.description" = "${r} (ROC)"; 262 }; 263 }) (lib.attrNames rooms); 264 }; 265 # acheron is only an A2DP sink (no headset or LE audio roles); like the 266 # other sources, phones are linked only by audio-matrix 267 wireplumber.extraConfig."10-bluetooth-hub" = { 268 "monitor.bluez.properties" = { 269 "bluez5.roles" = [ "a2dp_sink" ]; 270 # phones in HD audio mode pick other codecs, whose input node fails 271 "bluez5.codecs" = [ 272 "sbc" 273 "sbc_xq" 274 "aac" 275 ]; 276 }; 277 "monitor.bluez.rules" = [ 278 { 279 matches = [ { "node.name" = "~bluez_input.*"; } ]; 280 actions.update-props."node.autoconnect" = false; 281 } 282 ]; 283 }; 284 }; 285 286 # The UB500 dongle (RTL8761BU) firmware comes from linux-firmware. 287 # Discoverable and pairable only after the "Bluetooth pairing" button. 288 hardware.bluetooth = { 289 enable = true; 290 # the hostname plugin would override Name and Class with the hostname 291 # and chassis type 292 disabledPlugins = [ "hostname" ]; 293 settings.General = { 294 Name = "gg23"; 295 # audio/video major class, loudspeaker, so phones show a speaker 296 Class = "0x200414"; 297 DiscoverableTimeout = pairingTimeout; 298 PairableTimeout = pairingTimeout; 299 # a phone that forgot acheron can pair again during the window 300 JustWorksRepairing = "always"; 301 }; 302 }; 303 304 # Accepts pairing without PIN (NoInputNoOutput is just works) and service 305 # connections of paired devices. 306 systemd.services.bluetooth-agent = { 307 wantedBy = [ "bluetooth.service" ]; 308 bindsTo = [ "bluetooth.service" ]; 309 after = [ "bluetooth.service" ]; 310 serviceConfig = { 311 ExecStart = "${pkgs.bluez-tools}/bin/bt-agent -c NoInputNoOutput"; 312 DynamicUser = true; 313 Restart = "always"; 314 RestartSec = 2; 315 }; 316 }; 317 318 # AirPlay 2: macOS drives classic AirPlay 1 receivers through a legacy path 319 # that can wedge (endpoint stays half-active after switching outputs, and 320 # re-picking it fails with -16727 until AirPlayXPCHelper restarts). 321 services.shairport-sync = { 322 enable = true; 323 package = pkgs.shairport-sync-airplay2; 324 settings = { 325 general = { 326 name = "gg23"; 327 output_backend = "pipewire"; 328 # play early by about the acheron -> room ROC hop, so AirPlay video 329 # stays in sync in the rooms 330 audio_backend_latency_offset_in_seconds = -0.04; 331 }; 332 }; 333 }; 334 users.users.shairport.extraGroups = [ "pipewire" ]; 335 336 # PTP timing companion AirPlay 2 needs (UDP 319/320; int0 is trusted, et0 337 # stays closed). The unit comes from the package. 338 systemd.packages = [ pkgs.nqptp ]; 339 systemd.services.nqptp.wantedBy = [ "multi-user.target" ]; 340 systemd.services.shairport-sync = { 341 wants = [ "nqptp.service" ]; 342 after = [ "nqptp.service" ]; 343 # linked only by audio-matrix 344 environment.PIPEWIRE_AUTOCONNECT = "false"; 345 }; 346 347 # radio.lassul.us as a hub source, playing all the time; audio-matrix 348 # decides where it is heard 349 systemd.services.radio = { 350 wantedBy = [ 351 "multi-user.target" 352 "pipewire.service" 353 ]; 354 bindsTo = [ "pipewire.service" ]; 355 after = [ 356 "pipewire.service" 357 "network-online.target" 358 ]; 359 wants = [ "network-online.target" ]; 360 # linked only by audio-matrix 361 environment.PIPEWIRE_AUTOCONNECT = "false"; 362 serviceConfig = { 363 ExecStart = lib.escapeShellArgs [ 364 (lib.getExe pkgs.mpv) 365 "--no-video" 366 "--no-terminal" 367 "--ao=pipewire" 368 # node.name, see `sources` in matrixConfig 369 "--audio-client-name=radio" 370 # keep going through stream hiccups and server restarts 371 "--loop-playlist=inf" 372 "http://radio.lassul.us/radio.ogg" 373 ]; 374 DynamicUser = true; 375 SupplementaryGroups = [ "pipewire" ]; 376 Restart = "always"; 377 RestartSec = 5; 378 }; 379 }; 380 381 # mDNS for AirPlay on the LAN only; int0 is trusted, et0 must stay closed 382 services.avahi = { 383 allowInterfaces = [ "int0" ]; 384 openFirewall = false; 385 }; 386 387 # MQTT -> pipewire links, so Home Assistant (in podman) needs no access to 388 # pipewire. Restarted with pipewire, the state file restores the routes. 389 systemd.services.audio-matrix = { 390 wantedBy = [ 391 "multi-user.target" 392 "pipewire.service" 393 ]; 394 bindsTo = [ "pipewire.service" ]; 395 after = [ 396 "pipewire.service" 397 "mosquitto.service" 398 ]; 399 path = [ 400 config.services.pipewire.package 401 config.services.pipewire.wireplumber.package # wpctl, for room volumes 402 ]; 403 serviceConfig = { 404 ExecStart = "${ 405 pkgs.writers.writePython3 "audio-matrix" { 406 libraries = [ pkgs.python3Packages.paho-mqtt ]; 407 flakeIgnore = [ "E501" ]; 408 } ./matrix.py 409 } ${matrixConfig}"; 410 DynamicUser = true; 411 SupplementaryGroups = [ "pipewire" ]; 412 StateDirectory = "audio-matrix"; 413 Restart = "always"; 414 RestartSec = 2; 415 }; 416 }; 417 418 # MQTT button -> pairing window. BlueZ itself turns discoverable/pairable 419 # off after pairingTimeout; then devices bonded in the window get trusted, 420 # so they reconnect without the agent, and the state goes back to OFF. 421 systemd.services.bluetooth-pairing = { 422 wantedBy = [ "bluetooth.service" ]; 423 bindsTo = [ "bluetooth.service" ]; 424 after = [ 425 "bluetooth.service" 426 "mosquitto.service" 427 ]; 428 path = [ 429 pkgs.mosquitto 430 config.systemd.package 431 ]; 432 script = '' 433 ${lib.concatMapStrings (d: '' 434 mosquitto_pub ${mqtt} -r -t ${d.topic} -m ${lib.escapeShellArg (builtins.toJSON d.config)} 435 '') bluetoothDiscovery} 436 adapter() { 437 busctl set-property org.bluez /org/bluez/hci0 org.bluez.Adapter1 "$1" b "$2" 438 } 439 state() { 440 mosquitto_pub ${mqtt} -r -t gg23/bluetooth-pairing/state -m "$1" 441 } 442 trust() { 443 for d in $(busctl tree --list org.bluez | grep '/dev_[0-9A-F_]*$'); do 444 if [ "$(busctl get-property org.bluez "$d" org.bluez.Device1 Bonded)" = "b true" ]; then 445 busctl set-property org.bluez "$d" org.bluez.Device1 Trusted b true 446 fi 447 done 448 } 449 # bluetoothd powers hci0 on (AutoEnable) only after it shows up 450 until [ "$(busctl get-property org.bluez /org/bluez/hci0 org.bluez.Adapter1 Powered 2>/dev/null)" = "b true" ]; do 451 sleep 1 452 done 453 adapter Discoverable false 454 adapter Pairable false 455 trust 456 state OFF 457 mosquitto_sub ${mqtt} -t gg23/bluetooth-pairing/set | while read -r _; do 458 if [ -n "''${window-}" ] && kill -0 "$window" 2>/dev/null; then 459 continue 460 fi 461 adapter Pairable true 462 adapter Discoverable true 463 state ON 464 (sleep ${toString pairingTimeout}; trust; state OFF) & 465 window=$! 466 done 467 ''; 468 serviceConfig = { 469 DynamicUser = true; 470 Restart = "always"; 471 RestartSec = 2; 472 }; 473 }; 474}