infinicode 2.8.13 → 2.8.16

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 (38) hide show
  1. package/dist/kernel/federation/dashboard-html.d.ts +1 -1
  2. package/dist/kernel/federation/dashboard-html.js +32 -1
  3. package/dist/robopark/auto-start.d.ts +1 -1
  4. package/dist/robopark/preview-agent-launcher.d.ts +3 -0
  5. package/dist/robopark/preview-agent-launcher.js +8 -0
  6. package/dist/robopark/python-env.d.ts +10 -4
  7. package/dist/robopark/python-env.js +28 -16
  8. package/dist/robopark/setup.d.ts +1 -0
  9. package/dist/robopark/setup.js +22 -0
  10. package/dist/robopark/vision-agent-launcher.d.ts +7 -0
  11. package/dist/robopark/vision-agent-launcher.js +55 -0
  12. package/dist/robopark-cli.js +16 -0
  13. package/package.json +3 -1
  14. package/packages/robopark/pi-client/README.md +17 -0
  15. package/packages/robopark/pi-client/_install_steps.sh +29 -0
  16. package/packages/robopark/pi-client/client.py +305 -0
  17. package/packages/robopark/pi-client/install.sh +41 -0
  18. package/packages/robopark/pi-client/join_convo.sh +55 -0
  19. package/packages/robopark/pi-client/livekit_bridge.py +289 -0
  20. package/packages/robopark/pi-client/motor_bridge.py +82 -0
  21. package/packages/robopark/pi-client/pi_ui.py +375 -0
  22. package/packages/robopark/pi-client/requirements.txt +10 -0
  23. package/packages/robopark/pi-client/robopark-pi-client.service +26 -0
  24. package/packages/robopark/pi-client/robopark-pi-ui.service +24 -0
  25. package/packages/robopark/pi-client/smoke_bridge.py +15 -0
  26. package/packages/robopark/pi-client/stub_motor_server.py +46 -0
  27. package/packages/robopark/scheduler/main.py +45 -9
  28. package/packages/robopark/scheduler/preview_agent.py +132 -3
  29. package/packages/robopark/vision/.env.example +7 -0
  30. package/packages/robopark/vision/README.md +66 -0
  31. package/packages/robopark/vision/app_pi_clean.py +326 -0
  32. package/packages/robopark/vision/audio_server_pi.py +751 -0
  33. package/packages/robopark/vision/install.sh +34 -0
  34. package/packages/robopark/vision/motor_server.py +304 -0
  35. package/packages/robopark/vision/requirements-demo.txt +12 -0
  36. package/packages/robopark/vision/requirements_pi_unified.txt +101 -0
  37. package/packages/robopark/vision/run.sh +244 -0
  38. package/packages/robopark/vision/services/services.sh +12 -0
@@ -505,9 +505,11 @@ async def request_session(robot_id: str,
505
505
  if server["active"] >= server["max_sessions"]:
506
506
  raise HTTPException(503, "All servers at capacity")
507
507
 
508
- # Create session
508
+ # Create session. Room name MUST start with "robopark-" — that's the
509
+ # literal prefix voice_agent.py checks to enable production-mode
510
+ # (character/voice/motor resolution, unlimited idle timeout).
509
511
  session_id = f"session_{robot_id}_{int(datetime.utcnow().timestamp())}"
510
- room_name = f"robopark_{robot_id}_{int(datetime.utcnow().timestamp())}"
512
+ room_name = f"robopark-{robot_id}-{int(datetime.utcnow().timestamp())}"
511
513
 
512
514
  await db.execute("""
513
515
  INSERT INTO sessions (id, robot_id, server_id, room_name, started_at)
@@ -1370,6 +1372,7 @@ async def robot_stream(robot_id: str,
1370
1372
  # cam. Cam therefore runs ONLY while an operator is watching.
1371
1373
 
1372
1374
  PREVIEW_TTL_SECONDS = 45 # preview auto-expires this long after the last keepalive
1375
+ DEFAULT_VISION_PORT = 5000 # RoboVisionAI_PI's default Flask port (/video_feed, /api/motion/*)
1373
1376
 
1374
1377
  async def _pick_preview_server(db, prefer_id: Optional[str] = None):
1375
1378
  """Return a LiveKit server row for a preview: a preferred one if still present,
@@ -1518,12 +1521,29 @@ async def list_devices():
1518
1521
 
1519
1522
  @app.get("/api/devices/by-room/{room_name}")
1520
1523
  async def get_device_by_room(room_name: str):
1521
- """Look up the device bound to a LiveKit room. Convention: room = 'robopark-<device_id>'."""
1522
- if not room_name.startswith("robopark-"):
1523
- raise HTTPException(404, "Room is not a robopark room")
1524
- device_id = room_name[len("robopark-"):]
1524
+ """Look up the device bound to a LiveKit room.
1525
+
1526
+ Resolves via the sessions table (robust to any room-name format the
1527
+ scheduler generates — request-session's robopark-<id>-<ts>, etc.) rather
1528
+ than parsing the name, since a plain prefix-strip broke the moment
1529
+ request-session started including a trailing timestamp. Falls back to a
1530
+ prefix-strip for the standing-room convention (robopark-<device_id>, no
1531
+ trailing timestamp, no sessions row — e.g. robopark-pi-client's always-on
1532
+ room when production_mode is on)."""
1533
+ device_id = None
1525
1534
  async with aiosqlite.connect(DB_PATH) as db:
1526
1535
  db.row_factory = aiosqlite.Row
1536
+ async with db.execute(
1537
+ "SELECT robot_id FROM sessions WHERE room_name = ? ORDER BY started_at DESC LIMIT 1",
1538
+ (room_name,),
1539
+ ) as c:
1540
+ session = await c.fetchone()
1541
+ if session:
1542
+ device_id = session["robot_id"]
1543
+ elif room_name.startswith("robopark-"):
1544
+ device_id = room_name[len("robopark-"):]
1545
+ if not device_id:
1546
+ raise HTTPException(404, "No device bound to this room")
1527
1547
  async with db.execute("SELECT * FROM devices WHERE id = ?", (device_id,)) as c:
1528
1548
  row = await c.fetchone()
1529
1549
  if not row:
@@ -1889,7 +1909,11 @@ async def device_request_session(device_id: str,
1889
1909
 
1890
1910
  ts = int(datetime.utcnow().timestamp())
1891
1911
  session_id = f"session_{device_id}_{ts}"
1892
- room_name = f"robopark_{device_id}_{ts}"
1912
+ # Room name MUST start with "robopark-" — the literal prefix
1913
+ # voice_agent.py checks to enable production-mode (character/voice/
1914
+ # motor resolution via GET /api/devices/by-room, unlimited idle
1915
+ # timeout instead of the 30s default).
1916
+ room_name = f"robopark-{device_id}-{ts}"
1893
1917
 
1894
1918
  await db.execute("""
1895
1919
  INSERT INTO sessions (id, robot_id, server_id, room_name, started_at)
@@ -2027,13 +2051,22 @@ async def _robot_telemetry(db, robot_id: str) -> Optional[dict]:
2027
2051
  Returns trigger_count, sessions_total, avg_latency_ms, drop_rate, drops and
2028
2052
  an end_reason breakdown; None if the robot does not exist."""
2029
2053
  async with db.execute(
2030
- "SELECT trigger_count FROM robots WHERE id = ?", (robot_id,)
2054
+ "SELECT trigger_count, name FROM robots WHERE id = ?", (robot_id,)
2031
2055
  ) as c:
2032
2056
  robot = await c.fetchone()
2033
2057
  if not robot:
2034
2058
  return None
2035
2059
  trigger_count = robot["trigger_count"] or 0
2036
2060
 
2061
+ # Pull the device's freshest known LAN address (heartbeat > enrollment) so
2062
+ # the dashboard can reach the robot directly for e.g. the vision overlay
2063
+ # feed, which the scheduler doesn't proxy.
2064
+ async with db.execute(
2065
+ "SELECT last_seen_ip, lan_ip FROM devices WHERE id = ?", (robot_id,)
2066
+ ) as c:
2067
+ dev = await c.fetchone()
2068
+ lan_ip = (dev["last_seen_ip"] or dev["lan_ip"]) if dev else None
2069
+
2037
2070
  # Average latency over sessions that recorded a room join.
2038
2071
  async with db.execute(
2039
2072
  "SELECT started_at, joined_at FROM sessions WHERE robot_id = ? AND joined_at IS NOT NULL",
@@ -2066,6 +2099,9 @@ async def _robot_telemetry(db, robot_id: str) -> Optional[dict]:
2066
2099
 
2067
2100
  return {
2068
2101
  "robot_id": robot_id,
2102
+ "name": robot["name"],
2103
+ "lan_ip": lan_ip,
2104
+ "vision_port": DEFAULT_VISION_PORT,
2069
2105
  "trigger_count": trigger_count,
2070
2106
  "sessions_total": sessions_total,
2071
2107
  "avg_latency_ms": avg_latency_ms,
@@ -2822,7 +2858,7 @@ async def dashboard():
2822
2858
  <video id="preview-video" class="w-full" autoplay playsinline muted></video>
2823
2859
  <div class="text-xs text-gray-400 px-2 py-1">{{ preview.room }}</div>
2824
2860
  </div>
2825
- </div>
2861
+ </div>
2826
2862
 
2827
2863
  <!-- Servers Panel -->
2828
2864
  <div class="col-span-5 space-y-4">
@@ -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,66 @@
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
+ Both of these calls go to RoboVision's own server (port 5000 by default —
25
+ NOT the scheduler on 8080):
26
+
27
+ ```
28
+ curl -X POST http://localhost:5000/api/motion/webhook \
29
+ -H "Content-Type: application/json" \
30
+ -d '{"url": "http://localhost:5057/"}'
31
+ curl -X POST http://localhost:5000/api/motion/toggle \
32
+ -H "Content-Type: application/json" -d '{"active": true}'
33
+ ```
34
+
35
+ (`5057` is `preview_agent.py`'s default `--vision-webhook-port`; override with
36
+ `--vision-webhook-port` if RoboVision runs on a different host than the
37
+ preview agent.)
38
+
39
+ ## Required model files (NOT bundled)
40
+
41
+ `app_pi_clean.py` needs two Caffe model files that are **not shipped** in this
42
+ npm package (the weights are ~23MB and would bloat every `infinicode`/
43
+ `robopark` install, including installs that never touch RoboPark):
44
+
45
+ ```
46
+ deploy.prototxt
47
+ mobilenet_iter_73000.caffemodel
48
+ ```
49
+
50
+ Download the standard MobileNet-SSD (VOC) weights (e.g. from
51
+ chuanqi305/MobileNet-SSD) into this directory before running. Without them,
52
+ `app_pi_clean.py` still runs — object detection is silently disabled, motion
53
+ detection still works.
54
+
55
+ ## Running
56
+
57
+ Pure Python + OpenCV (Flask, cv2, numpy) — cross-platform, no bash required:
58
+
59
+ ```
60
+ pip install -r requirements_pi_unified.txt
61
+ python app_pi_clean.py
62
+ ```
63
+
64
+ `install.sh`/`run.sh` are Linux/Pi convenience wrappers (venv + systemd-style
65
+ process management) — not required on Windows/macOS, just run the script
66
+ directly.