This repository has no description
10 kB
333 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 settings.General = {
159 Name = "gg23";
160 # audio/video major class, loudspeaker, so phones show a speaker
161 Class = "0x200414";
162 DiscoverableTimeout = pairingTimeout;
163 PairableTimeout = pairingTimeout;
164 # a phone that forgot acheron can pair again during the window
165 JustWorksRepairing = "always";
166 };
167 };
168
169 # Accepts pairing without PIN (NoInputNoOutput is just works) and service
170 # connections of paired devices.
171 systemd.services.bluetooth-agent = {
172 wantedBy = [ "bluetooth.service" ];
173 bindsTo = [ "bluetooth.service" ];
174 after = [ "bluetooth.service" ];
175 serviceConfig = {
176 ExecStart = "${pkgs.bluez-tools}/bin/bt-agent -c NoInputNoOutput";
177 DynamicUser = true;
178 Restart = "always";
179 RestartSec = 2;
180 };
181 };
182
183 # AirPlay 2: macOS drives classic AirPlay 1 receivers through a legacy path
184 # that can wedge (endpoint stays half-active after switching outputs, and
185 # re-picking it fails with -16727 until AirPlayXPCHelper restarts).
186 services.shairport-sync = {
187 enable = true;
188 package = pkgs.shairport-sync-airplay2;
189 settings = {
190 general = {
191 name = "gg23";
192 output_backend = "pipewire";
193 # play early by about the hub -> room ROC hop, so AirPlay video
194 # stays in sync in the rooms
195 audio_backend_latency_offset_in_seconds = -0.04;
196 };
197 pipewire.sink_target = "hub";
198 };
199 };
200 users.users.shairport.extraGroups = [ "pipewire" ];
201
202 # PTP timing companion AirPlay 2 needs (UDP 319/320; int0 is trusted, et0
203 # stays closed). The unit comes from the package.
204 systemd.packages = [ pkgs.nqptp ];
205 systemd.services.nqptp.wantedBy = [ "multi-user.target" ];
206 systemd.services.shairport-sync = {
207 wants = [ "nqptp.service" ];
208 after = [ "nqptp.service" ];
209 };
210
211 # mDNS for AirPlay on the LAN only; int0 is trusted, et0 must stay closed
212 services.avahi = {
213 allowInterfaces = [ "int0" ];
214 openFirewall = false;
215 };
216
217 # MQTT -> pipewire links, so Home Assistant (in podman) needs no access to
218 # pipewire. Restarted with pipewire, the retained command restores the room.
219 systemd.services.audio-room = {
220 wantedBy = [
221 "multi-user.target"
222 "pipewire.service"
223 ];
224 bindsTo = [ "pipewire.service" ];
225 after = [
226 "pipewire.service"
227 "mosquitto.service"
228 ];
229 path = [
230 config.services.pipewire.package
231 pkgs.mosquitto
232 ];
233 script = ''
234 ${lib.concatMapStrings (d: ''
235 mosquitto_pub ${mqtt} -r -t ${d.topic} -m ${lib.escapeShellArg (builtins.toJSON d.config)}
236 '') discovery}
237 route() {
238 for r in ${toString rooms}; do
239 pw-link -d hub "roc-$r" 2>/dev/null
240 case "$1" in "$r" | both)
241 i=0
242 until pw-link hub "roc-$r"; do
243 i=$((i + 1))
244 [ "$i" -lt 10 ] || return 1
245 sleep 1
246 done
247 ;;
248 esac
249 done
250 }
251 mosquitto_sub ${mqtt} -t gg23/audio-room/set | while read -r room; do
252 case "$room" in
253 ${lib.concatStringsSep " | " choices}) ;;
254 *) echo "ignoring $room" >&2; continue ;;
255 esac
256 route "$room" && mosquitto_pub ${mqtt} -r -t gg23/audio-room/state -m "$room"
257 done
258 '';
259 serviceConfig = {
260 DynamicUser = true;
261 SupplementaryGroups = [ "pipewire" ];
262 Restart = "always";
263 RestartSec = 2;
264 };
265 };
266
267 # MQTT button -> pairing window. BlueZ itself turns discoverable/pairable
268 # off after pairingTimeout; then devices bonded in the window get trusted,
269 # so they reconnect without the agent, and the state goes back to OFF.
270 systemd.services.bluetooth-pairing = {
271 wantedBy = [ "bluetooth.service" ];
272 bindsTo = [ "bluetooth.service" ];
273 after = [
274 "bluetooth.service"
275 "mosquitto.service"
276 ];
277 path = [
278 pkgs.mosquitto
279 config.systemd.package
280 ];
281 script = ''
282 ${lib.concatMapStrings (d: ''
283 mosquitto_pub ${mqtt} -r -t ${d.topic} -m ${lib.escapeShellArg (builtins.toJSON d.config)}
284 '') bluetoothDiscovery}
285 adapter() {
286 busctl set-property org.bluez /org/bluez/hci0 org.bluez.Adapter1 "$1" b "$2"
287 }
288 state() {
289 mosquitto_pub ${mqtt} -r -t gg23/bluetooth-pairing/state -m "$1"
290 }
291 trust() {
292 for d in $(busctl tree --list org.bluez | grep '/dev_[0-9A-F_]*$'); do
293 if [ "$(busctl get-property org.bluez "$d" org.bluez.Device1 Bonded)" = "b true" ]; then
294 busctl set-property org.bluez "$d" org.bluez.Device1 Trusted b true
295 fi
296 done
297 }
298 adapter Discoverable false
299 adapter Pairable false
300 trust
301 state OFF
302 mosquitto_sub ${mqtt} -t gg23/bluetooth-pairing/set | while read -r _; do
303 if [ -n "''${window-}" ] && kill -0 "$window" 2>/dev/null; then
304 continue
305 fi
306 adapter Pairable true
307 adapter Discoverable true
308 state ON
309 (sleep ${toString pairingTimeout}; trust; state OFF) &
310 window=$!
311 done
312 '';
313 serviceConfig = {
314 DynamicUser = true;
315 Restart = "always";
316 RestartSec = 2;
317 };
318 };
319
320 environment.systemPackages = [
321 (pkgs.writers.writeDashBin "audio-room" ''
322 case "''${1-}" in
323 ${lib.concatStringsSep " | " choices})
324 exec ${pkgs.mosquitto}/bin/mosquitto_pub ${mqtt} -r -t gg23/audio-room/set -m "$1"
325 ;;
326 *)
327 echo "usage: audio-room ${lib.concatStringsSep "|" choices}" >&2
328 exit 1
329 ;;
330 esac
331 '')
332 ];
333}