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: 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. 8{ 9 self, 10 config, 11 lib, 12 pkgs, 13 ... 14}: 15let 16 # shodan and icarus have no stable LAN address (dynamic DHCP leases), so 17 # send to their static retiolum IPs; tinc's LocalDiscovery keeps that on the 18 # LAN. 19 kartei = import (self.inputs.kartei + "/modules/retiolum/hosts.nix") { inherit lib; }; 20 ip4 = n: kartei.own.${n}.ip4; 21 22 rooms = [ 23 "shodan" 24 "icarus" 25 ]; 26 choices = rooms ++ [ 27 "both" 28 "off" 29 ]; 30 31 mqtt = "-h localhost -u gg23 -P gg23-mqtt"; 32 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"; 47 }; 48 } 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; 61 62 # seconds acheron stays discoverable and pairable after the button press 63 pairingTimeout = 120; 64 65 bluetoothDiscovery = [ 66 { 67 topic = "homeassistant/button/bluetooth_pairing/config"; 68 config = { 69 name = "Bluetooth pairing"; 70 unique_id = "acheron_bluetooth_pairing"; 71 command_topic = "gg23/bluetooth-pairing/set"; 72 icon = "mdi:bluetooth-connect"; 73 }; 74 } 75 { 76 topic = "homeassistant/binary_sensor/bluetooth_pairing/config"; 77 config = { 78 name = "Bluetooth pairing"; 79 unique_id = "acheron_bluetooth_pairing_state"; 80 state_topic = "gg23/bluetooth-pairing/state"; 81 icon = "mdi:bluetooth-connect"; 82 }; 83 } 84 ]; 85in 86{ 87 services.pipewire = { 88 enable = true; 89 systemWide = true; 90 audio.enable = true; 91 extraConfig.pipewire."10-audio-hub" = { 92 "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 "context.modules" = [ 109 { 110 # from any LAN sender (`gg23-audio`); roc mixes concurrent 111 # sender sessions. Only reachable via int0, et0 is closed. 112 name = "libpipewire-module-roc-source"; 113 args = { 114 "local.ip" = "0.0.0.0"; 115 "local.source.port" = 10001; 116 "local.repair.port" = 10002; 117 "local.control.port" = 10003; 118 "fec.code" = "disable"; 119 "sess.latency.msec" = 30; 120 "source.name" = "roc-lan"; 121 "source.props" = { 122 "node.description" = "LAN (ROC)"; 123 "target.object" = "hub"; 124 }; 125 }; 126 } 127 ] 128 ++ map (r: { 129 name = "libpipewire-module-roc-sink"; 130 args = { 131 "remote.ip" = ip4 r; 132 "remote.source.port" = 10001; 133 "remote.repair.port" = 10002; 134 "remote.control.port" = 10003; 135 "fec.code" = "disable"; 136 "sink.name" = "roc-${r}"; 137 "sink.props"."node.description" = "${r} (ROC)"; 138 }; 139 }) rooms; 140 }; 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" 143 wireplumber.extraConfig."10-bluetooth-hub" = { 144 "monitor.bluez.properties"."bluez5.roles" = [ "a2dp_sink" ]; 145 "monitor.bluez.rules" = [ 146 { 147 matches = [ { "node.name" = "~bluez_input.*"; } ]; 148 actions.update-props."target.object" = "hub"; 149 } 150 ]; 151 }; 152 }; 153 154 # The UB500 dongle (RTL8761BU) firmware comes from linux-firmware. 155 # Discoverable and pairable only after the "Bluetooth pairing" button. 156 hardware.bluetooth = { 157 enable = true; 158 # the hostname plugin would override Name and Class with the hostname 159 # and chassis type 160 disabledPlugins = [ "hostname" ]; 161 settings.General = { 162 Name = "gg23"; 163 # audio/video major class, loudspeaker, so phones show a speaker 164 Class = "0x200414"; 165 DiscoverableTimeout = pairingTimeout; 166 PairableTimeout = pairingTimeout; 167 # a phone that forgot acheron can pair again during the window 168 JustWorksRepairing = "always"; 169 }; 170 }; 171 172 # Accepts pairing without PIN (NoInputNoOutput is just works) and service 173 # connections of paired devices. 174 systemd.services.bluetooth-agent = { 175 wantedBy = [ "bluetooth.service" ]; 176 bindsTo = [ "bluetooth.service" ]; 177 after = [ "bluetooth.service" ]; 178 serviceConfig = { 179 ExecStart = "${pkgs.bluez-tools}/bin/bt-agent -c NoInputNoOutput"; 180 DynamicUser = true; 181 Restart = "always"; 182 RestartSec = 2; 183 }; 184 }; 185 186 # AirPlay 2: macOS drives classic AirPlay 1 receivers through a legacy path 187 # that can wedge (endpoint stays half-active after switching outputs, and 188 # re-picking it fails with -16727 until AirPlayXPCHelper restarts). 189 services.shairport-sync = { 190 enable = true; 191 package = pkgs.shairport-sync-airplay2; 192 settings = { 193 general = { 194 name = "gg23"; 195 output_backend = "pipewire"; 196 # play early by about the hub -> room ROC hop, so AirPlay video 197 # stays in sync in the rooms 198 audio_backend_latency_offset_in_seconds = -0.04; 199 }; 200 pipewire.sink_target = "hub"; 201 }; 202 }; 203 users.users.shairport.extraGroups = [ "pipewire" ]; 204 205 # PTP timing companion AirPlay 2 needs (UDP 319/320; int0 is trusted, et0 206 # stays closed). The unit comes from the package. 207 systemd.packages = [ pkgs.nqptp ]; 208 systemd.services.nqptp.wantedBy = [ "multi-user.target" ]; 209 systemd.services.shairport-sync = { 210 wants = [ "nqptp.service" ]; 211 after = [ "nqptp.service" ]; 212 }; 213 214 # mDNS for AirPlay on the LAN only; int0 is trusted, et0 must stay closed 215 services.avahi = { 216 allowInterfaces = [ "int0" ]; 217 openFirewall = false; 218 }; 219 220 # MQTT -> pipewire links, so Home Assistant (in podman) needs no access to 221 # pipewire. Restarted with pipewire, the retained command restores the room. 222 systemd.services.audio-room = { 223 wantedBy = [ 224 "multi-user.target" 225 "pipewire.service" 226 ]; 227 bindsTo = [ "pipewire.service" ]; 228 after = [ 229 "pipewire.service" 230 "mosquitto.service" 231 ]; 232 path = [ 233 config.services.pipewire.package 234 pkgs.mosquitto 235 ]; 236 script = '' 237 ${lib.concatMapStrings (d: '' 238 mosquitto_pub ${mqtt} -r -t ${d.topic} -m ${lib.escapeShellArg (builtins.toJSON d.config)} 239 '') discovery} 240 route() { 241 for r in ${toString rooms}; do 242 pw-link -d hub "roc-$r" 2>/dev/null 243 case "$1" in "$r" | both) 244 i=0 245 until pw-link hub "roc-$r"; do 246 i=$((i + 1)) 247 [ "$i" -lt 10 ] || return 1 248 sleep 1 249 done 250 ;; 251 esac 252 done 253 } 254 mosquitto_sub ${mqtt} -t gg23/audio-room/set | while read -r room; do 255 case "$room" in 256 ${lib.concatStringsSep " | " choices}) ;; 257 *) echo "ignoring $room" >&2; continue ;; 258 esac 259 route "$room" && mosquitto_pub ${mqtt} -r -t gg23/audio-room/state -m "$room" 260 done 261 ''; 262 serviceConfig = { 263 DynamicUser = true; 264 SupplementaryGroups = [ "pipewire" ]; 265 Restart = "always"; 266 RestartSec = 2; 267 }; 268 }; 269 270 # MQTT button -> pairing window. BlueZ itself turns discoverable/pairable 271 # off after pairingTimeout; then devices bonded in the window get trusted, 272 # so they reconnect without the agent, and the state goes back to OFF. 273 systemd.services.bluetooth-pairing = { 274 wantedBy = [ "bluetooth.service" ]; 275 bindsTo = [ "bluetooth.service" ]; 276 after = [ 277 "bluetooth.service" 278 "mosquitto.service" 279 ]; 280 path = [ 281 pkgs.mosquitto 282 config.systemd.package 283 ]; 284 script = '' 285 ${lib.concatMapStrings (d: '' 286 mosquitto_pub ${mqtt} -r -t ${d.topic} -m ${lib.escapeShellArg (builtins.toJSON d.config)} 287 '') bluetoothDiscovery} 288 adapter() { 289 busctl set-property org.bluez /org/bluez/hci0 org.bluez.Adapter1 "$1" b "$2" 290 } 291 state() { 292 mosquitto_pub ${mqtt} -r -t gg23/bluetooth-pairing/state -m "$1" 293 } 294 trust() { 295 for d in $(busctl tree --list org.bluez | grep '/dev_[0-9A-F_]*$'); do 296 if [ "$(busctl get-property org.bluez "$d" org.bluez.Device1 Bonded)" = "b true" ]; then 297 busctl set-property org.bluez "$d" org.bluez.Device1 Trusted b true 298 fi 299 done 300 } 301 # bluetoothd powers hci0 on (AutoEnable) only after it shows up 302 until [ "$(busctl get-property org.bluez /org/bluez/hci0 org.bluez.Adapter1 Powered 2>/dev/null)" = "b true" ]; do 303 sleep 1 304 done 305 adapter Discoverable false 306 adapter Pairable false 307 trust 308 state OFF 309 mosquitto_sub ${mqtt} -t gg23/bluetooth-pairing/set | while read -r _; do 310 if [ -n "''${window-}" ] && kill -0 "$window" 2>/dev/null; then 311 continue 312 fi 313 adapter Pairable true 314 adapter Discoverable true 315 state ON 316 (sleep ${toString pairingTimeout}; trust; state OFF) & 317 window=$! 318 done 319 ''; 320 serviceConfig = { 321 DynamicUser = true; 322 Restart = "always"; 323 RestartSec = 2; 324 }; 325 }; 326 327 environment.systemPackages = [ 328 (pkgs.writers.writeDashBin "audio-room" '' 329 case "''${1-}" in 330 ${lib.concatStringsSep " | " choices}) 331 exec ${pkgs.mosquitto}/bin/mosquitto_pub ${mqtt} -r -t gg23/audio-room/set -m "$1" 332 ;; 333 *) 334 echo "usage: audio-room ${lib.concatStringsSep "|" choices}" >&2 335 exit 1 336 ;; 337 esac 338 '') 339 ]; 340}