robopark 3.3.49 → 3.3.50

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,914 +1,966 @@
1
- from flask import Flask, Response, jsonify, request
2
- from flask_cors import CORS
3
- import argparse
4
- import glob
5
- import cv2
6
- import os
7
- import sys
8
- import time
9
- import numpy as np
10
- import threading
11
- import requests
12
- import base64
13
- import struct
14
- import subprocess
15
-
16
- app = Flask(__name__)
17
- CORS(app)
18
-
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
227
-
228
-
229
- def get_camera():
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}")
251
- return camera
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
-
272
- # Global variables
273
- latest_detections = []
274
- lock = threading.Lock()
275
- caption_mode_enabled = False
276
- motion_detection_active = False
277
- motion_detected_state = False
278
- last_motion_time = 0
279
- motion_frame_buffer = None
280
- webhook_url = None
281
- last_webhook_send_time = 0
282
- webhook_send_interval = 0.5
283
-
284
- # Vision-confirm (Ollama Cloud) config — stage 2 of the motion pipeline.
285
- # Motion detection (frame-diff) is a cheap pre-filter; before we fire the
286
- # session webhook we ask a vision model to confirm a person is actually in
287
- # frame, to cut down on false triggers from pets/shadows/wind.
288
- OLLAMA_CLOUD_API_KEY = os.getenv("OLLAMA_CLOUD_API_KEY", "")
289
- OLLAMA_CLOUD_VISION_MODEL = os.getenv("OLLAMA_CLOUD_VISION_MODEL", "gemma3:27b")
290
- OLLAMA_CLOUD_VISION_URL = "https://ollama.com/v1/chat/completions"
291
- VISION_CONFIRM_TIMEOUT = 8
292
-
293
- # Simple object detection using OpenCV DNN (MobileNet SSD)
294
- try:
295
- # Load pre-trained MobileNet SSD model
296
- net = cv2.dnn.readNetFromCaffe(
297
- 'deploy.prototxt',
298
- 'mobilenet_iter_73000.caffemodel'
299
- )
300
- DETECTION_AVAILABLE = True
301
- print("Object detection model loaded")
302
- except:
303
- DETECTION_AVAILABLE = False
304
- print("Object detection model not found - running without detection")
305
-
306
- # COCO class labels
307
- CLASSES = ["background", "aeroplane", "bicycle", "bird", "boat",
308
- "bottle", "bus", "car", "cat", "chair", "cow", "diningtable",
309
- "dog", "horse", "motorbike", "person", "pottedplant", "sheep",
310
- "sofa", "train", "tvmonitor"]
311
-
312
- def detect_objects(frame, conf_threshold=0.5):
313
- """Detect objects using OpenCV DNN"""
314
- if not DETECTION_AVAILABLE:
315
- return frame, []
316
-
317
- (h, w) = frame.shape[:2]
318
- blob = cv2.dnn.blobFromImage(cv2.resize(frame, (300, 300)), 0.007843, (300, 300), 127.5)
319
- net.setInput(blob)
320
- detections_dnn = net.forward()
321
-
322
- detected_objects = []
323
-
324
- for i in range(detections_dnn.shape[2]):
325
- confidence = detections_dnn[0, 0, i, 2]
326
-
327
- if confidence > conf_threshold:
328
- idx = int(detections_dnn[0, 0, i, 1])
329
- if idx >= len(CLASSES):
330
- continue
331
-
332
- box = detections_dnn[0, 0, i, 3:7] * np.array([w, h, w, h])
333
- (startX, startY, endX, endY) = box.astype("int")
334
-
335
- label = CLASSES[idx]
336
-
337
- # Draw bounding box
338
- cv2.rectangle(frame, (startX, startY), (endX, endY), (0, 255, 0), 2)
339
-
340
- # Draw label with confidence
341
- text = f"{label}: {confidence*100:.1f}%"
342
- y = startY - 15 if startY - 15 > 15 else startY + 15
343
- cv2.putText(frame, text, (startX, y), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 2)
344
-
345
- detected_objects.append({
346
- "label": label,
347
- "confidence": float(confidence),
348
- "bbox": [int(startX), int(startY), int(endX), int(endY)],
349
- "is_focus": False
350
- })
351
-
352
- return frame, detected_objects
353
-
354
- def confirm_person_present(frame):
355
- """Ask an Ollama Cloud vision model whether a person is visible in `frame`.
356
-
357
- This is stage 2 of the motion pipeline: motion detection (frame-diff) is a
358
- cheap pre-filter, and this confirms a person is actually present before we
359
- fire the session webhook — cuts down on false triggers from pets, shadows,
360
- wind, etc.
361
-
362
- Fails OPEN (returns True) on any error — missing key, network failure,
363
- timeout, bad response — since the pre-existing motion-only trigger is the
364
- fallback behavior and a vision-API outage shouldn't silently disable the
365
- whole trigger system.
366
- """
367
- if not OLLAMA_CLOUD_API_KEY:
368
- # No key configured: skip the check entirely, preserve motion-only
369
- # behavior as the zero-config default. Caller logs this case.
370
- return True
371
-
372
- try:
373
- _, buffer = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, 80])
374
- img_base64 = base64.b64encode(buffer).decode('utf-8')
375
-
376
- payload = {
377
- "model": OLLAMA_CLOUD_VISION_MODEL,
378
- "messages": [
379
- {
380
- "role": "user",
381
- "content": [
382
- {
383
- "type": "text",
384
- "text": "Is there a person clearly visible in this image? Answer with only YES or NO."
385
- },
386
- {
387
- "type": "image_url",
388
- "image_url": {"url": f"data:image/jpeg;base64,{img_base64}"}
389
- }
390
- ]
391
- }
392
- ],
393
- "stream": False
394
- }
395
-
396
- response = requests.post(
397
- OLLAMA_CLOUD_VISION_URL,
398
- json=payload,
399
- headers={
400
- "Authorization": f"Bearer {OLLAMA_CLOUD_API_KEY}",
401
- "Content-Type": "application/json"
402
- },
403
- timeout=VISION_CONFIRM_TIMEOUT
404
- )
405
- response.raise_for_status()
406
-
407
- answer = response.json()["choices"][0]["message"]["content"].strip()
408
- return answer.upper().startswith("YES")
409
- except Exception as e:
410
- print(f"WARNING: vision-confirm error, failing open (treating as person present): {e}")
411
- return True
412
-
413
-
414
- def send_webhook(frame_data):
415
- """Send frame to webhook URL"""
416
- global webhook_url, last_webhook_send_time
417
-
418
- if not webhook_url:
419
- return
420
-
421
- current_time = time.time()
422
- if current_time - last_webhook_send_time < webhook_send_interval:
423
- return
424
-
425
- try:
426
- _, buffer = cv2.imencode('.jpg', frame_data)
427
- img_base64 = base64.b64encode(buffer).decode('utf-8')
428
-
429
- payload = {
430
- 'timestamp': time.strftime('%Y-%m-%dT%H:%M:%S'),
431
- 'image': img_base64,
432
- 'format': 'jpeg',
433
- 'encoding': 'base64'
434
- }
435
-
436
- def send_async():
437
- try:
438
- response = requests.post(webhook_url, json=payload, headers={'Content-Type': 'application/json'}, timeout=5)
439
- if response.status_code == 200:
440
- print(f"Webhook sent successfully")
441
- except Exception as e:
442
- print(f"Webhook error: {e}")
443
-
444
- thread = threading.Thread(target=send_async, daemon=True)
445
- thread.start()
446
- last_webhook_send_time = current_time
447
- except Exception as e:
448
- print(f"Error preparing webhook: {e}")
449
-
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()
496
-
497
- # Run object detection
498
- processed_frame, detections = detect_objects(frame, conf_threshold=0.5)
499
-
500
- with lock:
501
- latest_detections = detections
502
-
503
- # Motion detection logic
504
- if motion_detection_active:
505
- gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
506
- gray = cv2.GaussianBlur(gray, (21, 21), 0)
507
-
508
- if prev_gray is not None:
509
- frame_delta = cv2.absdiff(prev_gray, gray)
510
- thresh = cv2.threshold(frame_delta, 25, 255, cv2.THRESH_BINARY)[1]
511
- thresh = cv2.dilate(thresh, None, iterations=2)
512
- contours, _ = cv2.findContours(thresh.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
513
-
514
- motion_detected = False
515
- for contour in contours:
516
- if cv2.contourArea(contour) >= 500:
517
- motion_detected = True
518
- (x, y, w, h) = cv2.boundingRect(contour)
519
- cv2.rectangle(processed_frame, (x, y), (x + w, y + h), (0, 0, 255), 2)
520
- break
521
-
522
- if motion_detected:
523
- motion_detected_state = True
524
- last_motion_time = time.time()
525
- motion_frame_buffer = processed_frame.copy()
526
- print(f"Motion detected!")
527
-
528
- # Only run the (network-bound) vision-confirm + webhook
529
- # once per motion "event" — reuse the same debounce timer
530
- # send_webhook() itself uses, rather than calling the
531
- # vision API on every single frame while motion continues.
532
- if webhook_url and (time.time() - last_webhook_send_time >= webhook_send_interval):
533
- if not OLLAMA_CLOUD_API_KEY:
534
- send_webhook(processed_frame)
535
- elif confirm_person_present(processed_frame):
536
- send_webhook(processed_frame)
537
- else:
538
- print("Motion event suppressed: vision-confirm found no person present")
539
- else:
540
- motion_detected_state = False
541
-
542
- prev_gray = gray
543
-
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')
602
-
603
- @app.route('/')
604
- def index():
605
- return jsonify({"status": "ok", "message": "RoboVision Pi Server", "version": "1.0"})
606
-
607
- @app.route('/video_feed')
608
- def video_feed():
609
- return Response(generate_frames(), mimetype='multipart/x-mixed-replace; boundary=frame')
610
-
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
- })
632
-
633
- @app.route('/api/caption')
634
- def get_caption():
635
- return jsonify({"caption": "Awaiting caption..."})
636
-
637
- @app.route('/api/caption_mode', methods=['GET', 'POST'])
638
- def caption_mode():
639
- global caption_mode_enabled
640
- if request.method == 'GET':
641
- return jsonify({"enabled": caption_mode_enabled})
642
- data = request.json or {}
643
- caption_mode_enabled = bool(data.get('enabled', False))
644
- return jsonify({"enabled": caption_mode_enabled})
645
-
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
-
733
-
734
- @app.route('/api/camera/switch', methods=['POST'])
735
- def switch_camera():
736
- data = request.json or {}
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"})
830
-
831
- @app.route('/api/motion/status', methods=['GET'])
832
- def motion_status():
833
- global motion_detection_active, motion_detected_state, last_motion_time
834
- return jsonify({
835
- "active": motion_detection_active,
836
- "motion_detected": motion_detected_state,
837
- "last_motion": last_motion_time,
838
- "time_since_motion": time.time() - last_motion_time if last_motion_time > 0 else None
839
- })
840
-
841
- @app.route('/api/motion/toggle', methods=['POST'])
842
- def motion_toggle():
843
- global motion_detection_active
844
- data = request.json or {}
845
- motion_detection_active = bool(data.get('active', False))
846
- return jsonify({"status": "success", "active": motion_detection_active})
847
-
848
- @app.route('/api/motion/snapshot', methods=['GET'])
849
- def motion_snapshot():
850
- global motion_frame_buffer
851
- if motion_frame_buffer is not None:
852
- _, buffer = cv2.imencode('.jpg', motion_frame_buffer, [cv2.IMWRITE_JPEG_QUALITY, 85])
853
- img_base64 = base64.b64encode(buffer).decode('utf-8')
854
- return jsonify({
855
- "image": img_base64,
856
- "timestamp": time.time()
857
- })
858
- return jsonify({"error": "No frame available"}), 404
859
-
860
- @app.route('/api/motion/webhook', methods=['GET', 'POST'])
861
- def motion_webhook():
862
- global webhook_url
863
-
864
- if request.method == 'GET':
865
- return jsonify({
866
- "webhook_url": webhook_url or "",
867
- "configured": webhook_url is not None and len(webhook_url) > 0
868
- })
869
-
870
- data = request.json or {}
871
- new_url = data.get('url', '').strip()
872
-
873
- if new_url:
874
- webhook_url = new_url
875
- return jsonify({
876
- "status": "success",
877
- "message": "Webhook URL configured",
878
- "webhook_url": webhook_url
879
- })
880
- else:
881
- webhook_url = None
882
- return jsonify({
883
- "status": "success",
884
- "message": "Webhook URL cleared",
885
- "webhook_url": None
886
- })
887
-
888
- if __name__ == '__main__':
889
- parser = argparse.ArgumentParser(description="RoboVision — camera/motion detection server")
890
- parser.add_argument("--port", type=int, default=int(os.getenv("VISION_PORT", "5000")))
891
- parser.add_argument("--motion-webhook-url", default=os.getenv("MOTION_WEBHOOK_URL", ""),
892
- help="where to POST a snapshot when motion is detected, e.g. http://localhost:5057/")
893
- parser.add_argument("--motion-active", action="store_true",
894
- default=os.getenv("MOTION_ACTIVE", "").lower() in ("1", "true", "yes"),
895
- help="arm motion detection immediately on startup (no manual /api/motion/toggle call needed)")
896
- args = parser.parse_args()
897
-
898
- if args.motion_webhook_url:
899
- webhook_url = args.motion_webhook_url
900
- if args.motion_active:
901
- motion_detection_active = True
902
-
903
- print("=" * 60)
904
- print("RoboVision - Raspberry Pi Vision Server (Minimal)")
905
- print("=" * 60)
906
- print(f"Starting Flask server on http://0.0.0.0:{args.port}")
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)
1
+ from flask import Flask, Response, jsonify, request
2
+ from flask_cors import CORS
3
+ import argparse
4
+ import glob
5
+ import cv2
6
+ import os
7
+ import sys
8
+ import time
9
+ import numpy as np
10
+ import threading
11
+ import requests
12
+ import base64
13
+ import struct
14
+ import subprocess
15
+
16
+ app = Flask(__name__)
17
+ CORS(app)
18
+
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
+ # Whether the capture is open, tracked as a plain flag instead of asking the
65
+ # VideoCapture each time. cv2 calls serialise against the worker's in-flight
66
+ # read/open, so `camera.isOpened()` inside a request handler blocks for as long
67
+ # as MSMF is stuck opening a device -- which is exactly when an operator is
68
+ # trying to find out what is wrong. Diagnostics must never be able to hang.
69
+ camera_open_flag = False
70
+ # Set while a background inventory probe is running, so /api/media/inventory
71
+ # can answer instantly with what it already knows instead of waiting on OpenCV.
72
+ _camera_inventory_probing = False
73
+
74
+
75
+ def _camera_backends():
76
+ """Backends to try, in the order most likely to bind on this platform.
77
+
78
+ Windows leads with Media Foundation because that is the stack Chrome's
79
+ getUserMedia uses, and the kiosk that fails here streams fine in the
80
+ browser. DirectShow alone logs "backend is generally available but can't
81
+ be used to capture by index" and never binds on that hardware; it stays as
82
+ the second try because some older UVC bridges only enumerate there.
83
+ Linux/Pi keeps V4L2 first unchanged from the original behaviour.
84
+ """
85
+ if sys.platform == 'win32':
86
+ return [
87
+ (getattr(cv2, 'CAP_MSMF', cv2.CAP_ANY), 'msmf'),
88
+ (getattr(cv2, 'CAP_DSHOW', cv2.CAP_ANY), 'dshow'),
89
+ (cv2.CAP_ANY, 'default'),
90
+ ]
91
+ if sys.platform.startswith('linux'):
92
+ return [(getattr(cv2, 'CAP_V4L2', cv2.CAP_ANY), 'v4l2'), (cv2.CAP_ANY, 'default')]
93
+ return [(cv2.CAP_ANY, 'default')]
94
+
95
+
96
+ def _tune_capture(cap):
97
+ if sys.platform.startswith('linux'):
98
+ cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*'MJPG'))
99
+ cap.set(cv2.CAP_PROP_FPS, 15)
100
+ cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
101
+ cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
102
+
103
+
104
+ def _capture_yields_frame(cap):
105
+ """isOpened() is not proof of a working camera — MSMF/DSHOW both hand back
106
+ an "open" handle that never decodes a frame. Only a real read counts."""
107
+ for _ in range(CAMERA_FIRST_FRAME_TRIES):
108
+ try:
109
+ success, frame = cap.read()
110
+ except Exception as exc:
111
+ return False, f"read() raised {exc}"
112
+ if success and frame is not None and getattr(frame, 'size', 0):
113
+ return True, None
114
+ time.sleep(CAMERA_FIRST_FRAME_DELAY)
115
+ return False, f"opened but produced no frame in {CAMERA_FIRST_FRAME_TRIES} reads"
116
+
117
+
118
+ def _open_camera(index, backend=None):
119
+ """Open one device with one backend. Returns the capture (possibly closed)."""
120
+ if backend is None:
121
+ backend = _camera_backends()[0][0]
122
+ cap = cv2.VideoCapture(index, backend)
123
+ if cap.isOpened():
124
+ _tune_capture(cap)
125
+ return cap
126
+
127
+
128
+ def _windows_camera_names():
129
+ """Friendly camera names from Windows PnP, via PowerShell (no new deps).
130
+
131
+ OpenCV exposes no device-name API, so the correlation to indices is
132
+ positional and therefore HEURISTIC: the Nth camera PnP entity is assumed
133
+ to be OpenCV index N. That holds on the usual one-or-two-camera kiosk but
134
+ can be wrong when virtual cameras, IR sensors or non-UVC 'Image' devices
135
+ are installed. Selecting by index is always exact; selecting by name
136
+ depends on this guess.
137
+ """
138
+ script = (
139
+ "Get-CimInstance Win32_PnPEntity -ErrorAction SilentlyContinue | "
140
+ "Where-Object { $_.PNPClass -eq 'Camera' -or $_.PNPClass -eq 'Image' } | "
141
+ "ForEach-Object { $_.Name }"
142
+ )
143
+ try:
144
+ completed = subprocess.run(
145
+ ["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
146
+ capture_output=True,
147
+ text=True,
148
+ timeout=10,
149
+ creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0),
150
+ )
151
+ except Exception as exc:
152
+ print(f"Camera name enumeration failed: {exc}")
153
+ return []
154
+ return [line.strip() for line in (completed.stdout or "").splitlines() if line.strip()]
155
+
156
+
157
+ def _camera_names():
158
+ global _camera_name_cache, _camera_name_cache_at
159
+ now = time.monotonic()
160
+ if now - _camera_name_cache_at < CAMERA_NAME_CACHE_SECONDS:
161
+ return _camera_name_cache
162
+ names = _windows_camera_names() if sys.platform == 'win32' else []
163
+ _camera_name_cache = names
164
+ _camera_name_cache_at = now
165
+ return names
166
+
167
+
168
+ def _match_device_name(wanted):
169
+ """Resolve a human-typed camera name to device values.
170
+
171
+ Same matching rules as the microphone picker in audio-select.ts: exact
172
+ case-insensitive first, then a unique substring; ambiguity resolves to
173
+ nothing rather than a coin flip.
174
+ """
175
+ lower = wanted.strip().lower()
176
+ if not lower:
177
+ return []
178
+ if sys.platform.startswith('linux'):
179
+ pairs = [(entry.get("name", ""), entry.get("id")) for entry in _enumerate_cameras()]
180
+ else:
181
+ pairs = [(name, index) for index, name in enumerate(_camera_names())]
182
+ exact = [value for name, value in pairs if name.lower() == lower]
183
+ if len(exact) == 1:
184
+ return exact
185
+ partial = [value for name, value in pairs if lower in name.lower()]
186
+ if len(partial) == 1:
187
+ return partial
188
+ return []
189
+
190
+
191
+ def _camera_candidates(device):
192
+ """Device values to try, in order, for the currently configured selection."""
193
+ text = str(device).strip()
194
+ if text.lower() == 'auto':
195
+ return list(range(CAMERA_SCAN_MAX_INDEX + 1))
196
+ if text.isdigit():
197
+ return [int(text)]
198
+ if text.startswith('/dev/') or os.path.sep in text:
199
+ return [text]
200
+ return _match_device_name(text)
201
+
202
+
203
+ def _acquire_camera():
204
+ """Try every candidate device against every backend until one yields a frame.
205
+
206
+ Returns (capture, device, backend_label, attempt_log). `capture` is None if
207
+ nothing worked; the log names every backend/index pair that was tried and
208
+ why it failed, so the operator is not left guessing.
209
+ """
210
+ attempts = []
211
+ candidates = _camera_candidates(current_camera_index)
212
+ if not candidates:
213
+ known = ", ".join(_camera_names()) or "(none reported by the OS)"
214
+ attempts.append(f"no camera matches name {current_camera_index!r}; OS reports: {known}")
215
+ return None, None, None, attempts
216
+
217
+ for device in candidates:
218
+ for backend, label in _camera_backends():
219
+ cap = None
220
+ try:
221
+ cap = cv2.VideoCapture(device, backend)
222
+ except Exception as exc:
223
+ attempts.append(f"{label}:{device} VideoCapture() raised {exc}")
224
+ continue
225
+ if not cap.isOpened():
226
+ attempts.append(f"{label}:{device} isOpened()=False")
227
+ cap.release()
228
+ continue
229
+ _tune_capture(cap)
230
+ ok, reason = _capture_yields_frame(cap)
231
+ if ok:
232
+ return cap, device, label, attempts
233
+ attempts.append(f"{label}:{device} {reason}")
234
+ cap.release()
235
+ return None, None, None, attempts
236
+
237
+
238
+ def get_camera():
239
+ global camera, active_camera_device, active_camera_backend
240
+ global camera_open_attempts, camera_open_error, camera_open_flag
241
+ if camera is not None and camera.isOpened():
242
+ camera_open_flag = True
243
+ return camera
244
+
245
+ cap, device, backend, attempts = _acquire_camera()
246
+ camera_open_attempts = attempts
247
+ if cap is None:
248
+ camera = None
249
+ camera_open_flag = False
250
+ active_camera_device = None
251
+ active_camera_backend = None
252
+ camera_open_error = "; ".join(attempts) or "no candidate devices"
253
+ return None
254
+
255
+ camera = cap
256
+ camera_open_flag = True
257
+ active_camera_device = device
258
+ active_camera_backend = backend
259
+ camera_open_error = None
260
+ for failure in attempts:
261
+ print(f"Camera probe skipped {failure}")
262
+ print(f"Camera opened: device={device} backend={backend}")
263
+ return camera
264
+
265
+
266
+ def _report_camera_unavailable():
267
+ backends = "/".join(label for _, label in _camera_backends())
268
+ candidates = _camera_candidates(current_camera_index)
269
+ listed = ", ".join(str(c) for c in candidates) or "(none)"
270
+ print("=" * 60)
271
+ print("CAMERA UNAVAILABLE - giving up after "
272
+ f"{CAMERA_OPEN_MAX_ATTEMPTS} attempts")
273
+ print(f" configured device : {current_camera_index}")
274
+ print(f" backends tried : {backends}")
275
+ print(f" devices tried : {listed}")
276
+ for failure in camera_open_attempts or ["(no candidate devices to try)"]:
277
+ print(f" - {failure}")
278
+ print(" OS-reported cameras: " + (", ".join(_camera_names()) or "(none)"))
279
+ print(" Fix: plug in / free the camera, then POST /api/camera/switch "
280
+ "(or restart). Set ROBOPARK_CAMERA_DEVICE to a name or index; "
281
+ "GET /api/media/inventory lists what this machine can see.")
282
+ print("=" * 60)
283
+
284
+ # Global variables
285
+ latest_detections = []
286
+ lock = threading.Lock()
287
+ caption_mode_enabled = False
288
+ motion_detection_active = False
289
+ motion_detected_state = False
290
+ last_motion_time = 0
291
+ motion_frame_buffer = None
292
+ webhook_url = None
293
+ last_webhook_send_time = 0
294
+ webhook_send_interval = 0.5
295
+
296
+ # Vision-confirm (Ollama Cloud) config — stage 2 of the motion pipeline.
297
+ # Motion detection (frame-diff) is a cheap pre-filter; before we fire the
298
+ # session webhook we ask a vision model to confirm a person is actually in
299
+ # frame, to cut down on false triggers from pets/shadows/wind.
300
+ OLLAMA_CLOUD_API_KEY = os.getenv("OLLAMA_CLOUD_API_KEY", "")
301
+ OLLAMA_CLOUD_VISION_MODEL = os.getenv("OLLAMA_CLOUD_VISION_MODEL", "gemma3:27b")
302
+ OLLAMA_CLOUD_VISION_URL = "https://ollama.com/v1/chat/completions"
303
+ VISION_CONFIRM_TIMEOUT = 8
304
+
305
+ # Simple object detection using OpenCV DNN (MobileNet SSD)
306
+ try:
307
+ # Load pre-trained MobileNet SSD model
308
+ net = cv2.dnn.readNetFromCaffe(
309
+ 'deploy.prototxt',
310
+ 'mobilenet_iter_73000.caffemodel'
311
+ )
312
+ DETECTION_AVAILABLE = True
313
+ print("Object detection model loaded")
314
+ except:
315
+ DETECTION_AVAILABLE = False
316
+ print("Object detection model not found - running without detection")
317
+
318
+ # COCO class labels
319
+ CLASSES = ["background", "aeroplane", "bicycle", "bird", "boat",
320
+ "bottle", "bus", "car", "cat", "chair", "cow", "diningtable",
321
+ "dog", "horse", "motorbike", "person", "pottedplant", "sheep",
322
+ "sofa", "train", "tvmonitor"]
323
+
324
+ def detect_objects(frame, conf_threshold=0.5):
325
+ """Detect objects using OpenCV DNN"""
326
+ if not DETECTION_AVAILABLE:
327
+ return frame, []
328
+
329
+ (h, w) = frame.shape[:2]
330
+ blob = cv2.dnn.blobFromImage(cv2.resize(frame, (300, 300)), 0.007843, (300, 300), 127.5)
331
+ net.setInput(blob)
332
+ detections_dnn = net.forward()
333
+
334
+ detected_objects = []
335
+
336
+ for i in range(detections_dnn.shape[2]):
337
+ confidence = detections_dnn[0, 0, i, 2]
338
+
339
+ if confidence > conf_threshold:
340
+ idx = int(detections_dnn[0, 0, i, 1])
341
+ if idx >= len(CLASSES):
342
+ continue
343
+
344
+ box = detections_dnn[0, 0, i, 3:7] * np.array([w, h, w, h])
345
+ (startX, startY, endX, endY) = box.astype("int")
346
+
347
+ label = CLASSES[idx]
348
+
349
+ # Draw bounding box
350
+ cv2.rectangle(frame, (startX, startY), (endX, endY), (0, 255, 0), 2)
351
+
352
+ # Draw label with confidence
353
+ text = f"{label}: {confidence*100:.1f}%"
354
+ y = startY - 15 if startY - 15 > 15 else startY + 15
355
+ cv2.putText(frame, text, (startX, y), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 2)
356
+
357
+ detected_objects.append({
358
+ "label": label,
359
+ "confidence": float(confidence),
360
+ "bbox": [int(startX), int(startY), int(endX), int(endY)],
361
+ "is_focus": False
362
+ })
363
+
364
+ return frame, detected_objects
365
+
366
+ def confirm_person_present(frame):
367
+ """Ask an Ollama Cloud vision model whether a person is visible in `frame`.
368
+
369
+ This is stage 2 of the motion pipeline: motion detection (frame-diff) is a
370
+ cheap pre-filter, and this confirms a person is actually present before we
371
+ fire the session webhook — cuts down on false triggers from pets, shadows,
372
+ wind, etc.
373
+
374
+ Fails OPEN (returns True) on any error — missing key, network failure,
375
+ timeout, bad response — since the pre-existing motion-only trigger is the
376
+ fallback behavior and a vision-API outage shouldn't silently disable the
377
+ whole trigger system.
378
+ """
379
+ if not OLLAMA_CLOUD_API_KEY:
380
+ # No key configured: skip the check entirely, preserve motion-only
381
+ # behavior as the zero-config default. Caller logs this case.
382
+ return True
383
+
384
+ try:
385
+ _, buffer = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, 80])
386
+ img_base64 = base64.b64encode(buffer).decode('utf-8')
387
+
388
+ payload = {
389
+ "model": OLLAMA_CLOUD_VISION_MODEL,
390
+ "messages": [
391
+ {
392
+ "role": "user",
393
+ "content": [
394
+ {
395
+ "type": "text",
396
+ "text": "Is there a person clearly visible in this image? Answer with only YES or NO."
397
+ },
398
+ {
399
+ "type": "image_url",
400
+ "image_url": {"url": f"data:image/jpeg;base64,{img_base64}"}
401
+ }
402
+ ]
403
+ }
404
+ ],
405
+ "stream": False
406
+ }
407
+
408
+ response = requests.post(
409
+ OLLAMA_CLOUD_VISION_URL,
410
+ json=payload,
411
+ headers={
412
+ "Authorization": f"Bearer {OLLAMA_CLOUD_API_KEY}",
413
+ "Content-Type": "application/json"
414
+ },
415
+ timeout=VISION_CONFIRM_TIMEOUT
416
+ )
417
+ response.raise_for_status()
418
+
419
+ answer = response.json()["choices"][0]["message"]["content"].strip()
420
+ return answer.upper().startswith("YES")
421
+ except Exception as e:
422
+ print(f"WARNING: vision-confirm error, failing open (treating as person present): {e}")
423
+ return True
424
+
425
+
426
+ def send_webhook(frame_data):
427
+ """Send frame to webhook URL"""
428
+ global webhook_url, last_webhook_send_time
429
+
430
+ if not webhook_url:
431
+ return
432
+
433
+ current_time = time.time()
434
+ if current_time - last_webhook_send_time < webhook_send_interval:
435
+ return
436
+
437
+ try:
438
+ _, buffer = cv2.imencode('.jpg', frame_data)
439
+ img_base64 = base64.b64encode(buffer).decode('utf-8')
440
+
441
+ payload = {
442
+ 'timestamp': time.strftime('%Y-%m-%dT%H:%M:%S'),
443
+ 'image': img_base64,
444
+ 'format': 'jpeg',
445
+ 'encoding': 'base64'
446
+ }
447
+
448
+ def send_async():
449
+ try:
450
+ response = requests.post(webhook_url, json=payload, headers={'Content-Type': 'application/json'}, timeout=5)
451
+ if response.status_code == 200:
452
+ print(f"Webhook sent successfully")
453
+ except Exception as e:
454
+ print(f"Webhook error: {e}")
455
+
456
+ thread = threading.Thread(target=send_async, daemon=True)
457
+ thread.start()
458
+ last_webhook_send_time = current_time
459
+ except Exception as e:
460
+ print(f"Error preparing webhook: {e}")
461
+
462
+ def camera_worker():
463
+ global latest_detections, motion_detection_active, motion_detected_state
464
+ global last_motion_time, motion_frame_buffer
465
+ global latest_frame_bytes, latest_frame_sequence
466
+ global camera_read_started_at, camera_last_frame_at, camera
467
+
468
+ global camera_worker_started
469
+
470
+ prev_gray = None
471
+ failed_opens = 0
472
+
473
+ while True:
474
+ with camera_lock:
475
+ cam = get_camera()
476
+ if cam is None or not cam.isOpened():
477
+ failed_opens += 1
478
+ if failed_opens >= CAMERA_OPEN_MAX_ATTEMPTS:
479
+ # Bounded, not infinite: a doomed 1/sec retry loop buries the
480
+ # real error. Clearing the started flag lets an explicit
481
+ # /api/camera/switch or a new /video_feed request try again.
482
+ _report_camera_unavailable()
483
+ with frame_condition:
484
+ camera_worker_started = False
485
+ return
486
+ time.sleep(1)
487
+ continue
488
+ failed_opens = 0
489
+
490
+ camera_read_started_at = time.monotonic()
491
+ try:
492
+ # This is the only camera reader. Do not hold camera_lock here:
493
+ # the watchdog must be able to release a wedged V4L2 handle.
494
+ success, frame = cam.read()
495
+ except Exception as exc:
496
+ print(f"Camera read error: {exc}")
497
+ success, frame = False, None
498
+ finally:
499
+ camera_read_started_at = 0.0
500
+ if not success:
501
+ with camera_lock:
502
+ if camera is cam:
503
+ camera.release()
504
+ camera = None
505
+ camera_open_flag = False
506
+ time.sleep(0.1)
507
+ continue
508
+ camera_last_frame_at = time.monotonic()
509
+
510
+ # Run object detection
511
+ processed_frame, detections = detect_objects(frame, conf_threshold=0.5)
512
+
513
+ with lock:
514
+ latest_detections = detections
515
+
516
+ # Motion detection logic
517
+ if motion_detection_active:
518
+ gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
519
+ gray = cv2.GaussianBlur(gray, (21, 21), 0)
520
+
521
+ if prev_gray is not None:
522
+ frame_delta = cv2.absdiff(prev_gray, gray)
523
+ thresh = cv2.threshold(frame_delta, 25, 255, cv2.THRESH_BINARY)[1]
524
+ thresh = cv2.dilate(thresh, None, iterations=2)
525
+ contours, _ = cv2.findContours(thresh.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
526
+
527
+ motion_detected = False
528
+ for contour in contours:
529
+ if cv2.contourArea(contour) >= 500:
530
+ motion_detected = True
531
+ (x, y, w, h) = cv2.boundingRect(contour)
532
+ cv2.rectangle(processed_frame, (x, y), (x + w, y + h), (0, 0, 255), 2)
533
+ break
534
+
535
+ if motion_detected:
536
+ motion_detected_state = True
537
+ last_motion_time = time.time()
538
+ motion_frame_buffer = processed_frame.copy()
539
+ print(f"Motion detected!")
540
+
541
+ # Only run the (network-bound) vision-confirm + webhook
542
+ # once per motion "event" — reuse the same debounce timer
543
+ # send_webhook() itself uses, rather than calling the
544
+ # vision API on every single frame while motion continues.
545
+ if webhook_url and (time.time() - last_webhook_send_time >= webhook_send_interval):
546
+ if not OLLAMA_CLOUD_API_KEY:
547
+ send_webhook(processed_frame)
548
+ elif confirm_person_present(processed_frame):
549
+ send_webhook(processed_frame)
550
+ else:
551
+ print("Motion event suppressed: vision-confirm found no person present")
552
+ else:
553
+ motion_detected_state = False
554
+
555
+ prev_gray = gray
556
+
557
+ ret, buffer = cv2.imencode('.jpg', processed_frame, [cv2.IMWRITE_JPEG_QUALITY, 80])
558
+ if not ret:
559
+ continue
560
+ with frame_condition:
561
+ latest_frame_bytes = buffer.tobytes()
562
+ latest_frame_sequence += 1
563
+ frame_condition.notify_all()
564
+
565
+
566
+ def _ensure_camera_worker():
567
+ global camera_worker_started, camera_watchdog_started
568
+ with frame_condition:
569
+ if camera_worker_started:
570
+ return
571
+ camera_worker_started = True
572
+ start_watchdog = not camera_watchdog_started
573
+ camera_watchdog_started = True
574
+ threading.Thread(target=camera_worker, name='robovision-camera', daemon=True).start()
575
+ if start_watchdog:
576
+ # The worker can restart after a bounded open failure; the watchdog is
577
+ # stateless and must not be duplicated each time it does.
578
+ threading.Thread(target=_camera_watchdog, name='robovision-camera-watchdog', daemon=True).start()
579
+
580
+
581
+ def _camera_watchdog():
582
+ global camera, camera_read_started_at
583
+ while True:
584
+ time.sleep(2.0)
585
+ started = camera_read_started_at
586
+ if not started or time.monotonic() - started < 8.0:
587
+ continue
588
+ print("Camera read stalled for 8s; releasing V4L2 handle")
589
+ with camera_lock:
590
+ if camera is not None:
591
+ try:
592
+ camera.release()
593
+ except Exception:
594
+ pass
595
+ camera = None
596
+ camera_open_flag = False
597
+ camera_read_started_at = 0.0
598
+
599
+
600
+ def generate_frames():
601
+ _ensure_camera_worker()
602
+ sequence = -1
603
+ while True:
604
+ with frame_condition:
605
+ frame_condition.wait_for(
606
+ lambda: latest_frame_bytes is not None and latest_frame_sequence != sequence,
607
+ timeout=5.0,
608
+ )
609
+ if latest_frame_bytes is None or latest_frame_sequence == sequence:
610
+ continue
611
+ frame_bytes = latest_frame_bytes
612
+ sequence = latest_frame_sequence
613
+
614
+ yield (b'--frame\r\n'
615
+ b'Content-Type: image/jpeg\r\n\r\n' + frame_bytes + b'\r\n')
616
+
617
+ @app.route('/')
618
+ def index():
619
+ return jsonify({"status": "ok", "message": "RoboVision Pi Server", "version": "1.0"})
620
+
621
+ @app.route('/video_feed')
622
+ def video_feed():
623
+ return Response(generate_frames(), mimetype='multipart/x-mixed-replace; boundary=frame')
624
+
625
+ @app.route('/api/detections')
626
+ def get_detections():
627
+ with lock:
628
+ return jsonify(latest_detections)
629
+
630
+
631
+ @app.route('/api/camera/status')
632
+ def camera_status():
633
+ age = None if not camera_last_frame_at else round(time.monotonic() - camera_last_frame_at, 3)
634
+ return jsonify({
635
+ "device": str(current_camera_index),
636
+ "active_device": active_camera_device,
637
+ "active_backend": active_camera_backend,
638
+ "error": camera_open_error,
639
+ "attempts": camera_open_attempts,
640
+ # Deliberately the cached flag, not camera.isOpened(): see camera_open_flag.
641
+ "open": bool(camera_open_flag),
642
+ "worker_started": camera_worker_started,
643
+ "frame_sequence": latest_frame_sequence,
644
+ "last_frame_age_seconds": age,
645
+ "read_stalled": bool(camera_read_started_at and time.monotonic() - camera_read_started_at >= 8.0),
646
+ })
647
+
648
+ @app.route('/api/caption')
649
+ def get_caption():
650
+ return jsonify({"caption": "Awaiting caption..."})
651
+
652
+ @app.route('/api/caption_mode', methods=['GET', 'POST'])
653
+ def caption_mode():
654
+ global caption_mode_enabled
655
+ if request.method == 'GET':
656
+ return jsonify({"enabled": caption_mode_enabled})
657
+ data = request.json or {}
658
+ caption_mode_enabled = bool(data.get('enabled', False))
659
+ return jsonify({"enabled": caption_mode_enabled})
660
+
661
+ def _enumerate_cameras():
662
+ """List cameras with friendly names. Cached: probing reopens devices."""
663
+ global _camera_inventory_cache, _camera_inventory_cache_at
664
+ now = time.monotonic()
665
+ if now - _camera_inventory_cache_at < CAMERA_INVENTORY_CACHE_SECONDS:
666
+ return _camera_inventory_cache
667
+
668
+ available = []
669
+ if sys.platform.startswith('linux'):
670
+ # Query capabilities without starting a stream. Opening every V4L2
671
+ # node through OpenCV also opens metadata/output nodes and can contend
672
+ # with the camera stream already owned by RoboVision.
673
+ import fcntl
674
+ vidioc_querycap = 0x80685600
675
+ video_capture = 0x00000001
676
+ video_capture_mplane = 0x00001000
677
+ device_caps_flag = 0x80000000
678
+ for candidate in sorted(glob.glob('/dev/video*')):
679
+ fd = None
680
+ try:
681
+ fd = os.open(candidate, os.O_RDONLY | os.O_NONBLOCK)
682
+ capability = bytearray(104)
683
+ fcntl.ioctl(fd, vidioc_querycap, capability, True)
684
+ capabilities = struct.unpack_from('=I', capability, 84)[0]
685
+ device_caps = struct.unpack_from('=I', capability, 88)[0]
686
+ effective = device_caps if capabilities & device_caps_flag else capabilities
687
+ if not effective & (video_capture | video_capture_mplane):
688
+ continue
689
+ card = bytes(capability[16:48]).split(b'\0', 1)[0].decode('utf-8', 'replace')
690
+ available.append({
691
+ "index": candidate,
692
+ "id": candidate,
693
+ "name": card or f"Camera {candidate}",
694
+ "backend": "v4l2",
695
+ })
696
+ except (OSError, ValueError):
697
+ continue
698
+ finally:
699
+ if fd is not None:
700
+ os.close(fd)
701
+ else:
702
+ names = _camera_names()
703
+ for candidate in range(CAMERA_SCAN_MAX_INDEX + 1):
704
+ # Positional name correlation — see _windows_camera_names().
705
+ name = names[candidate] if candidate < len(names) else f"Camera {candidate}"
706
+ if camera is not None and camera.isOpened() and candidate == active_camera_device:
707
+ # Never reopen the device the streaming worker owns.
708
+ available.append({"index": candidate, "id": str(candidate), "name": name,
709
+ "backend": active_camera_backend, "active": True})
710
+ continue
711
+ for backend, label in _camera_backends():
712
+ cap = cv2.VideoCapture(candidate, backend)
713
+ opened = cap.isOpened()
714
+ cap.release()
715
+ if opened:
716
+ available.append({"index": candidate, "id": str(candidate), "name": name,
717
+ "backend": label, "active": False})
718
+ break
719
+
720
+ _camera_inventory_cache = available
721
+ _camera_inventory_cache_at = now
722
+ return available
723
+
724
+
725
+ @app.route('/api/cameras', methods=['GET'])
726
+ def list_cameras():
727
+ return jsonify({
728
+ "cameras": _enumerate_cameras(),
729
+ "current": current_camera_index,
730
+ "active_device": active_camera_device,
731
+ "active_backend": active_camera_backend,
732
+ })
733
+
734
+ def _reselect_camera(value):
735
+ """Point the worker at a new device (index, name or path) and revive it."""
736
+ global camera, current_camera_index, camera_open_error, camera_open_attempts
737
+ global camera_open_flag
738
+ with camera_lock:
739
+ if camera:
740
+ camera.release()
741
+ current_camera_index = _normalize_camera_device(value)
742
+ camera = None
743
+ camera_open_flag = False
744
+ camera_open_error = None
745
+ camera_open_attempts = []
746
+ # The worker exits after a bounded open failure; an explicit selection is
747
+ # the operator saying "try again".
748
+ _ensure_camera_worker()
749
+
750
+
751
+ @app.route('/api/camera/switch', methods=['POST'])
752
+ def switch_camera():
753
+ data = request.json or {}
754
+ new_index = data.get('device', data.get('index', 0))
755
+ if isinstance(new_index, str) and new_index.isdigit():
756
+ new_index = int(new_index)
757
+
758
+ _reselect_camera(new_index)
759
+ return jsonify({"status": "ok", "camera_index": current_camera_index})
760
+
761
+
762
+ def _audio_inventory():
763
+ """Adapt RoboVision's existing audio_server_pi /devices response."""
764
+ try:
765
+ response = requests.get(f"{ROBOVISION_AUDIO_URL.rstrip('/')}/devices", timeout=0.8)
766
+ response.raise_for_status()
767
+ payload = response.json()
768
+ inputs, outputs = [], []
769
+ for device in payload.get("devices", []):
770
+ item = {
771
+ "id": str(device["index"]),
772
+ "name": str(device.get("name", f"Audio device {device['index']}")),
773
+ "backend": "robovision_audio",
774
+ "sample_rate": device.get("default_samplerate"),
775
+ }
776
+ if device.get("max_input_channels", 0) > 0:
777
+ inputs.append(item.copy())
778
+ if device.get("max_output_channels", 0) > 0:
779
+ outputs.append(item.copy())
780
+ return inputs, outputs, {
781
+ "input": payload.get("bluetooth_input"),
782
+ "output": payload.get("bluetooth_output"),
783
+ "online": True,
784
+ }
785
+ except Exception as exc:
786
+ return [], [], {"online": False, "error": str(exc)}
787
+
788
+
789
+ def _enumerate_cameras_async():
790
+ """Whatever the last probe found, refreshed in the background.
791
+
792
+ `_enumerate_cameras()` opens devices through OpenCV, so calling it from a
793
+ request handler hands the caller a request that blocks for as long as the
794
+ driver does -- unbounded on a wedged Windows capture. This endpoint is the
795
+ one an operator (or `robopark start`) reaches for precisely when the camera
796
+ is misbehaving, so it must answer immediately even if the answer is stale
797
+ or empty. The probe runs on its own thread and lands in the cache for the
798
+ next call.
799
+ """
800
+ global _camera_inventory_probing
801
+ now = time.monotonic()
802
+ fresh = now - _camera_inventory_cache_at < CAMERA_INVENTORY_CACHE_SECONDS
803
+ if fresh:
804
+ return _camera_inventory_cache, False
805
+ if not _camera_inventory_probing:
806
+ _camera_inventory_probing = True
807
+
808
+ def probe():
809
+ global _camera_inventory_probing
810
+ try:
811
+ _enumerate_cameras()
812
+ except Exception as exc:
813
+ print(f"Camera inventory probe failed: {exc}")
814
+ finally:
815
+ _camera_inventory_probing = False
816
+
817
+ threading.Thread(target=probe, daemon=True).start()
818
+ return _camera_inventory_cache, True
819
+
820
+
821
+ @app.route('/api/media/inventory', methods=['GET'])
822
+ def media_inventory():
823
+ cameras, probing = _enumerate_cameras_async()
824
+ inputs, outputs, audio_state = _audio_inventory()
825
+ return jsonify({
826
+ "video": [{"id": "auto", "name": "Auto detect"}, {"id": "none", "name": "Disable camera"}] + cameras,
827
+ "video_state": {
828
+ "active_device": active_camera_device,
829
+ "active_backend": active_camera_backend,
830
+ "open": bool(camera_open_flag),
831
+ # True when the OpenCV probe is still running, so a caller can tell
832
+ # "no cameras found" apart from "not finished looking yet".
833
+ "probing": probing,
834
+ "error": camera_open_error,
835
+ "attempts": camera_open_attempts,
836
+ # Names come from the OS, indices from OpenCV; the pairing is
837
+ # positional and best-effort (see _windows_camera_names()). The raw
838
+ # OS list is exposed too so an operator can see when it is longer
839
+ # than the list of indices OpenCV can actually open.
840
+ "name_correlation": "heuristic" if sys.platform == 'win32' else "exact",
841
+ "os_reported_names": _camera_names(),
842
+ },
843
+ "audio_input": inputs,
844
+ "audio_output": outputs,
845
+ "selected": {
846
+ "video_device": str(current_camera_index),
847
+ "audio_device": str(audio_state.get("input") if audio_state.get("input") is not None else audio_input_device),
848
+ "audio_output_device": str(audio_state.get("output") if audio_state.get("output") is not None else audio_output_device),
849
+ },
850
+ "audio_server": audio_state,
851
+ "source": "robovision_pi",
852
+ })
853
+
854
+
855
+ @app.route('/api/media/config', methods=['GET', 'POST'])
856
+ def media_config():
857
+ global audio_input_device, audio_output_device, camera, current_camera_index
858
+ if request.method == 'POST':
859
+ data = request.json or {}
860
+ if "video_device" in data:
861
+ value = str(data["video_device"])
862
+ if value not in ("", "none"):
863
+ # "auto" is a legitimate selection now — it means scan.
864
+ _reselect_camera(value)
865
+ if "audio_device" in data:
866
+ audio_input_device = str(data["audio_device"])
867
+ if "audio_output_device" in data:
868
+ audio_output_device = str(data["audio_output_device"])
869
+ if "audio_device" in data or "audio_output_device" in data:
870
+ # audio_server_pi.py is RoboVision's authoritative selector.
871
+ # It accepts its original sounddevice indices via input/output.
872
+ payload = {}
873
+ if "audio_device" in data:
874
+ payload["input"] = int(audio_input_device) if audio_input_device.isdigit() else audio_input_device
875
+ if "audio_output_device" in data:
876
+ payload["output"] = int(audio_output_device) if audio_output_device.isdigit() else audio_output_device
877
+ try:
878
+ requests.post(f"{ROBOVISION_AUDIO_URL.rstrip('/')}/set-device", json=payload, timeout=0.8).raise_for_status()
879
+ except Exception:
880
+ pass
881
+ return jsonify({"video_device": str(current_camera_index), "audio_device": audio_input_device, "audio_output_device": audio_output_device, "source": "robovision_pi"})
882
+
883
+ @app.route('/api/motion/status', methods=['GET'])
884
+ def motion_status():
885
+ global motion_detection_active, motion_detected_state, last_motion_time
886
+ return jsonify({
887
+ "active": motion_detection_active,
888
+ "motion_detected": motion_detected_state,
889
+ "last_motion": last_motion_time,
890
+ "time_since_motion": time.time() - last_motion_time if last_motion_time > 0 else None
891
+ })
892
+
893
+ @app.route('/api/motion/toggle', methods=['POST'])
894
+ def motion_toggle():
895
+ global motion_detection_active
896
+ data = request.json or {}
897
+ motion_detection_active = bool(data.get('active', False))
898
+ return jsonify({"status": "success", "active": motion_detection_active})
899
+
900
+ @app.route('/api/motion/snapshot', methods=['GET'])
901
+ def motion_snapshot():
902
+ global motion_frame_buffer
903
+ if motion_frame_buffer is not None:
904
+ _, buffer = cv2.imencode('.jpg', motion_frame_buffer, [cv2.IMWRITE_JPEG_QUALITY, 85])
905
+ img_base64 = base64.b64encode(buffer).decode('utf-8')
906
+ return jsonify({
907
+ "image": img_base64,
908
+ "timestamp": time.time()
909
+ })
910
+ return jsonify({"error": "No frame available"}), 404
911
+
912
+ @app.route('/api/motion/webhook', methods=['GET', 'POST'])
913
+ def motion_webhook():
914
+ global webhook_url
915
+
916
+ if request.method == 'GET':
917
+ return jsonify({
918
+ "webhook_url": webhook_url or "",
919
+ "configured": webhook_url is not None and len(webhook_url) > 0
920
+ })
921
+
922
+ data = request.json or {}
923
+ new_url = data.get('url', '').strip()
924
+
925
+ if new_url:
926
+ webhook_url = new_url
927
+ return jsonify({
928
+ "status": "success",
929
+ "message": "Webhook URL configured",
930
+ "webhook_url": webhook_url
931
+ })
932
+ else:
933
+ webhook_url = None
934
+ return jsonify({
935
+ "status": "success",
936
+ "message": "Webhook URL cleared",
937
+ "webhook_url": None
938
+ })
939
+
940
+ if __name__ == '__main__':
941
+ parser = argparse.ArgumentParser(description="RoboVision — camera/motion detection server")
942
+ parser.add_argument("--port", type=int, default=int(os.getenv("VISION_PORT", "5000")))
943
+ parser.add_argument("--motion-webhook-url", default=os.getenv("MOTION_WEBHOOK_URL", ""),
944
+ help="where to POST a snapshot when motion is detected, e.g. http://localhost:5057/")
945
+ parser.add_argument("--motion-active", action="store_true",
946
+ default=os.getenv("MOTION_ACTIVE", "").lower() in ("1", "true", "yes"),
947
+ help="arm motion detection immediately on startup (no manual /api/motion/toggle call needed)")
948
+ args = parser.parse_args()
949
+
950
+ if args.motion_webhook_url:
951
+ webhook_url = args.motion_webhook_url
952
+ if args.motion_active:
953
+ motion_detection_active = True
954
+
955
+ print("=" * 60)
956
+ print("RoboVision - Raspberry Pi Vision Server (Minimal)")
957
+ print("=" * 60)
958
+ print(f"Starting Flask server on http://0.0.0.0:{args.port}")
959
+ if webhook_url:
960
+ print(f"Motion webhook: {webhook_url}")
961
+ print(f"Motion detection: {'ARMED' if motion_detection_active else 'off (POST /api/motion/toggle to arm)'}")
962
+ print("=" * 60)
963
+ # Start the single camera owner at boot. Production motion and MJPEG
964
+ # readiness must not depend on an operator opening the dashboard first.
965
+ _ensure_camera_worker()
966
+ app.run(host='0.0.0.0', port=args.port, debug=False, threaded=True)