infinicode 2.8.46 → 2.8.51

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.
@@ -69,6 +69,7 @@ logger = logging.getLogger("robopark.preview_agent")
69
69
  _DEVICE_INVENTORY_CACHE: Optional[dict] = None
70
70
  _DEVICE_INVENTORY_CACHE_AT = 0.0
71
71
  DEVICE_INVENTORY_CACHE_SECONDS = 30.0
72
+ ROBOVISION_MEDIA_URL = os.getenv("ROBOVISION_MEDIA_URL", "http://127.0.0.1:5000/api/media/inventory")
72
73
 
73
74
 
74
75
  def _normalize_livekit_url(url: Optional[str]) -> Optional[str]:
@@ -141,50 +142,67 @@ def _get_device_inventory() -> dict:
141
142
  and now - _DEVICE_INVENTORY_CACHE_AT < DEVICE_INVENTORY_CACHE_SECONDS):
142
143
  return _DEVICE_INVENTORY_CACHE
143
144
 
144
- inventory = {"video": [], "audio_input": [], "audio_output": [], "platform": sys.platform}
145
- inventory["video"].append({"id": "auto", "name": "Auto detect"})
146
- inventory["video"].append({"id": "none", "name": "Disable camera"})
147
-
148
- try:
149
- import cv2
150
- candidates = sorted(glob.glob("/dev/video*")) if os.name != "nt" else [str(i) for i in range(10)]
151
- for candidate in candidates:
152
- value = int(candidate) if os.name == "nt" else candidate
153
- backend = cv2.CAP_DSHOW if os.name == "nt" else cv2.CAP_ANY
154
- cap = cv2.VideoCapture(value, backend)
155
- if cap.isOpened():
156
- device_id = str(value)
157
- inventory["video"].append({"id": device_id, "name": f"Camera {candidate}", "backend": "dshow" if os.name == "nt" else "v4l2"})
158
- cap.release()
159
- except Exception as e:
160
- logger.debug(f"camera inventory unavailable: {e}")
161
-
162
- try:
163
- import pyaudio
164
- pa = pyaudio.PyAudio()
165
- default_in = None
166
- default_out = None
167
- try:
168
- wasapi = pa.get_host_api_info_by_type(pyaudio.paWASAPI)
169
- default_in = wasapi.get("defaultInputDevice")
170
- default_out = wasapi.get("defaultOutputDevice")
171
- except Exception:
172
- pass
173
- inventory["audio_input"].append({"id": "default", "name": "System default input"})
174
- inventory["audio_output"].append({"id": "default", "name": "System default output"})
175
- for i in range(pa.get_device_count()):
176
- info = pa.get_device_info_by_index(i)
177
- name = str(info.get("name", f"Audio device {i}"))
178
- item = {"id": str(i), "name": name, "host_api": str(info.get("hostApi", ""))}
179
- if info.get("maxInputChannels", 0) > 0:
180
- item["default"] = i == default_in
181
- inventory["audio_input"].append(item.copy())
182
- if info.get("maxOutputChannels", 0) > 0:
183
- item["default"] = i == default_out
184
- inventory["audio_output"].append(item.copy())
185
- pa.terminate()
186
- except Exception as e:
187
- logger.debug(f"audio inventory unavailable: {e}")
145
+ inventory = {"video": [], "audio_input": [], "audio_output": [], "platform": sys.platform}
146
+ inventory["video"].append({"id": "auto", "name": "Auto detect"})
147
+ inventory["video"].append({"id": "none", "name": "Disable camera"})
148
+
149
+ # RoboVisionAI_PI owns the production camera. Prefer its native inventory
150
+ # so this process never probes an already-open V4L2 device just to fill a
151
+ # dashboard dropdown.
152
+ robovision_inventory = None
153
+ try:
154
+ response = httpx.get(ROBOVISION_MEDIA_URL, timeout=0.8)
155
+ if response.is_success:
156
+ robovision_inventory = response.json()
157
+ for key in ("video", "audio_input", "audio_output"):
158
+ if isinstance(robovision_inventory.get(key), list):
159
+ inventory[key] = robovision_inventory[key]
160
+ inventory["source"] = robovision_inventory.get("source", "robovision_pi")
161
+ except Exception:
162
+ pass
163
+
164
+ if not robovision_inventory:
165
+ try:
166
+ import cv2
167
+ candidates = sorted(glob.glob("/dev/video*")) if os.name != "nt" else [str(i) for i in range(10)]
168
+ for candidate in candidates:
169
+ value = int(candidate) if os.name == "nt" else candidate
170
+ backend = cv2.CAP_DSHOW if os.name == "nt" else cv2.CAP_ANY
171
+ cap = cv2.VideoCapture(value, backend)
172
+ if cap.isOpened():
173
+ device_id = str(value)
174
+ inventory["video"].append({"id": device_id, "name": f"Camera {candidate}", "backend": "dshow" if os.name == "nt" else "v4l2"})
175
+ cap.release()
176
+ except Exception as e:
177
+ logger.debug(f"camera inventory unavailable: {e}")
178
+
179
+ if not robovision_inventory:
180
+ try:
181
+ import pyaudio
182
+ pa = pyaudio.PyAudio()
183
+ default_in = None
184
+ default_out = None
185
+ try:
186
+ wasapi = pa.get_host_api_info_by_type(pyaudio.paWASAPI)
187
+ default_in = wasapi.get("defaultInputDevice")
188
+ default_out = wasapi.get("defaultOutputDevice")
189
+ except Exception:
190
+ pass
191
+ inventory["audio_input"].append({"id": "default", "name": "System default input"})
192
+ inventory["audio_output"].append({"id": "default", "name": "System default output"})
193
+ for i in range(pa.get_device_count()):
194
+ info = pa.get_device_info_by_index(i)
195
+ name = str(info.get("name", f"Audio device {i}"))
196
+ item = {"id": str(i), "name": name, "host_api": str(info.get("hostApi", ""))}
197
+ if info.get("maxInputChannels", 0) > 0:
198
+ item["default"] = i == default_in
199
+ inventory["audio_input"].append(item.copy())
200
+ if info.get("maxOutputChannels", 0) > 0:
201
+ item["default"] = i == default_out
202
+ inventory["audio_output"].append(item.copy())
203
+ pa.terminate()
204
+ except Exception as e:
205
+ logger.debug(f"audio inventory unavailable: {e}")
188
206
 
189
207
  _DEVICE_INVENTORY_CACHE = inventory
190
208
  _DEVICE_INVENTORY_CACHE_AT = now
@@ -318,8 +336,10 @@ class PreviewAgent:
318
336
  self.device_id: Optional[str] = cfg.get("device_id")
319
337
  self.enrollment_token: Optional[str] = cfg.get("enrollment_token")
320
338
  self.video_device = cfg.get("video_device", os.getenv("VIDEO_DEVICE", "auto"))
321
- self.audio_device = cfg.get("audio_device", os.getenv("AUDIO_DEVICE", "default"))
322
- self.audio_output_device = cfg.get("audio_output_device", os.getenv("AUDIO_OUTPUT_DEVICE", "default"))
339
+ self.audio_device = cfg.get("audio_device", os.getenv("AUDIO_DEVICE", "default"))
340
+ self.audio_output_device = cfg.get("audio_output_device", os.getenv("AUDIO_OUTPUT_DEVICE", "default"))
341
+ self.robovision_url = cfg.get("robovision_url", os.getenv("ROBOVISION_URL", "http://127.0.0.1:5000"))
342
+ self.use_robovision_camera = bool(cfg.get("use_robovision_camera", False))
323
343
  self.width = int(cfg.get("video_width", os.getenv("VIDEO_WIDTH", DEFAULT_VIDEO_WIDTH)))
324
344
  self.height = int(cfg.get("video_height", os.getenv("VIDEO_HEIGHT", DEFAULT_VIDEO_HEIGHT)))
325
345
  self.fps = int(cfg.get("video_fps", os.getenv("VIDEO_FPS", DEFAULT_FPS)))
@@ -524,7 +544,7 @@ class PreviewAgent:
524
544
  if data.get("trigger"):
525
545
  await self._on_vision_motion({"source": data.get("source", "dashboard")})
526
546
 
527
- async def _poll_device_config(self) -> None:
547
+ async def _poll_device_config(self) -> None:
528
548
  """Apply dashboard-selected camera and microphone IDs before preview."""
529
549
  if not self._session or not self.device_id or not self.device_token:
530
550
  return
@@ -538,16 +558,41 @@ class PreviewAgent:
538
558
  headers={"Authorization": f"Bearer {self.device_token}"},
539
559
  timeout=10.0,
540
560
  )
541
- r.raise_for_status()
542
- data = r.json()
543
- if data.get("video_device") is not None:
544
- self.video_device = data["video_device"]
545
- if data.get("audio_device") is not None:
546
- self.audio_device = data["audio_device"]
547
- if data.get("audio_output_device") is not None:
548
- self.audio_output_device = data["audio_output_device"]
549
- except Exception as e:
550
- logger.debug(f"device config poll failed: {e}")
561
+ r.raise_for_status()
562
+ data = r.json()
563
+ changed = False
564
+ if data.get("video_device") is not None:
565
+ changed = changed or self.video_device != data["video_device"]
566
+ self.video_device = data["video_device"]
567
+ if data.get("audio_device") is not None:
568
+ changed = changed or self.audio_device != data["audio_device"]
569
+ self.audio_device = data["audio_device"]
570
+ if data.get("audio_output_device") is not None:
571
+ changed = changed or self.audio_output_device != data["audio_output_device"]
572
+ self.audio_output_device = data["audio_output_device"]
573
+ if changed:
574
+ await self._sync_robovision_media_config()
575
+ except Exception as e:
576
+ logger.debug(f"device config poll failed: {e}")
577
+
578
+ async def _sync_robovision_media_config(self) -> None:
579
+ """Apply scheduler media choices to the local RoboVisionAI_PI owner."""
580
+ try:
581
+ response = await self._session.post(
582
+ f"{self.robovision_url.rstrip('/')}/api/media/config",
583
+ json={
584
+ "video_device": self.video_device,
585
+ "audio_device": self.audio_device,
586
+ "audio_output_device": self.audio_output_device,
587
+ },
588
+ timeout=1.0,
589
+ )
590
+ if response.is_success:
591
+ global _DEVICE_INVENTORY_CACHE
592
+ _DEVICE_INVENTORY_CACHE = None
593
+ except Exception:
594
+ # RoboVision is optional on laptops and simulation nodes.
595
+ pass
551
596
 
552
597
  async def _check_vision_session(self) -> None:
553
598
  """Decide whether the active motion-triggered session should keep
@@ -908,7 +953,8 @@ class LiveKitPublisher:
908
953
  if video_enabled:
909
954
  try:
910
955
  self._capture = await asyncio.to_thread(
911
- create_video_capture, self.agent.video_device, self.agent.width, self.agent.height, self.agent.fps
956
+ create_video_capture, self.agent.video_device, self.agent.width, self.agent.height, self.agent.fps,
957
+ self.agent.robovision_url if self.agent.use_robovision_camera else None,
912
958
  )
913
959
  except Exception as e:
914
960
  # Camera availability must not block the microphone/session.
@@ -1389,15 +1435,26 @@ class Picamera2Capture(VideoCapture):
1389
1435
  pass
1390
1436
 
1391
1437
 
1392
- def create_video_capture(device: str, width: int, height: int, fps: int) -> Optional[VideoCapture]:
1438
+ def create_video_capture(device: str, width: int, height: int, fps: int,
1439
+ robovision_url: Optional[str] = None) -> Optional[VideoCapture]:
1393
1440
  if device.lower() in ("none", "", "false", "null"):
1394
1441
  return None
1395
- try:
1442
+ try:
1396
1443
  from livekit import rtc
1397
1444
  _ = rtc.VideoSource
1398
1445
  except Exception as e:
1399
1446
  logger.error(f"livekit python sdk not installed: {e}")
1400
- return None
1447
+ return None
1448
+
1449
+ if robovision_url:
1450
+ stream_url = f"{robovision_url.rstrip('/')}/video_feed"
1451
+ try:
1452
+ cap = OpencvVideoCapture(stream_url, width, height, fps)
1453
+ logger.info(f"using RoboVisionAI_PI camera stream {stream_url}")
1454
+ return cap
1455
+ except Exception as e:
1456
+ logger.error(f"RoboVision camera stream unavailable: {e}")
1457
+ return None
1401
1458
 
1402
1459
  # Auto-detect: prefer first V4L2 device, fall back to picamera2 if available.
1403
1460
  if device.lower() in ("auto", "default", "first"):
@@ -1538,7 +1595,9 @@ def main() -> None:
1538
1595
  parser.add_argument("--device-token", default=os.getenv("DEVICE_TOKEN"))
1539
1596
  parser.add_argument("--enrollment-token", default=os.getenv("ENROLLMENT_TOKEN"))
1540
1597
  parser.add_argument("--video-device", default=os.getenv("VIDEO_DEVICE", "auto"))
1541
- parser.add_argument("--audio-device", default=os.getenv("AUDIO_DEVICE", "default"))
1598
+ parser.add_argument("--audio-device", default=os.getenv("AUDIO_DEVICE", "default"))
1599
+ parser.add_argument("--robovision-url", default=None,
1600
+ help="use RoboVisionAI_PI's /video_feed as the LiveKit camera source")
1542
1601
  parser.add_argument("--width", type=int, default=int(os.getenv("VIDEO_WIDTH", DEFAULT_VIDEO_WIDTH)))
1543
1602
  parser.add_argument("--height", type=int, default=int(os.getenv("VIDEO_HEIGHT", DEFAULT_VIDEO_HEIGHT)))
1544
1603
  parser.add_argument("--fps", type=int, default=int(os.getenv("VIDEO_FPS", DEFAULT_FPS)))
@@ -1561,7 +1620,9 @@ def main() -> None:
1561
1620
  "scheduler_url": args.scheduler_url,
1562
1621
  "robot_id": args.robot_id,
1563
1622
  "video_device": args.video_device,
1564
- "audio_device": args.audio_device,
1623
+ "audio_device": args.audio_device,
1624
+ "robovision_url": args.robovision_url or os.getenv("ROBOVISION_URL", "http://127.0.0.1:5000"),
1625
+ "use_robovision_camera": bool(args.robovision_url),
1565
1626
  "video_width": args.width,
1566
1627
  "video_height": args.height,
1567
1628
  "video_fps": args.fps,
@@ -1,7 +1,8 @@
1
1
  from flask import Flask, Response, jsonify, request
2
2
  from flask_cors import CORS
3
- import argparse
4
- import cv2
3
+ import argparse
4
+ import glob
5
+ import cv2
5
6
  import os
6
7
  import sys
7
8
  import time
@@ -14,8 +15,10 @@ app = Flask(__name__)
14
15
  CORS(app)
15
16
 
16
17
  # Camera management
17
- current_camera_index = 0
18
- camera = None
18
+ current_camera_index = 0
19
+ camera = None
20
+ audio_input_device = "default"
21
+ audio_output_device = "default"
19
22
 
20
23
  def _open_camera(index):
21
24
  # On Windows, cv2.VideoCapture(index) with no explicit backend can
@@ -312,21 +315,24 @@ def caption_mode():
312
315
  caption_mode_enabled = bool(data.get('enabled', False))
313
316
  return jsonify({"enabled": caption_mode_enabled})
314
317
 
315
- @app.route('/api/cameras', methods=['GET'])
316
- def list_cameras():
317
- available = []
318
- for i in range(4):
319
- cap = _open_camera(i)
320
- if cap.isOpened():
321
- available.append({"index": i, "name": f"Camera {i}"})
322
- cap.release()
323
- return jsonify({"cameras": available, "current": current_camera_index})
318
+ @app.route('/api/cameras', methods=['GET'])
319
+ def list_cameras():
320
+ available = []
321
+ candidates = sorted(glob.glob('/dev/video*')) if sys.platform != 'win32' else list(range(4))
322
+ for candidate in candidates:
323
+ cap = _open_camera(candidate)
324
+ if cap.isOpened():
325
+ available.append({"index": candidate, "id": str(candidate), "name": f"Camera {candidate}", "backend": "v4l2" if sys.platform != 'win32' else "dshow"})
326
+ cap.release()
327
+ return jsonify({"cameras": available, "current": current_camera_index})
324
328
 
325
329
  @app.route('/api/camera/switch', methods=['POST'])
326
- def switch_camera():
330
+ def switch_camera():
327
331
  global camera, current_camera_index
328
332
  data = request.json or {}
329
- new_index = int(data.get('index', 0))
333
+ new_index = data.get('device', data.get('index', 0))
334
+ if isinstance(new_index, str) and new_index.isdigit():
335
+ new_index = int(new_index)
330
336
 
331
337
  if camera:
332
338
  camera.release()
@@ -334,7 +340,64 @@ def switch_camera():
334
340
  current_camera_index = new_index
335
341
  camera = None
336
342
 
337
- return jsonify({"status": "ok", "camera_index": current_camera_index})
343
+ return jsonify({"status": "ok", "camera_index": current_camera_index})
344
+
345
+
346
+ def _audio_inventory():
347
+ inputs = [{"id": "default", "name": "System default input"}]
348
+ outputs = [{"id": "default", "name": "System default output"}]
349
+ try:
350
+ import pyaudio
351
+ pa = pyaudio.PyAudio()
352
+ for index in range(pa.get_device_count()):
353
+ info = pa.get_device_info_by_index(index)
354
+ item = {"id": str(index), "name": str(info.get("name", f"Audio device {index}")), "host_api": str(info.get("hostApi", ""))}
355
+ if info.get("maxInputChannels", 0) > 0:
356
+ inputs.append(item.copy())
357
+ if info.get("maxOutputChannels", 0) > 0:
358
+ outputs.append(item.copy())
359
+ pa.terminate()
360
+ except Exception:
361
+ pass
362
+ return inputs, outputs
363
+
364
+
365
+ @app.route('/api/media/inventory', methods=['GET'])
366
+ def media_inventory():
367
+ cameras = list_cameras().get_json().get("cameras", [])
368
+ if not cameras and sys.platform != 'win32':
369
+ # The active V4L2 handle may reject a second probe. The node path is
370
+ # still a valid selectable camera and is safer than hiding it.
371
+ cameras = [{"id": path, "index": path, "name": f"Camera {path}", "backend": "v4l2"}
372
+ for path in sorted(glob.glob('/dev/video*'))]
373
+ inputs, outputs = _audio_inventory()
374
+ return jsonify({
375
+ "video": [{"id": "auto", "name": "Auto detect"}, {"id": "none", "name": "Disable camera"}] + cameras,
376
+ "audio_input": inputs,
377
+ "audio_output": outputs,
378
+ "selected": {"video_device": str(current_camera_index), "audio_device": audio_input_device, "audio_output_device": audio_output_device},
379
+ "source": "robovision_pi",
380
+ })
381
+
382
+
383
+ @app.route('/api/media/config', methods=['GET', 'POST'])
384
+ def media_config():
385
+ global audio_input_device, audio_output_device, camera, current_camera_index
386
+ if request.method == 'POST':
387
+ data = request.json or {}
388
+ if "video_device" in data:
389
+ value = str(data["video_device"])
390
+ if value not in ("", "auto", "none"):
391
+ switch_camera_value = value
392
+ if camera:
393
+ camera.release()
394
+ current_camera_index = int(switch_camera_value) if switch_camera_value.isdigit() else switch_camera_value
395
+ camera = None
396
+ if "audio_device" in data:
397
+ audio_input_device = str(data["audio_device"])
398
+ if "audio_output_device" in data:
399
+ audio_output_device = str(data["audio_output_device"])
400
+ return jsonify({"video_device": str(current_camera_index), "audio_device": audio_input_device, "audio_output_device": audio_output_device, "source": "robovision_pi"})
338
401
 
339
402
  @app.route('/api/motion/status', methods=['GET'])
340
403
  def motion_status():