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.
- package/README.md +88 -63
- package/bin/robopark.js +7 -17
- package/conversation/elevenlabs_agent.py +1985 -0
- package/conversation/requirements.txt +3 -0
- package/conversation/supervisor_store.py +189 -0
- package/dist/kernel/config-schema.js +37 -0
- package/dist/kernel/types.js +7 -0
- package/dist/robopark/access.js +99 -0
- package/dist/robopark/add-robot.js +188 -0
- package/dist/robopark/agent-ctl.js +305 -0
- package/dist/robopark/auto-start.js +289 -0
- package/dist/robopark/conversation.js +505 -0
- package/dist/robopark/deployment-commands.js +47 -0
- package/dist/robopark/discovery.js +180 -0
- package/dist/robopark/doctor.js +175 -0
- package/dist/robopark/enroll.js +68 -0
- package/dist/robopark/llm-set.js +87 -0
- package/dist/robopark/motor-control.js +195 -0
- package/dist/robopark/preview-agent-launcher.js +77 -0
- package/dist/robopark/probe.js +138 -0
- package/dist/robopark/profile.js +69 -0
- package/dist/robopark/python-env.js +162 -0
- package/dist/robopark/robot-runtime.js +489 -0
- package/dist/robopark/scan.js +97 -0
- package/dist/robopark/screen-control.js +55 -0
- package/dist/robopark/secrets.js +41 -0
- package/dist/robopark/serve.js +285 -0
- package/dist/robopark/server-add.js +114 -0
- package/dist/robopark/setup-livekit.js +300 -0
- package/dist/robopark/setup.js +286 -0
- package/dist/robopark/standalone.js +466 -0
- package/dist/robopark/stop-all.js +141 -0
- package/dist/robopark/verify.js +192 -0
- package/dist/robopark/vision-agent-launcher.js +98 -0
- package/dist/robopark/vision-control.js +81 -0
- package/dist/robopark-cli.js +799 -0
- package/package.json +21 -5
- package/pi-client/_install_steps.sh +29 -29
- package/pi-client/client.py +61 -2
- package/pi-client/install.sh +40 -40
- package/pi-client/join_convo.sh +54 -54
- package/pi-client/livekit_bridge.py +16 -7
- package/pi-client/motor_bridge.py +6 -3
- package/scheduler/fleet_config.json +75 -0
- package/scheduler/main.py +4505 -135
- package/scheduler/media_lock.py +57 -0
- package/scheduler/preview_agent.py +1465 -87
- package/scheduler/production_config.json +139 -0
- package/scheduler/robot_supervisor.py +1705 -0
- package/scheduler/scripts/install-robot-supervisor-linux.sh +33 -0
- package/scheduler/scripts/install-robot-supervisor-windows.ps1 +49 -0
- package/scheduler/scripts/robopark-supervisor.service +20 -0
- package/scheduler/scripts/start-scheduler-local.ps1 +50 -0
- package/scheduler/supervisor.example.json +26 -0
- package/scheduler/vision_motion_trigger.py +101 -0
- package/screen/screen_runtime.py +75 -0
- package/vision/app_pi_clean.py +253 -16
- package/vision/audio_server_pi.py +19 -0
- package/vision/install.sh +34 -34
- package/vision/motor_server.py +224 -61
- package/vision/requirements_camera.txt +6 -0
- package/vision/requirements_motor.txt +4 -0
- package/vision/requirements_pi_unified.txt +1 -0
- package/vision/requirements_vision_agent.txt +19 -0
- package/vision/run.sh +244 -244
- package/vision/services/services.sh +12 -12
- package/scheduler/__pycache__/main.cpython-312.pyc +0 -0
- package/scheduler/__pycache__/preview_agent.cpython-312.pyc +0 -0
package/vision/app_pi_clean.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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 =
|
|
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
|
-
|
|
332
|
-
camera
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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"
|