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.
- package/dist/kernel/federation/dashboard-html.d.ts +1 -1
- package/dist/kernel/federation/dashboard-html.js +7 -2
- package/dist/robopark/enroll.js +3 -3
- package/dist/robopark/preview-agent-launcher.d.ts +1 -0
- package/dist/robopark/preview-agent-launcher.js +5 -3
- package/dist/robopark/python-env.d.ts +6 -0
- package/dist/robopark/python-env.js +50 -1
- package/dist/robopark/setup.js +2 -0
- package/dist/robopark/vision-agent-launcher.js +4 -3
- package/package.json +1 -1
- package/packages/robopark/pi-client/client.py +76 -17
- package/packages/robopark/pi-client/livekit_bridge.py +24 -15
- package/packages/robopark/scheduler/main.py +20 -36
- package/packages/robopark/scheduler/preview_agent.py +124 -63
- package/packages/robopark/vision/app_pi_clean.py +79 -16
|
@@ -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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
self.
|
|
547
|
-
if data.get("
|
|
548
|
-
self.
|
|
549
|
-
|
|
550
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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 =
|
|
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():
|