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
@@ -1,6 +1,7 @@
1
1
  from flask import Flask, Response, jsonify, request
2
2
  from flask_cors import CORS
3
3
  import argparse
4
+ import glob
4
5
  import cv2
5
6
  import os
6
7
  import sys
@@ -9,13 +10,40 @@ import numpy as np
9
10
  import threading
10
11
  import requests
11
12
  import base64
13
+ import struct
12
14
 
13
15
  app = Flask(__name__)
14
16
  CORS(app)
15
17
 
16
- # Camera management
17
- current_camera_index = 0
18
+ # Camera management. Production installs create /dev/robopark-camera from
19
+ # the primary USB capture interface, avoiding /dev/videoN renumbering.
20
+ def _normalize_camera_device(value):
21
+ configured = str(value or "").strip()
22
+ if configured.lower() in ("", "auto", "default", "first"):
23
+ if sys.platform.startswith("linux"):
24
+ return "/dev/robopark-camera" if os.path.exists("/dev/robopark-camera") else "/dev/video0"
25
+ return 0
26
+ if configured.isdigit():
27
+ return int(configured)
28
+ return configured
29
+
30
+
31
+ _configured_camera = os.getenv("ROBOPARK_CAMERA_DEVICE", "")
32
+ current_camera_index = _normalize_camera_device(_configured_camera)
18
33
  camera = None
34
+ camera_lock = threading.Lock()
35
+ frame_condition = threading.Condition()
36
+ latest_frame_bytes = None
37
+ latest_frame_sequence = 0
38
+ camera_worker_started = False
39
+ camera_read_started_at = 0.0
40
+ camera_last_frame_at = 0.0
41
+ audio_input_device = "default"
42
+ audio_output_device = "default"
43
+ ROBOVISION_AUDIO_URL = os.getenv("ROBOVISION_AUDIO_URL", "http://127.0.0.1:8000")
44
+ _camera_inventory_cache = []
45
+ _camera_inventory_cache_at = 0.0
46
+ CAMERA_INVENTORY_CACHE_SECONDS = 30.0
19
47
 
20
48
  def _open_camera(index):
21
49
  # On Windows, cv2.VideoCapture(index) with no explicit backend can
@@ -26,6 +54,14 @@ def _open_camera(index):
26
54
  # enumerates standard Windows webcams.
27
55
  if sys.platform == 'win32':
28
56
  return cv2.VideoCapture(index, cv2.CAP_DSHOW)
57
+ if sys.platform.startswith('linux'):
58
+ cap = cv2.VideoCapture(index, cv2.CAP_V4L2)
59
+ if cap.isOpened():
60
+ cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*'MJPG'))
61
+ cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
62
+ cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
63
+ cap.set(cv2.CAP_PROP_FPS, 15)
64
+ return cap
29
65
  return cv2.VideoCapture(index)
30
66
 
31
67
 
@@ -216,22 +252,39 @@ def send_webhook(frame_data):
216
252
  except Exception as e:
217
253
  print(f"Error preparing webhook: {e}")
218
254
 
219
- def generate_frames():
255
+ def camera_worker():
220
256
  global latest_detections, motion_detection_active, motion_detected_state
221
257
  global last_motion_time, motion_frame_buffer
258
+ global latest_frame_bytes, latest_frame_sequence
259
+ global camera_read_started_at, camera_last_frame_at, camera
222
260
 
223
261
  prev_gray = None
224
262
 
225
263
  while True:
226
- cam = get_camera()
264
+ with camera_lock:
265
+ cam = get_camera()
227
266
  if cam is None or not cam.isOpened():
228
267
  time.sleep(1)
229
268
  continue
230
269
 
231
- success, frame = cam.read()
270
+ camera_read_started_at = time.monotonic()
271
+ try:
272
+ # This is the only camera reader. Do not hold camera_lock here:
273
+ # the watchdog must be able to release a wedged V4L2 handle.
274
+ success, frame = cam.read()
275
+ except Exception as exc:
276
+ print(f"Camera read error: {exc}")
277
+ success, frame = False, None
278
+ finally:
279
+ camera_read_started_at = 0.0
232
280
  if not success:
281
+ with camera_lock:
282
+ if camera is cam:
283
+ camera.release()
284
+ camera = None
233
285
  time.sleep(0.1)
234
286
  continue
287
+ camera_last_frame_at = time.monotonic()
235
288
 
236
289
  # Run object detection
237
290
  processed_frame, detections = detect_objects(frame, conf_threshold=0.5)
@@ -281,7 +334,55 @@ def generate_frames():
281
334
  prev_gray = gray
282
335
 
283
336
  ret, buffer = cv2.imencode('.jpg', processed_frame, [cv2.IMWRITE_JPEG_QUALITY, 80])
284
- frame_bytes = buffer.tobytes()
337
+ if not ret:
338
+ continue
339
+ with frame_condition:
340
+ latest_frame_bytes = buffer.tobytes()
341
+ latest_frame_sequence += 1
342
+ frame_condition.notify_all()
343
+
344
+
345
+ def _ensure_camera_worker():
346
+ global camera_worker_started
347
+ with frame_condition:
348
+ if camera_worker_started:
349
+ return
350
+ camera_worker_started = True
351
+ threading.Thread(target=camera_worker, name='robovision-camera', daemon=True).start()
352
+ threading.Thread(target=_camera_watchdog, name='robovision-camera-watchdog', daemon=True).start()
353
+
354
+
355
+ def _camera_watchdog():
356
+ global camera, camera_read_started_at
357
+ while True:
358
+ time.sleep(2.0)
359
+ started = camera_read_started_at
360
+ if not started or time.monotonic() - started < 8.0:
361
+ continue
362
+ print("Camera read stalled for 8s; releasing V4L2 handle")
363
+ with camera_lock:
364
+ if camera is not None:
365
+ try:
366
+ camera.release()
367
+ except Exception:
368
+ pass
369
+ camera = None
370
+ camera_read_started_at = 0.0
371
+
372
+
373
+ def generate_frames():
374
+ _ensure_camera_worker()
375
+ sequence = -1
376
+ while True:
377
+ with frame_condition:
378
+ frame_condition.wait_for(
379
+ lambda: latest_frame_bytes is not None and latest_frame_sequence != sequence,
380
+ timeout=5.0,
381
+ )
382
+ if latest_frame_bytes is None or latest_frame_sequence == sequence:
383
+ continue
384
+ frame_bytes = latest_frame_bytes
385
+ sequence = latest_frame_sequence
285
386
 
286
387
  yield (b'--frame\r\n'
287
388
  b'Content-Type: image/jpeg\r\n\r\n' + frame_bytes + b'\r\n')
@@ -299,6 +400,19 @@ def get_detections():
299
400
  with lock:
300
401
  return jsonify(latest_detections)
301
402
 
403
+
404
+ @app.route('/api/camera/status')
405
+ def camera_status():
406
+ age = None if not camera_last_frame_at else round(time.monotonic() - camera_last_frame_at, 3)
407
+ return jsonify({
408
+ "device": str(current_camera_index),
409
+ "open": bool(camera is not None and camera.isOpened()),
410
+ "worker_started": camera_worker_started,
411
+ "frame_sequence": latest_frame_sequence,
412
+ "last_frame_age_seconds": age,
413
+ "read_stalled": bool(camera_read_started_at and time.monotonic() - camera_read_started_at >= 8.0),
414
+ })
415
+
302
416
  @app.route('/api/caption')
303
417
  def get_caption():
304
418
  return jsonify({"caption": "Awaiting caption..."})
@@ -314,28 +428,148 @@ def caption_mode():
314
428
 
315
429
  @app.route('/api/cameras', methods=['GET'])
316
430
  def list_cameras():
431
+ global _camera_inventory_cache, _camera_inventory_cache_at
432
+ now = time.monotonic()
433
+ if now - _camera_inventory_cache_at < CAMERA_INVENTORY_CACHE_SECONDS:
434
+ return jsonify({"cameras": _camera_inventory_cache, "current": current_camera_index})
435
+
317
436
  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}"})
437
+ if sys.platform.startswith('linux'):
438
+ # Query capabilities without starting a stream. Opening every V4L2
439
+ # node through OpenCV also opens metadata/output nodes and can contend
440
+ # with the camera stream already owned by RoboVision.
441
+ import fcntl
442
+ vidioc_querycap = 0x80685600
443
+ video_capture = 0x00000001
444
+ video_capture_mplane = 0x00001000
445
+ device_caps_flag = 0x80000000
446
+ for candidate in sorted(glob.glob('/dev/video*')):
447
+ fd = None
448
+ try:
449
+ fd = os.open(candidate, os.O_RDONLY | os.O_NONBLOCK)
450
+ capability = bytearray(104)
451
+ fcntl.ioctl(fd, vidioc_querycap, capability, True)
452
+ capabilities = struct.unpack_from('=I', capability, 84)[0]
453
+ device_caps = struct.unpack_from('=I', capability, 88)[0]
454
+ effective = device_caps if capabilities & device_caps_flag else capabilities
455
+ if not effective & (video_capture | video_capture_mplane):
456
+ continue
457
+ card = bytes(capability[16:48]).split(b'\0', 1)[0].decode('utf-8', 'replace')
458
+ available.append({
459
+ "index": candidate,
460
+ "id": candidate,
461
+ "name": card or f"Camera {candidate}",
462
+ "backend": "v4l2",
463
+ })
464
+ except (OSError, ValueError):
465
+ continue
466
+ finally:
467
+ if fd is not None:
468
+ os.close(fd)
469
+ else:
470
+ for candidate in range(4):
471
+ cap = _open_camera(candidate)
472
+ if cap.isOpened():
473
+ available.append({"index": candidate, "id": str(candidate), "name": f"Camera {candidate}", "backend": "dshow"})
322
474
  cap.release()
475
+
476
+ _camera_inventory_cache = available
477
+ _camera_inventory_cache_at = now
323
478
  return jsonify({"cameras": available, "current": current_camera_index})
324
479
 
325
480
  @app.route('/api/camera/switch', methods=['POST'])
326
481
  def switch_camera():
327
482
  global camera, current_camera_index
328
483
  data = request.json or {}
329
- new_index = int(data.get('index', 0))
484
+ new_index = data.get('device', data.get('index', 0))
485
+ if isinstance(new_index, str) and new_index.isdigit():
486
+ new_index = int(new_index)
330
487
 
331
- if camera:
332
- camera.release()
333
-
334
- current_camera_index = new_index
335
- camera = None
488
+ with camera_lock:
489
+ if camera:
490
+ camera.release()
491
+ current_camera_index = _normalize_camera_device(new_index)
492
+ camera = None
336
493
 
337
494
  return jsonify({"status": "ok", "camera_index": current_camera_index})
338
495
 
496
+
497
+ def _audio_inventory():
498
+ """Adapt RoboVision's existing audio_server_pi /devices response."""
499
+ try:
500
+ response = requests.get(f"{ROBOVISION_AUDIO_URL.rstrip('/')}/devices", timeout=0.8)
501
+ response.raise_for_status()
502
+ payload = response.json()
503
+ inputs, outputs = [], []
504
+ for device in payload.get("devices", []):
505
+ item = {
506
+ "id": str(device["index"]),
507
+ "name": str(device.get("name", f"Audio device {device['index']}")),
508
+ "backend": "robovision_audio",
509
+ "sample_rate": device.get("default_samplerate"),
510
+ }
511
+ if device.get("max_input_channels", 0) > 0:
512
+ inputs.append(item.copy())
513
+ if device.get("max_output_channels", 0) > 0:
514
+ outputs.append(item.copy())
515
+ return inputs, outputs, {
516
+ "input": payload.get("bluetooth_input"),
517
+ "output": payload.get("bluetooth_output"),
518
+ "online": True,
519
+ }
520
+ except Exception as exc:
521
+ return [], [], {"online": False, "error": str(exc)}
522
+
523
+
524
+ @app.route('/api/media/inventory', methods=['GET'])
525
+ def media_inventory():
526
+ cameras = list_cameras().get_json().get("cameras", [])
527
+ inputs, outputs, audio_state = _audio_inventory()
528
+ return jsonify({
529
+ "video": [{"id": "auto", "name": "Auto detect"}, {"id": "none", "name": "Disable camera"}] + cameras,
530
+ "audio_input": inputs,
531
+ "audio_output": outputs,
532
+ "selected": {
533
+ "video_device": str(current_camera_index),
534
+ "audio_device": str(audio_state.get("input") if audio_state.get("input") is not None else audio_input_device),
535
+ "audio_output_device": str(audio_state.get("output") if audio_state.get("output") is not None else audio_output_device),
536
+ },
537
+ "audio_server": audio_state,
538
+ "source": "robovision_pi",
539
+ })
540
+
541
+
542
+ @app.route('/api/media/config', methods=['GET', 'POST'])
543
+ def media_config():
544
+ global audio_input_device, audio_output_device, camera, current_camera_index
545
+ if request.method == 'POST':
546
+ data = request.json or {}
547
+ if "video_device" in data:
548
+ value = str(data["video_device"])
549
+ if value not in ("", "auto", "none"):
550
+ switch_camera_value = value
551
+ if camera:
552
+ camera.release()
553
+ current_camera_index = _normalize_camera_device(switch_camera_value)
554
+ camera = None
555
+ if "audio_device" in data:
556
+ audio_input_device = str(data["audio_device"])
557
+ if "audio_output_device" in data:
558
+ audio_output_device = str(data["audio_output_device"])
559
+ if "audio_device" in data or "audio_output_device" in data:
560
+ # audio_server_pi.py is RoboVision's authoritative selector.
561
+ # It accepts its original sounddevice indices via input/output.
562
+ payload = {}
563
+ if "audio_device" in data:
564
+ payload["input"] = int(audio_input_device) if audio_input_device.isdigit() else audio_input_device
565
+ if "audio_output_device" in data:
566
+ payload["output"] = int(audio_output_device) if audio_output_device.isdigit() else audio_output_device
567
+ try:
568
+ requests.post(f"{ROBOVISION_AUDIO_URL.rstrip('/')}/set-device", json=payload, timeout=0.8).raise_for_status()
569
+ except Exception:
570
+ pass
571
+ return jsonify({"video_device": str(current_camera_index), "audio_device": audio_input_device, "audio_output_device": audio_output_device, "source": "robovision_pi"})
572
+
339
573
  @app.route('/api/motion/status', methods=['GET'])
340
574
  def motion_status():
341
575
  global motion_detection_active, motion_detected_state, last_motion_time
@@ -416,4 +650,7 @@ if __name__ == '__main__':
416
650
  print(f"Motion webhook: {webhook_url}")
417
651
  print(f"Motion detection: {'ARMED' if motion_detection_active else 'off (POST /api/motion/toggle to arm)'}")
418
652
  print("=" * 60)
653
+ # Start the single camera owner at boot. Production motion and MJPEG
654
+ # readiness must not depend on an operator opening the dashboard first.
655
+ _ensure_camera_worker()
419
656
  app.run(host='0.0.0.0', port=args.port, debug=False, threaded=True)
@@ -18,12 +18,29 @@ from datetime import datetime
18
18
  import logging
19
19
  import subprocess
20
20
  import base64
21
+ import functools
22
+ import sys
23
+ from pathlib import Path
21
24
  from groq import Groq
22
25
 
26
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scheduler"))
27
+ from media_lock import media_lock
28
+
23
29
  # Configure logging
24
30
  logging.basicConfig(level=logging.INFO, format='%(message)s')
25
31
  logger = logging.getLogger(__name__)
26
32
 
33
+
34
+ def exclusive_media(kind: str):
35
+ """Prevent diagnostics from stealing ALSA devices from production."""
36
+ def decorate(func):
37
+ @functools.wraps(func)
38
+ def wrapped(*args, **kwargs):
39
+ with media_lock(kind, timeout=3.0):
40
+ return func(*args, **kwargs)
41
+ return wrapped
42
+ return decorate
43
+
27
44
  @asynccontextmanager
28
45
  async def lifespan(app: FastAPI):
29
46
  """Lifespan event handler for startup and shutdown"""
@@ -173,6 +190,7 @@ def find_bluetooth_devices():
173
190
  # Find Bluetooth devices on startup
174
191
  find_bluetooth_devices()
175
192
 
193
+ @exclusive_media("speaker")
176
194
  def play_audio_file(file_path: str):
177
195
  """Play audio file using sounddevice (supports Bluetooth)"""
178
196
  try:
@@ -231,6 +249,7 @@ def get_available_input_devices():
231
249
  input_devices.append(i)
232
250
  return input_devices
233
251
 
252
+ @exclusive_media("microphone")
234
253
  def record_audio_with_vad():
235
254
  """
236
255
  Record audio from Bluetooth microphone with Voice Activity Detection
package/vision/install.sh CHANGED
@@ -1,34 +1,34 @@
1
- #!/usr/bin/env bash
2
- set -euo pipefail
3
-
4
- ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
- VENV_DIR="$ROOT_DIR/venv"
6
- REQ_FILE="$ROOT_DIR/requirements_pi_unified.txt"
7
-
8
- if ! command -v python3 >/dev/null 2>&1; then
9
- echo "python3 not found"
10
- exit 1
11
- fi
12
-
13
- if [ ! -f "$REQ_FILE" ]; then
14
- echo "Missing $REQ_FILE"
15
- exit 1
16
- fi
17
-
18
- echo "System packages (run once):"
19
- echo "sudo apt-get update"
20
- echo "sudo apt-get install -y python3-opencv python3-numpy python3-pil libatlas-base-dev libopenblas-dev libhdf5-dev libhdf5-serial-dev libhdf5-103 libqt5gui5 libqt5widgets5 libqt5test5 portaudio19-dev libsndfile1 alsa-utils bluetooth bluez bluez-tools pulseaudio pulseaudio-module-bluetooth python3-pyaudio"
21
- echo ""
22
-
23
- echo "Creating venv: $VENV_DIR"
24
- if [ ! -d "$VENV_DIR" ]; then
25
- python3 -m venv "$VENV_DIR" --system-site-packages
26
- fi
27
-
28
- source "$VENV_DIR/bin/activate"
29
- python -m pip install --upgrade pip
30
- python -m pip install -r "$REQ_FILE"
31
-
32
- echo ""
33
- echo "Done"
34
- echo "Next: ./run.sh start-term"
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
+ VENV_DIR="$ROOT_DIR/venv"
6
+ REQ_FILE="$ROOT_DIR/requirements_pi_unified.txt"
7
+
8
+ if ! command -v python3 >/dev/null 2>&1; then
9
+ echo "python3 not found"
10
+ exit 1
11
+ fi
12
+
13
+ if [ ! -f "$REQ_FILE" ]; then
14
+ echo "Missing $REQ_FILE"
15
+ exit 1
16
+ fi
17
+
18
+ echo "System packages (run once):"
19
+ echo "sudo apt-get update"
20
+ echo "sudo apt-get install -y python3-opencv python3-numpy python3-pil libatlas-base-dev libopenblas-dev libhdf5-dev libhdf5-serial-dev libhdf5-103 libqt5gui5 libqt5widgets5 libqt5test5 portaudio19-dev libsndfile1 alsa-utils bluetooth bluez bluez-tools pulseaudio pulseaudio-module-bluetooth python3-pyaudio"
21
+ echo ""
22
+
23
+ echo "Creating venv: $VENV_DIR"
24
+ if [ ! -d "$VENV_DIR" ]; then
25
+ python3 -m venv "$VENV_DIR" --system-site-packages
26
+ fi
27
+
28
+ source "$VENV_DIR/bin/activate"
29
+ python -m pip install --upgrade pip
30
+ python -m pip install -r "$REQ_FILE"
31
+
32
+ echo ""
33
+ echo "Done"
34
+ echo "Next: ./run.sh start-term"