infinicode 2.8.12 → 2.8.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/dist/kernel/federation/dashboard-html.d.ts +1 -1
  2. package/dist/kernel/federation/dashboard-html.js +49 -9
  3. package/dist/robopark/preview-agent-launcher.d.ts +3 -0
  4. package/dist/robopark/preview-agent-launcher.js +8 -0
  5. package/dist/robopark-cli.js +3 -0
  6. package/package.json +3 -1
  7. package/packages/robopark/pi-client/README.md +17 -0
  8. package/packages/robopark/pi-client/_install_steps.sh +29 -0
  9. package/packages/robopark/pi-client/client.py +305 -0
  10. package/packages/robopark/pi-client/install.sh +41 -0
  11. package/packages/robopark/pi-client/join_convo.sh +55 -0
  12. package/packages/robopark/pi-client/livekit_bridge.py +289 -0
  13. package/packages/robopark/pi-client/motor_bridge.py +82 -0
  14. package/packages/robopark/pi-client/pi_ui.py +375 -0
  15. package/packages/robopark/pi-client/requirements.txt +10 -0
  16. package/packages/robopark/pi-client/robopark-pi-client.service +26 -0
  17. package/packages/robopark/pi-client/robopark-pi-ui.service +24 -0
  18. package/packages/robopark/pi-client/smoke_bridge.py +15 -0
  19. package/packages/robopark/pi-client/stub_motor_server.py +46 -0
  20. package/packages/robopark/scheduler/main.py +45 -9
  21. package/packages/robopark/scheduler/preview_agent.py +132 -3
  22. package/packages/robopark/vision/.env.example +7 -0
  23. package/packages/robopark/vision/README.md +63 -0
  24. package/packages/robopark/vision/app_pi_clean.py +307 -0
  25. package/packages/robopark/vision/audio_server_pi.py +751 -0
  26. package/packages/robopark/vision/install.sh +34 -0
  27. package/packages/robopark/vision/motor_server.py +304 -0
  28. package/packages/robopark/vision/requirements_pi_unified.txt +101 -0
  29. package/packages/robopark/vision/run.sh +244 -0
  30. package/packages/robopark/vision/services/services.sh +12 -0
  31. package/packages/robopark/scheduler/__pycache__/main.cpython-312.pyc +0 -0
  32. package/packages/robopark/scheduler/__pycache__/preview_agent.cpython-312.pyc +0 -0
@@ -7,6 +7,15 @@ opens the selected camera + microphone and publishes them to the returned
7
7
  LiveKit room. When the preview expires or is stopped, the agent leaves the
8
8
  room and releases the hardware.
9
9
 
10
+ Also runs a small local HTTP listener for RoboVisionAI_PI's motion webhook
11
+ (POST /api/motion/webhook target — see RoboVisionAI_PI's app_pi_clean.py).
12
+ On a motion event it calls the scheduler's presence-triggered
13
+ POST /api/devices/{id}/request-session and publishes into the returned room
14
+ using the same LiveKitPublisher as the operator preview — this is the
15
+ "scene detection" trigger point the scheduler's request-session docstring
16
+ describes; RoboVisionAI_PI supplies the detection, this agent supplies the
17
+ bridge to an actual session.
18
+
10
19
  Configuration (env / ~/.robopark/preview_agent.json):
11
20
  SCHEDULER_URL base URL of the RoboPark scheduler
12
21
  ROBOT_ID robot identity in the scheduler (defaults to hostname)
@@ -18,6 +27,9 @@ Configuration (env / ~/.robopark/preview_agent.json):
18
27
  VIDEO_FPS capture fps (default 15)
19
28
  POLL_INTERVAL seconds between scheduler polls (default 3)
20
29
  HEARTBEAT_INTERVAL seconds between device heartbeats (default 30)
30
+ VISION_WEBHOOK_PORT local port for the RoboVision motion webhook (default 5057, 0 disables)
31
+ VISION_TRIGGER_COOLDOWN min seconds between motion-triggered sessions (default 20)
32
+ VISION_SESSION_SECONDS how long a motion-triggered session holds the publisher (default 90)
21
33
  """
22
34
  from __future__ import annotations
23
35
 
@@ -28,8 +40,10 @@ import logging
28
40
  import os
29
41
  import signal
30
42
  import sys
43
+ import threading
31
44
  import time
32
45
  from dataclasses import dataclass
46
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
33
47
  from pathlib import Path
34
48
  from typing import Optional
35
49
 
@@ -46,6 +60,9 @@ DEFAULT_VIDEO_HEIGHT = 480
46
60
  DEFAULT_FPS = 15
47
61
  DEFAULT_POLL_INTERVAL = 3.0
48
62
  DEFAULT_HEARTBEAT_INTERVAL = 30.0
63
+ DEFAULT_VISION_WEBHOOK_PORT = 5057
64
+ DEFAULT_VISION_TRIGGER_COOLDOWN = 20.0
65
+ DEFAULT_VISION_SESSION_SECONDS = 90.0
49
66
 
50
67
 
51
68
  def _load_config() -> dict:
@@ -120,7 +137,7 @@ async def _send_heartbeat(scheduler_url: str, device_id: str, token: str) -> Non
120
137
  async with httpx.AsyncClient() as client:
121
138
  await client.post(
122
139
  f"{scheduler_url.rstrip('/')}/api/devices/{device_id}/heartbeat",
123
- json={"status": "online"},
140
+ json={"status": "online", "ip": _get_lan_ip()},
124
141
  headers={"Authorization": f"Bearer {token}"},
125
142
  timeout=10.0,
126
143
  )
@@ -150,12 +167,19 @@ class PreviewAgent:
150
167
  self.fps = int(cfg.get("video_fps", os.getenv("VIDEO_FPS", DEFAULT_FPS)))
151
168
  self.poll_interval = float(cfg.get("poll_interval", os.getenv("POLL_INTERVAL", DEFAULT_POLL_INTERVAL)))
152
169
  self.heartbeat_interval = float(cfg.get("heartbeat_interval", os.getenv("HEARTBEAT_INTERVAL", DEFAULT_HEARTBEAT_INTERVAL)))
170
+ self.vision_webhook_port = int(cfg.get("vision_webhook_port", os.getenv("VISION_WEBHOOK_PORT", DEFAULT_VISION_WEBHOOK_PORT)))
171
+ self.vision_trigger_cooldown = float(cfg.get("vision_trigger_cooldown", os.getenv("VISION_TRIGGER_COOLDOWN", DEFAULT_VISION_TRIGGER_COOLDOWN)))
172
+ self.vision_session_seconds = float(cfg.get("vision_session_seconds", os.getenv("VISION_SESSION_SECONDS", DEFAULT_VISION_SESSION_SECONDS)))
153
173
 
154
174
  self._shutdown = asyncio.Event()
155
175
  self._task: Optional[asyncio.Task] = None
156
176
  self._session: Optional[httpx.AsyncClient] = None
157
177
  self._current_state: PreviewState = PreviewState()
158
178
  self._publisher: Optional["LiveKitPublisher"] = None
179
+ self._loop: Optional[asyncio.AbstractEventLoop] = None
180
+ self._vision_server: Optional[ThreadingHTTPServer] = None
181
+ self._last_vision_trigger: float = 0.0
182
+ self._vision_active_until: float = 0.0
159
183
 
160
184
  async def run(self) -> None:
161
185
  enrollment_token = os.getenv("ENROLLMENT_TOKEN") or self.enrollment_token
@@ -170,6 +194,8 @@ class PreviewAgent:
170
194
  await self._resolve_device_id()
171
195
 
172
196
  self._session = httpx.AsyncClient()
197
+ self._loop = asyncio.get_running_loop()
198
+ self._start_vision_webhook_server()
173
199
 
174
200
  tasks = [
175
201
  asyncio.create_task(self._poll_loop()),
@@ -182,6 +208,8 @@ class PreviewAgent:
182
208
  await t
183
209
  except asyncio.CancelledError:
184
210
  pass
211
+ if self._vision_server:
212
+ self._vision_server.shutdown()
185
213
  await self._stop_publisher()
186
214
  await self._session.aclose()
187
215
 
@@ -209,8 +237,17 @@ class PreviewAgent:
209
237
  async def _poll_loop(self) -> None:
210
238
  while not self._shutdown.is_set():
211
239
  try:
212
- state = await self._fetch_preview_state()
213
- await self._apply_state(state)
240
+ if time.time() >= self._vision_active_until:
241
+ # A motion-triggered session (if any) has ended its lease —
242
+ # let the operator-preview poll reconcile the publisher again.
243
+ if self._vision_active_until:
244
+ self._vision_active_until = 0.0
245
+ await self._stop_publisher()
246
+ self._current_state = PreviewState()
247
+ state = await self._fetch_preview_state()
248
+ await self._apply_state(state)
249
+ # else: a vision-triggered session currently owns the publisher —
250
+ # don't let the (unrelated) operator-preview state tear it down.
214
251
  except Exception as e:
215
252
  logger.warning(f"poll error: {e}")
216
253
  try:
@@ -281,6 +318,92 @@ class PreviewAgent:
281
318
  logger.warning(f"publisher stop error: {e}")
282
319
  self._publisher = None
283
320
 
321
+ # ---- vision (RoboVisionAI_PI motion webhook) -> presence-triggered session ----
322
+
323
+ def _start_vision_webhook_server(self) -> None:
324
+ if not self.vision_webhook_port:
325
+ return
326
+ agent = self
327
+
328
+ class Handler(BaseHTTPRequestHandler):
329
+ def log_message(self, fmt, *a): # noqa: A002 — quiet by default
330
+ logger.debug("vision webhook: " + fmt, *a)
331
+
332
+ def do_POST(self): # noqa: N802 — http.server's required method name
333
+ try:
334
+ length = int(self.headers.get("Content-Length", 0))
335
+ body = self.rfile.read(length) if length else b"{}"
336
+ payload = json.loads(body or b"{}")
337
+ except Exception as e:
338
+ self.send_response(400)
339
+ self.end_headers()
340
+ self.wfile.write(str(e).encode())
341
+ return
342
+ self.send_response(200)
343
+ self.end_headers()
344
+ self.wfile.write(b'{"ok":true}')
345
+ if agent._loop:
346
+ asyncio.run_coroutine_threadsafe(agent._on_vision_motion(payload), agent._loop)
347
+
348
+ def do_GET(self): # noqa: N802
349
+ self.send_response(200)
350
+ self.end_headers()
351
+ self.wfile.write(b'{"status":"ok","listening_for":"robovision motion webhook"}')
352
+
353
+ try:
354
+ self._vision_server = ThreadingHTTPServer(("0.0.0.0", self.vision_webhook_port), Handler)
355
+ thread = threading.Thread(target=self._vision_server.serve_forever, daemon=True)
356
+ thread.start()
357
+ logger.info(
358
+ f"vision webhook listening on :{self.vision_webhook_port} — "
359
+ f"point RoboVisionAI_PI's POST /api/motion/webhook at "
360
+ f"http://<this-host>:{self.vision_webhook_port}/"
361
+ )
362
+ except Exception as e:
363
+ logger.error(f"could not start vision webhook server: {e}")
364
+ self._vision_server = None
365
+
366
+ async def _on_vision_motion(self, payload: dict) -> None:
367
+ now = time.time()
368
+ if now - self._last_vision_trigger < self.vision_trigger_cooldown:
369
+ logger.debug("vision trigger suppressed (cooldown)")
370
+ return
371
+ self._last_vision_trigger = now
372
+ if not self.device_id or not self.device_token or not self._session:
373
+ logger.warning("vision motion event received but not enrolled yet — ignoring")
374
+ return
375
+ logger.info("motion detected by RoboVisionAI_PI — requesting a session")
376
+ try:
377
+ r = await self._session.post(
378
+ f"{self.scheduler_url.rstrip('/')}/api/devices/{self.device_id}/request-session",
379
+ headers={"Authorization": f"Bearer {self.device_token}"},
380
+ timeout=10.0,
381
+ )
382
+ r.raise_for_status()
383
+ data = r.json()
384
+ except Exception as e:
385
+ logger.error(f"request-session failed: {e}")
386
+ return
387
+ state = PreviewState(
388
+ active=True,
389
+ url=data.get("server_url"),
390
+ token=data.get("token"),
391
+ room=data.get("room_name"),
392
+ )
393
+ self._vision_active_until = time.time() + self.vision_session_seconds
394
+ self._current_state = state
395
+ await self._start_publisher(state)
396
+ session_id = data.get("session_id")
397
+ if session_id and self._session:
398
+ try:
399
+ await self._session.post(
400
+ f"{self.scheduler_url.rstrip('/')}/api/sessions/{session_id}/joined",
401
+ headers={"Authorization": f"Bearer {self.device_token}"},
402
+ timeout=10.0,
403
+ )
404
+ except Exception as e:
405
+ logger.debug(f"could not mark session joined: {e}")
406
+
284
407
  def shutdown(self) -> None:
285
408
  self._shutdown.set()
286
409
 
@@ -592,6 +715,9 @@ def main() -> None:
592
715
  parser.add_argument("--fps", type=int, default=int(os.getenv("VIDEO_FPS", DEFAULT_FPS)))
593
716
  parser.add_argument("--poll-interval", type=float, default=float(os.getenv("POLL_INTERVAL", DEFAULT_POLL_INTERVAL)))
594
717
  parser.add_argument("--heartbeat-interval", type=float, default=float(os.getenv("HEARTBEAT_INTERVAL", DEFAULT_HEARTBEAT_INTERVAL)))
718
+ parser.add_argument("--vision-webhook-port", type=int, default=int(os.getenv("VISION_WEBHOOK_PORT", DEFAULT_VISION_WEBHOOK_PORT)), help="local port for RoboVisionAI_PI's motion webhook (0 disables)")
719
+ parser.add_argument("--vision-trigger-cooldown", type=float, default=float(os.getenv("VISION_TRIGGER_COOLDOWN", DEFAULT_VISION_TRIGGER_COOLDOWN)))
720
+ parser.add_argument("--vision-session-seconds", type=float, default=float(os.getenv("VISION_SESSION_SECONDS", DEFAULT_VISION_SESSION_SECONDS)))
595
721
  parser.add_argument("--save-config", action="store_true", help="write CLI args to ~/.robopark/preview_agent.json")
596
722
  parser.add_argument("-v", "--verbose", action="store_true")
597
723
  args = parser.parse_args()
@@ -612,6 +738,9 @@ def main() -> None:
612
738
  "video_fps": args.fps,
613
739
  "poll_interval": args.poll_interval,
614
740
  "heartbeat_interval": args.heartbeat_interval,
741
+ "vision_webhook_port": args.vision_webhook_port,
742
+ "vision_trigger_cooldown": args.vision_trigger_cooldown,
743
+ "vision_session_seconds": args.vision_session_seconds,
615
744
  })
616
745
  if args.device_token:
617
746
  cfg["device_token"] = args.device_token
@@ -0,0 +1,7 @@
1
+ GROQ_API_KEY=
2
+ TRANSCRIPTION_ENABLED=true
3
+ WHISPER_MODEL=whisper-large-v3
4
+ AUTO_DETECT_LANGUAGE=true
5
+ BLUETOOTH_DEVICE_NAME=
6
+ WEBHOOK_AUDIO_DETECTED=
7
+ WEBHOOK_NO_AUDIO=
@@ -0,0 +1,63 @@
1
+ # RoboVision — camera/motion detection (robot-side)
2
+
3
+ Runs on the robot. A Flask server (`app_pi_clean.py`, port 5000 by default)
4
+ that:
5
+
6
+ - Streams the camera at `/video_feed` (MJPEG, with OpenCV-drawn bounding
7
+ boxes/labels baked into the frame) — this is what the Park dashboard's
8
+ "Show overlay" button in the camera drawer points at directly.
9
+ - Runs OpenCV DNN object detection (MobileNet SSD) on every frame —
10
+ `/api/detections` for the latest results.
11
+ - Runs frame-diff motion detection, toggled via `POST /api/motion/toggle
12
+ {"active": true}` — when motion is detected it POSTs a JPEG snapshot to a
13
+ configurable webhook (`POST /api/motion/webhook {"url": "..."}`).
14
+
15
+ ## Wiring it to an actual session (the production trigger)
16
+
17
+ RoboVision only *detects*; it doesn't itself start a robot session. The
18
+ scheduler's `preview_agent.py` (in `../scheduler/`) now runs a small built-in
19
+ listener for exactly this webhook and turns a motion event into a real,
20
+ presence-triggered session (`POST /api/devices/{id}/request-session`),
21
+ publishing camera + mic into the resulting LiveKit room. Point RoboVision at
22
+ it:
23
+
24
+ ```
25
+ curl -X POST http://localhost:8080/api/motion/webhook \
26
+ -H "Content-Type: application/json" \
27
+ -d '{"url": "http://localhost:5057/"}'
28
+ curl -X POST http://localhost:8080/api/motion/toggle \
29
+ -H "Content-Type: application/json" -d '{"active": true}'
30
+ ```
31
+
32
+ (`5057` is `preview_agent.py`'s default `--vision-webhook-port`; override with
33
+ `--vision-webhook-port` if RoboVision runs on a different host than the
34
+ preview agent.)
35
+
36
+ ## Required model files (NOT bundled)
37
+
38
+ `app_pi_clean.py` needs two Caffe model files that are **not shipped** in this
39
+ npm package (the weights are ~23MB and would bloat every `infinicode`/
40
+ `robopark` install, including installs that never touch RoboPark):
41
+
42
+ ```
43
+ deploy.prototxt
44
+ mobilenet_iter_73000.caffemodel
45
+ ```
46
+
47
+ Download the standard MobileNet-SSD (VOC) weights (e.g. from
48
+ chuanqi305/MobileNet-SSD) into this directory before running. Without them,
49
+ `app_pi_clean.py` still runs — object detection is silently disabled, motion
50
+ detection still works.
51
+
52
+ ## Running
53
+
54
+ Pure Python + OpenCV (Flask, cv2, numpy) — cross-platform, no bash required:
55
+
56
+ ```
57
+ pip install -r requirements_pi_unified.txt
58
+ python app_pi_clean.py
59
+ ```
60
+
61
+ `install.sh`/`run.sh` are Linux/Pi convenience wrappers (venv + systemd-style
62
+ process management) — not required on Windows/macOS, just run the script
63
+ directly.
@@ -0,0 +1,307 @@
1
+ from flask import Flask, Response, jsonify, request
2
+ from flask_cors import CORS
3
+ import cv2
4
+ import time
5
+ import numpy as np
6
+ import threading
7
+ import requests
8
+ import base64
9
+
10
+ app = Flask(__name__)
11
+ CORS(app)
12
+
13
+ # Camera management
14
+ current_camera_index = 0
15
+ camera = None
16
+
17
+ def get_camera():
18
+ global camera, current_camera_index
19
+ if camera is None or not camera.isOpened():
20
+ camera = cv2.VideoCapture(current_camera_index)
21
+ camera.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
22
+ camera.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
23
+ print(f"Camera {current_camera_index} initialized")
24
+ return camera
25
+
26
+ # Global variables
27
+ latest_detections = []
28
+ lock = threading.Lock()
29
+ caption_mode_enabled = False
30
+ motion_detection_active = False
31
+ motion_detected_state = False
32
+ last_motion_time = 0
33
+ motion_frame_buffer = None
34
+ webhook_url = None
35
+ last_webhook_send_time = 0
36
+ webhook_send_interval = 0.5
37
+
38
+ # Simple object detection using OpenCV DNN (MobileNet SSD)
39
+ try:
40
+ # Load pre-trained MobileNet SSD model
41
+ net = cv2.dnn.readNetFromCaffe(
42
+ 'deploy.prototxt',
43
+ 'mobilenet_iter_73000.caffemodel'
44
+ )
45
+ DETECTION_AVAILABLE = True
46
+ print("Object detection model loaded")
47
+ except:
48
+ DETECTION_AVAILABLE = False
49
+ print("Object detection model not found - running without detection")
50
+
51
+ # COCO class labels
52
+ CLASSES = ["background", "aeroplane", "bicycle", "bird", "boat",
53
+ "bottle", "bus", "car", "cat", "chair", "cow", "diningtable",
54
+ "dog", "horse", "motorbike", "person", "pottedplant", "sheep",
55
+ "sofa", "train", "tvmonitor"]
56
+
57
+ def detect_objects(frame, conf_threshold=0.5):
58
+ """Detect objects using OpenCV DNN"""
59
+ if not DETECTION_AVAILABLE:
60
+ return frame, []
61
+
62
+ (h, w) = frame.shape[:2]
63
+ blob = cv2.dnn.blobFromImage(cv2.resize(frame, (300, 300)), 0.007843, (300, 300), 127.5)
64
+ net.setInput(blob)
65
+ detections_dnn = net.forward()
66
+
67
+ detected_objects = []
68
+
69
+ for i in range(detections_dnn.shape[2]):
70
+ confidence = detections_dnn[0, 0, i, 2]
71
+
72
+ if confidence > conf_threshold:
73
+ idx = int(detections_dnn[0, 0, i, 1])
74
+ if idx >= len(CLASSES):
75
+ continue
76
+
77
+ box = detections_dnn[0, 0, i, 3:7] * np.array([w, h, w, h])
78
+ (startX, startY, endX, endY) = box.astype("int")
79
+
80
+ label = CLASSES[idx]
81
+
82
+ # Draw bounding box
83
+ cv2.rectangle(frame, (startX, startY), (endX, endY), (0, 255, 0), 2)
84
+
85
+ # Draw label with confidence
86
+ text = f"{label}: {confidence*100:.1f}%"
87
+ y = startY - 15 if startY - 15 > 15 else startY + 15
88
+ cv2.putText(frame, text, (startX, y), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 2)
89
+
90
+ detected_objects.append({
91
+ "label": label,
92
+ "confidence": float(confidence),
93
+ "bbox": [int(startX), int(startY), int(endX), int(endY)],
94
+ "is_focus": False
95
+ })
96
+
97
+ return frame, detected_objects
98
+
99
+ def send_webhook(frame_data):
100
+ """Send frame to webhook URL"""
101
+ global webhook_url, last_webhook_send_time
102
+
103
+ if not webhook_url:
104
+ return
105
+
106
+ current_time = time.time()
107
+ if current_time - last_webhook_send_time < webhook_send_interval:
108
+ return
109
+
110
+ try:
111
+ _, buffer = cv2.imencode('.jpg', frame_data)
112
+ img_base64 = base64.b64encode(buffer).decode('utf-8')
113
+
114
+ payload = {
115
+ 'timestamp': time.strftime('%Y-%m-%dT%H:%M:%S'),
116
+ 'image': img_base64,
117
+ 'format': 'jpeg',
118
+ 'encoding': 'base64'
119
+ }
120
+
121
+ def send_async():
122
+ try:
123
+ response = requests.post(webhook_url, json=payload, headers={'Content-Type': 'application/json'}, timeout=5)
124
+ if response.status_code == 200:
125
+ print(f"Webhook sent successfully")
126
+ except Exception as e:
127
+ print(f"Webhook error: {e}")
128
+
129
+ thread = threading.Thread(target=send_async, daemon=True)
130
+ thread.start()
131
+ last_webhook_send_time = current_time
132
+ except Exception as e:
133
+ print(f"Error preparing webhook: {e}")
134
+
135
+ def generate_frames():
136
+ global latest_detections, motion_detection_active, motion_detected_state
137
+ global last_motion_time, motion_frame_buffer
138
+
139
+ prev_gray = None
140
+
141
+ while True:
142
+ cam = get_camera()
143
+ if cam is None or not cam.isOpened():
144
+ time.sleep(1)
145
+ continue
146
+
147
+ success, frame = cam.read()
148
+ if not success:
149
+ time.sleep(0.1)
150
+ continue
151
+
152
+ # Run object detection
153
+ processed_frame, detections = detect_objects(frame, conf_threshold=0.5)
154
+
155
+ with lock:
156
+ latest_detections = detections
157
+
158
+ # Motion detection logic
159
+ if motion_detection_active:
160
+ gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
161
+ gray = cv2.GaussianBlur(gray, (21, 21), 0)
162
+
163
+ if prev_gray is not None:
164
+ frame_delta = cv2.absdiff(prev_gray, gray)
165
+ thresh = cv2.threshold(frame_delta, 25, 255, cv2.THRESH_BINARY)[1]
166
+ thresh = cv2.dilate(thresh, None, iterations=2)
167
+ contours, _ = cv2.findContours(thresh.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
168
+
169
+ motion_detected = False
170
+ for contour in contours:
171
+ if cv2.contourArea(contour) >= 500:
172
+ motion_detected = True
173
+ (x, y, w, h) = cv2.boundingRect(contour)
174
+ cv2.rectangle(processed_frame, (x, y), (x + w, y + h), (0, 0, 255), 2)
175
+ break
176
+
177
+ if motion_detected:
178
+ motion_detected_state = True
179
+ last_motion_time = time.time()
180
+ motion_frame_buffer = processed_frame.copy()
181
+ print(f"Motion detected!")
182
+ send_webhook(processed_frame)
183
+ else:
184
+ motion_detected_state = False
185
+
186
+ prev_gray = gray
187
+
188
+ ret, buffer = cv2.imencode('.jpg', processed_frame, [cv2.IMWRITE_JPEG_QUALITY, 80])
189
+ frame_bytes = buffer.tobytes()
190
+
191
+ yield (b'--frame\r\n'
192
+ b'Content-Type: image/jpeg\r\n\r\n' + frame_bytes + b'\r\n')
193
+
194
+ @app.route('/')
195
+ def index():
196
+ return jsonify({"status": "ok", "message": "RoboVision Pi Server", "version": "1.0"})
197
+
198
+ @app.route('/video_feed')
199
+ def video_feed():
200
+ return Response(generate_frames(), mimetype='multipart/x-mixed-replace; boundary=frame')
201
+
202
+ @app.route('/api/detections')
203
+ def get_detections():
204
+ with lock:
205
+ return jsonify(latest_detections)
206
+
207
+ @app.route('/api/caption')
208
+ def get_caption():
209
+ return jsonify({"caption": "Awaiting caption..."})
210
+
211
+ @app.route('/api/caption_mode', methods=['GET', 'POST'])
212
+ def caption_mode():
213
+ global caption_mode_enabled
214
+ if request.method == 'GET':
215
+ return jsonify({"enabled": caption_mode_enabled})
216
+ data = request.json or {}
217
+ caption_mode_enabled = bool(data.get('enabled', False))
218
+ return jsonify({"enabled": caption_mode_enabled})
219
+
220
+ @app.route('/api/cameras', methods=['GET'])
221
+ def list_cameras():
222
+ available = []
223
+ for i in range(4):
224
+ cap = cv2.VideoCapture(i)
225
+ if cap.isOpened():
226
+ available.append({"index": i, "name": f"Camera {i}"})
227
+ cap.release()
228
+ return jsonify({"cameras": available, "current": current_camera_index})
229
+
230
+ @app.route('/api/camera/switch', methods=['POST'])
231
+ def switch_camera():
232
+ global camera, current_camera_index
233
+ data = request.json or {}
234
+ new_index = int(data.get('index', 0))
235
+
236
+ if camera:
237
+ camera.release()
238
+
239
+ current_camera_index = new_index
240
+ camera = None
241
+
242
+ return jsonify({"status": "ok", "camera_index": current_camera_index})
243
+
244
+ @app.route('/api/motion/status', methods=['GET'])
245
+ def motion_status():
246
+ global motion_detection_active, motion_detected_state, last_motion_time
247
+ return jsonify({
248
+ "active": motion_detection_active,
249
+ "motion_detected": motion_detected_state,
250
+ "last_motion": last_motion_time,
251
+ "time_since_motion": time.time() - last_motion_time if last_motion_time > 0 else None
252
+ })
253
+
254
+ @app.route('/api/motion/toggle', methods=['POST'])
255
+ def motion_toggle():
256
+ global motion_detection_active
257
+ data = request.json or {}
258
+ motion_detection_active = bool(data.get('active', False))
259
+ return jsonify({"status": "success", "active": motion_detection_active})
260
+
261
+ @app.route('/api/motion/snapshot', methods=['GET'])
262
+ def motion_snapshot():
263
+ global motion_frame_buffer
264
+ if motion_frame_buffer is not None:
265
+ _, buffer = cv2.imencode('.jpg', motion_frame_buffer, [cv2.IMWRITE_JPEG_QUALITY, 85])
266
+ img_base64 = base64.b64encode(buffer).decode('utf-8')
267
+ return jsonify({
268
+ "image": img_base64,
269
+ "timestamp": time.time()
270
+ })
271
+ return jsonify({"error": "No frame available"}), 404
272
+
273
+ @app.route('/api/motion/webhook', methods=['GET', 'POST'])
274
+ def motion_webhook():
275
+ global webhook_url
276
+
277
+ if request.method == 'GET':
278
+ return jsonify({
279
+ "webhook_url": webhook_url or "",
280
+ "configured": webhook_url is not None and len(webhook_url) > 0
281
+ })
282
+
283
+ data = request.json or {}
284
+ new_url = data.get('url', '').strip()
285
+
286
+ if new_url:
287
+ webhook_url = new_url
288
+ return jsonify({
289
+ "status": "success",
290
+ "message": "Webhook URL configured",
291
+ "webhook_url": webhook_url
292
+ })
293
+ else:
294
+ webhook_url = None
295
+ return jsonify({
296
+ "status": "success",
297
+ "message": "Webhook URL cleared",
298
+ "webhook_url": None
299
+ })
300
+
301
+ if __name__ == '__main__':
302
+ print("=" * 60)
303
+ print("RoboVision - Raspberry Pi Vision Server (Minimal)")
304
+ print("=" * 60)
305
+ print("Starting Flask server on http://0.0.0.0:5000")
306
+ print("=" * 60)
307
+ app.run(host='0.0.0.0', port=5000, debug=False, threaded=True)