This repository has no description
0

Configure Feed

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

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 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); 205in 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 222 services.pipewire = { 223 enable = true; 224 systemWide = true; 225 audio.enable = true; 226 extraConfig.pipewire."10-audio-hub" = { 227 "context.properties"."default.clock.quantum" = 256; 228 "context.modules" = [ 229 { 230 # from any LAN sender (`gg23-audio`); roc mixes concurrent 231 # sender sessions. Only reachable via int0, et0 is closed. 232 name = "libpipewire-module-roc-source"; 233 args = { 234 "local.ip" = "0.0.0.0"; 235 "local.source.port" = 10001; 236 "local.repair.port" = 10002; 237 "local.control.port" = 10003; 238 "fec.code" = "disable"; 239 "sess.latency.msec" = 30; 240 "source.name" = "roc-lan"; 241 "source.props" = { 242 "node.description" = "LAN (ROC)"; 243 "node.autoconnect" = false; 244 }; 245 }; 246 } 247 ] 248 ++ map (r: { 249 name = "libpipewire-module-roc-sink"; 250 args = { 251 "remote.ip" = ip4 r; 252 "remote.source.port" = 10001; 253 "remote.repair.port" = 10002; 254 "remote.control.port" = 10003; 255 "fec.code" = "disable"; 256 "sink.name" = "roc-${r}"; 257 "sink.props"."node.description" = "${r} (ROC)"; 258 }; 259 }) (lib.attrNames rooms); 260 }; 261 # acheron is only an A2DP sink (no headset or LE audio roles); like the 262 # other sources, phones are linked only by audio-matrix 263 wireplumber.extraConfig."10-bluetooth-hub" = { 264 "monitor.bluez.properties" = { 265 "bluez5.roles" = [ "a2dp_sink" ]; 266 # phones in HD audio mode pick other codecs, whose input node fails 267 "bluez5.codecs" = [ 268 "sbc" 269 "sbc_xq" 270 "aac" 271 ]; 272 }; 273 "monitor.bluez.rules" = [ 274 { 275 matches = [ { "node.name" = "~bluez_input.*"; } ]; 276 actions.update-props."node.autoconnect" = false; 277 } 278 ]; 279 }; 280 }; 281 282 # The UB500 dongle (RTL8761BU) firmware comes from linux-firmware. 283 # Discoverable and pairable only after the "Bluetooth pairing" button. 284 hardware.bluetooth = { 285 enable = true; 286 # the hostname plugin would override Name and Class with the hostname 287 # and chassis type 288 disabledPlugins = [ "hostname" ]; 289 settings.General = { 290 Name = "gg23"; 291 # audio/video major class, loudspeaker, so phones show a speaker 292 Class = "0x200414"; 293 DiscoverableTimeout = pairingTimeout; 294 PairableTimeout = pairingTimeout; 295 # a phone that forgot acheron can pair again during the window 296 JustWorksRepairing = "always"; 297 }; 298 }; 299 300 # Accepts pairing without PIN (NoInputNoOutput is just works) and service 301 # connections of paired devices. 302 systemd.services.bluetooth-agent = { 303 wantedBy = [ "bluetooth.service" ]; 304 bindsTo = [ "bluetooth.service" ]; 305 after = [ "bluetooth.service" ]; 306 serviceConfig = { 307 ExecStart = "${pkgs.bluez-tools}/bin/bt-agent -c NoInputNoOutput"; 308 DynamicUser = true; 309 Restart = "always"; 310 RestartSec = 2; 311 }; 312 }; 313 314 # AirPlay 2: macOS drives classic AirPlay 1 receivers through a legacy path 315 # that can wedge (endpoint stays half-active after switching outputs, and 316 # re-picking it fails with -16727 until AirPlayXPCHelper restarts). 317 services.shairport-sync = { 318 enable = true; 319 package = pkgs.shairport-sync-airplay2; 320 settings = { 321 general = { 322 name = "gg23"; 323 output_backend = "pipewire"; 324 # play early by about the acheron -> room ROC hop, so AirPlay video 325 # stays in sync in the rooms 326 audio_backend_latency_offset_in_seconds = -0.04; 327 }; 328 }; 329 }; 330 users.users.shairport.extraGroups = [ "pipewire" ]; 331 332 # PTP timing companion AirPlay 2 needs (UDP 319/320; int0 is trusted, et0 333 # stays closed). The unit comes from the package. 334 systemd.packages = [ pkgs.nqptp ]; 335 systemd.services.nqptp.wantedBy = [ "multi-user.target" ]; 336 systemd.services.shairport-sync = { 337 wants = [ "nqptp.service" ]; 338 after = [ "nqptp.service" ]; 339 # linked only by audio-matrix 340 environment.PIPEWIRE_AUTOCONNECT = "false"; 341 }; 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 377 # mDNS for AirPlay on the LAN only; int0 is trusted, et0 must stay closed 378 services.avahi = { 379 allowInterfaces = [ "int0" ]; 380 openFirewall = false; 381 }; 382 383 # MQTT -> pipewire links, so Home Assistant (in podman) needs no access to 384 # pipewire. Restarted with pipewire, the state file restores the routes. 385 systemd.services.audio-matrix = { 386 wantedBy = [ 387 "multi-user.target" 388 "pipewire.service" 389 ]; 390 bindsTo = [ "pipewire.service" ]; 391 after = [ 392 "pipewire.service" 393 "mosquitto.service" 394 ]; 395 path = [ 396 config.services.pipewire.package 397 config.services.pipewire.wireplumber.package # wpctl, for room volumes 398 ]; 399 serviceConfig = { 400 ExecStart = "${ 401 pkgs.writers.writePython3 "audio-matrix" { 402 libraries = [ pkgs.python3Packages.paho-mqtt ]; 403 flakeIgnore = [ "E501" ]; 404 } ./matrix.py 405 } ${matrixConfig}"; 406 DynamicUser = true; 407 SupplementaryGroups = [ "pipewire" ]; 408 StateDirectory = "audio-matrix"; 409 Restart = "always"; 410 RestartSec = 2; 411 }; 412 }; 413 414 # MQTT button -> pairing window. BlueZ itself turns discoverable/pairable 415 # off after pairingTimeout; then devices bonded in the window get trusted, 416 # so they reconnect without the agent, and the state goes back to OFF. 417 systemd.services.bluetooth-pairing = { 418 wantedBy = [ "bluetooth.service" ]; 419 bindsTo = [ "bluetooth.service" ]; 420 after = [ 421 "bluetooth.service" 422 "mosquitto.service" 423 ]; 424 path = [ 425 pkgs.mosquitto 426 config.systemd.package 427 ]; 428 script = '' 429 ${lib.concatMapStrings (d: '' 430 mosquitto_pub ${mqtt} -r -t ${d.topic} -m ${lib.escapeShellArg (builtins.toJSON d.config)} 431 '') bluetoothDiscovery} 432 adapter() { 433 busctl set-property org.bluez /org/bluez/hci0 org.bluez.Adapter1 "$1" b "$2" 434 } 435 state() { 436 mosquitto_pub ${mqtt} -r -t gg23/bluetooth-pairing/state -m "$1" 437 } 438 trust() { 439 for d in $(busctl tree --list org.bluez | grep '/dev_[0-9A-F_]*$'); do 440 if [ "$(busctl get-property org.bluez "$d" org.bluez.Device1 Bonded)" = "b true" ]; then 441 busctl set-property org.bluez "$d" org.bluez.Device1 Trusted b true 442 fi 443 done 444 } 445 # bluetoothd powers hci0 on (AutoEnable) only after it shows up 446 until [ "$(busctl get-property org.bluez /org/bluez/hci0 org.bluez.Adapter1 Powered 2>/dev/null)" = "b true" ]; do 447 sleep 1 448 done 449 adapter Discoverable false 450 adapter Pairable false 451 trust 452 state OFF 453 mosquitto_sub ${mqtt} -t gg23/bluetooth-pairing/set | while read -r _; do 454 if [ -n "''${window-}" ] && kill -0 "$window" 2>/dev/null; then 455 continue 456 fi 457 adapter Pairable true 458 adapter Discoverable true 459 state ON 460 (sleep ${toString pairingTimeout}; trust; state OFF) & 461 window=$! 462 done 463 ''; 464 serviceConfig = { 465 DynamicUser = true; 466 Restart = "always"; 467 RestartSec = 2; 468 }; 469 }; 470}