infinicode 2.8.35 → 2.8.37

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.
@@ -0,0 +1,320 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ RoboPark Robot Supervisor — runs on each robot/satellite Pi (or a Windows
4
+ test rig), keeping every robot-side service alive without a human watching
5
+ a terminal.
6
+
7
+ Problem this solves: a real robot needs several independent processes —
8
+ preview_agent.py (LiveKit publish + heartbeat + motion webhook), optionally
9
+ a vision/motion-detection app (RoboVisionAI_PI's app_pi_clean.py or
10
+ equivalent), optionally a motor server — and today each one has to be
11
+ started by hand in its own terminal, with nothing bringing a crashed one
12
+ back. This supervisor is a single process management layer, not a merge of
13
+ those services into one program: each keeps its own hardware access,
14
+ failure mode, and restart behavior isolated from the others, while still
15
+ being "one thing to run" operationally.
16
+
17
+ Configuration: ~/.robopark/supervisor.json (see supervisor.example.json in
18
+ this directory for the schema). Services are opt-in — only preview_agent is
19
+ enabled by default, since vision_app/motor_server commands are specific to
20
+ each robot's actual hardware/codebase and have no safe generic default.
21
+
22
+ Usage:
23
+ python robot_supervisor.py [--config PATH]
24
+
25
+ Auto-start on boot:
26
+ Windows: scripts/install-robot-supervisor-windows.ps1 (Task Scheduler,
27
+ runs at user logon — required for mic/camera access, which
28
+ Windows restricts to an interactive session).
29
+ Linux: scripts/robopark-supervisor.service (systemd unit template).
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import argparse
34
+ import json
35
+ import logging
36
+ import os
37
+ import signal
38
+ import subprocess
39
+ import sys
40
+ import time
41
+ from dataclasses import dataclass, field
42
+ from pathlib import Path
43
+ from typing import Optional
44
+
45
+ logger = logging.getLogger("robopark.supervisor")
46
+
47
+ CONFIG_DIR = Path.home() / ".robopark"
48
+ DEFAULT_CONFIG_FILE = CONFIG_DIR / "supervisor.json"
49
+ LOG_DIR = CONFIG_DIR / "logs"
50
+
51
+ # A service that has run this long without exiting is considered stable —
52
+ # its failure/backoff count resets so a single crash after weeks of uptime
53
+ # doesn't get treated like a crash loop.
54
+ STABLE_UPTIME_SECONDS = 60.0
55
+ BACKOFF_BASE_SECONDS = 2.0
56
+ BACKOFF_MAX_SECONDS = 60.0
57
+ LOG_ROTATE_BYTES = 5 * 1024 * 1024 # rotate a service's log past 5MB
58
+ POLL_INTERVAL_SECONDS = 2.0
59
+ STATUS_REPORT_INTERVAL_SECONDS = 10.0
60
+
61
+
62
+ def _load_robopark_identity() -> Optional[tuple[str, str, str]]:
63
+ """Reuse preview_agent.py's own enrollment files (same ~/.robopark/ dir)
64
+ so the supervisor can report status to the scheduler without needing
65
+ separate credentials. Returns (device_id, scheduler_url, token) or None
66
+ if this robot hasn't been enrolled yet."""
67
+ cfg_file = CONFIG_DIR / "preview_agent.json"
68
+ token_file = CONFIG_DIR / "device_token"
69
+ if not cfg_file.exists() or not token_file.exists():
70
+ return None
71
+ try:
72
+ cfg = json.loads(cfg_file.read_text(encoding="utf8"))
73
+ device_id = cfg.get("device_id")
74
+ scheduler_url = cfg.get("scheduler_url", "http://localhost:8080")
75
+ token = token_file.read_text(encoding="utf8").strip()
76
+ if not device_id or not token:
77
+ return None
78
+ return device_id, scheduler_url, token
79
+ except Exception:
80
+ return None
81
+
82
+
83
+ def _report_status(states: "list[ServiceState]", identity: tuple[str, str, str]) -> list[dict]:
84
+ """POST current service status; returns any pending remote-control
85
+ commands the scheduler had queued for this device (e.g. an operator
86
+ clicking "restart" in the dashboard) -- delivered on this same
87
+ request/response cycle rather than a separate poll."""
88
+ device_id, scheduler_url, token = identity
89
+ try:
90
+ import httpx
91
+ except ImportError:
92
+ logger.debug("httpx not installed -- skipping status report (see requirements-robot.txt)")
93
+ return []
94
+ payload = {"services": [s.status_dict() for s in states]}
95
+ url = f"{scheduler_url.rstrip('/')}/api/devices/{device_id}/supervisor-status"
96
+ try:
97
+ resp = httpx.post(url, json=payload, headers={"Authorization": f"Bearer {token}"}, timeout=5.0)
98
+ resp.raise_for_status()
99
+ return resp.json().get("commands", [])
100
+ except Exception as e:
101
+ logger.debug(f"status report failed (scheduler unreachable?): {e}")
102
+ return []
103
+
104
+
105
+ @dataclass
106
+ class ServiceSpec:
107
+ name: str
108
+ enabled: bool
109
+ command: list[str]
110
+ cwd: Optional[str] = None
111
+ env: Optional[dict] = None
112
+
113
+
114
+ @dataclass
115
+ class ServiceState:
116
+ spec: ServiceSpec
117
+ proc: Optional[subprocess.Popen] = None
118
+ log_file: Optional[object] = None
119
+ started_at: float = 0.0
120
+ failure_count: int = 0
121
+ next_restart_at: float = 0.0
122
+ last_exit_code: Optional[int] = None
123
+ stopped: bool = False # true once the supervisor is shutting down
124
+
125
+ def status_dict(self) -> dict:
126
+ running = self.proc is not None
127
+ return {
128
+ "name": self.spec.name,
129
+ "enabled": self.spec.enabled,
130
+ "running": running,
131
+ "pid": self.proc.pid if running else None,
132
+ "uptime_seconds": (time.monotonic() - self.started_at) if running else None,
133
+ "failure_count": self.failure_count,
134
+ "last_exit_code": self.last_exit_code,
135
+ }
136
+
137
+
138
+ def _load_config(path: Path) -> tuple[list[ServiceSpec], Path]:
139
+ if not path.exists():
140
+ raise SystemExit(
141
+ f"No config at {path}. Copy supervisor.example.json there and "
142
+ f"edit it, or pass --config PATH."
143
+ )
144
+ data = json.loads(path.read_text(encoding="utf8"))
145
+ services = [
146
+ ServiceSpec(
147
+ name=s["name"],
148
+ enabled=bool(s.get("enabled", False)),
149
+ command=list(s["command"]),
150
+ cwd=s.get("cwd"),
151
+ env=s.get("env"),
152
+ )
153
+ for s in data.get("services", [])
154
+ ]
155
+ log_dir = Path(data.get("log_dir", str(LOG_DIR))).expanduser()
156
+ return services, log_dir
157
+
158
+
159
+ def _open_log(log_dir: Path, name: str):
160
+ log_dir.mkdir(parents=True, exist_ok=True)
161
+ path = log_dir / f"{name}.log"
162
+ if path.exists() and path.stat().st_size > LOG_ROTATE_BYTES:
163
+ rotated = log_dir / f"{name}.log.1"
164
+ try:
165
+ rotated.unlink(missing_ok=True)
166
+ path.rename(rotated)
167
+ except OSError as e:
168
+ logger.warning(f"log rotate failed for {name}: {e}")
169
+ return open(path, "a", encoding="utf8", buffering=1)
170
+
171
+
172
+ def _spawn(state: ServiceState, log_dir: Path) -> None:
173
+ spec = state.spec
174
+ state.log_file = _open_log(log_dir, spec.name)
175
+ env = os.environ.copy()
176
+ if spec.env:
177
+ env.update(spec.env)
178
+ banner = f"\n=== supervisor: starting {spec.name} at {time.strftime('%Y-%m-%d %H:%M:%S')} ===\n"
179
+ state.log_file.write(banner)
180
+ try:
181
+ state.proc = subprocess.Popen(
182
+ spec.command,
183
+ cwd=spec.cwd,
184
+ env=env,
185
+ stdout=state.log_file,
186
+ stderr=subprocess.STDOUT,
187
+ stdin=subprocess.DEVNULL,
188
+ )
189
+ state.started_at = time.monotonic()
190
+ logger.info(f"started {spec.name} (pid={state.proc.pid}): {' '.join(spec.command)}")
191
+ except Exception as e:
192
+ logger.error(f"failed to start {spec.name}: {e}")
193
+ state.proc = None
194
+ state.failure_count += 1
195
+
196
+
197
+ def _schedule_restart(state: ServiceState) -> None:
198
+ backoff = min(BACKOFF_BASE_SECONDS * (2 ** state.failure_count), BACKOFF_MAX_SECONDS)
199
+ state.next_restart_at = time.monotonic() + backoff
200
+ logger.warning(f"{state.spec.name} exited — restarting in {backoff:.0f}s (failure #{state.failure_count})")
201
+
202
+
203
+ def _terminate(state: ServiceState) -> None:
204
+ if not state.proc:
205
+ return
206
+ try:
207
+ state.proc.terminate()
208
+ try:
209
+ state.proc.wait(timeout=5)
210
+ except subprocess.TimeoutExpired:
211
+ logger.warning(f"{state.spec.name} did not exit in time, killing")
212
+ state.proc.kill()
213
+ state.proc.wait(timeout=5)
214
+ except Exception as e:
215
+ logger.error(f"error stopping {state.spec.name}: {e}")
216
+ finally:
217
+ if state.log_file:
218
+ state.log_file.close()
219
+ state.log_file = None
220
+
221
+
222
+ def _execute_command(state: ServiceState, action: str) -> None:
223
+ """Operator-initiated action from the dashboard. Currently only
224
+ "restart" -- terminate if running (the main loop's own restart logic
225
+ then respawns it), and clear any crash backoff since this isn't a
226
+ crash, it's a deliberate request that should take effect right away."""
227
+ if action != "restart":
228
+ logger.warning(f"ignoring unknown remote command {action!r} for {state.spec.name}")
229
+ return
230
+ logger.info(f"remote restart requested for {state.spec.name}")
231
+ if state.proc is not None:
232
+ _terminate(state)
233
+ state.proc = None
234
+ state.failure_count = 0
235
+ state.next_restart_at = 0.0 # due immediately -- the main loop respawns it next tick
236
+
237
+
238
+ def run(config_path: Path) -> None:
239
+ services, log_dir = _load_config(config_path)
240
+ enabled = [s for s in services if s.enabled]
241
+ if not enabled:
242
+ raise SystemExit(f"No enabled services in {config_path} — nothing to supervise.")
243
+
244
+ states = [ServiceState(spec=s) for s in enabled]
245
+ for state in states:
246
+ _spawn(state, log_dir)
247
+
248
+ shutdown = {"flag": False}
249
+
250
+ def _on_signal(signum, frame):
251
+ logger.info(f"received signal {signum}, shutting down all services…")
252
+ shutdown["flag"] = True
253
+
254
+ signal.signal(signal.SIGINT, _on_signal)
255
+ signal.signal(signal.SIGTERM, _on_signal)
256
+
257
+ logger.info(f"supervising {len(states)} service(s): {', '.join(s.spec.name for s in states)}")
258
+
259
+ last_status_report_at = 0.0
260
+ try:
261
+ while not shutdown["flag"]:
262
+ now = time.monotonic()
263
+ for state in states:
264
+ if state.proc is None:
265
+ if now >= state.next_restart_at:
266
+ _spawn(state, log_dir)
267
+ continue
268
+ ret = state.proc.poll()
269
+ if ret is None:
270
+ continue # still running
271
+ # Exited. A long uptime before exit resets the backoff —
272
+ # otherwise a service that's crash-looping keeps backing off.
273
+ uptime = now - state.started_at
274
+ state.last_exit_code = ret
275
+ if uptime >= STABLE_UPTIME_SECONDS:
276
+ state.failure_count = 0
277
+ else:
278
+ state.failure_count += 1
279
+ if state.log_file:
280
+ state.log_file.write(f"=== supervisor: {state.spec.name} exited with code {ret} (uptime {uptime:.0f}s) ===\n")
281
+ state.log_file.close()
282
+ state.log_file = None
283
+ state.proc = None
284
+ _schedule_restart(state)
285
+ if now - last_status_report_at >= STATUS_REPORT_INTERVAL_SECONDS:
286
+ last_status_report_at = now
287
+ identity = _load_robopark_identity()
288
+ if identity:
289
+ commands = _report_status(states, identity)
290
+ by_name = {s.spec.name: s for s in states}
291
+ for cmd in commands:
292
+ target = by_name.get(cmd.get("service_name"))
293
+ if target:
294
+ _execute_command(target, cmd.get("action", "restart"))
295
+ else:
296
+ logger.warning(f"remote command for unknown service {cmd.get('service_name')!r} ignored")
297
+ else:
298
+ logger.debug("not enrolled yet (no ~/.robopark/preview_agent.json + device_token) -- skipping status report")
299
+ time.sleep(POLL_INTERVAL_SECONDS)
300
+ finally:
301
+ for state in states:
302
+ _terminate(state)
303
+ logger.info("all services stopped, exiting")
304
+
305
+
306
+ def main() -> None:
307
+ # stdout/stderr are fully buffered (not line-buffered) once redirected to
308
+ # a file or pipe -- which is exactly how Task Scheduler/systemd run this.
309
+ # Without this, restart/crash log lines can sit unflushed for minutes.
310
+ sys.stdout.reconfigure(line_buffering=True)
311
+ sys.stderr.reconfigure(line_buffering=True)
312
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
313
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
314
+ parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG_FILE, help="path to supervisor.json")
315
+ args = parser.parse_args()
316
+ run(args.config)
317
+
318
+
319
+ if __name__ == "__main__":
320
+ main()
@@ -0,0 +1,33 @@
1
+ #!/bin/bash
2
+ # Installs the RoboPark robot supervisor as a systemd service on a real
3
+ # robot (Raspberry Pi or similar). Auto-starts on boot, restarts on crash.
4
+ set -euo pipefail
5
+
6
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
7
+ SUPERVISOR_DIR="$(dirname "$SCRIPT_DIR")"
8
+ UNIT_SRC="$SCRIPT_DIR/robopark-supervisor.service"
9
+ UNIT_DST="/etc/systemd/system/robopark-supervisor.service"
10
+
11
+ if [ ! -f "$SUPERVISOR_DIR/robot_supervisor.py" ]; then
12
+ echo "robot_supervisor.py not found in $SUPERVISOR_DIR" >&2
13
+ exit 1
14
+ fi
15
+
16
+ if [ ! -f "$HOME/.robopark/supervisor.json" ]; then
17
+ echo "No ~/.robopark/supervisor.json yet — copy supervisor.example.json there and edit it first:" >&2
18
+ echo " cp $SUPERVISOR_DIR/supervisor.example.json ~/.robopark/supervisor.json" >&2
19
+ exit 1
20
+ fi
21
+
22
+ echo "Installing systemd unit -> $UNIT_DST (requires sudo)"
23
+ sudo sed \
24
+ -e "s#WorkingDirectory=.*#WorkingDirectory=$SUPERVISOR_DIR#" \
25
+ -e "s#User=.*#User=$(whoami)#" \
26
+ "$UNIT_SRC" | sudo tee "$UNIT_DST" > /dev/null
27
+
28
+ sudo systemctl daemon-reload
29
+ sudo systemctl enable robopark-supervisor.service
30
+ sudo systemctl restart robopark-supervisor.service
31
+
32
+ echo "Installed and started. Check status: sudo systemctl status robopark-supervisor.service"
33
+ echo "Logs: journalctl -u robopark-supervisor.service -f (or ~/.robopark/logs/*.log per-service)"
@@ -0,0 +1,49 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Registers the RoboPark robot supervisor to auto-start at Windows logon.
4
+
5
+ .DESCRIPTION
6
+ Creates a Scheduled Task that runs robot_supervisor.py whenever this user
7
+ logs in, restarting it automatically if it ever exits unexpectedly. Runs
8
+ in the interactive session (not "run whether logged on or not") because
9
+ Windows restricts microphone/camera access to interactive sessions —
10
+ preview_agent.py needs both.
11
+
12
+ The supervisor itself (robot_supervisor.py) is what keeps the individual
13
+ robot services (preview_agent, vision app, motor server) alive; this task
14
+ is the one layer above that keeps the supervisor itself running.
15
+
16
+ .PARAMETER PythonExe
17
+ Path to python.exe. Defaults to whatever `python` resolves to on PATH.
18
+
19
+ .EXAMPLE
20
+ .\install-robot-supervisor-windows.ps1
21
+ .\install-robot-supervisor-windows.ps1 -PythonExe "C:\Python312\python.exe"
22
+ #>
23
+ param(
24
+ [string]$PythonExe = (Get-Command python -ErrorAction Stop).Source
25
+ )
26
+
27
+ $ErrorActionPreference = 'Stop'
28
+ $scriptDir = Split-Path -Parent $PSScriptRoot
29
+ $supervisorScript = Join-Path $scriptDir 'robot_supervisor.py'
30
+ if (-not (Test-Path $supervisorScript)) {
31
+ throw "robot_supervisor.py not found at $supervisorScript"
32
+ }
33
+
34
+ $taskName = 'RoboParkSupervisor'
35
+ $action = New-ScheduledTaskAction -Execute $PythonExe -Argument "`"$supervisorScript`"" -WorkingDirectory $scriptDir
36
+ $trigger = New-ScheduledTaskTrigger -AtLogOn
37
+ $settings = New-ScheduledTaskSettingsSet `
38
+ -RestartCount 999 `
39
+ -RestartInterval (New-TimeSpan -Minutes 1) `
40
+ -ExecutionTimeLimit (New-TimeSpan -Days 0) `
41
+ -AllowStartIfOnBatteries `
42
+ -DontStopIfGoingOnBatteries
43
+
44
+ Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Settings $settings -Force | Out-Null
45
+
46
+ Write-Output "Registered scheduled task '$taskName' — starts at logon, restarts on crash."
47
+ Write-Output "Config: copy supervisor.example.json to `$HOME\.robopark\supervisor.json and edit it first if you haven't."
48
+ Write-Output "Start it now without logging out: Start-ScheduledTask -TaskName '$taskName'"
49
+ Write-Output "Remove it later: Unregister-ScheduledTask -TaskName '$taskName' -Confirm:`$false"
@@ -0,0 +1,20 @@
1
+ [Unit]
2
+ Description=RoboPark robot supervisor (preview_agent + vision + motors)
3
+ After=network-online.target sound.target
4
+ Wants=network-online.target
5
+
6
+ [Service]
7
+ Type=simple
8
+ # Edit these two for your setup:
9
+ User=pi
10
+ WorkingDirectory=/home/pi/infinicode-main/packages/robopark/scheduler
11
+ ExecStart=/usr/bin/python3 robot_supervisor.py
12
+ Restart=always
13
+ RestartSec=5
14
+ # Camera/audio devices, GPIO — adjust group membership instead of running as
15
+ # root: `sudo usermod -aG video,audio,gpio pi`
16
+ StandardOutput=journal
17
+ StandardError=journal
18
+
19
+ [Install]
20
+ WantedBy=multi-user.target
@@ -0,0 +1,50 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Starts the RoboPark scheduler locally with every env var it needs.
4
+
5
+ .DESCRIPTION
6
+ main.py's own auth for the voice agent's session-keepalive calls
7
+ (ROBOPARK_AGENT_TOKEN, checked in POST /api/sessions/{id}/keepalive)
8
+ silently degrades to "every keepalive gets 401'd" if the scheduler is
9
+ ever started without it set -- no startup error, no crash, just every
10
+ motion-triggered session getting cut off by its own silence-timeout
11
+ within ~15s because last_activity_at never updates. This bit twice in
12
+ one session from ad-hoc restarts that forgot the env var. Use this
13
+ script (or set the same vars) for every local restart instead of a bare
14
+ `python main.py`.
15
+
16
+ ROBOPARK_AGENT_TOKEN here MUST match ROBOPARK_AGENT_TOKEN in the voice
17
+ agent's own .env -- it's the shared secret the agent container presents
18
+ via the X-RoboPark-Agent-Token header. Never hardcode the actual value
19
+ in this script (it's committed to git) -- pass it explicitly, set it in
20
+ your shell first, or point -AgentEnvFile at the agent's .env to read it
21
+ from there at run time.
22
+
23
+ .EXAMPLE
24
+ .\start-scheduler-local.ps1 -AgentToken "the-real-token"
25
+ .\start-scheduler-local.ps1 -AgentEnvFile "C:\path\to\ROBOVOICE-main\.env"
26
+ $env:ROBOPARK_AGENT_TOKEN = "the-real-token"; .\start-scheduler-local.ps1
27
+ #>
28
+ param(
29
+ [string]$DataDir = "C:\Users\yunge\OneDrive\Desktop\data",
30
+ [string]$AgentToken = $env:ROBOPARK_AGENT_TOKEN,
31
+ [string]$AgentEnvFile,
32
+ [string]$PythonExe = (Get-Command python -ErrorAction Stop).Source
33
+ )
34
+
35
+ $ErrorActionPreference = 'Stop'
36
+ $schedulerDir = Split-Path -Parent $PSScriptRoot
37
+
38
+ if (-not $AgentToken -and $AgentEnvFile -and (Test-Path $AgentEnvFile)) {
39
+ $line = Get-Content $AgentEnvFile | Where-Object { $_ -match '^ROBOPARK_AGENT_TOKEN=' } | Select-Object -First 1
40
+ if ($line) { $AgentToken = ($line -split '=', 2)[1].Trim() }
41
+ }
42
+ if (-not $AgentToken) {
43
+ throw "ROBOPARK_AGENT_TOKEN not provided. Pass -AgentToken, set `$env:ROBOPARK_AGENT_TOKEN first, or pass -AgentEnvFile pointing at the voice agent's .env."
44
+ }
45
+
46
+ $env:SCHEDULER_DATA_DIR = $DataDir
47
+ $env:ROBOPARK_AGENT_TOKEN = $AgentToken
48
+
49
+ Write-Output "Starting scheduler: SCHEDULER_DATA_DIR=$DataDir, ROBOPARK_AGENT_TOKEN set (len=$($AgentToken.Length))"
50
+ & $PythonExe (Join-Path $schedulerDir 'main.py')
@@ -0,0 +1,26 @@
1
+ {
2
+ "log_dir": "~/.robopark/logs",
3
+ "services": [
4
+ {
5
+ "name": "preview_agent",
6
+ "enabled": true,
7
+ "comment": "Core service — heartbeat, LiveKit publish, motion webhook listener. Reads its own settings from ~/.robopark/preview_agent.json (device_id, scheduler_url, etc), enrolled once via `robopark add-robot`.",
8
+ "command": ["python", "preview_agent.py"],
9
+ "cwd": "/absolute/path/to/infinicode-main/packages/robopark/scheduler"
10
+ },
11
+ {
12
+ "name": "vision_app",
13
+ "enabled": false,
14
+ "comment": "Real motion/object detection (e.g. RoboVisionAI_PI's app_pi_clean.py). Must POST to preview_agent's motion webhook (http://localhost:<vision-webhook-port>/, default 5057) on detection. Disabled by default — no safe generic command, fill in your robot's actual vision app path.",
15
+ "command": ["python", "app_pi_clean.py"],
16
+ "cwd": "/absolute/path/to/RoboVisionAI_PI"
17
+ },
18
+ {
19
+ "name": "motor_server",
20
+ "enabled": false,
21
+ "comment": "Only needed if your robot's motors are NOT wired to preview_agent's LiveKit data-channel path — i.e. voice_agent.py falls back to motor_server_url (set on the character preset / device) instead of the data-channel. Disabled by default.",
22
+ "command": ["python", "-m", "uvicorn", "motors:app", "--host", "0.0.0.0", "--port", "8001"],
23
+ "cwd": "/absolute/path/to/motor-server-project"
24
+ }
25
+ ]
26
+ }
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Minimal cross-platform motion detector -> preview_agent.py's motion webhook.
4
+
5
+ RoboVisionAI_PI's real app_pi_clean.py does full object detection + motion
6
+ + MJPEG streaming in one Flask app, but its motion loop only actually runs
7
+ while something is actively pulling /video_feed, and it wants Pi-specific
8
+ caffemodel files for the object-detection half. For a plain "does motion
9
+ actually reach the conversation pipeline" test (or a lightweight always-on
10
+ trigger on a robot that doesn't need the vision overlay), this is a much
11
+ smaller piece: open whatever camera OpenCV can see, do frame-differencing
12
+ motion detection, POST to the webhook on motion, done. No Flask, no model
13
+ files, works the same on Windows/Linux/Pi wherever cv2 can open a camera.
14
+
15
+ preview_agent.py's webhook handler accepts any POST body (even {}) as a
16
+ trigger signal -- it does not require the RoboVisionAI_PI payload shape --
17
+ so this only needs to hit the URL, not match any particular JSON schema.
18
+
19
+ Usage:
20
+ python vision_motion_trigger.py --webhook-url http://localhost:5058/
21
+ python vision_motion_trigger.py --camera 0 --min-area 800 --cooldown 15
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import logging
27
+ import time
28
+
29
+ logger = logging.getLogger("robopark.vision_motion_trigger")
30
+
31
+
32
+ def run(camera_index: int, webhook_url: str, min_area: float, cooldown: float, fps_limit: float) -> None:
33
+ import cv2
34
+ import httpx
35
+
36
+ cap = cv2.VideoCapture(camera_index)
37
+ if not cap.isOpened():
38
+ raise SystemExit(f"could not open camera index {camera_index}")
39
+ logger.info(f"camera {camera_index} opened, watching for motion (min_area={min_area}px, cooldown={cooldown}s)")
40
+
41
+ prev_gray = None
42
+ last_trigger = 0.0
43
+ frame_interval = 1.0 / fps_limit if fps_limit > 0 else 0.0
44
+ client = httpx.Client(timeout=5.0)
45
+
46
+ try:
47
+ while True:
48
+ loop_start = time.monotonic()
49
+ ok, frame = cap.read()
50
+ if not ok:
51
+ logger.warning("camera read failed, retrying")
52
+ time.sleep(1.0)
53
+ continue
54
+
55
+ gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
56
+ gray = cv2.GaussianBlur(gray, (21, 21), 0)
57
+
58
+ if prev_gray is not None:
59
+ delta = cv2.absdiff(prev_gray, gray)
60
+ thresh = cv2.threshold(delta, 25, 255, cv2.THRESH_BINARY)[1]
61
+ thresh = cv2.dilate(thresh, None, iterations=2)
62
+ contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
63
+ motion = any(cv2.contourArea(c) >= min_area for c in contours)
64
+
65
+ if motion:
66
+ now = time.time()
67
+ if now - last_trigger >= cooldown:
68
+ last_trigger = now
69
+ logger.info("motion detected, posting to webhook")
70
+ try:
71
+ client.post(webhook_url, json={"source": "vision_motion_trigger"})
72
+ except Exception as e:
73
+ logger.warning(f"webhook POST failed: {e}")
74
+ else:
75
+ logger.debug("motion detected, still in cooldown")
76
+
77
+ prev_gray = gray
78
+
79
+ if frame_interval:
80
+ elapsed = time.monotonic() - loop_start
81
+ if elapsed < frame_interval:
82
+ time.sleep(frame_interval - elapsed)
83
+ finally:
84
+ cap.release()
85
+ client.close()
86
+
87
+
88
+ def main() -> None:
89
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
90
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
91
+ parser.add_argument("--camera", type=int, default=0, help="OpenCV camera index (default 0)")
92
+ parser.add_argument("--webhook-url", default="http://localhost:5058/", help="preview_agent.py's motion webhook")
93
+ parser.add_argument("--min-area", type=float, default=500.0, help="minimum changed-pixel contour area to count as motion")
94
+ parser.add_argument("--cooldown", type=float, default=20.0, help="minimum seconds between triggers")
95
+ parser.add_argument("--fps-limit", type=float, default=5.0, help="cap capture rate to reduce CPU (0 = uncapped)")
96
+ args = parser.parse_args()
97
+ run(args.camera, args.webhook_url, args.min_area, args.cooldown, args.fps_limit)
98
+
99
+
100
+ if __name__ == "__main__":
101
+ main()