robopark 2.8.35 → 3.0.0

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 (68) hide show
  1. package/README.md +88 -63
  2. package/bin/robopark.js +7 -17
  3. package/conversation/elevenlabs_agent.py +1985 -0
  4. package/conversation/requirements.txt +3 -0
  5. package/conversation/supervisor_store.py +189 -0
  6. package/dist/kernel/config-schema.js +37 -0
  7. package/dist/kernel/types.js +7 -0
  8. package/dist/robopark/access.js +99 -0
  9. package/dist/robopark/add-robot.js +188 -0
  10. package/dist/robopark/agent-ctl.js +305 -0
  11. package/dist/robopark/auto-start.js +289 -0
  12. package/dist/robopark/conversation.js +505 -0
  13. package/dist/robopark/deployment-commands.js +47 -0
  14. package/dist/robopark/discovery.js +180 -0
  15. package/dist/robopark/doctor.js +175 -0
  16. package/dist/robopark/enroll.js +68 -0
  17. package/dist/robopark/llm-set.js +87 -0
  18. package/dist/robopark/motor-control.js +195 -0
  19. package/dist/robopark/preview-agent-launcher.js +77 -0
  20. package/dist/robopark/probe.js +138 -0
  21. package/dist/robopark/profile.js +69 -0
  22. package/dist/robopark/python-env.js +162 -0
  23. package/dist/robopark/robot-runtime.js +489 -0
  24. package/dist/robopark/scan.js +97 -0
  25. package/dist/robopark/screen-control.js +55 -0
  26. package/dist/robopark/secrets.js +41 -0
  27. package/dist/robopark/serve.js +285 -0
  28. package/dist/robopark/server-add.js +114 -0
  29. package/dist/robopark/setup-livekit.js +300 -0
  30. package/dist/robopark/setup.js +286 -0
  31. package/dist/robopark/standalone.js +466 -0
  32. package/dist/robopark/stop-all.js +141 -0
  33. package/dist/robopark/verify.js +192 -0
  34. package/dist/robopark/vision-agent-launcher.js +98 -0
  35. package/dist/robopark/vision-control.js +81 -0
  36. package/dist/robopark-cli.js +799 -0
  37. package/package.json +21 -5
  38. package/pi-client/_install_steps.sh +29 -29
  39. package/pi-client/client.py +61 -2
  40. package/pi-client/install.sh +40 -40
  41. package/pi-client/join_convo.sh +54 -54
  42. package/pi-client/livekit_bridge.py +16 -7
  43. package/pi-client/motor_bridge.py +6 -3
  44. package/scheduler/fleet_config.json +75 -0
  45. package/scheduler/main.py +4505 -135
  46. package/scheduler/media_lock.py +57 -0
  47. package/scheduler/preview_agent.py +1465 -87
  48. package/scheduler/production_config.json +139 -0
  49. package/scheduler/robot_supervisor.py +1705 -0
  50. package/scheduler/scripts/install-robot-supervisor-linux.sh +33 -0
  51. package/scheduler/scripts/install-robot-supervisor-windows.ps1 +49 -0
  52. package/scheduler/scripts/robopark-supervisor.service +20 -0
  53. package/scheduler/scripts/start-scheduler-local.ps1 +50 -0
  54. package/scheduler/supervisor.example.json +26 -0
  55. package/scheduler/vision_motion_trigger.py +101 -0
  56. package/screen/screen_runtime.py +75 -0
  57. package/vision/app_pi_clean.py +253 -16
  58. package/vision/audio_server_pi.py +19 -0
  59. package/vision/install.sh +34 -34
  60. package/vision/motor_server.py +224 -61
  61. package/vision/requirements_camera.txt +6 -0
  62. package/vision/requirements_motor.txt +4 -0
  63. package/vision/requirements_pi_unified.txt +1 -0
  64. package/vision/requirements_vision_agent.txt +19 -0
  65. package/vision/run.sh +244 -244
  66. package/vision/services/services.sh +12 -12
  67. package/scheduler/__pycache__/main.cpython-312.pyc +0 -0
  68. package/scheduler/__pycache__/preview_agent.cpython-312.pyc +0 -0
@@ -45,10 +45,14 @@ from __future__ import annotations
45
45
 
46
46
  import argparse
47
47
  import asyncio
48
+ import glob
48
49
  import json
49
50
  import logging
51
+ import math
50
52
  import os
51
53
  import signal
54
+ import socket
55
+ import struct
52
56
  import sys
53
57
  import threading
54
58
  import time
@@ -62,6 +66,282 @@ import httpx
62
66
 
63
67
  logger = logging.getLogger("robopark.preview_agent")
64
68
 
69
+ _DEVICE_INVENTORY_CACHE: Optional[dict] = None
70
+ _DEVICE_INVENTORY_CACHE_AT = 0.0
71
+ DEVICE_INVENTORY_CACHE_SECONDS = 5.0
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
98
+
99
+
100
+ def _normalize_livekit_url(url: Optional[str]) -> Optional[str]:
101
+ """Avoid Windows localhost IPv6/IPv4 ambiguity for local LiveKit."""
102
+ if not url:
103
+ return url
104
+ for scheme in ("ws", "wss"):
105
+ prefix = f"{scheme}://localhost"
106
+ if url.startswith(prefix):
107
+ return f"{scheme}://127.0.0.1" + url[len(prefix):]
108
+ return url
109
+
110
+
111
+ def _pcm16_scale_and_peak(data: bytes, gain: float) -> tuple[bytes, int]:
112
+ """Apply gain and measure peak without audioop (removed in Python 3.13)."""
113
+ from array import array
114
+
115
+ samples = array("h")
116
+ samples.frombytes(data[:len(data) - (len(data) % 2)])
117
+ if sys.byteorder != "little":
118
+ samples.byteswap()
119
+ peak = 0
120
+ for index, value in enumerate(samples):
121
+ scaled = max(-32768, min(32767, int(value * gain))) if gain != 1.0 else value
122
+ samples[index] = scaled
123
+ peak = max(peak, abs(scaled))
124
+ if sys.byteorder != "little":
125
+ samples.byteswap()
126
+ return samples.tobytes(), peak
127
+
128
+
129
+ def _pcm16_resample_mono(data: bytes, source_rate: int, target_rate: int) -> bytes:
130
+ """Linearly resample a PCM16 mono chunk using only the standard library."""
131
+ from array import array
132
+
133
+ if source_rate == target_rate or len(data) < 4:
134
+ return data
135
+ source = array("h")
136
+ source.frombytes(data[:len(data) - (len(data) % 2)])
137
+ if sys.byteorder != "little":
138
+ source.byteswap()
139
+ target_count = max(1, round(len(source) * target_rate / source_rate))
140
+ target = array("h", [0]) * target_count
141
+ scale = source_rate / target_rate
142
+ last = len(source) - 1
143
+ for index in range(target_count):
144
+ position = min(last, index * scale)
145
+ left = int(position)
146
+ right = min(last, left + 1)
147
+ fraction = position - left
148
+ target[index] = max(-32768, min(32767, round(
149
+ source[left] + (source[right] - source[left]) * fraction
150
+ )))
151
+ if sys.byteorder != "little":
152
+ target.byteswap()
153
+ return target.tobytes()
154
+
155
+
156
+ def _play_audio_effect(selected_output: str | None, effect: str) -> None:
157
+ """Play a short local cue without involving the voice pipeline."""
158
+ sample_rate = 48000
159
+ channels = 2
160
+ if effect == "motion":
161
+ notes = ((880, 0.09), (1320, 0.13))
162
+ else:
163
+ notes = ((660, 0.10), (440, 0.16))
164
+ selected = str(selected_output or "default")
165
+ frames = bytearray()
166
+ for frequency, duration in notes:
167
+ count = int(sample_rate * duration)
168
+ for n in range(count):
169
+ envelope = min(1.0, n / 240.0, (count - n) / 1200.0)
170
+ value = int(5000 * envelope * math.sin(2 * math.pi * frequency * n / sample_rate))
171
+ frames.extend(struct.pack("<hh", value, value))
172
+
173
+ if sys.platform.startswith("linux") and "hw:" in selected:
174
+ import re
175
+ import subprocess
176
+ from media_lock import media_lock
177
+
178
+ match = re.search(r"\b(hw:\d+,\d+)\b", selected)
179
+ if not match:
180
+ return
181
+ try:
182
+ with media_lock("speaker", timeout=3.0):
183
+ result = subprocess.run(
184
+ [
185
+ "aplay", "-q", "-D", f"plug{match.group(1)}", "-t", "raw",
186
+ "-f", "S16_LE", "-r", str(sample_rate), "-c", str(channels),
187
+ ],
188
+ input=bytes(frames), capture_output=True, timeout=3.0,
189
+ )
190
+ except TimeoutError as exc:
191
+ logger.warning("audio effect skipped: %s", exc)
192
+ return
193
+ if result.returncode:
194
+ logger.warning(
195
+ "audio effect failed on %s: %s",
196
+ match.group(1), result.stderr.decode("utf-8", errors="replace").strip(),
197
+ )
198
+ return
199
+
200
+ try:
201
+ import pyaudio
202
+ except Exception:
203
+ return
204
+ pa = pyaudio.PyAudio()
205
+ device_index = None
206
+ try:
207
+ if selected.strip().isdigit():
208
+ device_index = int(selected.strip())
209
+ elif selected.lower() == "default":
210
+ wasapi = pa.get_host_api_info_by_type(pyaudio.paWASAPI)
211
+ device_index = wasapi.get("defaultOutputDevice")
212
+ else:
213
+ needle = selected.lower()
214
+ for i in range(pa.get_device_count()):
215
+ info = pa.get_device_info_by_index(i)
216
+ if info.get("maxOutputChannels", 0) > 0 and needle in str(info.get("name", "")).lower():
217
+ device_index = i
218
+ break
219
+ stream = pa.open(
220
+ format=pyaudio.paInt16,
221
+ channels=channels,
222
+ rate=sample_rate,
223
+ output=True,
224
+ output_device_index=device_index,
225
+ )
226
+ stream.write(bytes(frames))
227
+ stream.stop_stream()
228
+ stream.close()
229
+ except Exception as e:
230
+ logger.debug(f"audio effect unavailable: {e}")
231
+ finally:
232
+ pa.terminate()
233
+
234
+
235
+ def _get_device_inventory() -> dict:
236
+ """Return discoverable camera and audio devices for dashboard selection."""
237
+ global _DEVICE_INVENTORY_CACHE, _DEVICE_INVENTORY_CACHE_AT
238
+ now = time.monotonic()
239
+ if (_DEVICE_INVENTORY_CACHE is not None
240
+ and now - _DEVICE_INVENTORY_CACHE_AT < DEVICE_INVENTORY_CACHE_SECONDS):
241
+ return _DEVICE_INVENTORY_CACHE
242
+
243
+ inventory = {"video": [], "audio_input": [], "audio_output": [], "platform": sys.platform}
244
+ inventory["video"].append({"id": "auto", "name": "Auto detect"})
245
+ inventory["video"].append({"id": "none", "name": "Disable camera"})
246
+
247
+ # RoboVisionAI_PI owns the production camera. Prefer its native inventory
248
+ # so this process never probes an already-open V4L2 device just to fill a
249
+ # dashboard dropdown.
250
+ robovision_inventory = None
251
+ try:
252
+ response = httpx.get(ROBOVISION_MEDIA_URL, timeout=0.8)
253
+ if response.is_success:
254
+ robovision_inventory = response.json()
255
+ for key in ("video", "audio_input", "audio_output"):
256
+ if isinstance(robovision_inventory.get(key), list):
257
+ inventory[key] = robovision_inventory[key]
258
+ inventory["source"] = robovision_inventory.get("source", "robovision_pi")
259
+ except Exception:
260
+ pass
261
+
262
+ if not robovision_inventory:
263
+ try:
264
+ import cv2
265
+ candidates = sorted(glob.glob("/dev/video*")) if os.name != "nt" else [str(i) for i in range(10)]
266
+ for candidate in candidates:
267
+ value = int(candidate) if os.name == "nt" else candidate
268
+ backend = cv2.CAP_DSHOW if os.name == "nt" else cv2.CAP_ANY
269
+ cap = cv2.VideoCapture(value, backend)
270
+ if cap.isOpened():
271
+ device_id = str(value)
272
+ inventory["video"].append({"id": device_id, "name": f"Camera {candidate}", "backend": "dshow" if os.name == "nt" else "v4l2"})
273
+ cap.release()
274
+ except Exception as e:
275
+ logger.debug(f"camera inventory unavailable: {e}")
276
+
277
+ if not robovision_inventory:
278
+ try:
279
+ import pyaudio
280
+ pa = pyaudio.PyAudio()
281
+ default_in = None
282
+ default_out = None
283
+ try:
284
+ wasapi = pa.get_host_api_info_by_type(pyaudio.paWASAPI)
285
+ default_in = wasapi.get("defaultInputDevice")
286
+ default_out = wasapi.get("defaultOutputDevice")
287
+ except Exception:
288
+ pass
289
+ inventory["audio_input"].append({"id": "default", "name": "System default input"})
290
+ inventory["audio_output"].append({"id": "default", "name": "System default output"})
291
+ for i in range(pa.get_device_count()):
292
+ info = pa.get_device_info_by_index(i)
293
+ name = str(info.get("name", f"Audio device {i}"))
294
+ item = {"id": str(i), "name": name, "host_api": str(info.get("hostApi", ""))}
295
+ if info.get("maxInputChannels", 0) > 0:
296
+ item["default"] = i == default_in
297
+ inventory["audio_input"].append(item.copy())
298
+ if info.get("maxOutputChannels", 0) > 0:
299
+ item["default"] = i == default_out
300
+ inventory["audio_output"].append(item.copy())
301
+ pa.terminate()
302
+ except Exception as e:
303
+ logger.debug(f"audio inventory unavailable: {e}")
304
+
305
+ media_health = {
306
+ "service_uid": os.geteuid() if hasattr(os, "geteuid") else None,
307
+ "camera_access": None,
308
+ "audio_access": None,
309
+ "camera_worker": None,
310
+ "camera_stalled": None,
311
+ "camera_frame_age_seconds": None,
312
+ }
313
+ if sys.platform.startswith("linux"):
314
+ camera_nodes = [
315
+ str(item.get("id")) for item in inventory.get("video", [])
316
+ if str(item.get("id", "")).startswith("/dev/video")
317
+ ]
318
+ sound_nodes = glob.glob("/dev/snd/pcm*")
319
+ media_health["camera_access"] = bool(camera_nodes) and all(
320
+ os.access(path, os.R_OK | os.W_OK) for path in camera_nodes[:1]
321
+ )
322
+ media_health["audio_access"] = bool(sound_nodes) and all(
323
+ os.access(path, os.R_OK | os.W_OK) for path in sound_nodes
324
+ )
325
+ try:
326
+ camera_response = httpx.get("http://127.0.0.1:5000/api/camera/status", timeout=0.6)
327
+ if camera_response.is_success:
328
+ camera_status = camera_response.json()
329
+ media_health["camera_worker"] = bool(camera_status.get("worker_started"))
330
+ media_health["camera_stalled"] = bool(camera_status.get("read_stalled"))
331
+ media_health["camera_frame_age_seconds"] = camera_status.get("last_frame_age_seconds")
332
+ except Exception:
333
+ pass
334
+ inventory["media_health"] = media_health
335
+
336
+ _DEVICE_INVENTORY_CACHE = inventory
337
+ _DEVICE_INVENTORY_CACHE_AT = now
338
+ logger.info(
339
+ f"device inventory: {len(inventory['video']) - 2} cameras, "
340
+ f"{len(inventory['audio_input']) - 1} inputs, "
341
+ f"{len(inventory['audio_output']) - 1} outputs"
342
+ )
343
+ return inventory
344
+
65
345
  CONFIG_DIR = Path.home() / ".robopark"
66
346
  CONFIG_FILE = CONFIG_DIR / "preview_agent.json"
67
347
  TOKEN_FILE = CONFIG_DIR / "device_token"
@@ -70,7 +350,7 @@ DEFAULT_VIDEO_WIDTH = 640
70
350
  DEFAULT_VIDEO_HEIGHT = 480
71
351
  DEFAULT_FPS = 15
72
352
  DEFAULT_POLL_INTERVAL = 3.0
73
- DEFAULT_HEARTBEAT_INTERVAL = 30.0
353
+ DEFAULT_HEARTBEAT_INTERVAL = 5.0
74
354
  DEFAULT_VISION_WEBHOOK_PORT = 5057
75
355
  DEFAULT_VISION_TRIGGER_COOLDOWN = 20.0
76
356
  DEFAULT_VISION_SESSION_SECONDS = 90.0
@@ -78,7 +358,10 @@ DEFAULT_VISION_SESSION_SECONDS = 90.0
78
358
  # (POST /api/sessions/{id}/keepalive, called by the voice agent — a
79
359
  # different repo, not this one) before preview_agent tears it down.
80
360
  # vision_session_seconds remains a hard ceiling regardless of activity.
81
- DEFAULT_VISION_SILENCE_TIMEOUT = 15.0
361
+ # Cloud STT/LLM/TTS can legitimately leave a session quiet for several
362
+ # seconds. The voice agent has its own VAD-aware 60s idle policy; this remote
363
+ # safety timeout must not preempt a turn while it is thinking or speaking.
364
+ DEFAULT_VISION_SILENCE_TIMEOUT = 60.0
82
365
 
83
366
 
84
367
  def _load_config() -> dict:
@@ -108,15 +391,51 @@ def _save_token(token: str) -> None:
108
391
  os.chmod(TOKEN_FILE, 0o600)
109
392
 
110
393
 
111
- async def _enroll(scheduler_url: str, enrollment_token: str, robot_id: str) -> str:
112
- """Enroll this Pi with the scheduler and return the device token."""
394
+ def _mesh_proxy_headers() -> dict:
395
+ """Add hub authentication only when the runtime supplied it.
396
+
397
+ Direct LAN scheduler calls work unchanged. Tailscale calls use the hub's
398
+ /robopark proxy, which needs this separate mesh credential while the normal
399
+ Authorization header remains the scheduler device token.
400
+ """
401
+ token = os.getenv("ROBOPARK_MESH_TOKEN", "").strip()
402
+ return {"X-RoboPark-Mesh-Token": token} if token else {}
403
+
404
+
405
+ async def _bootstrap_mesh_device(scheduler_url: str, robot_id: str) -> tuple[str, str]:
406
+ """Create or recover this robot's scheduler identity using mesh auth."""
407
+ async with httpx.AsyncClient(headers=_mesh_proxy_headers()) as client:
408
+ response = await client.post(
409
+ f"{scheduler_url.rstrip('/')}/api/devices/bootstrap",
410
+ json={"name": robot_id, "lan_ip": _get_lan_ip(), "livekit_url": os.getenv("ROBOPARK_LIVEKIT_URL") or None},
411
+ timeout=30.0,
412
+ )
413
+ response.raise_for_status()
414
+ data = response.json()
415
+ device_id = str(data.get("device_id") or "").strip()
416
+ device_token = str(data.get("device_token") or "").strip()
417
+ if not device_id or not device_token:
418
+ raise RuntimeError("mesh bootstrap did not return device credentials")
419
+ _save_token(device_token)
420
+ cfg = _load_config()
421
+ cfg["device_id"] = device_id
422
+ cfg["device_token"] = device_token
423
+ cfg["scheduler_url"] = scheduler_url
424
+ _save_config(cfg)
425
+ logger.info("mesh bootstrap resolved scheduler device %s", device_id)
426
+ return device_id, device_token
427
+
428
+
429
+ async def _enroll(scheduler_url: str, enrollment_token: str, robot_id: str) -> tuple[str, str]:
430
+ """Enroll this Pi and return the exact scheduler identity and token."""
113
431
  import socket
114
432
  payload = {
115
433
  "enrollment_token": enrollment_token,
116
434
  "name": robot_id,
117
435
  "lan_ip": _get_lan_ip(),
436
+ "livekit_url": os.getenv("ROBOPARK_LIVEKIT_URL") or None,
118
437
  }
119
- async with httpx.AsyncClient() as client:
438
+ async with httpx.AsyncClient(headers=_mesh_proxy_headers()) as client:
120
439
  r = await client.post(
121
440
  f"{scheduler_url.rstrip('/')}/api/devices/enroll",
122
441
  json=payload,
@@ -124,16 +443,17 @@ async def _enroll(scheduler_url: str, enrollment_token: str, robot_id: str) -> s
124
443
  )
125
444
  r.raise_for_status()
126
445
  data = r.json()
127
- device_token = data.get("device_token")
128
- if not device_token:
129
- raise RuntimeError("enrollment did not return a device_token")
446
+ device_id = str(data.get("device_id") or "").strip()
447
+ device_token = str(data.get("device_token") or "").strip()
448
+ if not device_id or not device_token:
449
+ raise RuntimeError("enrollment did not return device credentials")
130
450
  _save_token(device_token)
131
451
  cfg = _load_config()
132
- cfg["device_id"] = data.get("device_id")
452
+ cfg["device_id"] = device_id
133
453
  cfg["scheduler_url"] = data.get("scheduler_url", scheduler_url)
134
454
  _save_config(cfg)
135
- logger.info(f"enrolled as device {data.get('device_id')}")
136
- return device_token
455
+ logger.info("enrolled as device %s", device_id)
456
+ return device_id, device_token
137
457
 
138
458
 
139
459
  def _get_lan_ip() -> Optional[str]:
@@ -148,17 +468,39 @@ def _get_lan_ip() -> Optional[str]:
148
468
  return None
149
469
 
150
470
 
151
- async def _send_heartbeat(scheduler_url: str, device_id: str, token: str) -> None:
471
+ async def _send_heartbeat(
472
+ scheduler_url: str, device_id: str, token: str
473
+ ) -> tuple[Optional[bool], bool]:
152
474
  try:
475
+ inventory = _get_device_inventory()
476
+ headers = {"Authorization": f"Bearer {token}", **_mesh_proxy_headers()}
153
477
  async with httpx.AsyncClient() as client:
154
- await client.post(
478
+ response = await client.post(
155
479
  f"{scheduler_url.rstrip('/')}/api/devices/{device_id}/heartbeat",
156
- json={"status": "online", "ip": _get_lan_ip()},
157
- headers={"Authorization": f"Bearer {token}"},
480
+ json={"status": "online", "ip": _get_lan_ip(), "device_inventory": inventory,
481
+ "livekit_url": os.getenv("ROBOPARK_LIVEKIT_URL") or None},
482
+ headers=headers,
158
483
  timeout=10.0,
159
484
  )
485
+ if response.status_code in (401, 404):
486
+ logger.warning(
487
+ "heartbeat credentials rejected for %s (%s)",
488
+ device_id,
489
+ response.status_code,
490
+ )
491
+ return None, False
492
+ response.raise_for_status()
493
+ logger.info(
494
+ "heartbeat accepted: inventory source=%s camera=%d mic=%d speaker=%d",
495
+ inventory.get("source", "local"),
496
+ len(inventory.get("video", [])),
497
+ len(inventory.get("audio_input", [])),
498
+ len(inventory.get("audio_output", [])),
499
+ )
500
+ return bool(response.json().get("production_mode", False)), True
160
501
  except Exception as e:
161
- logger.debug(f"heartbeat failed: {e}")
502
+ logger.warning(f"heartbeat failed: {e}")
503
+ return None, True
162
504
 
163
505
 
164
506
  @dataclass
@@ -167,6 +509,8 @@ class PreviewState:
167
509
  url: Optional[str] = None
168
510
  token: Optional[str] = None
169
511
  room: Optional[str] = None
512
+ mode: str = "preview"
513
+ session_id: Optional[str] = None
170
514
 
171
515
 
172
516
  class PreviewAgent:
@@ -178,6 +522,17 @@ class PreviewAgent:
178
522
  self.enrollment_token: Optional[str] = cfg.get("enrollment_token")
179
523
  self.video_device = cfg.get("video_device", os.getenv("VIDEO_DEVICE", "auto"))
180
524
  self.audio_device = cfg.get("audio_device", os.getenv("AUDIO_DEVICE", "default"))
525
+ # Scheduler inventory IDs come from RoboVision/sounddevice. They are
526
+ # not guaranteed to equal PyAudio's device indexes, so capture must
527
+ # use the resolved hardware name reported in that same inventory.
528
+ self.audio_capture_device = self.audio_device
529
+ self.audio_output_device = cfg.get("audio_output_device", os.getenv("AUDIO_OUTPUT_DEVICE", "default"))
530
+ # Keep RoboVision/sounddevice inventory IDs out of PyAudio. Both APIs
531
+ # number the same ALSA cards differently, so resolve the selected ID
532
+ # to its hardware name before opening any playback stream.
533
+ self.audio_playback_device = self.audio_output_device
534
+ self.robovision_url = cfg.get("robovision_url", os.getenv("ROBOVISION_URL", "http://127.0.0.1:5000"))
535
+ self.use_robovision_camera = bool(cfg.get("use_robovision_camera", True))
181
536
  self.width = int(cfg.get("video_width", os.getenv("VIDEO_WIDTH", DEFAULT_VIDEO_WIDTH)))
182
537
  self.height = int(cfg.get("video_height", os.getenv("VIDEO_HEIGHT", DEFAULT_VIDEO_HEIGHT)))
183
538
  self.fps = int(cfg.get("video_fps", os.getenv("VIDEO_FPS", DEFAULT_FPS)))
@@ -187,6 +542,17 @@ class PreviewAgent:
187
542
  self.vision_trigger_cooldown = float(cfg.get("vision_trigger_cooldown", os.getenv("VISION_TRIGGER_COOLDOWN", DEFAULT_VISION_TRIGGER_COOLDOWN)))
188
543
  self.vision_session_seconds = float(cfg.get("vision_session_seconds", os.getenv("VISION_SESSION_SECONDS", DEFAULT_VISION_SESSION_SECONDS)))
189
544
  self.vision_silence_timeout = float(cfg.get("vision_silence_timeout", os.getenv("VISION_SILENCE_TIMEOUT", DEFAULT_VISION_SILENCE_TIMEOUT)))
545
+ self.local_camera_motion = str(
546
+ # Production needs an always-on detector. The preview publisher
547
+ # remains the single camera owner when a session starts, so this
548
+ # does not require the separate OpenCV vision server.
549
+ cfg.get("local_camera_motion", os.getenv("LOCAL_CAMERA_MOTION", "true"))
550
+ ).lower() in ("1", "true", "yes", "on")
551
+ # The character greeting is the production acknowledgement. A local
552
+ # motion beep serializes on the same ALSA output and delays allocation.
553
+ self.motion_cue_enabled = str(
554
+ cfg.get("motion_cue_enabled", os.getenv("ROBOPARK_MOTION_CUE", "false"))
555
+ ).lower() in ("1", "true", "yes", "on")
190
556
 
191
557
  self._shutdown = asyncio.Event()
192
558
  self._task: Optional[asyncio.Task] = None
@@ -200,19 +566,96 @@ class PreviewAgent:
200
566
  # with a hard ceiling — see DEFAULT_VISION_SILENCE_TIMEOUT above and
201
567
  # _check_vision_session() below for the full design.
202
568
  self._vision_session_id: Optional[str] = None
569
+ self._vision_trigger_in_flight = False
570
+ self._speaker_test_in_flight = False
571
+ # The USB adapter is an exclusive ALSA endpoint. Keep operator tests
572
+ # and motion cues from racing each other while LiveKit is paused.
573
+ self._speaker_operation_lock = asyncio.Lock()
203
574
  self._vision_hard_deadline: float = 0.0
204
575
  self._vision_last_activity: float = 0.0
576
+ self.production_mode = False
577
+ self._robovision_motion_state: Optional[bool] = None
578
+ self._remote_session_ended = False
579
+ self._last_device_config_poll = 0.0
580
+ self._motion_reference = None
581
+ self._last_motion_sample = 0.0
582
+ self._motion_capture: Optional[VideoCapture] = None
583
+ self._motion_capture_lock = asyncio.Lock()
584
+ self._mesh_bootstrap_ready = False
585
+ self._next_mesh_bootstrap = 0.0
586
+ self._reported_pipeline: set[tuple[str, str]] = set()
587
+
588
+ async def _report_pipeline(self, stage: str, status: str = "ok", message: str = "",
589
+ details: Optional[dict] = None, once: bool = True) -> None:
590
+ """Report a real robot-side transition for the Park test timeline."""
591
+ if not self._session or not self.device_id or not self.device_token:
592
+ return
593
+ session_id = self._vision_session_id or self._current_state.session_id
594
+ key = (session_id or "idle", stage)
595
+ if once and key in self._reported_pipeline:
596
+ return
597
+ try:
598
+ response = await self._session.post(
599
+ f"{self.scheduler_url.rstrip('/')}/api/devices/{self.device_id}/pipeline-events",
600
+ headers={"Authorization": f"Bearer {self.device_token}"},
601
+ json={"stage": stage, "status": status, "message": message,
602
+ "session_id": session_id, "source": "preview_agent",
603
+ "details": details or {}},
604
+ timeout=5.0,
605
+ )
606
+ response.raise_for_status()
607
+ if once:
608
+ self._reported_pipeline.add(key)
609
+ except Exception as exc:
610
+ logger.debug("pipeline event %s was not accepted: %s", stage, exc)
611
+
612
+ async def _ensure_mesh_identity(self) -> bool:
613
+ if not _mesh_proxy_headers():
614
+ return bool(self.device_id and self.device_token)
615
+ now = time.monotonic()
616
+ if self._mesh_bootstrap_ready:
617
+ return True
618
+ if now < self._next_mesh_bootstrap:
619
+ return False
620
+ self._next_mesh_bootstrap = now + 5.0
621
+ try:
622
+ self.device_id, self.device_token = await _bootstrap_mesh_device(
623
+ self.scheduler_url, self.robot_id
624
+ )
625
+ self._mesh_bootstrap_ready = True
626
+ return True
627
+ except Exception as exc:
628
+ logger.warning("mesh device bootstrap failed: %s", exc)
629
+ return False
205
630
 
206
631
  async def run(self) -> None:
207
632
  enrollment_token = os.getenv("ENROLLMENT_TOKEN") or self.enrollment_token
633
+ # A UI-minted token identifies a specific pre-created device row.
634
+ # Consume it before generic mesh recovery so heartbeats cannot bind to
635
+ # a stale same-name identity.
208
636
  if not self.device_token and enrollment_token:
209
- self.device_token = await _enroll(self.scheduler_url, enrollment_token, self.robot_id)
637
+ try:
638
+ self.device_id, self.device_token = await _enroll(
639
+ self.scheduler_url, enrollment_token, self.robot_id
640
+ )
641
+ self._mesh_bootstrap_ready = True
642
+ except Exception as exc:
643
+ if not _mesh_proxy_headers():
644
+ raise
645
+ # Enrollment tokens are intentionally one-time. A service
646
+ # reinstall can retain the original systemd argument after
647
+ # its credential file was removed; recover through the hub's
648
+ # authenticated mesh path rather than crash-loop forever.
649
+ logger.warning("device enrollment failed; recovering through mesh bootstrap: %s", exc)
650
+ await self._ensure_mesh_identity()
651
+ elif _mesh_proxy_headers():
652
+ await self._ensure_mesh_identity()
210
653
 
211
654
  if not self.device_token:
212
655
  logger.error("no DEVICE_TOKEN and no ENROLLMENT_TOKEN; cannot poll scheduler")
213
656
  sys.exit(1)
214
657
 
215
- self._session = httpx.AsyncClient()
658
+ self._session = httpx.AsyncClient(headers=_mesh_proxy_headers())
216
659
 
217
660
  # Must come after self._session exists — _resolve_device_id() guards
218
661
  # on it and silently no-ops otherwise. On a fresh enroll (no cached
@@ -231,6 +674,11 @@ class PreviewAgent:
231
674
  asyncio.create_task(self._poll_loop()),
232
675
  asyncio.create_task(self._heartbeat_loop()),
233
676
  ]
677
+ # In production the robot's motion detector sends the webhook. Do not
678
+ # open the same Windows camera locally unless explicitly requested;
679
+ # doing both creates a DirectShow ownership conflict with LiveKit.
680
+ if self.local_camera_motion:
681
+ tasks.append(asyncio.create_task(self._motion_loop()))
234
682
  await self._shutdown.wait()
235
683
  for t in tasks:
236
684
  t.cancel()
@@ -241,8 +689,75 @@ class PreviewAgent:
241
689
  if self._vision_server:
242
690
  self._vision_server.shutdown()
243
691
  await self._stop_publisher()
692
+ await self._stop_motion_capture()
244
693
  await self._session.aclose()
245
694
 
695
+ async def _stop_motion_capture(self) -> None:
696
+ async with self._motion_capture_lock:
697
+ capture = self._motion_capture
698
+ self._motion_capture = None
699
+ if capture is not None:
700
+ await asyncio.to_thread(capture.stop)
701
+
702
+ async def _motion_loop(self) -> None:
703
+ """Detect motion from RoboVision's shared stream without owning V4L2."""
704
+ while not self._shutdown.is_set():
705
+ if not self.production_mode:
706
+ await self._stop_motion_capture()
707
+ await asyncio.sleep(0.5)
708
+ continue
709
+ if self._vision_session_id or self._current_state.active:
710
+ await self._stop_motion_capture()
711
+ await asyncio.sleep(0.25)
712
+ continue
713
+ try:
714
+ async with self._motion_capture_lock:
715
+ if self._motion_capture is None:
716
+ self._motion_capture = await asyncio.to_thread(
717
+ create_video_capture,
718
+ self.video_device,
719
+ self.width,
720
+ self.height,
721
+ self.fps,
722
+ self.robovision_url,
723
+ )
724
+ self._motion_reference = None
725
+ if self._motion_capture is None:
726
+ continue
727
+ logger.info("motion sampler connected to RoboVision shared stream")
728
+ frame = await asyncio.to_thread(self._motion_capture.read)
729
+ if frame is not None:
730
+ self._detect_motion(frame)
731
+ except Exception as e:
732
+ logger.warning(f"motion camera unavailable: {e}")
733
+ await self._stop_motion_capture()
734
+ await asyncio.sleep(2.0)
735
+ await asyncio.sleep(0.1)
736
+
737
+ def _detect_motion(self, frame) -> None:
738
+ """Compare sparse RGB samples and trigger only meaningful scene changes."""
739
+ now = time.monotonic()
740
+ if now - self._last_motion_sample < 0.25:
741
+ return
742
+ self._last_motion_sample = now
743
+ try:
744
+ import numpy as np
745
+
746
+ data = np.frombuffer(bytes(frame.data), dtype=np.uint8)
747
+ sample = data.reshape(frame.height, frame.width, 3)[::12, ::12].mean(axis=2)
748
+ previous = self._motion_reference
749
+ self._motion_reference = sample
750
+ if previous is None or previous.shape != sample.shape:
751
+ return
752
+ change = float(np.abs(sample - previous).mean())
753
+ threshold = float(os.getenv("VISION_MOTION_THRESHOLD", "12"))
754
+ if change >= threshold:
755
+ asyncio.create_task(
756
+ self._on_vision_motion({"source": "preview_camera", "change": change})
757
+ )
758
+ except Exception as e:
759
+ logger.debug(f"preview motion sampling failed: {e}")
760
+
246
761
  async def _resolve_device_id(self) -> None:
247
762
  """Look up our device_id from the scheduler using the token."""
248
763
  if not self._session or not self.device_token:
@@ -274,6 +789,9 @@ class PreviewAgent:
274
789
  # operator-preview state tear it down.
275
790
  await self._check_vision_session()
276
791
  if not self._vision_session_id:
792
+ await self._poll_trigger_command()
793
+ if not self._vision_session_id:
794
+ await self._poll_device_config()
277
795
  state = await self._fetch_preview_state()
278
796
  await self._apply_state(state)
279
797
  except Exception as e:
@@ -283,6 +801,83 @@ class PreviewAgent:
283
801
  except asyncio.TimeoutError:
284
802
  pass
285
803
 
804
+ async def _poll_trigger_command(self) -> None:
805
+ """Consume dashboard test triggers through the authenticated device path."""
806
+ if not self._session or not self.device_id or not self.device_token:
807
+ return
808
+ r = await self._session.get(
809
+ f"{self.scheduler_url.rstrip('/')}/api/devices/{self.device_id}/trigger-command",
810
+ headers={"Authorization": f"Bearer {self.device_token}"},
811
+ timeout=10.0,
812
+ )
813
+ r.raise_for_status()
814
+ data = r.json()
815
+ self.production_mode = bool(data.get("production_mode", self.production_mode))
816
+ if data.get("trigger"):
817
+ await self._on_vision_motion({"source": data.get("source", "dashboard")})
818
+
819
+ async def _poll_device_config(self) -> None:
820
+ """Apply dashboard-selected camera and microphone IDs before preview."""
821
+ if not self._session or not self.device_id or not self.device_token:
822
+ return
823
+ now = time.monotonic()
824
+ if now - self._last_device_config_poll < 5.0:
825
+ return
826
+ self._last_device_config_poll = now
827
+ try:
828
+ r = await self._session.get(
829
+ f"{self.scheduler_url.rstrip('/')}/api/devices/{self.device_id}/config",
830
+ headers={"Authorization": f"Bearer {self.device_token}"},
831
+ timeout=10.0,
832
+ )
833
+ r.raise_for_status()
834
+ data = r.json()
835
+ changed = False
836
+ if data.get("video_device") is not None:
837
+ changed = changed or self.video_device != data["video_device"]
838
+ self.video_device = data["video_device"]
839
+ if data.get("audio_device") is not None:
840
+ changed = changed or self.audio_device != data["audio_device"]
841
+ self.audio_device = data["audio_device"]
842
+ inventory = data.get("device_inventory") or {}
843
+ selected = str(self.audio_device)
844
+ self.audio_capture_device = _resolve_inventory_audio(
845
+ inventory.get("audio_input", []), selected,
846
+ os.getenv("ROBOPARK_AUDIO_INPUT_MATCH", ""),
847
+ )
848
+ if data.get("audio_output_device") is not None:
849
+ changed = changed or self.audio_output_device != data["audio_output_device"]
850
+ self.audio_output_device = data["audio_output_device"]
851
+ inventory = data.get("device_inventory") or {}
852
+ selected = str(self.audio_output_device)
853
+ self.audio_playback_device = _resolve_inventory_audio(
854
+ inventory.get("audio_output", []), selected,
855
+ os.getenv("ROBOPARK_AUDIO_OUTPUT_MATCH", ""),
856
+ )
857
+ if changed:
858
+ await self._sync_robovision_media_config()
859
+ except Exception as e:
860
+ logger.debug(f"device config poll failed: {e}")
861
+
862
+ async def _sync_robovision_media_config(self) -> None:
863
+ """Apply scheduler media choices to the local RoboVisionAI_PI owner."""
864
+ try:
865
+ response = await self._session.post(
866
+ f"{self.robovision_url.rstrip('/')}/api/media/config",
867
+ json={
868
+ "video_device": self.video_device,
869
+ "audio_device": self.audio_device,
870
+ "audio_output_device": self.audio_output_device,
871
+ },
872
+ timeout=1.0,
873
+ )
874
+ if response.is_success:
875
+ global _DEVICE_INVENTORY_CACHE
876
+ _DEVICE_INVENTORY_CACHE = None
877
+ except Exception:
878
+ # RoboVision is optional on laptops and simulation nodes.
879
+ pass
880
+
286
881
  async def _check_vision_session(self) -> None:
287
882
  """Decide whether the active motion-triggered session should keep
288
883
  holding the publisher.
@@ -314,6 +909,10 @@ class PreviewAgent:
314
909
 
315
910
  try:
316
911
  last_activity = await self._fetch_session_last_activity(self._vision_session_id)
912
+ if self._remote_session_ended:
913
+ logger.info("scheduler ended the vision session — stopping publisher")
914
+ await self._end_vision_session()
915
+ return
317
916
  if last_activity is not None and last_activity > self._vision_last_activity:
318
917
  self._vision_last_activity = last_activity
319
918
  except Exception as e:
@@ -341,6 +940,9 @@ class PreviewAgent:
341
940
  )
342
941
  r.raise_for_status()
343
942
  data = r.json()
943
+ if data.get("ended_at"):
944
+ self._remote_session_ended = True
945
+ return None
344
946
  ts = data.get("last_activity_at") or data.get("started_at")
345
947
  if not ts:
346
948
  return None
@@ -357,7 +959,8 @@ class PreviewAgent:
357
959
  # picked up (unrelated to this specific dispatch, but a real bug:
358
960
  # every silence-timeout before this fix leaked a permanently
359
961
  # "active" session).
360
- if self._session and self.device_token:
962
+ remote_session_ended = self._remote_session_ended
963
+ if not remote_session_ended and self._session and self.device_token:
361
964
  try:
362
965
  await self._session.post(
363
966
  f"{self.scheduler_url.rstrip('/')}/api/robots/{self.device_id or self.robot_id}/end-session",
@@ -367,21 +970,101 @@ class PreviewAgent:
367
970
  )
368
971
  except Exception as e:
369
972
  logger.warning(f"failed to notify scheduler of session end: {e}")
973
+ await self._report_pipeline("session_ended", "ok", "Robot conversation loop stopped")
370
974
  self._vision_session_id = None
975
+ self._vision_trigger_in_flight = False
371
976
  self._vision_hard_deadline = 0.0
372
977
  self._vision_last_activity = 0.0
978
+ self._remote_session_ended = False
373
979
  await self._stop_publisher()
980
+ await asyncio.to_thread(_play_audio_effect, self.audio_playback_device, "disconnect")
374
981
  self._current_state = PreviewState()
375
982
 
376
983
  async def _heartbeat_loop(self) -> None:
377
984
  while not self._shutdown.is_set():
985
+ if _mesh_proxy_headers() and not self._mesh_bootstrap_ready:
986
+ await self._ensure_mesh_identity()
378
987
  if self.device_id and self.device_token:
379
- await _send_heartbeat(self.scheduler_url, self.device_id, self.device_token)
988
+ production_mode, credentials_ok = await _send_heartbeat(
989
+ self.scheduler_url, self.device_id, self.device_token
990
+ )
991
+ if not credentials_ok:
992
+ self._mesh_bootstrap_ready = False
993
+ self._next_mesh_bootstrap = 0.0
994
+ if production_mode is not None:
995
+ self.production_mode = production_mode
996
+ # Exactly one motion detector is active. With the default
997
+ # local sampler enabled, preview reads RoboVision's shared
998
+ # MJPEG stream and RoboVision only owns capture/encoding.
999
+ robovision_motion = bool(production_mode and not self.local_camera_motion)
1000
+ if self._robovision_motion_state != robovision_motion:
1001
+ try:
1002
+ response = await self._session.post(
1003
+ f"{self.robovision_url.rstrip('/')}/api/motion/toggle",
1004
+ json={"enabled": robovision_motion}, timeout=1.5,
1005
+ )
1006
+ response.raise_for_status()
1007
+ self._robovision_motion_state = robovision_motion
1008
+ except Exception as exc:
1009
+ logger.warning(f"failed to synchronize RoboVision motion mode: {exc}")
1010
+ await self._report_pipeline("robot_online", message="Robot heartbeat accepted")
1011
+ inventory = _get_device_inventory()
1012
+ real_video = [d for d in inventory.get("video", []) if str(d.get("id", "")).lower() not in ("auto", "none")]
1013
+ real_inputs = [d for d in inventory.get("audio_input", []) if str(d.get("id", "")).lower() not in ("default", "none")]
1014
+ real_outputs = [d for d in inventory.get("audio_output", []) if str(d.get("id", "")).lower() not in ("default", "none")]
1015
+ await self._report_pipeline("camera_ready", "ok" if real_video else "blocked", f"{len(real_video)} camera device(s) detected")
1016
+ await self._report_pipeline("microphone_ready", "ok" if real_inputs else "blocked", f"{len(real_inputs)} microphone device(s) detected")
1017
+ await self._report_pipeline("speaker_ready", "ok" if real_outputs else "blocked", f"{len(real_outputs)} speaker device(s) detected")
1018
+ if not production_mode and self._vision_session_id:
1019
+ await self._end_vision_session()
1020
+ await self._poll_speaker_test()
380
1021
  try:
381
1022
  await asyncio.wait_for(self._shutdown.wait(), timeout=self.heartbeat_interval)
382
1023
  except asyncio.TimeoutError:
383
1024
  pass
384
1025
 
1026
+ async def _poll_speaker_test(self) -> None:
1027
+ """Execute queued speaker tests even when no optional supervisor runs."""
1028
+ if self._speaker_test_in_flight or not self._session or not self.device_id or not self.device_token:
1029
+ return
1030
+ headers = {"Authorization": f"Bearer {self.device_token}", **_mesh_proxy_headers()}
1031
+ try:
1032
+ response = await self._session.get(
1033
+ f"{self.scheduler_url.rstrip('/')}/api/devices/{self.device_id}/shell/next-speaker-test",
1034
+ headers=headers, timeout=5.0,
1035
+ )
1036
+ response.raise_for_status()
1037
+ request = response.json().get("request")
1038
+ if not request:
1039
+ return
1040
+ self._speaker_test_in_flight = True
1041
+ from robot_supervisor import _speaker_roundtrip_test
1042
+ async with self._speaker_operation_lock:
1043
+ # LiveKit owns the USB microphone and speaker continuously.
1044
+ # Pause it while holding the same lock as the motion cue so
1045
+ # nothing can reopen ALSA before the explicit test starts.
1046
+ restore_state = self._current_state if self._publisher else None
1047
+ if restore_state:
1048
+ await self._stop_publisher()
1049
+ await asyncio.sleep(1.0)
1050
+ try:
1051
+ result = await asyncio.to_thread(_speaker_roundtrip_test, request.get("params") or {})
1052
+ result["media_owner_paused"] = bool(restore_state)
1053
+ finally:
1054
+ if restore_state and restore_state.active:
1055
+ await self._start_publisher(restore_state)
1056
+ result_response = await self._session.post(
1057
+ f"{self.scheduler_url.rstrip('/')}/api/devices/{self.device_id}/supervisor-output",
1058
+ json={"kind": "speaker_test", "service": None, "payload": result,
1059
+ "request_id": request.get("id")},
1060
+ headers=headers, timeout=8.0,
1061
+ )
1062
+ result_response.raise_for_status()
1063
+ except Exception as e:
1064
+ logger.warning(f"speaker test execution failed: {e}")
1065
+ finally:
1066
+ self._speaker_test_in_flight = False
1067
+
385
1068
  async def _fetch_preview_state(self) -> PreviewState:
386
1069
  if not self._session or not self.device_token:
387
1070
  return PreviewState()
@@ -396,17 +1079,20 @@ class PreviewAgent:
396
1079
  return PreviewState()
397
1080
  return PreviewState(
398
1081
  active=True,
399
- url=data.get("url"),
1082
+ url=_normalize_livekit_url(data.get("url")),
400
1083
  token=data.get("token"),
401
1084
  room=data.get("room"),
1085
+ mode=data.get("mode", "preview"),
1086
+ session_id=data.get("session_id"),
402
1087
  )
403
1088
 
404
1089
  async def _apply_state(self, state: PreviewState) -> None:
405
1090
  same = (
406
1091
  state.active == self._current_state.active
407
1092
  and state.room == self._current_state.room
408
- and state.token == self._current_state.token
409
1093
  and state.url == self._current_state.url
1094
+ and state.mode == self._current_state.mode
1095
+ and state.session_id == self._current_state.session_id
410
1096
  )
411
1097
  if same:
412
1098
  return
@@ -416,17 +1102,25 @@ class PreviewAgent:
416
1102
  return
417
1103
  await self._start_publisher(state)
418
1104
 
419
- async def _start_publisher(self, state: PreviewState) -> None:
1105
+ async def _start_publisher(self, state: PreviewState) -> bool:
420
1106
  await self._stop_publisher()
421
1107
  if not state.url or not state.token or not state.room:
422
- return
1108
+ return False
1109
+ pub = None
423
1110
  try:
424
1111
  pub = LiveKitPublisher(state.url, state.token, state.room, self)
425
1112
  await pub.start()
426
1113
  self._publisher = pub
427
1114
  logger.info(f"joined preview room {state.room}")
1115
+ return True
428
1116
  except Exception as e:
429
1117
  logger.error(f"failed to start publisher: {e}")
1118
+ if pub is not None:
1119
+ try:
1120
+ await pub.stop()
1121
+ except Exception as cleanup_error:
1122
+ logger.debug(f"publisher cleanup after start failure: {cleanup_error}")
1123
+ return False
430
1124
 
431
1125
  async def _stop_publisher(self) -> None:
432
1126
  if self._publisher:
@@ -482,15 +1176,42 @@ class PreviewAgent:
482
1176
  self._vision_server = None
483
1177
 
484
1178
  async def _on_vision_motion(self, payload: dict) -> None:
1179
+ # vision_trigger_cooldown is a MINIMUM SPACING between trigger
1180
+ # attempts, not "an active session is fine to interrupt" -- without
1181
+ # this separate check, continuous ambient motion (someone standing
1182
+ # in frame) re-fires every cooldown window regardless of whether a
1183
+ # conversation is already in progress, tearing down the room and
1184
+ # restarting it before the greeting/response ever finishes playing.
1185
+ # Only allow a new trigger once the previous vision session has
1186
+ # actually ended (silence timeout, hard ceiling, or remote end).
1187
+ if self._vision_session_id or self._vision_trigger_in_flight:
1188
+ logger.debug("vision trigger suppressed (a vision session is already active)")
1189
+ return
485
1190
  now = time.time()
486
1191
  if now - self._last_vision_trigger < self.vision_trigger_cooldown:
487
1192
  logger.debug("vision trigger suppressed (cooldown)")
488
1193
  return
489
1194
  self._last_vision_trigger = now
1195
+ if not self.production_mode:
1196
+ logger.info("motion event ignored because production mode is OFF")
1197
+ return
490
1198
  if not self.device_id or not self.device_token or not self._session:
491
1199
  logger.warning("vision motion event received but not enrolled yet — ignoring")
492
1200
  return
1201
+ self._vision_trigger_in_flight = True
1202
+ # Observability must not add a scheduler round trip before the actual
1203
+ # session request. The reporter handles and logs its own errors.
1204
+ asyncio.create_task(self._report_pipeline(
1205
+ "motion_detected", "ok",
1206
+ f"Motion received from {payload.get('source', 'camera')}", once=False,
1207
+ ))
493
1208
  logger.info("motion detected by RoboVisionAI_PI — requesting a session")
1209
+ # RoboVision owns the physical camera and exposes a shared stream, so
1210
+ # there is no capture handle to tear down and no reason to sleep here.
1211
+ if self.motion_cue_enabled:
1212
+ logger.warning("ROBOPARK_MOTION_CUE is enabled; the diagnostic cue adds greeting latency")
1213
+ async with self._speaker_operation_lock:
1214
+ await asyncio.to_thread(_play_audio_effect, self.audio_playback_device, "motion")
494
1215
  try:
495
1216
  r = await self._session.post(
496
1217
  f"{self.scheduler_url.rstrip('/')}/api/devices/{self.device_id}/request-session",
@@ -501,10 +1222,12 @@ class PreviewAgent:
501
1222
  data = r.json()
502
1223
  except Exception as e:
503
1224
  logger.error(f"request-session failed: {e}")
1225
+ await self._report_pipeline("scheduler_session", "failed", f"Session request failed: {type(e).__name__}", once=False)
1226
+ self._vision_trigger_in_flight = False
504
1227
  return
505
1228
  state = PreviewState(
506
1229
  active=True,
507
- url=data.get("server_url"),
1230
+ url=_normalize_livekit_url(data.get("server_url")),
508
1231
  token=data.get("token"),
509
1232
  room=data.get("room_name"),
510
1233
  )
@@ -512,23 +1235,47 @@ class PreviewAgent:
512
1235
  voice_config = data.get("voice_config")
513
1236
  now = time.time()
514
1237
  self._vision_session_id = session_id
1238
+ self._vision_trigger_in_flight = False
515
1239
  self._vision_hard_deadline = now + self.vision_session_seconds
516
1240
  self._vision_last_activity = now
1241
+ self._remote_session_ended = False
517
1242
  self._current_state = state
1243
+ await self._report_pipeline("scheduler_session", "ok", "Scheduler allocated a conversation session")
518
1244
  # If the scheduler returned a voice config, let the agent know by
519
1245
  # posting it to our local webhook endpoint. The preview agent itself
520
1246
  # does not consume it, but this makes the config observable locally
521
1247
  # and lets downstream components (audio server, vision, etc.) adapt.
522
1248
  if voice_config and session_id:
523
1249
  logger.info(f"scheduler voice config for session {session_id}: {voice_config}")
524
- await self._start_publisher(state)
1250
+ joined = await self._start_publisher(state)
1251
+ if not joined:
1252
+ await self._report_pipeline(
1253
+ "livekit_join", "failed", "Robot could not connect to its assigned LiveKit route",
1254
+ details={"server_url": state.url}, once=False,
1255
+ )
1256
+ if session_id and self._session:
1257
+ try:
1258
+ response = await self._session.post(
1259
+ f"{self.scheduler_url.rstrip('/')}/api/robots/{self.device_id}/end-session",
1260
+ params={"reason": "livekit_join_failed"},
1261
+ headers={"Authorization": f"Bearer {self.device_token}"}, timeout=10.0,
1262
+ )
1263
+ response.raise_for_status()
1264
+ except Exception as e:
1265
+ logger.debug(f"could not end failed LiveKit session: {e}")
1266
+ self._vision_session_id = None
1267
+ self._vision_hard_deadline = 0.0
1268
+ self._vision_last_activity = 0.0
1269
+ self._current_state = PreviewState()
1270
+ return
525
1271
  if session_id and self._session:
526
1272
  try:
527
- await self._session.post(
1273
+ response = await self._session.post(
528
1274
  f"{self.scheduler_url.rstrip('/')}/api/sessions/{session_id}/joined",
529
1275
  headers={"Authorization": f"Bearer {self.device_token}"},
530
1276
  timeout=10.0,
531
1277
  )
1278
+ response.raise_for_status()
532
1279
  except Exception as e:
533
1280
  logger.debug(f"could not mark session joined: {e}")
534
1281
 
@@ -554,32 +1301,51 @@ class LiveKitPublisher:
554
1301
  self._stop_event = asyncio.Event()
555
1302
  self._tasks: list[asyncio.Task] = []
556
1303
  self._capture: Optional["VideoCapture"] = None
1304
+ self._mic_capture: Optional["AudioCapture"] = None
1305
+ self._mic_streaming = asyncio.Event()
1306
+ self._mic_error: Optional[str] = None
1307
+ # The launch hardware has no acoustic echo cancellation. Publishing
1308
+ # the amplified USB microphone while the robot speaker plays TTS makes
1309
+ # the voice worker hear itself and trigger barge-in, truncating or
1310
+ # chopping its own response. Keep the track alive with silence while
1311
+ # playback is active, plus a short room-echo decay tail.
1312
+ self._half_duplex = str(os.getenv("ROBOPARK_HALF_DUPLEX", "true")).lower() in (
1313
+ "1", "true", "yes", "on",
1314
+ )
1315
+ self._speaker_playback_active = threading.Event()
1316
+ self._speaker_gate_until = 0.0
1317
+ self._speaker_started_at = 0.0
1318
+ self._barge_in_until = 0.0
1319
+ self._echo_mic_floor = 0.0
1320
+ self._barge_in_candidate_frames = 0
1321
+ self._speaker_echo_tail = max(
1322
+ 0.1, min(float(os.getenv("ROBOPARK_ECHO_TAIL_MS", "350")) / 1000.0, 2.0)
1323
+ )
1324
+ self._adaptive_barge_in = str(
1325
+ os.getenv("ROBOPARK_ADAPTIVE_BARGE_IN", "true")
1326
+ ).lower() in ("1", "true", "yes", "on")
1327
+ self._barge_in_min_peak = max(
1328
+ 256, min(int(os.getenv("ROBOPARK_BARGE_IN_MIN_PEAK", "2200")), 20000)
1329
+ )
1330
+ self._barge_in_ratio = max(
1331
+ 1.25, min(float(os.getenv("ROBOPARK_BARGE_IN_ECHO_RATIO", "2.4")), 8.0)
1332
+ )
1333
+ self._barge_in_hold = max(
1334
+ 0.4, min(float(os.getenv("ROBOPARK_BARGE_IN_HOLD_MS", "1400")) / 1000.0, 3.0)
1335
+ )
1336
+ # Motion sampling state belongs to the publisher instance. Keeping it
1337
+ # initialized here prevents shutdown/reopen paths from raising while
1338
+ # the camera is being handed between preview and voice sessions.
1339
+ self._last_motion_sample = 0.0
557
1340
 
558
1341
  async def start(self) -> None:
559
1342
  self.room = self.rtc.Room()
560
1343
  await self.room.connect(self.url, self.token)
1344
+ await self.agent._report_pipeline("livekit_join", "ok", "Robot joined the LiveKit room")
561
1345
 
562
- # Video
563
- self.video_source = self.rtc.VideoSource(self.agent.width, self.agent.height)
564
- self.video_track = self.rtc.LocalVideoTrack.create_video_track("camera", self.video_source)
565
- vopts = self.rtc.TrackPublishOptions()
566
- vopts.source = self.rtc.TrackSource.SOURCE_CAMERA
567
- await self.room.local_participant.publish_track(self.video_track, vopts)
568
-
569
- # Audio
570
- self.audio_source = self.rtc.AudioSource(48000, 1)
571
- self.audio_track = self.rtc.LocalAudioTrack.create_audio_track("microphone", self.audio_source)
572
- aopts = self.rtc.TrackPublishOptions()
573
- aopts.source = self.rtc.TrackSource.SOURCE_MICROPHONE
574
- await self.room.local_participant.publish_track(self.audio_track, aopts)
575
-
576
- # Register speaker playback (subscribe to the voice agent's TTS audio
577
- # track) BEFORE opening the camera. Camera open is a slow/occasionally
578
- # hanging blocking call (see create_video_capture below), and since
579
- # asyncio is single-threaded, running it inline here would stall the
580
- # entire event loop — including receiving the agent's greeting audio
581
- # — until it finished, so a short "Hello friend!" greeting could be
582
- # over and gone before we ever got a chance to subscribe to it.
1346
+ # Register after signaling. Registering during Room.connect can invoke
1347
+ # callbacks while the native LiveKit participant state is incomplete;
1348
+ # on Windows that has caused an unrecoverable native client abort.
583
1349
  self._playback_streams: dict = {}
584
1350
 
585
1351
  def _on_track_subscribed(track, publication, participant):
@@ -587,57 +1353,399 @@ class LiveKitPublisher:
587
1353
  if track.kind != self.rtc.TrackKind.KIND_AUDIO:
588
1354
  return
589
1355
  if participant.identity == self.room.local_participant.identity:
590
- return # don't play our own mic back
1356
+ return
1357
+ self._tasks.append(asyncio.create_task(self.agent._report_pipeline("tts_subscribed", "ok", "Subscribed to remote voice audio")))
591
1358
  self._tasks.append(asyncio.create_task(self._play_remote_audio(track, publication.sid)))
592
1359
 
593
1360
  self.room.on("track_subscribed", _on_track_subscribed)
594
1361
  logger.info("audio out: track_subscribed listener registered")
595
1362
 
596
- if has_audio():
597
- self._tasks.append(asyncio.create_task(self._audio_loop()))
1363
+ # Open and warm the camera before creating the native LiveKit source.
1364
+ # Windows webcams may ignore the requested 640x480 mode and return a
1365
+ # different size (for example 480x360); capturing that frame into a
1366
+ # mismatched VideoSource can abort the native SDK.
1367
+ video_enabled = str(self.agent.video_device).lower() not in ("none", "", "false", "null")
1368
+ first_frame = None
1369
+ if video_enabled:
1370
+ try:
1371
+ self._capture = await asyncio.to_thread(
1372
+ create_video_capture, self.agent.video_device, self.agent.width, self.agent.height, self.agent.fps,
1373
+ self.agent.robovision_url if self.agent.use_robovision_camera else None,
1374
+ )
1375
+ except Exception as e:
1376
+ # Camera availability must not block the microphone/session.
1377
+ # Windows camera drivers can fail during a reopen while the
1378
+ # audio path remains healthy and should still accept speech.
1379
+ logger.warning(f"camera unavailable for this session; continuing audio-only: {e}")
1380
+ self._capture = None
1381
+ if self._capture:
1382
+ for _ in range(12):
1383
+ try:
1384
+ first_frame = await asyncio.to_thread(self._capture.read)
1385
+ except Exception as e:
1386
+ logger.warning(f"camera read failed during warm-up; continuing audio-only: {e}")
1387
+ first_frame = None
1388
+ break
1389
+ if first_frame is not None:
1390
+ logger.info(
1391
+ "camera warm-up produced a valid frame (%sx%s)",
1392
+ first_frame.width,
1393
+ first_frame.height,
1394
+ )
1395
+ break
1396
+ await asyncio.sleep(0.08)
1397
+ if first_frame is None:
1398
+ self._capture.stop()
1399
+ self._capture = None
1400
+
1401
+ # Do not publish dummy tracks for explicitly disabled devices. Apart
1402
+ # from misleading the worker, creating native LiveKit sources for a
1403
+ # disabled Windows device has caused unstable track publication.
1404
+ audio_enabled = str(self.agent.audio_device).lower() not in ("none", "", "false", "null")
1405
+ if first_frame is not None:
1406
+ self.video_source = self.rtc.VideoSource(first_frame.width, first_frame.height)
1407
+ self.video_track = self.rtc.LocalVideoTrack.create_video_track("camera", self.video_source)
1408
+ vopts = self.rtc.TrackPublishOptions()
1409
+ vopts.source = self.rtc.TrackSource.SOURCE_CAMERA
1410
+ await self.room.local_participant.publish_track(self.video_track, vopts)
1411
+ self.video_source.capture_frame(first_frame)
1412
+ await self.agent._report_pipeline("camera_published", "ok", "Camera track published")
1413
+
1414
+ if audio_enabled:
1415
+ self.audio_source = self.rtc.AudioSource(48000, 1)
1416
+ self.audio_track = self.rtc.LocalAudioTrack.create_audio_track("microphone", self.audio_source)
1417
+ aopts = self.rtc.TrackPublishOptions()
1418
+ aopts.source = self.rtc.TrackSource.SOURCE_MICROPHONE
1419
+ await self.room.local_participant.publish_track(self.audio_track, aopts)
598
1420
 
599
- # Camera open can block for a long time (flaky USB/driver reopen) —
600
- # run it in a thread so it can't stall the event loop (and therefore
601
- # incoming agent audio / heartbeats / the playback subscribed above).
602
- self._capture = await asyncio.to_thread(
603
- create_video_capture, self.agent.video_device, self.agent.width, self.agent.height, self.agent.fps
604
- )
605
- if self._capture:
1421
+ # Register speaker playback (subscribe to the voice agent's TTS audio
1422
+ # track) BEFORE opening the camera. Camera open is a slow/occasionally
1423
+ # hanging blocking call (see create_video_capture below), and since
1424
+ # asyncio is single-threaded, running it inline here would stall the
1425
+ # entire event loop — including receiving the agent's greeting audio
1426
+ # — until it finished, so a short "Hello friend!" greeting could be
1427
+ # over and gone before we ever got a chance to subscribe to it.
1428
+ if audio_enabled:
1429
+ self._tasks.append(asyncio.create_task(self._audio_retry_loop()))
1430
+ try:
1431
+ await asyncio.wait_for(self._mic_streaming.wait(), timeout=5.0)
1432
+ except asyncio.TimeoutError:
1433
+ detail = self._mic_error or f"no PCM received from {self.agent.audio_capture_device}"
1434
+ await self.agent._report_pipeline(
1435
+ "microphone_published", "blocked",
1436
+ f"Microphone track published but PCM capture did not start: {detail}",
1437
+ {"device": self.agent.audio_capture_device, "error": detail},
1438
+ )
1439
+ logger.warning(
1440
+ "microphone PCM is not ready (%s); keeping the session alive while capture retries",
1441
+ detail,
1442
+ )
1443
+
1444
+ if self._capture and self.video_source:
606
1445
  self._tasks.append(asyncio.create_task(self._video_loop()))
607
1446
 
608
1447
  async def _play_remote_audio(self, track, sid: str) -> None:
609
- try:
610
- import pyaudio
611
- except Exception as e:
612
- logger.warning(f"pyaudio unavailable for playback: {e}")
613
- return
614
1448
  OUT_RATE = 48000
615
- pa = pyaudio.PyAudio()
616
- out = pa.open(format=pyaudio.paInt16, channels=1, rate=OUT_RATE, output=True)
617
- logger.info(f"audio out: opened playback stream for {sid}")
1449
+ OUT_CHANNELS = 2
1450
+ selected_output = str(self.agent.audio_playback_device or "default")
1451
+ pa = None
1452
+ output_device_index = None
1453
+ speaker_guard = None
1454
+
1455
+ # RoboVision inventories Linux devices through sounddevice/PortAudio,
1456
+ # but the numeric indices are not stable across PyAudio builds. More
1457
+ # importantly, BMW's production USB adapter is already proven through
1458
+ # ALSA's plughw conversion path. Use that exact endpoint for live TTS
1459
+ # instead of reopening the unrelated PyAudio index.
1460
+ if sys.platform.startswith("linux") and "hw:" in selected_output:
1461
+ import re
1462
+ import subprocess
1463
+ from media_lock import media_lock
1464
+
1465
+ match = re.search(r"\b(hw:\d+,\d+)\b", selected_output)
1466
+ if not match:
1467
+ logger.warning(f"audio out: no ALSA hardware address in {selected_output!r}")
1468
+ return
1469
+ alsa_device = f"plug{match.group(1)}"
1470
+ try:
1471
+ speaker_guard = media_lock("speaker", timeout=8.0).acquire()
1472
+ process = subprocess.Popen(
1473
+ [
1474
+ "aplay", "-q", "-D", alsa_device, "-t", "raw",
1475
+ "-f", "S16_LE", "-r", str(OUT_RATE), "-c", str(OUT_CHANNELS),
1476
+ ],
1477
+ stdin=subprocess.PIPE,
1478
+ stderr=subprocess.PIPE,
1479
+ )
1480
+ except Exception as exc:
1481
+ if speaker_guard is not None:
1482
+ speaker_guard.release()
1483
+ logger.warning(f"audio out: could not acquire {alsa_device}: {exc}")
1484
+ return
1485
+ if process.stdin is None:
1486
+ logger.warning(f"audio out: aplay did not expose stdin for {alsa_device}")
1487
+ process.kill()
1488
+ process.wait(timeout=1)
1489
+ speaker_guard.release()
1490
+ return
1491
+
1492
+ class _AplayOutput:
1493
+ def write(self, chunk: bytes) -> None:
1494
+ if process.poll() is not None:
1495
+ detail = ""
1496
+ if process.stderr is not None:
1497
+ detail = process.stderr.read().decode("utf-8", errors="replace").strip()
1498
+ raise OSError(detail or f"aplay exited {process.returncode}")
1499
+ process.stdin.write(chunk)
1500
+ process.stdin.flush()
1501
+
1502
+ def stop_stream(self) -> None:
1503
+ if process.stdin and not process.stdin.closed:
1504
+ process.stdin.close()
1505
+ try:
1506
+ process.wait(timeout=2)
1507
+ except subprocess.TimeoutExpired:
1508
+ process.terminate()
1509
+ try:
1510
+ process.wait(timeout=1)
1511
+ except subprocess.TimeoutExpired:
1512
+ process.kill()
1513
+ process.wait(timeout=1)
1514
+
1515
+ def close(self) -> None:
1516
+ return
1517
+
1518
+ out = _AplayOutput()
1519
+ output_device_index = alsa_device
1520
+ else:
1521
+ try:
1522
+ import pyaudio
1523
+ except Exception as e:
1524
+ logger.warning(f"pyaudio unavailable for playback: {e}")
1525
+ return
1526
+ pa = pyaudio.PyAudio()
1527
+ # Same MME-vs-WASAPI gotcha as mic capture (see
1528
+ # PyAudioCapture._resolve_device): prefer the endpoint backing the
1529
+ # Windows volume mixer instead of the silent MME default.
1530
+ if selected_output.strip().isdigit():
1531
+ output_device_index = int(selected_output.strip())
1532
+ try:
1533
+ if selected_output.lower() == "default":
1534
+ wasapi = pa.get_host_api_info_by_type(pyaudio.paWASAPI)
1535
+ idx = wasapi.get("defaultOutputDevice")
1536
+ if idx is not None and idx >= 0:
1537
+ output_device_index = idx
1538
+ elif output_device_index is None:
1539
+ needle = selected_output.lower()
1540
+ for i in range(pa.get_device_count()):
1541
+ info = pa.get_device_info_by_index(i)
1542
+ if info.get("maxOutputChannels", 0) > 0 and needle in str(info.get("name", "")).lower():
1543
+ output_device_index = i
1544
+ break
1545
+ except Exception as e:
1546
+ logger.debug(f"audio out: WASAPI default output lookup failed, using PyAudio default: {e}")
1547
+ out = pa.open(
1548
+ format=pyaudio.paInt16, channels=OUT_CHANNELS, rate=OUT_RATE, output=True,
1549
+ output_device_index=output_device_index,
1550
+ frames_per_buffer=960,
1551
+ )
1552
+ logger.info(f"audio out: opened playback stream for {sid} (device_index={output_device_index})")
618
1553
  frame_count = 0
1554
+ mismatch_logged = False
1555
+ try:
1556
+ import numpy as np
1557
+ except Exception as e:
1558
+ np = None
1559
+ logger.warning(f"numpy unavailable for playback resampling: {e}")
1560
+
1561
+ # Frames arrive over the network in irregular bursts (TTS streaming,
1562
+ # scheduling jitter); writing each one straight to a blocking PyAudio
1563
+ # stream inline ties the audio device's write timing to that jitter,
1564
+ # which is what produced "heavily distorted/staticky" playback even
1565
+ # with matching rate/channels. Decouple the two with a small jitter
1566
+ # buffer: a writer thread drains a queue into PyAudio on its own
1567
+ # steady pace, independent of how unevenly frames actually arrive.
1568
+ import queue
1569
+ import threading
1570
+ write_queue: "queue.Queue[Optional[bytes]]" = queue.Queue()
1571
+ written_frames = [0]
1572
+ PREBUFFER_CHUNKS = 3
1573
+ stream_failed = threading.Event()
1574
+ playback_reported = threading.Event()
1575
+ event_loop = asyncio.get_running_loop()
1576
+ echo_gate_peak = max(16, min(int(os.getenv("ROBOPARK_ECHO_GATE_PEAK", "96")), 4096))
1577
+
1578
+ def _outbound_peak(chunk: bytes) -> int:
1579
+ if len(chunk) < 2:
1580
+ return 0
1581
+ try:
1582
+ samples = memoryview(chunk).cast("h")
1583
+ # Stereo duplication means sampling every eighth value is
1584
+ # sufficient and keeps the writer thread lightweight.
1585
+ return max((abs(int(value)) for value in samples[::8]), default=0)
1586
+ except (TypeError, ValueError):
1587
+ return 0
1588
+
1589
+ def _open_echo_gate() -> None:
1590
+ if not self._half_duplex:
1591
+ return
1592
+ if not self._speaker_playback_active.is_set():
1593
+ self._speaker_started_at = time.monotonic()
1594
+ self._echo_mic_floor = 0.0
1595
+ self._barge_in_candidate_frames = 0
1596
+ self._speaker_playback_active.set()
1597
+ self._speaker_gate_until = time.monotonic() + self._speaker_echo_tail
1598
+
1599
+ def _extend_echo_gate() -> None:
1600
+ if self._half_duplex:
1601
+ self._speaker_gate_until = time.monotonic() + self._speaker_echo_tail
1602
+
1603
+ def _safe_write(chunk: bytes) -> bool:
1604
+ if stream_failed.is_set():
1605
+ return False
1606
+ try:
1607
+ audible = _outbound_peak(chunk) >= echo_gate_peak
1608
+ if audible:
1609
+ _open_echo_gate()
1610
+ elif time.monotonic() >= self._speaker_gate_until:
1611
+ self._speaker_playback_active.clear()
1612
+ out.write(chunk)
1613
+ if audible:
1614
+ _extend_echo_gate()
1615
+ if not playback_reported.is_set():
1616
+ playback_reported.set()
1617
+ asyncio.run_coroutine_threadsafe(
1618
+ self.agent._report_pipeline(
1619
+ "playback_started", "ok", "First TTS audio chunk written to robot speaker"
1620
+ ),
1621
+ event_loop,
1622
+ )
1623
+ return True
1624
+ except Exception as e:
1625
+ stream_failed.set()
1626
+ logger.warning(f"audio out stream closed; disabling playback for this track: {e}")
1627
+ return False
1628
+
1629
+ def _writer():
1630
+ buffered = []
1631
+ started = False
1632
+ while True:
1633
+ try:
1634
+ chunk = write_queue.get()
1635
+ except queue.Empty:
1636
+ continue
1637
+ if chunk is None:
1638
+ # Drain any short tail when the remote track ends.
1639
+ for pending in buffered:
1640
+ if not _safe_write(pending):
1641
+ break
1642
+ break
1643
+ if not started:
1644
+ buffered.append(chunk)
1645
+ if len(buffered) < PREBUFFER_CHUNKS:
1646
+ continue
1647
+ for pending in buffered:
1648
+ if not _safe_write(pending):
1649
+ break
1650
+ buffered.clear()
1651
+ if stream_failed.is_set():
1652
+ break
1653
+ started = True
1654
+ continue
1655
+ try:
1656
+ if _safe_write(chunk):
1657
+ written_frames[0] += 1
1658
+ if written_frames[0] == 1:
1659
+ logger.info(f"audio out: writer thread wrote its first chunk for {sid}")
1660
+ elif stream_failed.is_set():
1661
+ break
1662
+ except Exception as e:
1663
+ # A single write failing (e.g. a transient device hiccup) must
1664
+ # not silently kill the whole thread — that leaves every
1665
+ # later frame queued with nothing left to consume them,
1666
+ # which looks exactly like "no audio at all" even though
1667
+ # frames kept arriving fine. Log loudly and keep going.
1668
+ logger.warning(f"audio out write error (continuing): {e}")
1669
+
1670
+ writer_thread = threading.Thread(target=_writer, daemon=True)
1671
+ writer_thread.start()
1672
+
619
1673
  try:
620
1674
  stream = self.rtc.AudioStream(track=track)
621
1675
  async for frame in stream:
622
1676
  af = frame.frame if hasattr(frame, "frame") else frame
623
- data = bytes(af.data)
624
1677
  frame_count += 1
625
1678
  if frame_count == 1:
626
- logger.info(f"audio out: first frame received for {sid} (rate={af.sample_rate})")
627
- if af.sample_rate != OUT_RATE:
628
- # Frames from Kokoro/TTS are already 48kHz in this stack;
629
- # a mismatch here would need resampling like the Pi
630
- # client does, but isn't expected on the local test loop.
631
- logger.debug(
632
- f"audio out: unexpected sample rate {af.sample_rate}, expected {OUT_RATE}"
1679
+ logger.info(
1680
+ f"audio out: first frame received for {sid} "
1681
+ f"(rate={af.sample_rate}, channels={getattr(af, 'num_channels', 1)})"
633
1682
  )
634
- out.write(data)
1683
+ in_rate = af.sample_rate
1684
+ in_channels = int(getattr(af, "num_channels", 1) or 1)
1685
+ raw = bytes(af.data)
1686
+ if np is not None:
1687
+ in_data = np.frombuffer(raw, dtype=np.int16)
1688
+ if in_channels > 1:
1689
+ in_data = in_data.reshape(-1, in_channels).mean(axis=1).astype(np.int16)
1690
+ else:
1691
+ in_data = raw
1692
+
1693
+ if in_rate == OUT_RATE or np is None:
1694
+ if in_rate != OUT_RATE and not mismatch_logged:
1695
+ mismatch_logged = True
1696
+ logger.warning(
1697
+ f"audio out: sample rate {in_rate} != {OUT_RATE} and numpy "
1698
+ f"unavailable — playing unresampled (expect distortion)"
1699
+ )
1700
+ mono_data = in_data
1701
+ else:
1702
+ # Mirror the real Pi client's resampling (pi-client/livekit_bridge.py):
1703
+ # writing raw bytes straight to a fixed-rate output stream when the
1704
+ # source rate differs (e.g. Kokoro's native rate vs our 48kHz stream)
1705
+ # is what produced the "heavily distorted/staticky" playback — every
1706
+ # frame needs resampling to OUT_RATE first, not just the ones that
1707
+ # happen to already match.
1708
+ if not mismatch_logged:
1709
+ mismatch_logged = True
1710
+ logger.info(f"audio out: resampling {in_rate}Hz -> {OUT_RATE}Hz for {sid}")
1711
+ ratio = OUT_RATE / in_rate
1712
+ n_out = int(len(in_data) * ratio)
1713
+ indices = np.linspace(0, len(in_data) - 1, n_out)
1714
+ out_data = np.interp(indices, np.arange(len(in_data)), in_data.astype(np.float64)).astype(np.int16)
1715
+ mono_data = out_data
1716
+
1717
+ if np is not None:
1718
+ # The Windows endpoint is stereo; duplicate mono LiveKit audio explicitly.
1719
+ data = np.repeat(mono_data[:, None], OUT_CHANNELS, axis=1).astype(np.int16).tobytes()
1720
+ else:
1721
+ data = b"".join(raw[i:i + 2] * OUT_CHANNELS for i in range(0, len(raw), 2))
1722
+ write_queue.put(data)
635
1723
  except Exception as e:
636
1724
  logger.warning(f"audio out stream error: {e}")
637
1725
  finally:
638
- out.stop_stream()
1726
+ cancelling = bool(asyncio.current_task() and asyncio.current_task().cancelling())
1727
+ if cancelling:
1728
+ # On publisher teardown, close ALSA first. Waiting for the
1729
+ # writer while aplay still owns the device leaves hw:X,Y busy
1730
+ # long enough for the queued dashboard test to fail.
1731
+ stream_failed.set()
1732
+ out.stop_stream()
1733
+ write_queue.put(None)
1734
+ writer_thread.join(timeout=2.0)
1735
+ logger.info(
1736
+ f"audio out: {sid} received {frame_count} frames, "
1737
+ f"writer wrote {written_frames[0]} chunks, queue backlog at close={write_queue.qsize()}"
1738
+ )
1739
+ if not cancelling:
1740
+ out.stop_stream()
639
1741
  out.close()
640
- pa.terminate()
1742
+ if pa is not None:
1743
+ pa.terminate()
1744
+ if speaker_guard is not None:
1745
+ speaker_guard.release()
1746
+ if self._half_duplex:
1747
+ self._speaker_gate_until = time.monotonic() + self._speaker_echo_tail
1748
+ self._speaker_playback_active.clear()
641
1749
 
642
1750
  async def stop(self) -> None:
643
1751
  self._stop_event.set()
@@ -648,6 +1756,9 @@ class LiveKitPublisher:
648
1756
  except asyncio.CancelledError:
649
1757
  pass
650
1758
  self._tasks.clear()
1759
+ if self._mic_capture:
1760
+ await asyncio.to_thread(self._mic_capture.stop)
1761
+ self._mic_capture = None
651
1762
  if self._capture:
652
1763
  self._capture.stop()
653
1764
  self._capture = None
@@ -669,6 +1780,7 @@ class LiveKitPublisher:
669
1780
  frame = await asyncio.to_thread(self._capture.read)
670
1781
  if frame is not None:
671
1782
  self.video_source.capture_frame(frame)
1783
+ self._detect_motion(frame)
672
1784
  elapsed = time.monotonic() - start
673
1785
  sleep_for = frame_interval - elapsed
674
1786
  if sleep_for > 0:
@@ -677,12 +1789,65 @@ class LiveKitPublisher:
677
1789
  except asyncio.TimeoutError:
678
1790
  pass
679
1791
 
1792
+ def _detect_motion(self, frame: "rtc.VideoFrame") -> None:
1793
+ """Detect motion from the already-published camera frame.
1794
+
1795
+ The preview publisher is the sole camera owner. Keeping motion
1796
+ detection here avoids opening the Windows camera a second time from a
1797
+ separate detector process, which caused black frames and crashes.
1798
+ """
1799
+ now = time.monotonic()
1800
+ if now - self._last_motion_sample < 0.25:
1801
+ return
1802
+ self._last_motion_sample = now
1803
+ try:
1804
+ import numpy as np
1805
+
1806
+ data = np.frombuffer(bytes(frame.data), dtype=np.uint8)
1807
+ sample = data.reshape(frame.height, frame.width, 3)[::12, ::12].mean(axis=2)
1808
+ previous = self._motion_reference
1809
+ self._motion_reference = sample
1810
+ if previous is None or previous.shape != sample.shape:
1811
+ return
1812
+ change = float(np.abs(sample - previous).mean())
1813
+ if change >= float(os.getenv("VISION_MOTION_THRESHOLD", "12")):
1814
+ asyncio.create_task(
1815
+ self._on_vision_motion({"source": "preview_camera", "change": change})
1816
+ )
1817
+ except Exception as e:
1818
+ logger.debug(f"preview motion sampling failed: {e}")
1819
+
1820
+ async def _audio_retry_loop(self) -> None:
1821
+ """Keep microphone capture alive across transient ALSA ownership errors."""
1822
+ while not self._stop_event.is_set():
1823
+ try:
1824
+ await self._audio_loop()
1825
+ except asyncio.CancelledError:
1826
+ raise
1827
+ except Exception as e:
1828
+ self._mic_error = str(e)
1829
+ logger.warning(
1830
+ f"audio_loop: capture failed on {self.agent.audio_capture_device}: {e}; retrying"
1831
+ )
1832
+ finally:
1833
+ mic = self._mic_capture
1834
+ self._mic_capture = None
1835
+ if mic is not None:
1836
+ await asyncio.to_thread(mic.stop)
1837
+ if not self._stop_event.is_set():
1838
+ try:
1839
+ await asyncio.wait_for(self._stop_event.wait(), timeout=1.0)
1840
+ except asyncio.TimeoutError:
1841
+ pass
1842
+
680
1843
  async def _audio_loop(self) -> None:
681
1844
  assert self.audio_source is not None
682
- mic = create_audio_capture(self.agent.audio_device)
1845
+ mic = create_audio_capture(self.agent.audio_capture_device)
683
1846
  if mic is None:
684
1847
  logger.warning("audio_loop: create_audio_capture returned None, mic will not publish")
685
1848
  return
1849
+ self._mic_capture = mic
1850
+ self._mic_error = None
686
1851
  logger.info(f"audio_loop: mic capture started ({type(mic).__name__})")
687
1852
  # Read in bigger batches (100ms) instead of one 20ms frame per
688
1853
  # asyncio.to_thread() dispatch. Each dispatch/poll cycle has a fixed
@@ -698,12 +1863,13 @@ class LiveKitPublisher:
698
1863
  samples_per_frame = int(48000 * frame_ms / 1000)
699
1864
  samples_per_batch = int(48000 * BATCH_MS / 1000)
700
1865
  bytes_per_frame = samples_per_frame * 2 # int16 mono
701
- import audioop
1866
+ mic_gain = max(1.0, min(float(os.getenv("ROBOPARK_MIC_GAIN", "4.0")), 12.0))
702
1867
  _diag_peak = 0
703
1868
  _diag_count = 0
704
1869
  _diag_last_log = time.monotonic()
705
1870
  _diag_read_ms = 0.0
706
1871
  _diag_publish_ms = 0.0
1872
+ _echo_gate_was_active = False
707
1873
  batch_interval = BATCH_MS / 1000
708
1874
  while not self._stop_event.is_set():
709
1875
  _iter_start = time.monotonic()
@@ -715,11 +1881,63 @@ class LiveKitPublisher:
715
1881
  _pub_start = time.monotonic()
716
1882
  for off in range(0, len(data) - bytes_per_frame + 1, bytes_per_frame):
717
1883
  chunk = data[off:off + bytes_per_frame]
1884
+ now = time.monotonic()
1885
+ echo_gate_active = self._half_duplex and (
1886
+ self._speaker_playback_active.is_set()
1887
+ or now < self._speaker_gate_until
1888
+ )
1889
+ # Learn the microphone's speaker-echo floor while output is
1890
+ # active. A nearby visitor speaking produces a fast peak well
1891
+ # above that floor; reopen the mic briefly so LiveKit VAD can
1892
+ # cancel normal TTS. The initial 300 ms remains protected.
1893
+ _, raw_peak = _pcm16_scale_and_peak(chunk, 1.0)
1894
+ if echo_gate_active and self._adaptive_barge_in:
1895
+ if self._echo_mic_floor <= 0:
1896
+ self._echo_mic_floor = float(raw_peak)
1897
+ else:
1898
+ self._echo_mic_floor = self._echo_mic_floor * 0.92 + raw_peak * 0.08
1899
+ threshold = max(
1900
+ self._barge_in_min_peak,
1901
+ int(self._echo_mic_floor * self._barge_in_ratio),
1902
+ )
1903
+ warmed = now - self._speaker_started_at >= 0.3
1904
+ if warmed and raw_peak >= threshold:
1905
+ self._barge_in_candidate_frames += 1
1906
+ else:
1907
+ self._barge_in_candidate_frames = 0
1908
+ if self._barge_in_candidate_frames >= 3:
1909
+ if now >= self._barge_in_until:
1910
+ logger.info(
1911
+ "audio_loop: adaptive barge-in opened mic "
1912
+ "(peak=%d threshold=%d echo_floor=%d)",
1913
+ raw_peak, threshold, int(self._echo_mic_floor),
1914
+ )
1915
+ self._barge_in_until = now + self._barge_in_hold
1916
+ barge_in_active = self._adaptive_barge_in and now < self._barge_in_until
1917
+ if echo_gate_active and not barge_in_active:
1918
+ # Preserve 20 ms frame cadence; only suppress content.
1919
+ # Stopping publication would create gaps and destabilize
1920
+ # VAD/endpointing when listening resumes.
1921
+ chunk = b"\x00" * len(chunk)
1922
+ p = 0
1923
+ else:
1924
+ chunk, p = _pcm16_scale_and_peak(chunk, mic_gain)
1925
+ if echo_gate_active != _echo_gate_was_active:
1926
+ logger.info(
1927
+ "audio_loop: speaker echo gate %s",
1928
+ "active" if echo_gate_active else "released",
1929
+ )
1930
+ _echo_gate_was_active = echo_gate_active
718
1931
  frame = self.rtc.AudioFrame(
719
1932
  data=chunk, sample_rate=48000, num_channels=1, samples_per_channel=samples_per_frame,
720
1933
  )
721
1934
  await self.audio_source.capture_frame(frame)
722
- p = audioop.max(chunk, 2)
1935
+ if not self._mic_streaming.is_set():
1936
+ self._mic_streaming.set()
1937
+ await self.agent._report_pipeline(
1938
+ "microphone_published", "ok",
1939
+ "Microphone track published with live PCM frames",
1940
+ )
723
1941
  _diag_peak = max(_diag_peak, p)
724
1942
  _diag_count += 1
725
1943
  _diag_read_ms += (_t1 - _t0) * 1000
@@ -764,10 +1982,22 @@ class OpencvVideoCapture(VideoCapture):
764
1982
  self.cv2 = cv2
765
1983
  if isinstance(device, str) and device.startswith("/dev/video"):
766
1984
  device = int(device.replace("/dev/video", ""))
767
- self.cap = cv2.VideoCapture(device)
1985
+ elif isinstance(device, str) and device.isdigit():
1986
+ device = int(device)
1987
+ # MSMF frequently returns intermittent grab failures for USB cameras
1988
+ # on Windows. DirectShow is more stable for this long-lived stream;
1989
+ # retain the default backend as a fallback for unusual devices.
1990
+ if os.name == "nt" and isinstance(device, int):
1991
+ self.cap = cv2.VideoCapture(device, cv2.CAP_DSHOW)
1992
+ if not self.cap.isOpened():
1993
+ self.cap.release()
1994
+ self.cap = cv2.VideoCapture(device)
1995
+ else:
1996
+ self.cap = cv2.VideoCapture(device)
768
1997
  self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, width)
769
1998
  self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height)
770
1999
  self.cap.set(cv2.CAP_PROP_FPS, fps)
2000
+ self._last_black_log = 0.0
771
2001
  self._ensure_frame()
772
2002
 
773
2003
  def _ensure_frame(self):
@@ -780,11 +2010,28 @@ class OpencvVideoCapture(VideoCapture):
780
2010
  ok, bgr = self.cap.read()
781
2011
  if not ok or bgr is None:
782
2012
  return None
2013
+ # OpenCV can report a successful read while a Windows camera driver
2014
+ # returns an effectively black frame (privacy shutter, wrong device,
2015
+ # or a stale DirectShow handle). Do not publish misleading video.
2016
+ mean = float(bgr.mean())
2017
+ if mean <= 1.0 and float(bgr.max()) <= 8.0:
2018
+ now = time.monotonic()
2019
+ if now - self._last_black_log >= 10.0:
2020
+ logger.error(
2021
+ "camera returned black frames from device %s; check camera selection, "
2022
+ "privacy shutter, and Windows camera permissions",
2023
+ self.cap,
2024
+ )
2025
+ self._last_black_log = now
2026
+ return None
2027
+ # The current LiveKit RTC SDK exposes RGB/RGBA frame types, not BGR.
2028
+ # OpenCV captures BGR, so convert before constructing the frame.
2029
+ rgb = self.cv2.cvtColor(bgr, self.cv2.COLOR_BGR2RGB)
783
2030
  return rtc.VideoFrame(
784
- width=bgr.shape[1],
785
- height=bgr.shape[0],
786
- type=rtc.VideoBufferType.BGR,
787
- data=bgr.tobytes(),
2031
+ width=rgb.shape[1],
2032
+ height=rgb.shape[0],
2033
+ type=rtc.VideoBufferType.RGB24,
2034
+ data=rgb.tobytes(),
788
2035
  )
789
2036
 
790
2037
  def stop(self):
@@ -812,7 +2059,7 @@ class Picamera2Capture(VideoCapture):
812
2059
  return rtc.VideoFrame(
813
2060
  width=self.width,
814
2061
  height=self.height,
815
- type=rtc.VideoBufferType.RGB,
2062
+ type=rtc.VideoBufferType.RGB24,
816
2063
  data=arr.tobytes(),
817
2064
  )
818
2065
 
@@ -823,7 +2070,8 @@ class Picamera2Capture(VideoCapture):
823
2070
  pass
824
2071
 
825
2072
 
826
- def create_video_capture(device: str, width: int, height: int, fps: int) -> Optional[VideoCapture]:
2073
+ def create_video_capture(device: str, width: int, height: int, fps: int,
2074
+ robovision_url: Optional[str] = None) -> Optional[VideoCapture]:
827
2075
  if device.lower() in ("none", "", "false", "null"):
828
2076
  return None
829
2077
  try:
@@ -833,6 +2081,16 @@ def create_video_capture(device: str, width: int, height: int, fps: int) -> Opti
833
2081
  logger.error(f"livekit python sdk not installed: {e}")
834
2082
  return None
835
2083
 
2084
+ if robovision_url:
2085
+ stream_url = f"{robovision_url.rstrip('/')}/video_feed"
2086
+ try:
2087
+ cap = OpencvVideoCapture(stream_url, width, height, fps)
2088
+ logger.info(f"using RoboVisionAI_PI camera stream {stream_url}")
2089
+ return cap
2090
+ except Exception as e:
2091
+ logger.error(f"RoboVision camera stream unavailable: {e}")
2092
+ return None
2093
+
836
2094
  # Auto-detect: prefer first V4L2 device, fall back to picamera2 if available.
837
2095
  if device.lower() in ("auto", "default", "first"):
838
2096
  for i in range(4):
@@ -870,6 +2128,112 @@ class AudioCapture:
870
2128
  raise NotImplementedError
871
2129
 
872
2130
 
2131
+ class AlsaAudioCapture(AudioCapture):
2132
+ """Capture Linux PCM through the same ALSA path used by onsite tests."""
2133
+
2134
+ def __init__(self, device: str):
2135
+ import os
2136
+ import re
2137
+ import select
2138
+ import subprocess
2139
+ from media_lock import media_lock
2140
+
2141
+ match = re.search(r"\b(hw:\d+,\d+)\b", device)
2142
+ if not match:
2143
+ raise ValueError(f"no ALSA hardware address in {device!r}")
2144
+ self.buffer = bytearray()
2145
+ self.media_guard = media_lock("microphone", timeout=1.5).acquire()
2146
+ self.process = None
2147
+ self.source_rate = 48000
2148
+ errors = []
2149
+ # The fleet USB microphone normally accepts 48 kHz through ALSA's
2150
+ # plug layer. Some firmware revisions expose only native 44.1 kHz;
2151
+ # accept that rate and resample below rather than publishing silence.
2152
+ for source_rate in (48000, 44100):
2153
+ alsa_device = f"plug{match.group(1)}"
2154
+ process = None
2155
+ try:
2156
+ process = subprocess.Popen(
2157
+ [
2158
+ "arecord", "-q", "-D", alsa_device, "-t", "raw",
2159
+ "-f", "S16_LE", "-r", str(source_rate), "-c", "1",
2160
+ "--period-size", str(max(256, source_rate // 50)),
2161
+ ],
2162
+ stdout=subprocess.PIPE,
2163
+ stderr=subprocess.PIPE,
2164
+ bufsize=0,
2165
+ )
2166
+ if process.stdout is None:
2167
+ raise RuntimeError("arecord did not provide a PCM stream")
2168
+ ready, _, _ = select.select([process.stdout], [], [], 2.0)
2169
+ if not ready:
2170
+ if process.poll() is None:
2171
+ raise TimeoutError("arecord produced no PCM within 2 seconds")
2172
+ detail = process.stderr.read().decode("utf-8", errors="replace").strip() if process.stderr else ""
2173
+ raise OSError(detail or f"arecord exited {process.returncode}")
2174
+ first = os.read(process.stdout.fileno(), max(2048, source_rate // 25 * 2))
2175
+ if not first:
2176
+ detail = process.stderr.read().decode("utf-8", errors="replace").strip() if process.stderr else ""
2177
+ raise OSError(detail or "arecord returned an empty PCM frame")
2178
+ self.process = process
2179
+ self.source_rate = source_rate
2180
+ self.alsa_device = alsa_device
2181
+ if source_rate != 48000:
2182
+ first = _pcm16_resample_mono(first, source_rate, 48000)
2183
+ self.buffer.extend(first)
2184
+ break
2185
+ except Exception as exc:
2186
+ errors.append(f"{source_rate}Hz: {exc}")
2187
+ if process is not None:
2188
+ if process.poll() is None:
2189
+ process.terminate()
2190
+ try:
2191
+ process.wait(timeout=1)
2192
+ except subprocess.TimeoutExpired:
2193
+ process.kill()
2194
+ process.wait(timeout=1)
2195
+ if self.process is None:
2196
+ self.media_guard.release()
2197
+ raise OSError(f"ALSA capture failed on plug{match.group(1)} ({'; '.join(errors)})")
2198
+
2199
+ def read(self, samples_per_frame: int):
2200
+ import os
2201
+ from livekit import rtc
2202
+
2203
+ bytes_needed = samples_per_frame * 2
2204
+ source_bytes_needed = max(2, int(samples_per_frame * self.source_rate / 48000) * 2)
2205
+ while len(self.buffer) < bytes_needed:
2206
+ chunk = os.read(self.process.stdout.fileno(), source_bytes_needed)
2207
+ if not chunk:
2208
+ detail = ""
2209
+ if self.process.stderr is not None:
2210
+ detail = self.process.stderr.read().decode("utf-8", errors="replace").strip()
2211
+ raise OSError(f"ALSA capture stopped on {self.alsa_device}: {detail or 'no PCM data'}")
2212
+ if self.source_rate != 48000:
2213
+ chunk = _pcm16_resample_mono(chunk, self.source_rate, 48000)
2214
+ self.buffer.extend(chunk)
2215
+ data = bytes(self.buffer[:bytes_needed])
2216
+ del self.buffer[:bytes_needed]
2217
+ return rtc.AudioFrame(
2218
+ data=data,
2219
+ sample_rate=48000,
2220
+ num_channels=1,
2221
+ samples_per_channel=samples_per_frame,
2222
+ )
2223
+
2224
+ def stop(self):
2225
+ try:
2226
+ if self.process.poll() is None:
2227
+ self.process.terminate()
2228
+ try:
2229
+ self.process.wait(timeout=2)
2230
+ except Exception:
2231
+ self.process.kill()
2232
+ self.process.wait(timeout=1)
2233
+ finally:
2234
+ self.media_guard.release()
2235
+
2236
+
873
2237
  class PyAudioCapture(AudioCapture):
874
2238
  def __init__(self, device: str | int | None):
875
2239
  import pyaudio
@@ -890,6 +2254,8 @@ class PyAudioCapture(AudioCapture):
890
2254
  def _resolve_device(self, device: str | int | None) -> Optional[int]:
891
2255
  if isinstance(device, int):
892
2256
  return device
2257
+ if isinstance(device, str) and device.strip().isdigit():
2258
+ return int(device.strip())
893
2259
  if device is None or device == "default":
894
2260
  # PyAudio's global default resolves through the MME host API,
895
2261
  # which on Windows can silently capture pure silence (no error,
@@ -950,6 +2316,12 @@ def has_audio() -> bool:
950
2316
  def create_audio_capture(device: str) -> Optional[AudioCapture]:
951
2317
  if device.lower() in ("none", "", "false", "null"):
952
2318
  return None
2319
+ if sys.platform.startswith("linux") and "hw:" in device:
2320
+ try:
2321
+ return AlsaAudioCapture(device)
2322
+ except Exception as e:
2323
+ logger.warning(f"ALSA capture unavailable for {device}: {e}")
2324
+ raise
953
2325
  try:
954
2326
  import pyaudio # noqa: F401
955
2327
  return PyAudioCapture(device if device != "default" else None)
@@ -971,6 +2343,8 @@ def main() -> None:
971
2343
  parser.add_argument("--enrollment-token", default=os.getenv("ENROLLMENT_TOKEN"))
972
2344
  parser.add_argument("--video-device", default=os.getenv("VIDEO_DEVICE", "auto"))
973
2345
  parser.add_argument("--audio-device", default=os.getenv("AUDIO_DEVICE", "default"))
2346
+ parser.add_argument("--robovision-url", default=None,
2347
+ help="use RoboVisionAI_PI's /video_feed as the LiveKit camera source")
974
2348
  parser.add_argument("--width", type=int, default=int(os.getenv("VIDEO_WIDTH", DEFAULT_VIDEO_WIDTH)))
975
2349
  parser.add_argument("--height", type=int, default=int(os.getenv("VIDEO_HEIGHT", DEFAULT_VIDEO_HEIGHT)))
976
2350
  parser.add_argument("--fps", type=int, default=int(os.getenv("VIDEO_FPS", DEFAULT_FPS)))
@@ -994,6 +2368,10 @@ def main() -> None:
994
2368
  "robot_id": args.robot_id,
995
2369
  "video_device": args.video_device,
996
2370
  "audio_device": args.audio_device,
2371
+ "robovision_url": args.robovision_url or os.getenv("ROBOVISION_URL", "http://127.0.0.1:5000"),
2372
+ "use_robovision_camera": bool(args.robovision_url) or os.getenv(
2373
+ "ROBOVISION_CAMERA", "true"
2374
+ ).lower() in ("1", "true", "yes", "on"),
997
2375
  "video_width": args.width,
998
2376
  "video_height": args.height,
999
2377
  "video_fps": args.fps,