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.
@@ -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
- result = subprocess.run(
111
- [
112
- "aplay", "-q", "-D", f"plug{match.group(1)}", "-t", "raw",
113
- "-f", "S16_LE", "-r", str(sample_rate), "-c", str(channels),
114
- ],
115
- input=bytes(frames), capture_output=True, timeout=3.0,
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 = next(
711
- (
712
- str(item.get("name"))
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 = next(
724
- (
725
- str(item.get("name"))
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._audio_loop()))
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
- raise RuntimeError(f"microphone capture did not start for {self.agent.audio_capture_device}")
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
- process = subprocess.Popen(
1297
- [
1298
- "aplay", "-q", "-D", alsa_device, "-t", "raw",
1299
- "-f", "S16_LE", "-r", str(OUT_RATE), "-c", str(OUT_CHANNELS),
1300
- ],
1301
- stdin=subprocess.PIPE,
1302
- stderr=subprocess.PIPE,
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 _audio_loop(self) -> None:
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.set()
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.process = subprocess.Popen(
1850
- [
1851
- "arecord", "-q", "-D", alsa_device, "-t", "raw",
1852
- "-f", "S16_LE", "-r", "48000", "-c", "1",
1853
- ],
1854
- stdout=subprocess.PIPE,
1855
- stderr=subprocess.DEVNULL,
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
- raise OSError(f"ALSA capture stopped on {self.alsa_device}")
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
- if self.process.poll() is None:
1881
- self.process.terminate()
1882
- try:
1883
- self.process.wait(timeout=2)
1884
- except Exception:
1885
- self.process.kill()
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
- for attempt in range(5):
604
- if attempt:
605
- time.sleep(0.5)
606
- try:
607
- completed = subprocess.run(
608
- command,
609
- input=raw,
610
- stdout=subprocess.DEVNULL,
611
- stderr=subprocess.PIPE,
612
- timeout=max(5.0, len(raw) / max(1, sample_rate * channels * 2) + 3.0),
613
- check=False,
614
- )
615
- except Exception as exc:
616
- last_error = f"{type(exc).__name__}: {exc}"
617
- continue
618
- if completed.returncode == 0:
619
- return True, alsa_device
620
- last_error = completed.stderr.decode("utf-8", errors="replace").strip() or f"aplay exited {completed.returncode}"
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
- current_camera_index = 0
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
- app.run(host='0.0.0.0', port=args.port, debug=False, threaded=True)
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
- from groq import Groq
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
- def play_audio_file(file_path: str):
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
- def record_audio_with_vad():
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