@olares/lares-core 0.15.6 → 0.15.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +2 -1
  2. package/brand/identity.js +33 -3
  3. package/drive/cat.js +62 -0
  4. package/drive/download.js +3 -3
  5. package/drive/execute.js +8 -2
  6. package/drive/files-path.js +33 -0
  7. package/drive/flowstudio-files.js +87 -0
  8. package/drive/ls.js +3 -3
  9. package/drive/paths.js +23 -13
  10. package/drive/tools.js +4 -2
  11. package/entrance/surface.js +96 -0
  12. package/files/app-link.js +64 -0
  13. package/files/backend-client.js +266 -0
  14. package/files/deliverables.js +55 -1
  15. package/files/intake.js +0 -63
  16. package/files/mention.js +1 -51
  17. package/files/preview-groups.js +71 -3
  18. package/files/preview-local.js +69 -0
  19. package/files/preview-workspace.js +114 -13
  20. package/files/preview.js +171 -16
  21. package/files/produced-gate.js +197 -0
  22. package/files/produced-mentions.js +19 -0
  23. package/files/published-tools.js +69 -0
  24. package/files/published-turn-data.js +84 -0
  25. package/i18n/chat-model.js +5 -5
  26. package/i18n/mcp.js +83 -0
  27. package/i18n/mobile.js +100 -2
  28. package/i18n/preview.js +10 -0
  29. package/i18n/search.js +13 -7
  30. package/i18n/skills.js +31 -0
  31. package/i18n/voice.js +88 -19
  32. package/larepass/chat.js +9 -0
  33. package/larepass/host.js +7 -2
  34. package/larepass/mux.js +210 -2
  35. package/larepass/process-fold.js +239 -0
  36. package/larepass/queue.js +44 -0
  37. package/larepass/rpc.js +50 -3
  38. package/larepass/runtime.js +215 -83
  39. package/larepass/session-cache.js +6 -0
  40. package/larepass/settings.js +15 -7
  41. package/larepass/transcript.js +49 -11
  42. package/mcp/approval.js +152 -0
  43. package/mcp/client.js +38 -0
  44. package/mcp/config.js +139 -0
  45. package/media/catalog-row.js +244 -0
  46. package/media/generation.js +707 -0
  47. package/media/tool.js +92 -0
  48. package/olares/entrance.js +67 -15
  49. package/olares/identity.js +13 -136
  50. package/olares/session-identity.js +12 -12
  51. package/olares/trusted-host.js +0 -29
  52. package/package.json +48 -4
  53. package/router/catalog-cache.d.ts +1 -1
  54. package/router/catalog-cache.js +25 -14
  55. package/router/catalog-sync.js +8 -3
  56. package/router/catalog.d.ts +6 -1
  57. package/router/catalog.js +18 -2
  58. package/router/gateway.d.ts +9 -1
  59. package/router/gateway.js +40 -4
  60. package/router/gemini-schema.js +125 -0
  61. package/router/generation-guard.js +82 -0
  62. package/router/models.d.ts +6 -0
  63. package/router/models.js +22 -3
  64. package/router/search.js +10 -2
  65. package/router/session-model.js +53 -0
  66. package/router/shim.js +104 -3
  67. package/router/sref.js +126 -0
  68. package/router/stt.js +43 -12
  69. package/router/tts.js +118 -0
  70. package/search/client.js +5 -3
  71. package/search/config.js +53 -39
  72. package/search/menu.js +45 -10
  73. package/skills/client.js +42 -0
  74. package/skills/packs.js +40 -0
  75. package/skills/state.d.ts +22 -0
  76. package/skills/state.js +142 -0
  77. package/tools/crypto-uuid.js +3 -6
  78. package/tools/head-script.js +15 -0
  79. package/voice/agent.js +50 -0
  80. package/voice/capture.js +250 -0
  81. package/voice/client.js +126 -24
  82. package/voice/config.js +11 -1
  83. package/voice/duplex.js +44 -0
  84. package/voice/endpoint.js +57 -0
  85. package/voice/host/bluetooth-ready.py +1196 -0
  86. package/voice/host/install-host.sh +102 -0
  87. package/voice/input.js +4 -0
  88. package/voice/install.js +146 -0
  89. package/voice/languages.js +11 -1
  90. package/voice/link.js +86 -0
  91. package/voice/pcm.js +100 -0
  92. package/voice/playback.js +7 -0
  93. package/voice/problem.js +46 -0
  94. package/voice/recorder.js +79 -4
  95. package/voice/script.js +35 -0
  96. package/voice/service.js +253 -10
  97. package/voice/session.js +128 -0
  98. package/voice/speech.js +93 -0
  99. package/files/preview-cache.js +0 -100
@@ -0,0 +1,1196 @@
1
+ #!/usr/bin/env python3
2
+ """Bluetooth headset helper for voice capture.
3
+
4
+ ensure() makes a paired headset able to capture before a recording starts.
5
+ scan lists devices advertising right now. connect pairs and connects one of them.
6
+ After boot the radio has dropped the link, so serve() pages the trusted
7
+ headset again until it answers or the reconnect window closes.
8
+
9
+
10
+ A connected card is ready once it is on the microphone profile and that input
11
+ exists. Switching an already linked card does not wait for a silence probe.
12
+ The probe stays on a fresh connect, where the codec often stays quiet for
13
+ several seconds.
14
+ """
15
+
16
+ import ctypes
17
+ import glob
18
+ import json
19
+ import os
20
+ import pwd
21
+ import re
22
+ import signal
23
+ import socket
24
+ import struct
25
+ import subprocess
26
+ import sys
27
+ import time
28
+ import wave
29
+ from pathlib import Path
30
+
31
+ HEADSET_PROFILE = "headset-head-unit-msbc"
32
+ PROBE_SECONDS = "1.2"
33
+ LIVE_PEAK = 1
34
+ POLL_SECONDS = 12
35
+ PROFILE_WAIT_SECONDS = 3
36
+ SCAN_SECONDS = 8
37
+ MAX_DEVICES = 16
38
+ # A headset answers pages for about a minute after the host radio returns.
39
+ RESTORE_TRIES = 8
40
+ RESTORE_GAP_SECONDS = 3
41
+ ARM_TRIES = 15
42
+ ADDRESS = re.compile(r"^[0-9A-F]{2}(?::[0-9A-F]{2}){5}$")
43
+ DEVICE_LINE = re.compile(
44
+ r"\[(?:NEW|CHG)\] Device ([0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5})(?:\s+(.*))?$"
45
+ )
46
+ ANSI = re.compile(r"\x1b\[[0-9;]*m")
47
+ WIDEBAND = "/usr/local/sbin/bluetooth-wideband-speech"
48
+ AF_BLUETOOTH = 31
49
+ BTPROTO_HCI = 1
50
+ HCI_CHANNEL_CONTROL = 3
51
+ HCI_DEV_NONE = 0xFFFF
52
+ OP_READ_INFO = 0x0004
53
+ OP_POWER = 0x0005
54
+ OP_CONNECTABLE = 0x0007
55
+ OP_WBS = 0x0047
56
+ EV_COMPLETE = 0x0001
57
+ EV_STATUS = 0x0002
58
+ POWERED = 1 << 0
59
+ CONNECTABLE = 1 << 1
60
+ WIDE = 1 << 17
61
+ WIREPLUMBER_SNIPPET = """\
62
+ bluez_monitor.properties["with-logind"] = false
63
+ bluez_midi_monitor.properties["with-logind"] = false
64
+ bluez_monitor.properties["bluez5.hfphsp-backend"] = "native"
65
+ bluez_monitor.properties["bluez5.hfphsp-backend-native-modem"] = "any"
66
+ """
67
+
68
+
69
+ def audio_user():
70
+ try:
71
+ return pwd.getpwnam("olares")
72
+ except KeyError:
73
+ return None
74
+
75
+
76
+ def run(cmd, user=None, timeout=20):
77
+ if user is not None:
78
+ cmd = ["runuser", "-u", user.pw_name, "--", "env", f"XDG_RUNTIME_DIR=/run/user/{user.pw_uid}", *cmd]
79
+ return subprocess.run(cmd, text=True, capture_output=True, timeout=timeout)
80
+
81
+
82
+ def pcm_peak(path):
83
+ try:
84
+ with wave.open(path) as wav:
85
+ width = wav.getsampwidth()
86
+ raw = wav.readframes(wav.getnframes())
87
+ except (wave.Error, FileNotFoundError, EOFError):
88
+ return -1
89
+ if width != 2 or len(raw) < 2:
90
+ return -1
91
+ peak = 0
92
+ for offset in range(0, len(raw) - 1, 2):
93
+ sample = int.from_bytes(raw[offset:offset + 2], "little", signed=True)
94
+ magnitude = sample if sample >= 0 else -sample
95
+ if magnitude > peak:
96
+ peak = magnitude
97
+ return peak
98
+
99
+
100
+ def source_labels(text):
101
+ found = {}
102
+ name = ""
103
+ for raw in str(text or "").splitlines():
104
+ line = raw.strip()
105
+ if line.startswith("Name:"):
106
+ name = line.split(":", 1)[1].strip()
107
+ elif name and line.startswith("Description:"):
108
+ found[name] = line.split(":", 1)[1].strip()
109
+ name = ""
110
+ return found
111
+
112
+
113
+ def capture_ready(user, source):
114
+ listed = run(["pactl", "list", "sources"], user=user, timeout=8)
115
+ return {"ready": True, "source": source, "label": source_labels(listed.stdout).get(source, "")}
116
+
117
+
118
+ def bluez_sources(user):
119
+ listed = run(["pactl", "list", "sources", "short"], user=user, timeout=8)
120
+ sources = []
121
+ for line in listed.stdout.splitlines():
122
+ parts = line.split()
123
+ if len(parts) >= 2 and parts[1].startswith("bluez_input."):
124
+ sources.append(parts[1])
125
+ return sources
126
+
127
+
128
+ def probe(user, source):
129
+ path = "/tmp/lares-bt-probe.wav"
130
+ try:
131
+ os.remove(path)
132
+ except FileNotFoundError:
133
+ pass
134
+ cmd = [
135
+ "timeout", PROBE_SECONDS, "pw-record",
136
+ "--target", source,
137
+ "--rate=16000", "--channels=1", "--format=s16",
138
+ path,
139
+ ]
140
+ try:
141
+ run(cmd, user=user, timeout=8)
142
+ except subprocess.TimeoutExpired:
143
+ return -1
144
+ return pcm_peak(path)
145
+
146
+
147
+ def live_source(user):
148
+ for source in bluez_sources(user):
149
+ if probe(user, source) >= LIVE_PEAK:
150
+ return source
151
+ return ""
152
+
153
+
154
+ def paired_headsets():
155
+ listed = run(["bluetoothctl", "devices"], timeout=8)
156
+ headsets = []
157
+ for mac in known_addresses(listed.stdout):
158
+ info = run(["bluetoothctl", "info", mac], timeout=8)
159
+ text = info.stdout
160
+ if is_voice_device(text):
161
+ headsets.append((mac, "Connected: yes" in text))
162
+ return headsets
163
+
164
+
165
+ def card_for_node(node, prefix):
166
+ # bluez_input.AA_BB_CC_DD_EE_FF.0 → bluez_card.AA_BB_CC_DD_EE_FF
167
+ body = node.removeprefix(prefix)
168
+ mac = body.rsplit(".", 1)[0]
169
+ return f"bluez_card.{mac}"
170
+
171
+
172
+ def card_for_source(source):
173
+ return card_for_node(source, "bluez_input.")
174
+
175
+
176
+ def bluetooth_cards(user):
177
+ cards = []
178
+ for source in bluez_sources(user):
179
+ card = card_for_source(source)
180
+ if card not in cards:
181
+ cards.append(card)
182
+ listed = run(["pactl", "list", "sinks", "short"], user=user, timeout=8)
183
+ for line in listed.stdout.splitlines():
184
+ parts = line.split()
185
+ if len(parts) < 2 or not parts[1].startswith("bluez_output."):
186
+ continue
187
+ card = card_for_node(parts[1], "bluez_output.")
188
+ if card not in cards:
189
+ cards.append(card)
190
+ return cards
191
+
192
+
193
+ def call_sink_name(text):
194
+ """The call speaker. Wideband mSBC wins over narrowband CVSD."""
195
+ name = ""
196
+ chosen = {"msbc": "", "cvsd": ""}
197
+ for raw in str(text or "").splitlines():
198
+ line = raw.strip()
199
+ if line.startswith("Name: bluez_output."):
200
+ name = line.split("Name:", 1)[1].strip()
201
+ elif name and "api.bluez5.codec" in line:
202
+ codec = line.split("=", 1)[-1].strip().strip('"')
203
+ if codec in chosen and not chosen[codec]:
204
+ chosen[codec] = name
205
+ name = ""
206
+ return chosen["msbc"] or chosen["cvsd"]
207
+
208
+
209
+ def call_sink(user):
210
+ listed = run(["pactl", "list", "sinks"], user=user, timeout=8)
211
+ return call_sink_name(listed.stdout)
212
+
213
+
214
+ playback_proc = None
215
+ capture_proc = None
216
+ # Set when a sounding readout is cut. The next capture drops the speaker tail, once.
217
+ # A readout that played to its end leaves no tail, so the take keeps the first words.
218
+ playback_unsettled = False
219
+ CAPTURE_FILE = "capture.s16"
220
+ # WebRTC gain inside PipeWire. A voice that is not against the headset stays
221
+ # loud enough to transcribe. Pulse names are translated to webrtc.* by the module.
222
+ ECHO_SOURCE = "lares.echo-cancel-source"
223
+ ECHO_SINK = "lares.echo-cancel-sink"
224
+ ECHO_ARGS = "digital_gain_control=1 noise_suppression=1 high_pass_filter=1"
225
+
226
+
227
+ def consume_settle():
228
+ global playback_unsettled
229
+ due = playback_unsettled
230
+ playback_unsettled = False
231
+ return due
232
+
233
+
234
+ # A few minutes of 16 kHz mono. Past this the next take opens a new link.
235
+ CAPTURE_KEEP_BYTES = 8 * 1024 * 1024
236
+
237
+
238
+ def stop_playback():
239
+ """Drop a readout that is still running so the headset can return to the microphone."""
240
+ global playback_proc
241
+ proc = playback_proc
242
+ playback_proc = None
243
+ if proc is None or proc.poll() is not None:
244
+ return False
245
+ proc.terminate()
246
+ try:
247
+ proc.wait(timeout=2)
248
+ except subprocess.TimeoutExpired:
249
+ proc.kill()
250
+ return True
251
+
252
+
253
+ def interrupt_playback():
254
+ """The microphone takes the link. A readout it cuts still rings in the earcup."""
255
+ global playback_unsettled
256
+ if stop_playback():
257
+ playback_unsettled = True
258
+
259
+
260
+ def stop_playback_request():
261
+ interrupt_playback()
262
+ return {"op": "playback-stop", "ready": True, "stopped": True}
263
+
264
+
265
+ def stop_proc(proc):
266
+ if proc is None or proc.poll() is not None:
267
+ return
268
+ try:
269
+ os.killpg(proc.pid, signal.SIGTERM)
270
+ except ProcessLookupError:
271
+ return
272
+ try:
273
+ proc.wait(timeout=0.3)
274
+ except subprocess.TimeoutExpired:
275
+ try:
276
+ os.killpg(proc.pid, signal.SIGKILL)
277
+ except ProcessLookupError:
278
+ return
279
+ proc.wait(timeout=2)
280
+
281
+
282
+ def stop_capture():
283
+ """Drop the call link. Doing this at the end of a sentence stalls the machine."""
284
+ global capture_proc
285
+ proc = capture_proc
286
+ capture_proc = None
287
+ stop_proc(proc)
288
+
289
+
290
+ def keep_capture(alive, size):
291
+ return bool(alive) and 0 <= size < CAPTURE_KEEP_BYTES
292
+
293
+
294
+ def echo_cancel_text(user):
295
+ return run(["pactl", "list", "modules"], user=user, timeout=8).stdout
296
+
297
+
298
+ def echo_cancel_ready(user, source, sink):
299
+ text = echo_cancel_text(user)
300
+ return (
301
+ f"source_master={source}" in text
302
+ and f"sink_master={sink}" in text
303
+ and f"source_name={ECHO_SOURCE}" in text
304
+ and ECHO_ARGS in text
305
+ )
306
+
307
+
308
+ def unload_echo_cancel(user):
309
+ listed = run(["pactl", "list", "modules", "short"], user=user, timeout=8)
310
+ for line in listed.stdout.splitlines():
311
+ parts = line.split("\t")
312
+ if len(parts) >= 2 and parts[1] == "module-echo-cancel":
313
+ run(["pactl", "unload-module", parts[0]], user=user, timeout=8)
314
+
315
+
316
+ def ensure_echo_cancel(user, source):
317
+ """Capture through echo-cancel so a normal speaking distance is still intelligible.
318
+
319
+ The raw headset is quiet once you step back, and that is what the recognizer
320
+ mishears. The module's digital gain is the same control the browser mic used.
321
+ A missing module leaves the raw headset in place.
322
+ """
323
+ sink = call_sink(user)
324
+ if not source or not sink:
325
+ return source
326
+ if echo_cancel_ready(user, source, sink):
327
+ return ECHO_SOURCE
328
+ unload_echo_cancel(user)
329
+ loaded = run([
330
+ "pactl", "load-module", "module-echo-cancel",
331
+ f"source_master={source}",
332
+ f"sink_master={sink}",
333
+ f"source_name={ECHO_SOURCE}",
334
+ f"sink_name={ECHO_SINK}",
335
+ "rate=16000",
336
+ "channels=1",
337
+ "aec_method=webrtc",
338
+ f"aec_args={ECHO_ARGS}",
339
+ ], user=user, timeout=8)
340
+ if loaded.returncode != 0:
341
+ return source
342
+ names = run(["pactl", "list", "sources", "short"], user=user, timeout=8).stdout
343
+ if ECHO_SOURCE in names:
344
+ return ECHO_SOURCE
345
+ return source
346
+
347
+
348
+ def speaker_sink(user, sink):
349
+ names = run(["pactl", "list", "sinks", "short"], user=user, timeout=8).stdout
350
+ if ECHO_SINK in names:
351
+ return ECHO_SINK
352
+ return sink
353
+
354
+
355
+ def begin_capture(directory):
356
+ """Record the headset on this machine. The browser only watches the phase.
357
+
358
+ pw-cat writes the device latency as digital silence when the target is
359
+ stdout. A file target does not, and it still grows while the take is open.
360
+ The process stays up across takes: closing it drops the Bluetooth call
361
+ link, and that stalls the machine.
362
+ """
363
+ global capture_proc
364
+ path = Path(directory) / CAPTURE_FILE
365
+ if capture_proc is not None and capture_proc.poll() is None and path.is_file():
366
+ size = path.stat().st_size
367
+ if keep_capture(True, size):
368
+ interrupt_playback()
369
+ return {
370
+ "op": "capture-start",
371
+ "ready": True,
372
+ "source": "",
373
+ "file": CAPTURE_FILE,
374
+ "offset": size,
375
+ "settle": consume_settle(),
376
+ }
377
+ stop_capture()
378
+ ready = ensure()
379
+ ready["op"] = "capture-start"
380
+ if not ready.get("ready") or ready.get("skipped") or not ready.get("source"):
381
+ return ready
382
+ user = audio_user()
383
+ if user is None or not os.path.isdir(f"/run/user/{user.pw_uid}"):
384
+ return {"op": "capture-start", "ready": True, "skipped": True, "reason": "no desktop audio session"}
385
+ path = Path(directory) / CAPTURE_FILE
386
+ path.unlink(missing_ok=True)
387
+ source = ensure_echo_cancel(user, ready["source"])
388
+ proc = subprocess.Popen(
389
+ [
390
+ "runuser", "-u", user.pw_name, "--", "env", f"XDG_RUNTIME_DIR=/run/user/{user.pw_uid}",
391
+ "pw-cat", "--record", "--target", source,
392
+ "--rate", "16000", "--channels", "1", "--format", "s16",
393
+ "--latency", "40ms", str(path),
394
+ ],
395
+ stdout=subprocess.DEVNULL,
396
+ stderr=subprocess.DEVNULL,
397
+ start_new_session=True,
398
+ )
399
+ capture_proc = proc
400
+ time.sleep(0.2)
401
+ if proc.poll() is not None:
402
+ stop_capture()
403
+ return {"op": "capture-start", "ready": False, "error": "microphone did not start"}
404
+ if path.exists():
405
+ os.chmod(path, 0o644)
406
+ return {
407
+ "op": "capture-start",
408
+ "ready": True,
409
+ "source": ready["source"],
410
+ "label": ready.get("label", ""),
411
+ "file": CAPTURE_FILE,
412
+ "offset": 0,
413
+ "settle": consume_settle(),
414
+ }
415
+
416
+
417
+ def end_capture():
418
+ return {"op": "capture-stop", "ready": True}
419
+
420
+
421
+ def reset_capture():
422
+ """A take heard only digital zeros. The kernel can lose the SCO handle
423
+ ("SCO packet for unknown connection handle") while the card stays on the
424
+ call profile, and reopening the recorder keeps that dead link. Leaving the
425
+ profile rebuilds it.
426
+ """
427
+ stop_capture()
428
+ user = audio_user()
429
+ if user is not None and os.path.isdir(f"/run/user/{user.pw_uid}"):
430
+ for card in bluetooth_cards(user):
431
+ run(["pactl", "set-card-profile", card, "off"], user=user, timeout=8)
432
+ run(["pactl", "set-card-profile", card, HEADSET_PROFILE], user=user, timeout=8)
433
+ return {"op": "capture-reset", "ready": True}
434
+
435
+
436
+ def spawn(cmd, user):
437
+ full = ["runuser", "-u", user.pw_name, "--", "env", f"XDG_RUNTIME_DIR=/run/user/{user.pw_uid}", *cmd]
438
+ return subprocess.Popen(full, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
439
+
440
+
441
+ def play_file(directory, name):
442
+ """Play speech on the machine the headset is plugged into.
443
+
444
+ The browser's player reports success without opening a playback stream, so
445
+ a readout has to be played here. Ready means the speaker monitor heard it.
446
+ """
447
+ global playback_proc, playback_unsettled
448
+ safe = Path(str(name or "")).name
449
+ path = Path(directory) / safe
450
+ if safe != str(name or "") or not safe.endswith(".wav") or not path.is_file():
451
+ return {"op": "play", "ready": False, "error": "playback file is missing"}
452
+ user = audio_user()
453
+ if user is None or not os.path.isdir(f"/run/user/{user.pw_uid}"):
454
+ return {"op": "play", "ready": True, "skipped": True}
455
+ os.chmod(path, 0o644)
456
+ prepared = prepare_playback()
457
+ if prepared.get("skipped"):
458
+ return {"op": "play", "ready": True, "skipped": True}
459
+ if not prepared.get("ready"):
460
+ return {"op": "play", "ready": False, "error": prepared.get("error") or "bluetooth speaker did not become ready"}
461
+ sink = speaker_sink(user, prepared["sink"])
462
+ run(["pactl", "set-sink-volume", sink, "80%"], user=user, timeout=8)
463
+ stop_playback()
464
+ playback_unsettled = False
465
+ proc = spawn(["pw-play", "--target", sink, str(path)], user)
466
+ playback_proc = proc
467
+ for stale in Path(directory).glob("speech-*.wav"):
468
+ if stale.name != safe:
469
+ stale.unlink(missing_ok=True)
470
+ time.sleep(0.2)
471
+ if proc.poll() is not None:
472
+ err = proc.stderr.read().decode(errors="replace")[:200].strip() if proc.stderr else ""
473
+ return {"op": "play", "ready": False, "playing": False, "error": err or "playback ended before it started"}
474
+ # The headset monitor stays digital silence while the earcup plays.
475
+ heard = probe(user, f"{sink}.monitor") >= LIVE_PEAK or (
476
+ sink in (call_sink(user), ECHO_SINK) and proc.poll() is None
477
+ )
478
+ if heard:
479
+ return {"op": "play", "ready": True, "playing": True, "sink": sink}
480
+ stop_playback()
481
+ return {"op": "play", "ready": False, "playing": False, "error": "speaker did not receive audio"}
482
+
483
+
484
+ def open_stream(directory, name, rate=24000, channels=1):
485
+ """Start the speaker and return once it is waiting for PCM.
486
+
487
+ The caller writes 24 kHz mono s16le into the fifo as each sentence arrives.
488
+ pw-cat reads raw PCM from stdin; giving it the fifo path makes sndfile
489
+ try to parse the stream as a container and exit before any sample plays.
490
+ """
491
+ global playback_proc, playback_unsettled
492
+ safe = Path(str(name or "")).name
493
+ try:
494
+ rate = int(rate)
495
+ channels = int(channels)
496
+ except (TypeError, ValueError):
497
+ return {"op": "play-stream", "ready": False, "error": "invalid playback format"}
498
+ if safe != str(name or "") or not safe.endswith(".pcm") or rate < 8000 or rate > 96000 or channels != 1:
499
+ return {"op": "play-stream", "ready": False, "error": "invalid playback format"}
500
+ user = audio_user()
501
+ if user is None or not os.path.isdir(f"/run/user/{user.pw_uid}"):
502
+ return {"op": "play-stream", "ready": True, "skipped": True}
503
+ prepared = prepare_playback()
504
+ if prepared.get("skipped"):
505
+ return {"op": "play-stream", "ready": True, "skipped": True}
506
+ if not prepared.get("ready"):
507
+ return {"op": "play-stream", "ready": False, "error": prepared.get("error") or "bluetooth speaker did not become ready"}
508
+ sink = speaker_sink(user, prepared["sink"])
509
+ run(["pactl", "set-sink-volume", sink, "80%"], user=user, timeout=8)
510
+ stop_playback()
511
+ playback_unsettled = False
512
+ path = Path(directory) / safe
513
+ for stale in Path(directory).glob("speech-*.pcm"):
514
+ if stale.name != safe:
515
+ stale.unlink(missing_ok=True)
516
+ if path.exists():
517
+ path.unlink()
518
+ os.mkfifo(path, 0o666)
519
+ os.chmod(path, 0o666)
520
+ proc = spawn([
521
+ "sh", "-c",
522
+ 'exec pw-cat --playback --target "$1" --rate "$2" --channels 1 --format s16 --latency 40ms - < "$3"',
523
+ "pw-cat",
524
+ sink,
525
+ str(rate),
526
+ str(path),
527
+ ], user)
528
+ playback_proc = proc
529
+ time.sleep(0.2)
530
+ if proc.poll() is not None:
531
+ err = proc.stderr.read().decode(errors="replace")[:200].strip() if proc.stderr else ""
532
+ return {"op": "play-stream", "ready": False, "playing": False, "error": err or "playback ended before it started"}
533
+ return {"op": "play-stream", "ready": True, "playing": True, "sink": sink, "file": safe}
534
+
535
+
536
+ def prepare_playback():
537
+ """Play on the call profile.
538
+
539
+ The card stays on the microphone profile, so the next recording does not
540
+ reopen the headset link. The call speaker is mSBC, 16 kHz mono.
541
+ """
542
+ user = audio_user()
543
+ if user is None or not os.path.isdir(f"/run/user/{user.pw_uid}"):
544
+ return {"op": "playback", "ready": True, "skipped": True}
545
+ cards = bluetooth_cards(user)
546
+ if not cards:
547
+ connect_headset()
548
+ cards = bluetooth_cards(user)
549
+ listed = run(["pactl", "list", "cards"], user=user, timeout=8)
550
+ for card in cards_to_switch(cards, active_profiles(listed.stdout)):
551
+ run(["pactl", "set-card-profile", card, HEADSET_PROFILE], user=user, timeout=8)
552
+ deadline = time.monotonic() + 8
553
+ while time.monotonic() < deadline:
554
+ sink = call_sink(user)
555
+ if sink:
556
+ sources = bluez_sources(user)
557
+ if sources:
558
+ ensure_echo_cancel(user, sources[0])
559
+ run(["pactl", "set-default-sink", sink], user=user, timeout=8)
560
+ return {"op": "playback", "ready": True, "sink": speaker_sink(user, sink)}
561
+ time.sleep(0.4)
562
+ return {"op": "playback", "ready": False, "error": "bluetooth speaker did not become ready"}
563
+
564
+
565
+ def ensure_wireplumber(user):
566
+ path = Path(user.pw_dir) / ".config/wireplumber/bluetooth.lua.d/80-bluetooth-without-seat.lua"
567
+ current = path.read_text() if path.exists() else ""
568
+ if "hfphsp-backend-native-modem" in current and "enable-msbc" not in current and "with-logind" in current:
569
+ return False
570
+ path.parent.mkdir(parents=True, exist_ok=True)
571
+ path.write_text(WIREPLUMBER_SNIPPET)
572
+ os.chown(path, user.pw_uid, user.pw_gid)
573
+ run(["systemctl", "--user", "restart", "wireplumber"], user=user, timeout=30)
574
+ time.sleep(2)
575
+ return True
576
+
577
+
578
+ def wideband_ready(settings):
579
+ return bool(settings is not None and settings & POWERED and settings & WIDE)
580
+
581
+
582
+ class SockAddrHCI(ctypes.Structure):
583
+ _fields_ = [
584
+ ("hci_family", ctypes.c_ushort),
585
+ ("hci_dev", ctypes.c_ushort),
586
+ ("hci_channel", ctypes.c_ushort),
587
+ ]
588
+
589
+
590
+ def bind_management(sock):
591
+ """Open the kernel management channel.
592
+
593
+ Python's socket.bind on an HCI socket only accepts a device index and
594
+ forces the raw channel, so the management socket never comes up and the
595
+ adapter looks missing.
596
+ """
597
+ addr = SockAddrHCI(AF_BLUETOOTH, HCI_DEV_NONE, HCI_CHANNEL_CONTROL)
598
+ err = ctypes.CDLL(None, use_errno=True).bind(
599
+ sock.fileno(), ctypes.byref(addr), ctypes.sizeof(addr),
600
+ )
601
+ if err != 0:
602
+ code = ctypes.get_errno()
603
+ raise OSError(code, os.strerror(code))
604
+
605
+
606
+ def bluetooth_audio_plugin():
607
+ return bool(glob.glob("/usr/lib/*/spa-0.2/bluez5/libspa-bluez5.so"))
608
+
609
+
610
+ def _mgmt_recv(sock):
611
+ # One recv is one datagram. A short read discards the rest of the message.
612
+ data = sock.recv(4096)
613
+ if len(data) < 6:
614
+ raise OSError("bluetooth management socket closed")
615
+ event, _index, length = struct.unpack_from("<HHH", data)
616
+ body = data[6:6 + length]
617
+ if len(body) < length:
618
+ raise OSError("truncated bluetooth management packet")
619
+ return event, body
620
+
621
+
622
+ def _mgmt_transact(sock, opcode, payload=b""):
623
+ sock.sendall(struct.pack("<HHH", opcode, 0, len(payload)) + payload)
624
+ while True:
625
+ event, body = _mgmt_recv(sock)
626
+ if len(body) < 3:
627
+ continue
628
+ op, status = struct.unpack_from("<HB", body)
629
+ if op != opcode or event not in (EV_COMPLETE, EV_STATUS):
630
+ continue
631
+ if status != 0:
632
+ raise OSError(f"management op {opcode:#06x} failed with status {status:#04x}")
633
+ return body
634
+
635
+
636
+ def controller_settings():
637
+ try:
638
+ sock = socket.socket(AF_BLUETOOTH, socket.SOCK_RAW, BTPROTO_HCI)
639
+ except OSError:
640
+ return None
641
+ sock.settimeout(8)
642
+ try:
643
+ bind_management(sock)
644
+ body = _mgmt_transact(sock, OP_READ_INFO)
645
+ except (OSError, TimeoutError):
646
+ return None
647
+ finally:
648
+ sock.close()
649
+ if len(body) < 20:
650
+ return None
651
+ return struct.unpack_from("<I", body, 16)[0]
652
+
653
+
654
+ def enable_wideband():
655
+ sock = socket.socket(AF_BLUETOOTH, socket.SOCK_RAW, BTPROTO_HCI)
656
+ sock.settimeout(8)
657
+ try:
658
+ bind_management(sock)
659
+ _mgmt_transact(sock, OP_POWER, bytes([0]))
660
+ _mgmt_transact(sock, OP_WBS, bytes([1]))
661
+ _mgmt_transact(sock, OP_POWER, bytes([1]))
662
+ body = _mgmt_transact(sock, OP_CONNECTABLE, bytes([1]))
663
+ finally:
664
+ sock.close()
665
+ if len(body) < 7:
666
+ return None
667
+ return struct.unpack_from("<I", body, 3)[0]
668
+
669
+
670
+ def ensure_wideband():
671
+ """Turn wideband speech on. BlueZ 5.72 forgets it, and the kernel only accepts it while the radio is off."""
672
+ settings = controller_settings()
673
+ if settings is None:
674
+ if os.path.isfile(WIDEBAND):
675
+ run([WIDEBAND], timeout=20)
676
+ return "" if controller_settings() is not None else "bluetooth adapter is not available"
677
+ return "bluetooth adapter is not available"
678
+ if wideband_ready(settings):
679
+ return ""
680
+ try:
681
+ enabled = enable_wideband()
682
+ except (OSError, TimeoutError):
683
+ enabled = None
684
+ if wideband_ready(enabled) or wideband_ready(controller_settings()):
685
+ return ""
686
+ if os.path.isfile(WIDEBAND):
687
+ run([WIDEBAND], timeout=20)
688
+ if wideband_ready(controller_settings()):
689
+ return ""
690
+ return "bluetooth wideband speech is off"
691
+
692
+
693
+ def prepare_audio():
694
+ if not bluetooth_audio_plugin():
695
+ return "bluetooth audio is not available"
696
+ err = ensure_wideband()
697
+ if err:
698
+ return err
699
+ user = audio_user()
700
+ if user is not None and os.path.isdir(f"/run/user/{user.pw_uid}"):
701
+ ensure_wireplumber(user)
702
+ return ""
703
+
704
+
705
+ def connect_headset(timeout=20):
706
+ headsets = paired_headsets()
707
+ if not headsets:
708
+ return ""
709
+ connected = [mac for mac, is_up in headsets if is_up]
710
+ mac = connected[0] if connected else headsets[0][0]
711
+ if mac not in connected:
712
+ run(["bluetoothctl", "connect", mac], timeout=timeout)
713
+ time.sleep(1)
714
+ return mac
715
+
716
+
717
+ def new_restore():
718
+ return {"phase": "connect", "attempts": 0, "arm_attempts": 0, "next_at": 0.0}
719
+
720
+
721
+ def restore_step(state, headsets, session_ready, armed, now):
722
+ """What boot should do next for the trusted headset. No radio I/O here.
723
+
724
+ A connected call sink is done. A link without a desktop session waits.
725
+ Otherwise page the headset a bounded number of times.
726
+ """
727
+ if state.get("phase") == "done":
728
+ return state, None
729
+ attempts = state.get("attempts", 0)
730
+ arm_attempts = state.get("arm_attempts", 0)
731
+ up = any(connected for _, connected in headsets or [])
732
+ if now < state.get("next_at", 0) and not (up and armed):
733
+ return state, None
734
+ if up and armed:
735
+ return {**state, "phase": "done"}, None
736
+ if up and not session_ready:
737
+ return {**state, "phase": "wait", "next_at": now + 5}, None
738
+ if up:
739
+ if arm_attempts >= ARM_TRIES:
740
+ return {**state, "phase": "done"}, None
741
+ return {
742
+ **state,
743
+ "phase": "arm",
744
+ "arm_attempts": arm_attempts + 1,
745
+ "next_at": now + 2,
746
+ }, "arm"
747
+ if attempts >= RESTORE_TRIES:
748
+ return {**state, "phase": "done"}, None
749
+ # An empty list at startup is bluetoothd still loading, not "no headset".
750
+ if not headsets:
751
+ return {
752
+ **state,
753
+ "phase": "connect",
754
+ "attempts": attempts + 1,
755
+ "next_at": now + RESTORE_GAP_SECONDS,
756
+ }, None
757
+ return {
758
+ **state,
759
+ "phase": "connect",
760
+ "attempts": attempts + 1,
761
+ "next_at": now + RESTORE_GAP_SECONDS,
762
+ }, "connect"
763
+
764
+
765
+ def tick_restore(state, now=None):
766
+ """Page a trusted headset that the reboot disconnected. Requests still win."""
767
+ now = time.monotonic() if now is None else now
768
+ if state.get("phase") == "done" or now < state.get("next_at", 0):
769
+ return state
770
+ try:
771
+ headsets = paired_headsets()
772
+ except (OSError, subprocess.SubprocessError):
773
+ return {**state, "next_at": now + RESTORE_GAP_SECONDS}
774
+ user = audio_user()
775
+ session = user is not None and os.path.isdir(f"/run/user/{user.pw_uid}")
776
+ armed = False
777
+ if session:
778
+ try:
779
+ armed = bool(call_sink(user))
780
+ except (OSError, subprocess.SubprocessError):
781
+ armed = False
782
+ state, action = restore_step(state, headsets, session, armed, now)
783
+ try:
784
+ if action == "connect":
785
+ connect_headset(timeout=12)
786
+ elif action == "arm" and user is not None:
787
+ select_headset(user)
788
+ except (OSError, subprocess.SubprocessError):
789
+ pass
790
+ return state
791
+
792
+
793
+ def active_profiles(text):
794
+ found = {}
795
+ name = ""
796
+ for raw in str(text or "").splitlines():
797
+ line = raw.strip()
798
+ if line.startswith("Name:"):
799
+ name = line.split(":", 1)[1].strip()
800
+ elif name and line.startswith("Active Profile:"):
801
+ found[name] = line.split(":", 1)[1].strip()
802
+ name = ""
803
+ return found
804
+
805
+
806
+ def cards_to_switch(cards, profiles):
807
+ return [card for card in cards if profiles.get(card) != HEADSET_PROFILE]
808
+
809
+
810
+ def arm_headset(user):
811
+ """Return the headset input, switching off the speaker profile only when needed.
812
+
813
+ The profile change is the whole wait. A silence probe is for a cold connect.
814
+ """
815
+ cards = bluetooth_cards(user)
816
+ if not cards:
817
+ return ""
818
+ listed = run(["pactl", "list", "cards"], user=user, timeout=8)
819
+ for card in cards_to_switch(cards, active_profiles(listed.stdout)):
820
+ run(["pactl", "set-card-profile", card, HEADSET_PROFILE], user=user, timeout=8)
821
+ deadline = time.monotonic() + PROFILE_WAIT_SECONDS
822
+ while True:
823
+ sources = bluez_sources(user)
824
+ if sources:
825
+ source = sources[0]
826
+ run(["pactl", "set-default-source", source], user=user, timeout=8)
827
+ return source
828
+ if time.monotonic() >= deadline:
829
+ return ""
830
+ time.sleep(0.2)
831
+
832
+
833
+ def select_profile(user, source):
834
+ if not source:
835
+ return
836
+ run(["pactl", "set-card-profile", card_for_source(source), HEADSET_PROFILE], user=user, timeout=8)
837
+ run(["pactl", "set-default-source", source], user=user, timeout=8)
838
+
839
+
840
+ def select_headset(user):
841
+ for card in bluetooth_cards(user):
842
+ run(["pactl", "set-card-profile", card, HEADSET_PROFILE], user=user, timeout=8)
843
+
844
+
845
+ def wait_until_live(user, seconds):
846
+ deadline = time.monotonic() + seconds
847
+ while time.monotonic() < deadline:
848
+ source = live_source(user)
849
+ if source:
850
+ select_profile(user, source)
851
+ return source
852
+ time.sleep(1)
853
+ return ""
854
+
855
+
856
+ def ensure():
857
+ interrupt_playback()
858
+ user = audio_user()
859
+ if user is None or not os.path.isdir(f"/run/user/{user.pw_uid}"):
860
+ return {"ready": True, "skipped": True, "reason": "no desktop audio session"}
861
+ source = arm_headset(user)
862
+ if source:
863
+ return capture_ready(user, source)
864
+ ensure_wideband()
865
+ ensure_wireplumber(user)
866
+ connect_headset()
867
+ select_headset(user)
868
+ source = wait_until_live(user, POLL_SECONDS)
869
+ if source:
870
+ return capture_ready(user, source)
871
+ # The first codec answer is often dropped. Reconnecting gives the retry a clean link.
872
+ headsets = paired_headsets()
873
+ if headsets:
874
+ mac = headsets[0][0]
875
+ run(["bluetoothctl", "disconnect", mac], timeout=15)
876
+ time.sleep(1)
877
+ run(["bluetoothctl", "connect", mac], timeout=20)
878
+ time.sleep(1)
879
+ select_headset(user)
880
+ source = wait_until_live(user, POLL_SECONDS)
881
+ if source:
882
+ return capture_ready(user, source)
883
+ if not headsets and not bluez_sources(user):
884
+ return {"ready": True, "skipped": True, "reason": "no bluetooth headset"}
885
+ return {"ready": False, "error": "bluetooth headset is not capturing"}
886
+
887
+
888
+ def valid_address(address):
889
+ return bool(ADDRESS.fullmatch(str(address or "").strip().upper()))
890
+
891
+
892
+ def parse_scan(text):
893
+ found = {}
894
+ order = []
895
+ for raw in str(text or "").splitlines():
896
+ match = DEVICE_LINE.search(ANSI.sub("", raw).strip())
897
+ if not match:
898
+ continue
899
+ mac = match.group(1).upper()
900
+ rest = (match.group(2) or "").strip()
901
+ if mac not in found:
902
+ order.append(mac)
903
+ found[mac] = {"name": "", "alias": ""}
904
+ entry = found[mac]
905
+ if rest.startswith("Alias:"):
906
+ entry["alias"] = rest.split(":", 1)[1].strip()
907
+ elif rest.startswith("Name:"):
908
+ entry["name"] = rest.split(":", 1)[1].strip()
909
+ elif rest and ":" not in rest.split(" ", 1)[0] and not entry["name"]:
910
+ entry["name"] = rest
911
+ return [{"address": mac, "name": found[mac]["alias"] or found[mac]["name"]} for mac in order]
912
+
913
+
914
+ def device_name(text):
915
+ alias = ""
916
+ name = ""
917
+ for line in str(text or "").splitlines():
918
+ stripped = line.strip()
919
+ if stripped.startswith("Alias:"):
920
+ alias = stripped.split(":", 1)[1].strip()
921
+ elif stripped.startswith("Name:"):
922
+ name = stripped.split(":", 1)[1].strip()
923
+ return alias or name
924
+
925
+
926
+ def known_addresses(text):
927
+ found = []
928
+ for line in ANSI.sub("", str(text or "")).splitlines():
929
+ parts = line.split()
930
+ if len(parts) >= 2 and parts[0] == "Device" and valid_address(parts[1]):
931
+ mac = parts[1].upper()
932
+ if mac not in found:
933
+ found.append(mac)
934
+ return found
935
+
936
+
937
+ def address_named(address, name):
938
+ shown = re.sub(r"[^0-9A-F]", "", str(name or "").upper())
939
+ return bool(shown) and shown == re.sub(r"[^0-9A-F]", "", str(address or "").upper())
940
+
941
+
942
+ def is_voice_device(text):
943
+ """A headset. Phones and computers also advertise the headset UUIDs."""
944
+ plain = ANSI.sub("", str(text or ""))
945
+ icon = ""
946
+ for line in plain.splitlines():
947
+ stripped = line.strip()
948
+ if stripped.startswith("Icon:"):
949
+ icon = stripped.split(":", 1)[1].strip()
950
+ break
951
+ if icon in ("computer", "phone", "video-display"):
952
+ return False
953
+ if icon in ("audio-headset", "audio-headphones"):
954
+ return True
955
+ match = re.search(r"Class:\s+0x([0-9A-Fa-f]+)", plain)
956
+ if not match:
957
+ return False
958
+ value = int(match.group(1), 16)
959
+ major = (value >> 8) & 0x1F
960
+ minor = (value >> 2) & 0x3F
961
+ return major == 0x04 and minor in {0x01, 0x02, 0x04, 0x06, 0x07}
962
+
963
+
964
+ def scan_candidates(scanned, known):
965
+ """Paired devices first, then named advertisers. An address-shaped name is not a device."""
966
+ names = {}
967
+ order = []
968
+ for mac in known or []:
969
+ mac = str(mac or "").upper()
970
+ if not valid_address(mac) or mac in names:
971
+ continue
972
+ names[mac] = ""
973
+ order.append(mac)
974
+ for item in scanned or []:
975
+ mac = str(item.get("address") or "").upper()
976
+ if not valid_address(mac) or mac in names:
977
+ continue
978
+ name = item.get("name") or ""
979
+ if address_named(mac, name):
980
+ continue
981
+ names[mac] = name
982
+ order.append(mac)
983
+ return [{"address": mac, "name": names[mac]} for mac in order]
984
+
985
+
986
+ def voice_devices(candidates):
987
+ """Voice devices only. A connected headset stays even if it did not advertise,
988
+ and a second address with the same name does not show up as disconnected.
989
+ """
990
+ found = []
991
+ for item in candidates or []:
992
+ info = ANSI.sub("", str(item.get("info") or ""))
993
+ if not is_voice_device(info):
994
+ continue
995
+ mac = str(item.get("address") or "").upper()
996
+ if not valid_address(mac):
997
+ continue
998
+ name = device_name(info) or item.get("name") or mac
999
+ if address_named(mac, name):
1000
+ continue
1001
+ found.append({
1002
+ "address": mac,
1003
+ "name": name,
1004
+ "paired": "Paired: yes" in info,
1005
+ "connected": "Connected: yes" in info,
1006
+ })
1007
+ connected_names = {item["name"] for item in found if item["connected"]}
1008
+ kept = [
1009
+ item for item in found
1010
+ if item["connected"] or item["name"] not in connected_names
1011
+ ]
1012
+ kept.sort(key=lambda item: not item["connected"])
1013
+ return kept[:MAX_DEVICES]
1014
+
1015
+
1016
+ def source_for_address(user, mac):
1017
+ token = mac.replace(":", "_")
1018
+ for source in bluez_sources(user):
1019
+ if token in source:
1020
+ return source
1021
+ return ""
1022
+
1023
+
1024
+ def wait_for_source(user, mac, seconds):
1025
+ deadline = time.monotonic() + seconds
1026
+ while time.monotonic() < deadline:
1027
+ source = source_for_address(user, mac)
1028
+ if source:
1029
+ select_profile(user, source)
1030
+ return source
1031
+ time.sleep(1)
1032
+ return ""
1033
+
1034
+
1035
+ def activate_capture(mac):
1036
+ user = audio_user()
1037
+ if user is None or not os.path.isdir(f"/run/user/{user.pw_uid}"):
1038
+ return "no desktop audio session"
1039
+ if wait_for_source(user, mac, POLL_SECONDS):
1040
+ return ""
1041
+ run(["bluetoothctl", "disconnect", mac], timeout=15)
1042
+ time.sleep(1)
1043
+ run(["bluetoothctl", "connect", mac], timeout=20)
1044
+ time.sleep(1)
1045
+ if wait_for_source(user, mac, POLL_SECONDS):
1046
+ return ""
1047
+ return "bluetooth headset is not capturing"
1048
+
1049
+
1050
+ def scan_nearby():
1051
+ err = prepare_audio()
1052
+ if err:
1053
+ return {"op": "scan", "devices": [], "error": err, "code": "voice_bluetooth_unavailable"}
1054
+ try:
1055
+ run(["bluetoothctl", "power", "on"], timeout=8)
1056
+ except subprocess.TimeoutExpired:
1057
+ pass
1058
+ except FileNotFoundError:
1059
+ return {"op": "scan", "devices": [], "error": "bluetoothctl is not installed"}
1060
+ try:
1061
+ listed = run(
1062
+ ["bluetoothctl", "--timeout", str(SCAN_SECONDS), "scan", "on"],
1063
+ timeout=SCAN_SECONDS + 8,
1064
+ )
1065
+ text = f"{listed.stdout or ''}\n{listed.stderr or ''}"
1066
+ except subprocess.TimeoutExpired as err:
1067
+ text = f"{err.stdout or ''}\n{err.stderr or ''}"
1068
+ except FileNotFoundError:
1069
+ return {"op": "scan", "devices": [], "error": "bluetoothctl is not installed"}
1070
+ known = known_addresses(run(["bluetoothctl", "devices"], timeout=8).stdout)
1071
+ candidates = scan_candidates(parse_scan(text), known)
1072
+ detailed = []
1073
+ for item in candidates:
1074
+ info = run(["bluetoothctl", "info", item["address"]], timeout=8)
1075
+ detailed.append({**item, "info": info.stdout})
1076
+ return {"op": "scan", "devices": voice_devices(detailed)}
1077
+
1078
+
1079
+ def connect_address(address):
1080
+ mac = str(address or "").strip().upper()
1081
+ if not valid_address(mac):
1082
+ return {"op": "connect", "connected": False, "error": "invalid bluetooth address"}
1083
+ err = prepare_audio()
1084
+ if err:
1085
+ return {"op": "connect", "address": mac, "connected": False, "error": err, "code": "voice_bluetooth_unavailable"}
1086
+ try:
1087
+ run(["bluetoothctl", "power", "on"], timeout=8)
1088
+ except subprocess.TimeoutExpired:
1089
+ pass
1090
+ except FileNotFoundError:
1091
+ return {"op": "connect", "address": mac, "connected": False, "error": "bluetoothctl is not installed"}
1092
+ info = run(["bluetoothctl", "info", mac], timeout=8)
1093
+ if "Paired: yes" not in info.stdout:
1094
+ run(
1095
+ ["bluetoothctl", "--agent", "NoInputNoOutput", "--timeout", "30", "pair", mac],
1096
+ timeout=35,
1097
+ )
1098
+ run(["bluetoothctl", "trust", mac], timeout=10)
1099
+ run(["bluetoothctl", "--timeout", "20", "connect", mac], timeout=25)
1100
+ info = run(["bluetoothctl", "info", mac], timeout=8)
1101
+ connected = "Connected: yes" in info.stdout
1102
+ result = {
1103
+ "op": "connect",
1104
+ "address": mac,
1105
+ "name": device_name(info.stdout) or mac,
1106
+ "connected": connected,
1107
+ }
1108
+ if not connected:
1109
+ result["error"] = "bluetooth connect failed"
1110
+ return result
1111
+ err = activate_capture(mac)
1112
+ if err:
1113
+ result["connected"] = False
1114
+ result["error"] = err
1115
+ return result
1116
+
1117
+
1118
+ def dispatch(payload, directory):
1119
+ op = payload.get("op") or "ensure"
1120
+ if op == "scan":
1121
+ return scan_nearby()
1122
+ if op == "connect":
1123
+ return connect_address(payload.get("address"))
1124
+ if op == "ensure":
1125
+ outcome = ensure()
1126
+ outcome["op"] = "ensure"
1127
+ return outcome
1128
+ if op == "playback":
1129
+ return prepare_playback()
1130
+ if op == "play":
1131
+ return play_file(directory, payload.get("file"))
1132
+ if op == "play-stream":
1133
+ return open_stream(directory, payload.get("file"), payload.get("rate"), payload.get("channels"))
1134
+ if op == "playback-stop":
1135
+ return stop_playback_request()
1136
+ if op == "capture-start":
1137
+ return begin_capture(directory)
1138
+ if op == "capture-stop":
1139
+ return end_capture()
1140
+ if op == "capture-reset":
1141
+ return reset_capture()
1142
+ return {"op": op, "error": "unknown bluetooth operation"}
1143
+
1144
+
1145
+ def serve(directory):
1146
+ directory.mkdir(parents=True, exist_ok=True)
1147
+ os.chmod(directory, 0o777)
1148
+ agent = directory / "agent.json"
1149
+ request = directory / "request.json"
1150
+ result = directory / "result.json"
1151
+ restore = new_restore()
1152
+ while True:
1153
+ agent.write_text(json.dumps({"pid": os.getpid(), "at": time.time()}))
1154
+ if request.exists():
1155
+ try:
1156
+ payload = json.loads(request.read_text() or "{}")
1157
+ except json.JSONDecodeError:
1158
+ payload = {}
1159
+ request.unlink(missing_ok=True)
1160
+ try:
1161
+ outcome = dispatch(payload, directory)
1162
+ except Exception as err:
1163
+ outcome = {
1164
+ "op": payload.get("op") or "ensure",
1165
+ "ready": False,
1166
+ "connected": False,
1167
+ "error": str(err),
1168
+ }
1169
+ outcome["id"] = payload.get("id", "")
1170
+ result.write_text(json.dumps(outcome))
1171
+ os.chmod(result, 0o644)
1172
+ else:
1173
+ restore = tick_restore(restore)
1174
+ time.sleep(0.2)
1175
+
1176
+
1177
+ def main():
1178
+ command = sys.argv[1] if len(sys.argv) > 1 else "ensure"
1179
+ if command == "serve":
1180
+ raw = os.environ.get("LARES_VOICE_BLUETOOTH_DIR", "")
1181
+ if not raw:
1182
+ raise SystemExit("LARES_VOICE_BLUETOOTH_DIR is required")
1183
+ serve(Path(raw))
1184
+ return
1185
+ if command == "wideband":
1186
+ err = ensure_wideband()
1187
+ if err:
1188
+ raise SystemExit(err)
1189
+ return
1190
+ if command != "ensure":
1191
+ raise SystemExit(f"unknown command {command}")
1192
+ print(json.dumps(ensure()))
1193
+
1194
+
1195
+ if __name__ == "__main__":
1196
+ main()