robopark 3.3.38 → 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.
@@ -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
- 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)
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
47
-
48
- def _open_camera(index):
49
- # On Windows, cv2.VideoCapture(index) with no explicit backend can
50
- # auto-probe into an unrelated backend (e.g. Orbbec's "obsensor") that
51
- # fails with "Camera index out of range" for a perfectly normal UVC
52
- # webcam — isOpened() then stays False forever and callers retry in an
53
- # infinite doomed loop. Force DirectShow, the backend that actually
54
- # enumerates standard Windows webcams.
55
- if sys.platform == 'win32':
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
65
- return cv2.VideoCapture(index)
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, current_camera_index
70
- if camera is None or not camera.isOpened():
71
- camera = _open_camera(current_camera_index)
72
- camera.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
73
- camera.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
74
- print(f"Camera {current_camera_index} initialized")
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
- prev_gray = None
262
-
263
- while True:
264
- with camera_lock:
265
- cam = get_camera()
266
- if cam is None or not cam.isOpened():
267
- time.sleep(1)
268
- continue
269
-
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
280
- if not success:
281
- with camera_lock:
282
- if camera is cam:
283
- camera.release()
284
- camera = None
285
- time.sleep(0.1)
286
- continue
287
- camera_last_frame_at = time.monotonic()
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
- 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
386
-
387
- yield (b'--frame\r\n'
388
- b'Content-Type: image/jpeg\r\n\r\n' + frame_bytes + b'\r\n')
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
- "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
- })
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
- @app.route('/api/cameras', methods=['GET'])
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
-
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
- 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"})
474
- cap.release()
475
-
476
- _camera_inventory_cache = available
477
- _camera_inventory_cache_at = now
478
- return jsonify({"cameras": available, "current": current_camera_index})
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
- with camera_lock:
489
- if camera:
490
- camera.release()
491
- current_camera_index = _normalize_camera_device(new_index)
492
- camera = None
493
-
494
- return jsonify({"status": "ok", "camera_index": current_camera_index})
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"})
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)