robopark 3.3.37 → 3.3.39
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/robopark/audio-select.js +128 -0
- package/dist/robopark/audio-select.js.map +1 -0
- package/dist/robopark/device-preempt.js +185 -0
- package/dist/robopark/device-preempt.js.map +1 -0
- package/dist/robopark/robot-runtime.js +7 -4
- package/dist/robopark/robot-runtime.js.map +1 -1
- package/dist/robopark/start.js +174 -14
- package/dist/robopark/start.js.map +1 -1
- package/dist/robopark-cli.js +4 -0
- package/dist/robopark-cli.js.map +1 -1
- package/package.json +1 -1
- package/scheduler/static/voice-join.html +48 -2
- package/ui/standalone/.next/BUILD_ID +1 -1
- package/ui/standalone/.next/app-build-manifest.json +63 -63
- package/ui/standalone/.next/app-path-routes-manifest.json +16 -16
- package/ui/standalone/.next/build-manifest.json +2 -2
- package/ui/standalone/.next/server/app-paths-manifest.json +16 -16
- package/ui/standalone/.next/server/pages/500.html +1 -1
- package/ui/standalone/public/voice-join.html +48 -2
- package/vision/__pycache__/app_pi_clean.cpython-312.pyc +0 -0
- package/vision/app_pi_clean.py +570 -312
- /package/ui/standalone/.next/static/{8E9-VDckMmTbkbuwnaatU → FmpkBgViIbmAAJNmgm-TV}/_buildManifest.js +0 -0
- /package/ui/standalone/.next/static/{8E9-VDckMmTbkbuwnaatU → FmpkBgViIbmAAJNmgm-TV}/_ssgManifest.js +0 -0
package/vision/app_pi_clean.py
CHANGED
|
@@ -1,79 +1,274 @@
|
|
|
1
1
|
from flask import Flask, Response, jsonify, request
|
|
2
2
|
from flask_cors import CORS
|
|
3
|
-
import argparse
|
|
4
|
-
import glob
|
|
5
|
-
import cv2
|
|
3
|
+
import argparse
|
|
4
|
+
import glob
|
|
5
|
+
import cv2
|
|
6
6
|
import os
|
|
7
7
|
import sys
|
|
8
8
|
import time
|
|
9
9
|
import numpy as np
|
|
10
|
-
import threading
|
|
11
|
-
import requests
|
|
12
|
-
import base64
|
|
13
|
-
import struct
|
|
10
|
+
import threading
|
|
11
|
+
import requests
|
|
12
|
+
import base64
|
|
13
|
+
import struct
|
|
14
|
+
import subprocess
|
|
14
15
|
|
|
15
16
|
app = Flask(__name__)
|
|
16
17
|
CORS(app)
|
|
17
18
|
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
return
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
19
|
+
# Camera management. Production installs create /dev/robopark-camera from
|
|
20
|
+
# the primary USB capture interface, avoiding /dev/videoN renumbering.
|
|
21
|
+
def _normalize_camera_device(value):
|
|
22
|
+
configured = str(value or "").strip()
|
|
23
|
+
if configured.lower() in ("", "auto", "default", "first"):
|
|
24
|
+
if sys.platform.startswith("linux"):
|
|
25
|
+
return "/dev/robopark-camera" if os.path.exists("/dev/robopark-camera") else "/dev/video0"
|
|
26
|
+
# Off-Pi we scan instead of assuming index 0: a Windows kiosk with a
|
|
27
|
+
# virtual/IR device in the way exposes the real webcam at 1 or 2.
|
|
28
|
+
return "auto"
|
|
29
|
+
if configured.isdigit():
|
|
30
|
+
return int(configured)
|
|
31
|
+
return configured
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_configured_camera = os.getenv("ROBOPARK_CAMERA_DEVICE", "")
|
|
35
|
+
current_camera_index = _normalize_camera_device(_configured_camera)
|
|
36
|
+
camera = None
|
|
37
|
+
camera_lock = threading.Lock()
|
|
38
|
+
frame_condition = threading.Condition()
|
|
39
|
+
latest_frame_bytes = None
|
|
40
|
+
latest_frame_sequence = 0
|
|
41
|
+
camera_worker_started = False
|
|
42
|
+
camera_watchdog_started = False
|
|
43
|
+
camera_read_started_at = 0.0
|
|
44
|
+
camera_last_frame_at = 0.0
|
|
45
|
+
audio_input_device = "default"
|
|
46
|
+
audio_output_device = "default"
|
|
47
|
+
ROBOVISION_AUDIO_URL = os.getenv("ROBOVISION_AUDIO_URL", "http://127.0.0.1:8000")
|
|
48
|
+
_camera_inventory_cache = []
|
|
49
|
+
_camera_inventory_cache_at = 0.0
|
|
50
|
+
CAMERA_INVENTORY_CACHE_SECONDS = 30.0
|
|
51
|
+
_camera_name_cache = []
|
|
52
|
+
_camera_name_cache_at = 0.0
|
|
53
|
+
CAMERA_NAME_CACHE_SECONDS = 30.0
|
|
54
|
+
|
|
55
|
+
CAMERA_SCAN_MAX_INDEX = int(os.getenv("ROBOPARK_CAMERA_SCAN_MAX", "3"))
|
|
56
|
+
CAMERA_OPEN_MAX_ATTEMPTS = int(os.getenv("ROBOPARK_CAMERA_OPEN_ATTEMPTS", "3"))
|
|
57
|
+
CAMERA_FIRST_FRAME_TRIES = 5
|
|
58
|
+
CAMERA_FIRST_FRAME_DELAY = 0.15
|
|
59
|
+
|
|
60
|
+
active_camera_device = None
|
|
61
|
+
active_camera_backend = None
|
|
62
|
+
camera_open_attempts = []
|
|
63
|
+
camera_open_error = None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _camera_backends():
|
|
67
|
+
"""Backends to try, in the order most likely to bind on this platform.
|
|
68
|
+
|
|
69
|
+
Windows leads with Media Foundation because that is the stack Chrome's
|
|
70
|
+
getUserMedia uses, and the kiosk that fails here streams fine in the
|
|
71
|
+
browser. DirectShow alone logs "backend is generally available but can't
|
|
72
|
+
be used to capture by index" and never binds on that hardware; it stays as
|
|
73
|
+
the second try because some older UVC bridges only enumerate there.
|
|
74
|
+
Linux/Pi keeps V4L2 first — unchanged from the original behaviour.
|
|
75
|
+
"""
|
|
76
|
+
if sys.platform == 'win32':
|
|
77
|
+
return [
|
|
78
|
+
(getattr(cv2, 'CAP_MSMF', cv2.CAP_ANY), 'msmf'),
|
|
79
|
+
(getattr(cv2, 'CAP_DSHOW', cv2.CAP_ANY), 'dshow'),
|
|
80
|
+
(cv2.CAP_ANY, 'default'),
|
|
81
|
+
]
|
|
82
|
+
if sys.platform.startswith('linux'):
|
|
83
|
+
return [(getattr(cv2, 'CAP_V4L2', cv2.CAP_ANY), 'v4l2'), (cv2.CAP_ANY, 'default')]
|
|
84
|
+
return [(cv2.CAP_ANY, 'default')]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _tune_capture(cap):
|
|
88
|
+
if sys.platform.startswith('linux'):
|
|
89
|
+
cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*'MJPG'))
|
|
90
|
+
cap.set(cv2.CAP_PROP_FPS, 15)
|
|
91
|
+
cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
|
|
92
|
+
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _capture_yields_frame(cap):
|
|
96
|
+
"""isOpened() is not proof of a working camera — MSMF/DSHOW both hand back
|
|
97
|
+
an "open" handle that never decodes a frame. Only a real read counts."""
|
|
98
|
+
for _ in range(CAMERA_FIRST_FRAME_TRIES):
|
|
99
|
+
try:
|
|
100
|
+
success, frame = cap.read()
|
|
101
|
+
except Exception as exc:
|
|
102
|
+
return False, f"read() raised {exc}"
|
|
103
|
+
if success and frame is not None and getattr(frame, 'size', 0):
|
|
104
|
+
return True, None
|
|
105
|
+
time.sleep(CAMERA_FIRST_FRAME_DELAY)
|
|
106
|
+
return False, f"opened but produced no frame in {CAMERA_FIRST_FRAME_TRIES} reads"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _open_camera(index, backend=None):
|
|
110
|
+
"""Open one device with one backend. Returns the capture (possibly closed)."""
|
|
111
|
+
if backend is None:
|
|
112
|
+
backend = _camera_backends()[0][0]
|
|
113
|
+
cap = cv2.VideoCapture(index, backend)
|
|
114
|
+
if cap.isOpened():
|
|
115
|
+
_tune_capture(cap)
|
|
116
|
+
return cap
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _windows_camera_names():
|
|
120
|
+
"""Friendly camera names from Windows PnP, via PowerShell (no new deps).
|
|
121
|
+
|
|
122
|
+
OpenCV exposes no device-name API, so the correlation to indices is
|
|
123
|
+
positional and therefore HEURISTIC: the Nth camera PnP entity is assumed
|
|
124
|
+
to be OpenCV index N. That holds on the usual one-or-two-camera kiosk but
|
|
125
|
+
can be wrong when virtual cameras, IR sensors or non-UVC 'Image' devices
|
|
126
|
+
are installed. Selecting by index is always exact; selecting by name
|
|
127
|
+
depends on this guess.
|
|
128
|
+
"""
|
|
129
|
+
script = (
|
|
130
|
+
"Get-CimInstance Win32_PnPEntity -ErrorAction SilentlyContinue | "
|
|
131
|
+
"Where-Object { $_.PNPClass -eq 'Camera' -or $_.PNPClass -eq 'Image' } | "
|
|
132
|
+
"ForEach-Object { $_.Name }"
|
|
133
|
+
)
|
|
134
|
+
try:
|
|
135
|
+
completed = subprocess.run(
|
|
136
|
+
["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
|
|
137
|
+
capture_output=True,
|
|
138
|
+
text=True,
|
|
139
|
+
timeout=10,
|
|
140
|
+
creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0),
|
|
141
|
+
)
|
|
142
|
+
except Exception as exc:
|
|
143
|
+
print(f"Camera name enumeration failed: {exc}")
|
|
144
|
+
return []
|
|
145
|
+
return [line.strip() for line in (completed.stdout or "").splitlines() if line.strip()]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _camera_names():
|
|
149
|
+
global _camera_name_cache, _camera_name_cache_at
|
|
150
|
+
now = time.monotonic()
|
|
151
|
+
if now - _camera_name_cache_at < CAMERA_NAME_CACHE_SECONDS:
|
|
152
|
+
return _camera_name_cache
|
|
153
|
+
names = _windows_camera_names() if sys.platform == 'win32' else []
|
|
154
|
+
_camera_name_cache = names
|
|
155
|
+
_camera_name_cache_at = now
|
|
156
|
+
return names
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _match_device_name(wanted):
|
|
160
|
+
"""Resolve a human-typed camera name to device values.
|
|
161
|
+
|
|
162
|
+
Same matching rules as the microphone picker in audio-select.ts: exact
|
|
163
|
+
case-insensitive first, then a unique substring; ambiguity resolves to
|
|
164
|
+
nothing rather than a coin flip.
|
|
165
|
+
"""
|
|
166
|
+
lower = wanted.strip().lower()
|
|
167
|
+
if not lower:
|
|
168
|
+
return []
|
|
169
|
+
if sys.platform.startswith('linux'):
|
|
170
|
+
pairs = [(entry.get("name", ""), entry.get("id")) for entry in _enumerate_cameras()]
|
|
171
|
+
else:
|
|
172
|
+
pairs = [(name, index) for index, name in enumerate(_camera_names())]
|
|
173
|
+
exact = [value for name, value in pairs if name.lower() == lower]
|
|
174
|
+
if len(exact) == 1:
|
|
175
|
+
return exact
|
|
176
|
+
partial = [value for name, value in pairs if lower in name.lower()]
|
|
177
|
+
if len(partial) == 1:
|
|
178
|
+
return partial
|
|
179
|
+
return []
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _camera_candidates(device):
|
|
183
|
+
"""Device values to try, in order, for the currently configured selection."""
|
|
184
|
+
text = str(device).strip()
|
|
185
|
+
if text.lower() == 'auto':
|
|
186
|
+
return list(range(CAMERA_SCAN_MAX_INDEX + 1))
|
|
187
|
+
if text.isdigit():
|
|
188
|
+
return [int(text)]
|
|
189
|
+
if text.startswith('/dev/') or os.path.sep in text:
|
|
190
|
+
return [text]
|
|
191
|
+
return _match_device_name(text)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _acquire_camera():
|
|
195
|
+
"""Try every candidate device against every backend until one yields a frame.
|
|
196
|
+
|
|
197
|
+
Returns (capture, device, backend_label, attempt_log). `capture` is None if
|
|
198
|
+
nothing worked; the log names every backend/index pair that was tried and
|
|
199
|
+
why it failed, so the operator is not left guessing.
|
|
200
|
+
"""
|
|
201
|
+
attempts = []
|
|
202
|
+
candidates = _camera_candidates(current_camera_index)
|
|
203
|
+
if not candidates:
|
|
204
|
+
known = ", ".join(_camera_names()) or "(none reported by the OS)"
|
|
205
|
+
attempts.append(f"no camera matches name {current_camera_index!r}; OS reports: {known}")
|
|
206
|
+
return None, None, None, attempts
|
|
207
|
+
|
|
208
|
+
for device in candidates:
|
|
209
|
+
for backend, label in _camera_backends():
|
|
210
|
+
cap = None
|
|
211
|
+
try:
|
|
212
|
+
cap = cv2.VideoCapture(device, backend)
|
|
213
|
+
except Exception as exc:
|
|
214
|
+
attempts.append(f"{label}:{device} VideoCapture() raised {exc}")
|
|
215
|
+
continue
|
|
216
|
+
if not cap.isOpened():
|
|
217
|
+
attempts.append(f"{label}:{device} isOpened()=False")
|
|
218
|
+
cap.release()
|
|
219
|
+
continue
|
|
220
|
+
_tune_capture(cap)
|
|
221
|
+
ok, reason = _capture_yields_frame(cap)
|
|
222
|
+
if ok:
|
|
223
|
+
return cap, device, label, attempts
|
|
224
|
+
attempts.append(f"{label}:{device} {reason}")
|
|
225
|
+
cap.release()
|
|
226
|
+
return None, None, None, attempts
|
|
66
227
|
|
|
67
228
|
|
|
68
229
|
def get_camera():
|
|
69
|
-
global camera,
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
camera
|
|
73
|
-
|
|
74
|
-
|
|
230
|
+
global camera, active_camera_device, active_camera_backend
|
|
231
|
+
global camera_open_attempts, camera_open_error
|
|
232
|
+
if camera is not None and camera.isOpened():
|
|
233
|
+
return camera
|
|
234
|
+
|
|
235
|
+
cap, device, backend, attempts = _acquire_camera()
|
|
236
|
+
camera_open_attempts = attempts
|
|
237
|
+
if cap is None:
|
|
238
|
+
camera = None
|
|
239
|
+
active_camera_device = None
|
|
240
|
+
active_camera_backend = None
|
|
241
|
+
camera_open_error = "; ".join(attempts) or "no candidate devices"
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
camera = cap
|
|
245
|
+
active_camera_device = device
|
|
246
|
+
active_camera_backend = backend
|
|
247
|
+
camera_open_error = None
|
|
248
|
+
for failure in attempts:
|
|
249
|
+
print(f"Camera probe skipped {failure}")
|
|
250
|
+
print(f"Camera opened: device={device} backend={backend}")
|
|
75
251
|
return camera
|
|
76
252
|
|
|
253
|
+
|
|
254
|
+
def _report_camera_unavailable():
|
|
255
|
+
backends = "/".join(label for _, label in _camera_backends())
|
|
256
|
+
candidates = _camera_candidates(current_camera_index)
|
|
257
|
+
listed = ", ".join(str(c) for c in candidates) or "(none)"
|
|
258
|
+
print("=" * 60)
|
|
259
|
+
print("CAMERA UNAVAILABLE - giving up after "
|
|
260
|
+
f"{CAMERA_OPEN_MAX_ATTEMPTS} attempts")
|
|
261
|
+
print(f" configured device : {current_camera_index}")
|
|
262
|
+
print(f" backends tried : {backends}")
|
|
263
|
+
print(f" devices tried : {listed}")
|
|
264
|
+
for failure in camera_open_attempts or ["(no candidate devices to try)"]:
|
|
265
|
+
print(f" - {failure}")
|
|
266
|
+
print(" OS-reported cameras: " + (", ".join(_camera_names()) or "(none)"))
|
|
267
|
+
print(" Fix: plug in / free the camera, then POST /api/camera/switch "
|
|
268
|
+
"(or restart). Set ROBOPARK_CAMERA_DEVICE to a name or index; "
|
|
269
|
+
"GET /api/media/inventory lists what this machine can see.")
|
|
270
|
+
print("=" * 60)
|
|
271
|
+
|
|
77
272
|
# Global variables
|
|
78
273
|
latest_detections = []
|
|
79
274
|
lock = threading.Lock()
|
|
@@ -252,39 +447,52 @@ def send_webhook(frame_data):
|
|
|
252
447
|
except Exception as e:
|
|
253
448
|
print(f"Error preparing webhook: {e}")
|
|
254
449
|
|
|
255
|
-
def camera_worker():
|
|
256
|
-
global latest_detections, motion_detection_active, motion_detected_state
|
|
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
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
450
|
+
def camera_worker():
|
|
451
|
+
global latest_detections, motion_detection_active, motion_detected_state
|
|
452
|
+
global last_motion_time, motion_frame_buffer
|
|
453
|
+
global latest_frame_bytes, latest_frame_sequence
|
|
454
|
+
global camera_read_started_at, camera_last_frame_at, camera
|
|
455
|
+
|
|
456
|
+
global camera_worker_started
|
|
457
|
+
|
|
458
|
+
prev_gray = None
|
|
459
|
+
failed_opens = 0
|
|
460
|
+
|
|
461
|
+
while True:
|
|
462
|
+
with camera_lock:
|
|
463
|
+
cam = get_camera()
|
|
464
|
+
if cam is None or not cam.isOpened():
|
|
465
|
+
failed_opens += 1
|
|
466
|
+
if failed_opens >= CAMERA_OPEN_MAX_ATTEMPTS:
|
|
467
|
+
# Bounded, not infinite: a doomed 1/sec retry loop buries the
|
|
468
|
+
# real error. Clearing the started flag lets an explicit
|
|
469
|
+
# /api/camera/switch or a new /video_feed request try again.
|
|
470
|
+
_report_camera_unavailable()
|
|
471
|
+
with frame_condition:
|
|
472
|
+
camera_worker_started = False
|
|
473
|
+
return
|
|
474
|
+
time.sleep(1)
|
|
475
|
+
continue
|
|
476
|
+
failed_opens = 0
|
|
477
|
+
|
|
478
|
+
camera_read_started_at = time.monotonic()
|
|
479
|
+
try:
|
|
480
|
+
# This is the only camera reader. Do not hold camera_lock here:
|
|
481
|
+
# the watchdog must be able to release a wedged V4L2 handle.
|
|
482
|
+
success, frame = cam.read()
|
|
483
|
+
except Exception as exc:
|
|
484
|
+
print(f"Camera read error: {exc}")
|
|
485
|
+
success, frame = False, None
|
|
486
|
+
finally:
|
|
487
|
+
camera_read_started_at = 0.0
|
|
488
|
+
if not success:
|
|
489
|
+
with camera_lock:
|
|
490
|
+
if camera is cam:
|
|
491
|
+
camera.release()
|
|
492
|
+
camera = None
|
|
493
|
+
time.sleep(0.1)
|
|
494
|
+
continue
|
|
495
|
+
camera_last_frame_at = time.monotonic()
|
|
288
496
|
|
|
289
497
|
# Run object detection
|
|
290
498
|
processed_frame, detections = detect_objects(frame, conf_threshold=0.5)
|
|
@@ -333,59 +541,64 @@ def camera_worker():
|
|
|
333
541
|
|
|
334
542
|
prev_gray = gray
|
|
335
543
|
|
|
336
|
-
ret, buffer = cv2.imencode('.jpg', processed_frame, [cv2.IMWRITE_JPEG_QUALITY, 80])
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
544
|
+
ret, buffer = cv2.imencode('.jpg', processed_frame, [cv2.IMWRITE_JPEG_QUALITY, 80])
|
|
545
|
+
if not ret:
|
|
546
|
+
continue
|
|
547
|
+
with frame_condition:
|
|
548
|
+
latest_frame_bytes = buffer.tobytes()
|
|
549
|
+
latest_frame_sequence += 1
|
|
550
|
+
frame_condition.notify_all()
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _ensure_camera_worker():
|
|
554
|
+
global camera_worker_started, camera_watchdog_started
|
|
555
|
+
with frame_condition:
|
|
556
|
+
if camera_worker_started:
|
|
557
|
+
return
|
|
558
|
+
camera_worker_started = True
|
|
559
|
+
start_watchdog = not camera_watchdog_started
|
|
560
|
+
camera_watchdog_started = True
|
|
561
|
+
threading.Thread(target=camera_worker, name='robovision-camera', daemon=True).start()
|
|
562
|
+
if start_watchdog:
|
|
563
|
+
# The worker can restart after a bounded open failure; the watchdog is
|
|
564
|
+
# stateless and must not be duplicated each time it does.
|
|
565
|
+
threading.Thread(target=_camera_watchdog, name='robovision-camera-watchdog', daemon=True).start()
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _camera_watchdog():
|
|
569
|
+
global camera, camera_read_started_at
|
|
570
|
+
while True:
|
|
571
|
+
time.sleep(2.0)
|
|
572
|
+
started = camera_read_started_at
|
|
573
|
+
if not started or time.monotonic() - started < 8.0:
|
|
574
|
+
continue
|
|
575
|
+
print("Camera read stalled for 8s; releasing V4L2 handle")
|
|
576
|
+
with camera_lock:
|
|
577
|
+
if camera is not None:
|
|
578
|
+
try:
|
|
579
|
+
camera.release()
|
|
580
|
+
except Exception:
|
|
581
|
+
pass
|
|
582
|
+
camera = None
|
|
583
|
+
camera_read_started_at = 0.0
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def generate_frames():
|
|
587
|
+
_ensure_camera_worker()
|
|
588
|
+
sequence = -1
|
|
589
|
+
while True:
|
|
590
|
+
with frame_condition:
|
|
591
|
+
frame_condition.wait_for(
|
|
592
|
+
lambda: latest_frame_bytes is not None and latest_frame_sequence != sequence,
|
|
593
|
+
timeout=5.0,
|
|
594
|
+
)
|
|
595
|
+
if latest_frame_bytes is None or latest_frame_sequence == sequence:
|
|
596
|
+
continue
|
|
597
|
+
frame_bytes = latest_frame_bytes
|
|
598
|
+
sequence = latest_frame_sequence
|
|
599
|
+
|
|
600
|
+
yield (b'--frame\r\n'
|
|
601
|
+
b'Content-Type: image/jpeg\r\n\r\n' + frame_bytes + b'\r\n')
|
|
389
602
|
|
|
390
603
|
@app.route('/')
|
|
391
604
|
def index():
|
|
@@ -395,23 +608,27 @@ def index():
|
|
|
395
608
|
def video_feed():
|
|
396
609
|
return Response(generate_frames(), mimetype='multipart/x-mixed-replace; boundary=frame')
|
|
397
610
|
|
|
398
|
-
@app.route('/api/detections')
|
|
399
|
-
def get_detections():
|
|
400
|
-
with lock:
|
|
401
|
-
return jsonify(latest_detections)
|
|
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
|
-
"
|
|
410
|
-
"
|
|
411
|
-
"
|
|
412
|
-
"
|
|
413
|
-
"
|
|
414
|
-
|
|
611
|
+
@app.route('/api/detections')
|
|
612
|
+
def get_detections():
|
|
613
|
+
with lock:
|
|
614
|
+
return jsonify(latest_detections)
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
@app.route('/api/camera/status')
|
|
618
|
+
def camera_status():
|
|
619
|
+
age = None if not camera_last_frame_at else round(time.monotonic() - camera_last_frame_at, 3)
|
|
620
|
+
return jsonify({
|
|
621
|
+
"device": str(current_camera_index),
|
|
622
|
+
"active_device": active_camera_device,
|
|
623
|
+
"active_backend": active_camera_backend,
|
|
624
|
+
"error": camera_open_error,
|
|
625
|
+
"attempts": camera_open_attempts,
|
|
626
|
+
"open": bool(camera is not None and camera.isOpened()),
|
|
627
|
+
"worker_started": camera_worker_started,
|
|
628
|
+
"frame_sequence": latest_frame_sequence,
|
|
629
|
+
"last_frame_age_seconds": age,
|
|
630
|
+
"read_stalled": bool(camera_read_started_at and time.monotonic() - camera_read_started_at >= 8.0),
|
|
631
|
+
})
|
|
415
632
|
|
|
416
633
|
@app.route('/api/caption')
|
|
417
634
|
def get_caption():
|
|
@@ -426,149 +643,190 @@ def caption_mode():
|
|
|
426
643
|
caption_mode_enabled = bool(data.get('enabled', False))
|
|
427
644
|
return jsonify({"enabled": caption_mode_enabled})
|
|
428
645
|
|
|
429
|
-
|
|
430
|
-
|
|
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
|
|
435
|
-
|
|
436
|
-
available = []
|
|
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
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
646
|
+
def _enumerate_cameras():
|
|
647
|
+
"""List cameras with friendly names. Cached: probing reopens devices."""
|
|
648
|
+
global _camera_inventory_cache, _camera_inventory_cache_at
|
|
649
|
+
now = time.monotonic()
|
|
650
|
+
if now - _camera_inventory_cache_at < CAMERA_INVENTORY_CACHE_SECONDS:
|
|
651
|
+
return _camera_inventory_cache
|
|
652
|
+
|
|
653
|
+
available = []
|
|
654
|
+
if sys.platform.startswith('linux'):
|
|
655
|
+
# Query capabilities without starting a stream. Opening every V4L2
|
|
656
|
+
# node through OpenCV also opens metadata/output nodes and can contend
|
|
657
|
+
# with the camera stream already owned by RoboVision.
|
|
658
|
+
import fcntl
|
|
659
|
+
vidioc_querycap = 0x80685600
|
|
660
|
+
video_capture = 0x00000001
|
|
661
|
+
video_capture_mplane = 0x00001000
|
|
662
|
+
device_caps_flag = 0x80000000
|
|
663
|
+
for candidate in sorted(glob.glob('/dev/video*')):
|
|
664
|
+
fd = None
|
|
665
|
+
try:
|
|
666
|
+
fd = os.open(candidate, os.O_RDONLY | os.O_NONBLOCK)
|
|
667
|
+
capability = bytearray(104)
|
|
668
|
+
fcntl.ioctl(fd, vidioc_querycap, capability, True)
|
|
669
|
+
capabilities = struct.unpack_from('=I', capability, 84)[0]
|
|
670
|
+
device_caps = struct.unpack_from('=I', capability, 88)[0]
|
|
671
|
+
effective = device_caps if capabilities & device_caps_flag else capabilities
|
|
672
|
+
if not effective & (video_capture | video_capture_mplane):
|
|
673
|
+
continue
|
|
674
|
+
card = bytes(capability[16:48]).split(b'\0', 1)[0].decode('utf-8', 'replace')
|
|
675
|
+
available.append({
|
|
676
|
+
"index": candidate,
|
|
677
|
+
"id": candidate,
|
|
678
|
+
"name": card or f"Camera {candidate}",
|
|
679
|
+
"backend": "v4l2",
|
|
680
|
+
})
|
|
681
|
+
except (OSError, ValueError):
|
|
682
|
+
continue
|
|
683
|
+
finally:
|
|
684
|
+
if fd is not None:
|
|
685
|
+
os.close(fd)
|
|
686
|
+
else:
|
|
687
|
+
names = _camera_names()
|
|
688
|
+
for candidate in range(CAMERA_SCAN_MAX_INDEX + 1):
|
|
689
|
+
# Positional name correlation — see _windows_camera_names().
|
|
690
|
+
name = names[candidate] if candidate < len(names) else f"Camera {candidate}"
|
|
691
|
+
if camera is not None and camera.isOpened() and candidate == active_camera_device:
|
|
692
|
+
# Never reopen the device the streaming worker owns.
|
|
693
|
+
available.append({"index": candidate, "id": str(candidate), "name": name,
|
|
694
|
+
"backend": active_camera_backend, "active": True})
|
|
695
|
+
continue
|
|
696
|
+
for backend, label in _camera_backends():
|
|
697
|
+
cap = cv2.VideoCapture(candidate, backend)
|
|
698
|
+
opened = cap.isOpened()
|
|
699
|
+
cap.release()
|
|
700
|
+
if opened:
|
|
701
|
+
available.append({"index": candidate, "id": str(candidate), "name": name,
|
|
702
|
+
"backend": label, "active": False})
|
|
703
|
+
break
|
|
704
|
+
|
|
705
|
+
_camera_inventory_cache = available
|
|
706
|
+
_camera_inventory_cache_at = now
|
|
707
|
+
return available
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
@app.route('/api/cameras', methods=['GET'])
|
|
711
|
+
def list_cameras():
|
|
712
|
+
return jsonify({
|
|
713
|
+
"cameras": _enumerate_cameras(),
|
|
714
|
+
"current": current_camera_index,
|
|
715
|
+
"active_device": active_camera_device,
|
|
716
|
+
"active_backend": active_camera_backend,
|
|
717
|
+
})
|
|
718
|
+
|
|
719
|
+
def _reselect_camera(value):
|
|
720
|
+
"""Point the worker at a new device (index, name or path) and revive it."""
|
|
721
|
+
global camera, current_camera_index, camera_open_error, camera_open_attempts
|
|
722
|
+
with camera_lock:
|
|
723
|
+
if camera:
|
|
724
|
+
camera.release()
|
|
725
|
+
current_camera_index = _normalize_camera_device(value)
|
|
726
|
+
camera = None
|
|
727
|
+
camera_open_error = None
|
|
728
|
+
camera_open_attempts = []
|
|
729
|
+
# The worker exits after a bounded open failure; an explicit selection is
|
|
730
|
+
# the operator saying "try again".
|
|
731
|
+
_ensure_camera_worker()
|
|
732
|
+
|
|
479
733
|
|
|
480
734
|
@app.route('/api/camera/switch', methods=['POST'])
|
|
481
|
-
def switch_camera():
|
|
482
|
-
global camera, current_camera_index
|
|
735
|
+
def switch_camera():
|
|
483
736
|
data = request.json or {}
|
|
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)
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
"
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
"
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
if "
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
737
|
+
new_index = data.get('device', data.get('index', 0))
|
|
738
|
+
if isinstance(new_index, str) and new_index.isdigit():
|
|
739
|
+
new_index = int(new_index)
|
|
740
|
+
|
|
741
|
+
_reselect_camera(new_index)
|
|
742
|
+
return jsonify({"status": "ok", "camera_index": current_camera_index})
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def _audio_inventory():
|
|
746
|
+
"""Adapt RoboVision's existing audio_server_pi /devices response."""
|
|
747
|
+
try:
|
|
748
|
+
response = requests.get(f"{ROBOVISION_AUDIO_URL.rstrip('/')}/devices", timeout=0.8)
|
|
749
|
+
response.raise_for_status()
|
|
750
|
+
payload = response.json()
|
|
751
|
+
inputs, outputs = [], []
|
|
752
|
+
for device in payload.get("devices", []):
|
|
753
|
+
item = {
|
|
754
|
+
"id": str(device["index"]),
|
|
755
|
+
"name": str(device.get("name", f"Audio device {device['index']}")),
|
|
756
|
+
"backend": "robovision_audio",
|
|
757
|
+
"sample_rate": device.get("default_samplerate"),
|
|
758
|
+
}
|
|
759
|
+
if device.get("max_input_channels", 0) > 0:
|
|
760
|
+
inputs.append(item.copy())
|
|
761
|
+
if device.get("max_output_channels", 0) > 0:
|
|
762
|
+
outputs.append(item.copy())
|
|
763
|
+
return inputs, outputs, {
|
|
764
|
+
"input": payload.get("bluetooth_input"),
|
|
765
|
+
"output": payload.get("bluetooth_output"),
|
|
766
|
+
"online": True,
|
|
767
|
+
}
|
|
768
|
+
except Exception as exc:
|
|
769
|
+
return [], [], {"online": False, "error": str(exc)}
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
@app.route('/api/media/inventory', methods=['GET'])
|
|
773
|
+
def media_inventory():
|
|
774
|
+
cameras = _enumerate_cameras()
|
|
775
|
+
inputs, outputs, audio_state = _audio_inventory()
|
|
776
|
+
return jsonify({
|
|
777
|
+
"video": [{"id": "auto", "name": "Auto detect"}, {"id": "none", "name": "Disable camera"}] + cameras,
|
|
778
|
+
"video_state": {
|
|
779
|
+
"active_device": active_camera_device,
|
|
780
|
+
"active_backend": active_camera_backend,
|
|
781
|
+
"open": bool(camera is not None and camera.isOpened()),
|
|
782
|
+
"error": camera_open_error,
|
|
783
|
+
"attempts": camera_open_attempts,
|
|
784
|
+
# Names come from the OS, indices from OpenCV; the pairing is
|
|
785
|
+
# positional and best-effort (see _windows_camera_names()). The raw
|
|
786
|
+
# OS list is exposed too so an operator can see when it is longer
|
|
787
|
+
# than the list of indices OpenCV can actually open.
|
|
788
|
+
"name_correlation": "heuristic" if sys.platform == 'win32' else "exact",
|
|
789
|
+
"os_reported_names": _camera_names(),
|
|
790
|
+
},
|
|
791
|
+
"audio_input": inputs,
|
|
792
|
+
"audio_output": outputs,
|
|
793
|
+
"selected": {
|
|
794
|
+
"video_device": str(current_camera_index),
|
|
795
|
+
"audio_device": str(audio_state.get("input") if audio_state.get("input") is not None else audio_input_device),
|
|
796
|
+
"audio_output_device": str(audio_state.get("output") if audio_state.get("output") is not None else audio_output_device),
|
|
797
|
+
},
|
|
798
|
+
"audio_server": audio_state,
|
|
799
|
+
"source": "robovision_pi",
|
|
800
|
+
})
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
@app.route('/api/media/config', methods=['GET', 'POST'])
|
|
804
|
+
def media_config():
|
|
805
|
+
global audio_input_device, audio_output_device, camera, current_camera_index
|
|
806
|
+
if request.method == 'POST':
|
|
807
|
+
data = request.json or {}
|
|
808
|
+
if "video_device" in data:
|
|
809
|
+
value = str(data["video_device"])
|
|
810
|
+
if value not in ("", "none"):
|
|
811
|
+
# "auto" is a legitimate selection now — it means scan.
|
|
812
|
+
_reselect_camera(value)
|
|
813
|
+
if "audio_device" in data:
|
|
814
|
+
audio_input_device = str(data["audio_device"])
|
|
815
|
+
if "audio_output_device" in data:
|
|
816
|
+
audio_output_device = str(data["audio_output_device"])
|
|
817
|
+
if "audio_device" in data or "audio_output_device" in data:
|
|
818
|
+
# audio_server_pi.py is RoboVision's authoritative selector.
|
|
819
|
+
# It accepts its original sounddevice indices via input/output.
|
|
820
|
+
payload = {}
|
|
821
|
+
if "audio_device" in data:
|
|
822
|
+
payload["input"] = int(audio_input_device) if audio_input_device.isdigit() else audio_input_device
|
|
823
|
+
if "audio_output_device" in data:
|
|
824
|
+
payload["output"] = int(audio_output_device) if audio_output_device.isdigit() else audio_output_device
|
|
825
|
+
try:
|
|
826
|
+
requests.post(f"{ROBOVISION_AUDIO_URL.rstrip('/')}/set-device", json=payload, timeout=0.8).raise_for_status()
|
|
827
|
+
except Exception:
|
|
828
|
+
pass
|
|
829
|
+
return jsonify({"video_device": str(current_camera_index), "audio_device": audio_input_device, "audio_output_device": audio_output_device, "source": "robovision_pi"})
|
|
572
830
|
|
|
573
831
|
@app.route('/api/motion/status', methods=['GET'])
|
|
574
832
|
def motion_status():
|
|
@@ -646,11 +904,11 @@ if __name__ == '__main__':
|
|
|
646
904
|
print("RoboVision - Raspberry Pi Vision Server (Minimal)")
|
|
647
905
|
print("=" * 60)
|
|
648
906
|
print(f"Starting Flask server on http://0.0.0.0:{args.port}")
|
|
649
|
-
if webhook_url:
|
|
650
|
-
print(f"Motion webhook: {webhook_url}")
|
|
651
|
-
print(f"Motion detection: {'ARMED' if motion_detection_active else 'off (POST /api/motion/toggle to arm)'}")
|
|
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()
|
|
656
|
-
app.run(host='0.0.0.0', port=args.port, debug=False, threaded=True)
|
|
907
|
+
if webhook_url:
|
|
908
|
+
print(f"Motion webhook: {webhook_url}")
|
|
909
|
+
print(f"Motion detection: {'ARMED' if motion_detection_active else 'off (POST /api/motion/toggle to arm)'}")
|
|
910
|
+
print("=" * 60)
|
|
911
|
+
# Start the single camera owner at boot. Production motion and MJPEG
|
|
912
|
+
# readiness must not depend on an operator opening the dashboard first.
|
|
913
|
+
_ensure_camera_worker()
|
|
914
|
+
app.run(host='0.0.0.0', port=args.port, debug=False, threaded=True)
|