This repository has no description
11 kB
340 lines
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}