infinicode 2.8.91 → 2.8.93
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.
- package/dist/kernel/federation/dashboard-html.d.ts +1 -1
- package/dist/kernel/federation/dashboard-html.js +1 -1
- package/dist/robopark/auto-start.js +51 -11
- package/dist/robopark/robot-runtime.js +48 -0
- package/dist/robopark/setup.js +4 -1
- package/dist/robopark/vision-agent-launcher.js +27 -6
- package/docs/ROBOPARK_PRODUCTION_MEMORY.md +15 -0
- package/package.json +1 -1
- package/packages/robopark/scheduler/media_lock.py +57 -0
- package/packages/robopark/scheduler/preview_agent.py +160 -52
- package/packages/robopark/scheduler/robot_supervisor.py +23 -18
- package/packages/robopark/vision/app_pi_clean.py +12 -7
- package/packages/robopark/vision/audio_server_pi.py +24 -5
|
@@ -70,6 +70,31 @@ _DEVICE_INVENTORY_CACHE: Optional[dict] = None
|
|
|
70
70
|
_DEVICE_INVENTORY_CACHE_AT = 0.0
|
|
71
71
|
DEVICE_INVENTORY_CACHE_SECONDS = 5.0
|
|
72
72
|
ROBOVISION_MEDIA_URL = os.getenv("ROBOVISION_MEDIA_URL", "http://127.0.0.1:5000/api/media/inventory")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _stable_audio_label(value: object) -> str:
|
|
76
|
+
"""Compare USB product names without volatile ALSA card coordinates."""
|
|
77
|
+
import re
|
|
78
|
+
return " ".join(re.sub(r"\s*\(hw:\d+,\d+\)\s*$", "", str(value), flags=re.I).lower().split())
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _resolve_inventory_audio(items: list, selected: object, preferred: str) -> str:
|
|
82
|
+
if preferred:
|
|
83
|
+
wanted = _stable_audio_label(preferred)
|
|
84
|
+
match = next((item for item in items if _stable_audio_label(item.get("name")) == wanted), None)
|
|
85
|
+
if match and match.get("name"):
|
|
86
|
+
return str(match["name"])
|
|
87
|
+
selected_text = str(selected)
|
|
88
|
+
selected_label = _stable_audio_label(selected_text)
|
|
89
|
+
match = next(
|
|
90
|
+
(
|
|
91
|
+
item for item in items
|
|
92
|
+
if str(item.get("id")) == selected_text
|
|
93
|
+
or _stable_audio_label(item.get("name")) == selected_label
|
|
94
|
+
),
|
|
95
|
+
None,
|
|
96
|
+
)
|
|
97
|
+
return str(match.get("name")) if match and match.get("name") else selected_text
|
|
73
98
|
|
|
74
99
|
|
|
75
100
|
def _normalize_livekit_url(url: Optional[str]) -> Optional[str]:
|
|
@@ -103,17 +128,23 @@ def _play_audio_effect(selected_output: str | None, effect: str) -> None:
|
|
|
103
128
|
if sys.platform.startswith("linux") and "hw:" in selected:
|
|
104
129
|
import re
|
|
105
130
|
import subprocess
|
|
131
|
+
from media_lock import media_lock
|
|
106
132
|
|
|
107
133
|
match = re.search(r"\b(hw:\d+,\d+)\b", selected)
|
|
108
134
|
if not match:
|
|
109
135
|
return
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
136
|
+
try:
|
|
137
|
+
with media_lock("speaker", timeout=3.0):
|
|
138
|
+
result = subprocess.run(
|
|
139
|
+
[
|
|
140
|
+
"aplay", "-q", "-D", f"plug{match.group(1)}", "-t", "raw",
|
|
141
|
+
"-f", "S16_LE", "-r", str(sample_rate), "-c", str(channels),
|
|
142
|
+
],
|
|
143
|
+
input=bytes(frames), capture_output=True, timeout=3.0,
|
|
144
|
+
)
|
|
145
|
+
except TimeoutError as exc:
|
|
146
|
+
logger.warning("audio effect skipped: %s", exc)
|
|
147
|
+
return
|
|
117
148
|
if result.returncode:
|
|
118
149
|
logger.warning(
|
|
119
150
|
"audio effect failed on %s: %s",
|
|
@@ -225,7 +256,38 @@ def _get_device_inventory() -> dict:
|
|
|
225
256
|
pa.terminate()
|
|
226
257
|
except Exception as e:
|
|
227
258
|
logger.debug(f"audio inventory unavailable: {e}")
|
|
228
|
-
|
|
259
|
+
|
|
260
|
+
media_health = {
|
|
261
|
+
"service_uid": os.geteuid() if hasattr(os, "geteuid") else None,
|
|
262
|
+
"camera_access": None,
|
|
263
|
+
"audio_access": None,
|
|
264
|
+
"camera_worker": None,
|
|
265
|
+
"camera_stalled": None,
|
|
266
|
+
"camera_frame_age_seconds": None,
|
|
267
|
+
}
|
|
268
|
+
if sys.platform.startswith("linux"):
|
|
269
|
+
camera_nodes = [
|
|
270
|
+
str(item.get("id")) for item in inventory.get("video", [])
|
|
271
|
+
if str(item.get("id", "")).startswith("/dev/video")
|
|
272
|
+
]
|
|
273
|
+
sound_nodes = glob.glob("/dev/snd/pcm*")
|
|
274
|
+
media_health["camera_access"] = bool(camera_nodes) and all(
|
|
275
|
+
os.access(path, os.R_OK | os.W_OK) for path in camera_nodes[:1]
|
|
276
|
+
)
|
|
277
|
+
media_health["audio_access"] = bool(sound_nodes) and all(
|
|
278
|
+
os.access(path, os.R_OK | os.W_OK) for path in sound_nodes
|
|
279
|
+
)
|
|
280
|
+
try:
|
|
281
|
+
camera_response = httpx.get("http://127.0.0.1:5000/api/camera/status", timeout=0.6)
|
|
282
|
+
if camera_response.is_success:
|
|
283
|
+
camera_status = camera_response.json()
|
|
284
|
+
media_health["camera_worker"] = bool(camera_status.get("worker_started"))
|
|
285
|
+
media_health["camera_stalled"] = bool(camera_status.get("read_stalled"))
|
|
286
|
+
media_health["camera_frame_age_seconds"] = camera_status.get("last_frame_age_seconds")
|
|
287
|
+
except Exception:
|
|
288
|
+
pass
|
|
289
|
+
inventory["media_health"] = media_health
|
|
290
|
+
|
|
229
291
|
_DEVICE_INVENTORY_CACHE = inventory
|
|
230
292
|
_DEVICE_INVENTORY_CACHE_AT = now
|
|
231
293
|
logger.info(
|
|
@@ -707,26 +769,18 @@ class PreviewAgent:
|
|
|
707
769
|
self.audio_device = data["audio_device"]
|
|
708
770
|
inventory = data.get("device_inventory") or {}
|
|
709
771
|
selected = str(self.audio_device)
|
|
710
|
-
self.audio_capture_device =
|
|
711
|
-
(
|
|
712
|
-
|
|
713
|
-
for item in inventory.get("audio_input", [])
|
|
714
|
-
if str(item.get("id")) == selected and item.get("name")
|
|
715
|
-
),
|
|
716
|
-
self.audio_device,
|
|
772
|
+
self.audio_capture_device = _resolve_inventory_audio(
|
|
773
|
+
inventory.get("audio_input", []), selected,
|
|
774
|
+
os.getenv("ROBOPARK_AUDIO_INPUT_MATCH", ""),
|
|
717
775
|
)
|
|
718
776
|
if data.get("audio_output_device") is not None:
|
|
719
777
|
changed = changed or self.audio_output_device != data["audio_output_device"]
|
|
720
778
|
self.audio_output_device = data["audio_output_device"]
|
|
721
779
|
inventory = data.get("device_inventory") or {}
|
|
722
780
|
selected = str(self.audio_output_device)
|
|
723
|
-
self.audio_playback_device =
|
|
724
|
-
(
|
|
725
|
-
|
|
726
|
-
for item in inventory.get("audio_output", [])
|
|
727
|
-
if str(item.get("id")) == selected and item.get("name")
|
|
728
|
-
),
|
|
729
|
-
self.audio_output_device,
|
|
781
|
+
self.audio_playback_device = _resolve_inventory_audio(
|
|
782
|
+
inventory.get("audio_output", []), selected,
|
|
783
|
+
os.getenv("ROBOPARK_AUDIO_OUTPUT_MATCH", ""),
|
|
730
784
|
)
|
|
731
785
|
if changed:
|
|
732
786
|
await self._sync_robovision_media_config()
|
|
@@ -1257,17 +1311,14 @@ class LiveKitPublisher:
|
|
|
1257
1311
|
# — until it finished, so a short "Hello friend!" greeting could be
|
|
1258
1312
|
# over and gone before we ever got a chance to subscribe to it.
|
|
1259
1313
|
if audio_enabled:
|
|
1260
|
-
self._tasks.append(asyncio.create_task(self.
|
|
1314
|
+
self._tasks.append(asyncio.create_task(self._audio_retry_loop()))
|
|
1261
1315
|
try:
|
|
1262
1316
|
await asyncio.wait_for(self._mic_streaming.wait(), timeout=5.0)
|
|
1263
|
-
await self.agent._report_pipeline(
|
|
1264
|
-
"microphone_published", "ok", "Microphone track published with live PCM frames"
|
|
1265
|
-
)
|
|
1266
1317
|
except asyncio.TimeoutError:
|
|
1267
1318
|
await self.agent._report_pipeline(
|
|
1268
1319
|
"microphone_published", "blocked", "Microphone track published but PCM capture did not start"
|
|
1269
1320
|
)
|
|
1270
|
-
|
|
1321
|
+
logger.warning("microphone PCM is not ready; keeping the session alive while capture retries")
|
|
1271
1322
|
|
|
1272
1323
|
if self._capture and self.video_source:
|
|
1273
1324
|
self._tasks.append(asyncio.create_task(self._video_loop()))
|
|
@@ -1278,6 +1329,7 @@ class LiveKitPublisher:
|
|
|
1278
1329
|
selected_output = str(self.agent.audio_playback_device or "default")
|
|
1279
1330
|
pa = None
|
|
1280
1331
|
output_device_index = None
|
|
1332
|
+
speaker_guard = None
|
|
1281
1333
|
|
|
1282
1334
|
# RoboVision inventories Linux devices through sounddevice/PortAudio,
|
|
1283
1335
|
# but the numeric indices are not stable across PyAudio builds. More
|
|
@@ -1287,23 +1339,33 @@ class LiveKitPublisher:
|
|
|
1287
1339
|
if sys.platform.startswith("linux") and "hw:" in selected_output:
|
|
1288
1340
|
import re
|
|
1289
1341
|
import subprocess
|
|
1342
|
+
from media_lock import media_lock
|
|
1290
1343
|
|
|
1291
1344
|
match = re.search(r"\b(hw:\d+,\d+)\b", selected_output)
|
|
1292
1345
|
if not match:
|
|
1293
1346
|
logger.warning(f"audio out: no ALSA hardware address in {selected_output!r}")
|
|
1294
1347
|
return
|
|
1295
1348
|
alsa_device = f"plug{match.group(1)}"
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1349
|
+
try:
|
|
1350
|
+
speaker_guard = media_lock("speaker", timeout=8.0).acquire()
|
|
1351
|
+
process = subprocess.Popen(
|
|
1352
|
+
[
|
|
1353
|
+
"aplay", "-q", "-D", alsa_device, "-t", "raw",
|
|
1354
|
+
"-f", "S16_LE", "-r", str(OUT_RATE), "-c", str(OUT_CHANNELS),
|
|
1355
|
+
],
|
|
1356
|
+
stdin=subprocess.PIPE,
|
|
1357
|
+
stderr=subprocess.PIPE,
|
|
1358
|
+
)
|
|
1359
|
+
except Exception as exc:
|
|
1360
|
+
if speaker_guard is not None:
|
|
1361
|
+
speaker_guard.release()
|
|
1362
|
+
logger.warning(f"audio out: could not acquire {alsa_device}: {exc}")
|
|
1363
|
+
return
|
|
1304
1364
|
if process.stdin is None:
|
|
1305
1365
|
logger.warning(f"audio out: aplay did not expose stdin for {alsa_device}")
|
|
1306
1366
|
process.kill()
|
|
1367
|
+
process.wait(timeout=1)
|
|
1368
|
+
speaker_guard.release()
|
|
1307
1369
|
return
|
|
1308
1370
|
|
|
1309
1371
|
class _AplayOutput:
|
|
@@ -1525,6 +1587,8 @@ class LiveKitPublisher:
|
|
|
1525
1587
|
out.close()
|
|
1526
1588
|
if pa is not None:
|
|
1527
1589
|
pa.terminate()
|
|
1590
|
+
if speaker_guard is not None:
|
|
1591
|
+
speaker_guard.release()
|
|
1528
1592
|
|
|
1529
1593
|
async def stop(self) -> None:
|
|
1530
1594
|
self._stop_event.set()
|
|
@@ -1596,7 +1660,29 @@ class LiveKitPublisher:
|
|
|
1596
1660
|
except Exception as e:
|
|
1597
1661
|
logger.debug(f"preview motion sampling failed: {e}")
|
|
1598
1662
|
|
|
1599
|
-
async def
|
|
1663
|
+
async def _audio_retry_loop(self) -> None:
|
|
1664
|
+
"""Keep microphone capture alive across transient ALSA ownership errors."""
|
|
1665
|
+
while not self._stop_event.is_set():
|
|
1666
|
+
try:
|
|
1667
|
+
await self._audio_loop()
|
|
1668
|
+
except asyncio.CancelledError:
|
|
1669
|
+
raise
|
|
1670
|
+
except Exception as e:
|
|
1671
|
+
logger.warning(
|
|
1672
|
+
f"audio_loop: capture failed on {self.agent.audio_capture_device}: {e}; retrying"
|
|
1673
|
+
)
|
|
1674
|
+
finally:
|
|
1675
|
+
mic = self._mic_capture
|
|
1676
|
+
self._mic_capture = None
|
|
1677
|
+
if mic is not None:
|
|
1678
|
+
await asyncio.to_thread(mic.stop)
|
|
1679
|
+
if not self._stop_event.is_set():
|
|
1680
|
+
try:
|
|
1681
|
+
await asyncio.wait_for(self._stop_event.wait(), timeout=1.0)
|
|
1682
|
+
except asyncio.TimeoutError:
|
|
1683
|
+
pass
|
|
1684
|
+
|
|
1685
|
+
async def _audio_loop(self) -> None:
|
|
1600
1686
|
assert self.audio_source is not None
|
|
1601
1687
|
mic = create_audio_capture(self.agent.audio_capture_device)
|
|
1602
1688
|
if mic is None:
|
|
@@ -1642,7 +1728,12 @@ class LiveKitPublisher:
|
|
|
1642
1728
|
data=chunk, sample_rate=48000, num_channels=1, samples_per_channel=samples_per_frame,
|
|
1643
1729
|
)
|
|
1644
1730
|
await self.audio_source.capture_frame(frame)
|
|
1645
|
-
self._mic_streaming.
|
|
1731
|
+
if not self._mic_streaming.is_set():
|
|
1732
|
+
self._mic_streaming.set()
|
|
1733
|
+
await self.agent._report_pipeline(
|
|
1734
|
+
"microphone_published", "ok",
|
|
1735
|
+
"Microphone track published with live PCM frames",
|
|
1736
|
+
)
|
|
1646
1737
|
p = audioop.max(chunk, 2)
|
|
1647
1738
|
_diag_peak = max(_diag_peak, p)
|
|
1648
1739
|
_diag_count += 1
|
|
@@ -1840,21 +1931,30 @@ class AlsaAudioCapture(AudioCapture):
|
|
|
1840
1931
|
def __init__(self, device: str):
|
|
1841
1932
|
import re
|
|
1842
1933
|
import subprocess
|
|
1934
|
+
from media_lock import media_lock
|
|
1843
1935
|
|
|
1844
1936
|
match = re.search(r"\b(hw:\d+,\d+)\b", device)
|
|
1845
1937
|
if not match:
|
|
1846
1938
|
raise ValueError(f"no ALSA hardware address in {device!r}")
|
|
1847
1939
|
alsa_device = f"plug{match.group(1)}"
|
|
1848
1940
|
self.buffer = bytearray()
|
|
1849
|
-
self.
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1941
|
+
self.media_guard = media_lock("microphone", timeout=5.0).acquire()
|
|
1942
|
+
try:
|
|
1943
|
+
self.process = subprocess.Popen(
|
|
1944
|
+
[
|
|
1945
|
+
"arecord", "-q", "-D", alsa_device, "-t", "raw",
|
|
1946
|
+
"-f", "S16_LE", "-r", "48000", "-c", "1",
|
|
1947
|
+
],
|
|
1948
|
+
stdout=subprocess.PIPE,
|
|
1949
|
+
stderr=subprocess.PIPE,
|
|
1950
|
+
)
|
|
1951
|
+
except Exception:
|
|
1952
|
+
self.media_guard.release()
|
|
1953
|
+
raise
|
|
1857
1954
|
if self.process.stdout is None:
|
|
1955
|
+
self.process.kill()
|
|
1956
|
+
self.process.wait(timeout=1)
|
|
1957
|
+
self.media_guard.release()
|
|
1858
1958
|
raise RuntimeError("arecord did not provide a PCM stream")
|
|
1859
1959
|
self.alsa_device = alsa_device
|
|
1860
1960
|
|
|
@@ -1865,7 +1965,10 @@ class AlsaAudioCapture(AudioCapture):
|
|
|
1865
1965
|
while len(self.buffer) < bytes_needed:
|
|
1866
1966
|
chunk = self.process.stdout.read(bytes_needed - len(self.buffer))
|
|
1867
1967
|
if not chunk:
|
|
1868
|
-
|
|
1968
|
+
detail = ""
|
|
1969
|
+
if self.process.stderr is not None:
|
|
1970
|
+
detail = self.process.stderr.read().decode("utf-8", errors="replace").strip()
|
|
1971
|
+
raise OSError(f"ALSA capture stopped on {self.alsa_device}: {detail or 'no PCM data'}")
|
|
1869
1972
|
self.buffer.extend(chunk)
|
|
1870
1973
|
data = bytes(self.buffer[:bytes_needed])
|
|
1871
1974
|
del self.buffer[:bytes_needed]
|
|
@@ -1877,12 +1980,16 @@ class AlsaAudioCapture(AudioCapture):
|
|
|
1877
1980
|
)
|
|
1878
1981
|
|
|
1879
1982
|
def stop(self):
|
|
1880
|
-
|
|
1881
|
-
self.process.
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1983
|
+
try:
|
|
1984
|
+
if self.process.poll() is None:
|
|
1985
|
+
self.process.terminate()
|
|
1986
|
+
try:
|
|
1987
|
+
self.process.wait(timeout=2)
|
|
1988
|
+
except Exception:
|
|
1989
|
+
self.process.kill()
|
|
1990
|
+
self.process.wait(timeout=1)
|
|
1991
|
+
finally:
|
|
1992
|
+
self.media_guard.release()
|
|
1886
1993
|
|
|
1887
1994
|
|
|
1888
1995
|
class PyAudioCapture(AudioCapture):
|
|
@@ -1972,6 +2079,7 @@ def create_audio_capture(device: str) -> Optional[AudioCapture]:
|
|
|
1972
2079
|
return AlsaAudioCapture(device)
|
|
1973
2080
|
except Exception as e:
|
|
1974
2081
|
logger.warning(f"ALSA capture unavailable for {device}: {e}")
|
|
2082
|
+
raise
|
|
1975
2083
|
try:
|
|
1976
2084
|
import pyaudio # noqa: F401
|
|
1977
2085
|
return PyAudioCapture(device if device != "default" else None)
|
|
@@ -600,24 +600,29 @@ def _linux_aplay_pcm16(raw: bytes, selected_output: str, sample_rate: int,
|
|
|
600
600
|
# ALSA can retain an exclusive USB handle briefly after the LiveKit
|
|
601
601
|
# playback stream closes. Retry for a bounded period instead of declaring
|
|
602
602
|
# a valid device unsupported on the first EBUSY response.
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
603
|
+
try:
|
|
604
|
+
from media_lock import media_lock
|
|
605
|
+
with media_lock("speaker", timeout=8.0):
|
|
606
|
+
for attempt in range(5):
|
|
607
|
+
if attempt:
|
|
608
|
+
time.sleep(0.5)
|
|
609
|
+
try:
|
|
610
|
+
completed = subprocess.run(
|
|
611
|
+
command,
|
|
612
|
+
input=raw,
|
|
613
|
+
stdout=subprocess.DEVNULL,
|
|
614
|
+
stderr=subprocess.PIPE,
|
|
615
|
+
timeout=max(5.0, len(raw) / max(1, sample_rate * channels * 2) + 3.0),
|
|
616
|
+
check=False,
|
|
617
|
+
)
|
|
618
|
+
except Exception as exc:
|
|
619
|
+
last_error = f"{type(exc).__name__}: {exc}"
|
|
620
|
+
continue
|
|
621
|
+
if completed.returncode == 0:
|
|
622
|
+
return True, alsa_device
|
|
623
|
+
last_error = completed.stderr.decode("utf-8", errors="replace").strip() or f"aplay exited {completed.returncode}"
|
|
624
|
+
except TimeoutError as exc:
|
|
625
|
+
return False, str(exc)
|
|
621
626
|
return False, f"{alsa_device}: {last_error}"
|
|
622
627
|
|
|
623
628
|
|
|
@@ -15,8 +15,10 @@ import struct
|
|
|
15
15
|
app = Flask(__name__)
|
|
16
16
|
CORS(app)
|
|
17
17
|
|
|
18
|
-
# Camera management
|
|
19
|
-
|
|
18
|
+
# Camera management. Production installs create /dev/robopark-camera from
|
|
19
|
+
# the primary USB capture interface, avoiding /dev/videoN renumbering.
|
|
20
|
+
_configured_camera = os.getenv("ROBOPARK_CAMERA_DEVICE", "")
|
|
21
|
+
current_camera_index = _configured_camera or ("/dev/robopark-camera" if os.path.exists("/dev/robopark-camera") else 0)
|
|
20
22
|
camera = None
|
|
21
23
|
camera_lock = threading.Lock()
|
|
22
24
|
frame_condition = threading.Condition()
|
|
@@ -625,8 +627,11 @@ if __name__ == '__main__':
|
|
|
625
627
|
print("RoboVision - Raspberry Pi Vision Server (Minimal)")
|
|
626
628
|
print("=" * 60)
|
|
627
629
|
print(f"Starting Flask server on http://0.0.0.0:{args.port}")
|
|
628
|
-
if webhook_url:
|
|
629
|
-
print(f"Motion webhook: {webhook_url}")
|
|
630
|
-
print(f"Motion detection: {'ARMED' if motion_detection_active else 'off (POST /api/motion/toggle to arm)'}")
|
|
631
|
-
print("=" * 60)
|
|
632
|
-
|
|
630
|
+
if webhook_url:
|
|
631
|
+
print(f"Motion webhook: {webhook_url}")
|
|
632
|
+
print(f"Motion detection: {'ARMED' if motion_detection_active else 'off (POST /api/motion/toggle to arm)'}")
|
|
633
|
+
print("=" * 60)
|
|
634
|
+
# Start the single camera owner at boot. Production motion and MJPEG
|
|
635
|
+
# readiness must not depend on an operator opening the dashboard first.
|
|
636
|
+
_ensure_camera_worker()
|
|
637
|
+
app.run(host='0.0.0.0', port=args.port, debug=False, threaded=True)
|
|
@@ -17,12 +17,29 @@ import os
|
|
|
17
17
|
from datetime import datetime
|
|
18
18
|
import logging
|
|
19
19
|
import subprocess
|
|
20
|
-
import base64
|
|
21
|
-
|
|
20
|
+
import base64
|
|
21
|
+
import functools
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from groq import Groq
|
|
25
|
+
|
|
26
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scheduler"))
|
|
27
|
+
from media_lock import media_lock
|
|
22
28
|
|
|
23
29
|
# Configure logging
|
|
24
30
|
logging.basicConfig(level=logging.INFO, format='%(message)s')
|
|
25
|
-
logger = logging.getLogger(__name__)
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def exclusive_media(kind: str):
|
|
35
|
+
"""Prevent diagnostics from stealing ALSA devices from production."""
|
|
36
|
+
def decorate(func):
|
|
37
|
+
@functools.wraps(func)
|
|
38
|
+
def wrapped(*args, **kwargs):
|
|
39
|
+
with media_lock(kind, timeout=3.0):
|
|
40
|
+
return func(*args, **kwargs)
|
|
41
|
+
return wrapped
|
|
42
|
+
return decorate
|
|
26
43
|
|
|
27
44
|
@asynccontextmanager
|
|
28
45
|
async def lifespan(app: FastAPI):
|
|
@@ -173,7 +190,8 @@ def find_bluetooth_devices():
|
|
|
173
190
|
# Find Bluetooth devices on startup
|
|
174
191
|
find_bluetooth_devices()
|
|
175
192
|
|
|
176
|
-
|
|
193
|
+
@exclusive_media("speaker")
|
|
194
|
+
def play_audio_file(file_path: str):
|
|
177
195
|
"""Play audio file using sounddevice (supports Bluetooth)"""
|
|
178
196
|
try:
|
|
179
197
|
logger.info(f"🔊 Playing audio: {file_path}")
|
|
@@ -231,7 +249,8 @@ def get_available_input_devices():
|
|
|
231
249
|
input_devices.append(i)
|
|
232
250
|
return input_devices
|
|
233
251
|
|
|
234
|
-
|
|
252
|
+
@exclusive_media("microphone")
|
|
253
|
+
def record_audio_with_vad():
|
|
235
254
|
"""
|
|
236
255
|
Record audio from Bluetooth microphone with Voice Activity Detection
|
|
237
256
|
Rotates through available devices if no audio detected
|