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.
- package/dist/kernel/federation/dashboard-html.d.ts +1 -1
- package/dist/kernel/federation/dashboard-html.js +32 -1
- package/dist/robopark/auto-start.d.ts +1 -1
- package/dist/robopark/preview-agent-launcher.d.ts +3 -0
- package/dist/robopark/preview-agent-launcher.js +8 -0
- package/dist/robopark/python-env.d.ts +10 -4
- package/dist/robopark/python-env.js +28 -16
- package/dist/robopark/setup.d.ts +1 -0
- package/dist/robopark/setup.js +22 -0
- package/dist/robopark/vision-agent-launcher.d.ts +7 -0
- package/dist/robopark/vision-agent-launcher.js +55 -0
- package/dist/robopark-cli.js +16 -0
- package/package.json +3 -1
- package/packages/robopark/pi-client/README.md +17 -0
- package/packages/robopark/pi-client/_install_steps.sh +29 -0
- package/packages/robopark/pi-client/client.py +305 -0
- package/packages/robopark/pi-client/install.sh +41 -0
- package/packages/robopark/pi-client/join_convo.sh +55 -0
- package/packages/robopark/pi-client/livekit_bridge.py +289 -0
- package/packages/robopark/pi-client/motor_bridge.py +82 -0
- package/packages/robopark/pi-client/pi_ui.py +375 -0
- package/packages/robopark/pi-client/requirements.txt +10 -0
- package/packages/robopark/pi-client/robopark-pi-client.service +26 -0
- package/packages/robopark/pi-client/robopark-pi-ui.service +24 -0
- package/packages/robopark/pi-client/smoke_bridge.py +15 -0
- package/packages/robopark/pi-client/stub_motor_server.py +46 -0
- package/packages/robopark/scheduler/main.py +45 -9
- package/packages/robopark/scheduler/preview_agent.py +132 -3
- package/packages/robopark/vision/.env.example +7 -0
- package/packages/robopark/vision/README.md +66 -0
- package/packages/robopark/vision/app_pi_clean.py +326 -0
- package/packages/robopark/vision/audio_server_pi.py +751 -0
- package/packages/robopark/vision/install.sh +34 -0
- package/packages/robopark/vision/motor_server.py +304 -0
- package/packages/robopark/vision/requirements-demo.txt +12 -0
- package/packages/robopark/vision/requirements_pi_unified.txt +101 -0
- package/packages/robopark/vision/run.sh +244 -0
- 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"
|
|
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.
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
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
|
-
|
|
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
|
-
|
|
213
|
-
|
|
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,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.
|