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.
- package/dist/kernel/federation/dashboard-html.d.ts +1 -1
- package/dist/kernel/federation/dashboard-html.js +3542 -333
- package/dist/kernel/federation/transport-http.d.ts +2 -0
- package/dist/kernel/federation/transport-http.js +56 -0
- package/package.json +105 -105
- package/packages/robopark/scheduler/main.py +838 -29
- package/packages/robopark/scheduler/preview_agent.py +620 -57
- package/packages/robopark/scheduler/robot_supervisor.py +320 -0
- package/packages/robopark/scheduler/scripts/install-robot-supervisor-linux.sh +33 -0
- package/packages/robopark/scheduler/scripts/install-robot-supervisor-windows.ps1 +49 -0
- package/packages/robopark/scheduler/scripts/robopark-supervisor.service +20 -0
- package/packages/robopark/scheduler/scripts/start-scheduler-local.ps1 +50 -0
- package/packages/robopark/scheduler/supervisor.example.json +26 -0
- package/packages/robopark/scheduler/vision_motion_trigger.py +101 -0
|
@@ -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()
|