infinicode 2.8.36 → 2.8.38
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 +4586 -364
- package/dist/kernel/federation/transport-http.d.ts +2 -0
- package/dist/kernel/federation/transport-http.js +56 -0
- package/dist/robopark/add-robot.d.ts +2 -0
- package/dist/robopark/add-robot.js +9 -0
- package/dist/robopark/secrets.d.ts +8 -0
- package/dist/robopark/secrets.js +41 -0
- package/dist/robopark/setup.d.ts +3 -0
- package/dist/robopark/setup.js +9 -2
- package/dist/robopark-cli.js +22 -0
- package/package.json +105 -105
- package/packages/robopark/scheduler/main.py +1573 -108
- package/packages/robopark/scheduler/preview_agent.py +620 -57
- package/packages/robopark/scheduler/robot_supervisor.py +729 -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,729 @@
|
|
|
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 math
|
|
37
|
+
import os
|
|
38
|
+
import signal
|
|
39
|
+
import struct
|
|
40
|
+
import subprocess
|
|
41
|
+
import sys
|
|
42
|
+
import time
|
|
43
|
+
from dataclasses import dataclass, field
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
from typing import Optional
|
|
46
|
+
|
|
47
|
+
logger = logging.getLogger("robopark.supervisor")
|
|
48
|
+
|
|
49
|
+
CONFIG_DIR = Path.home() / ".robopark"
|
|
50
|
+
DEFAULT_CONFIG_FILE = CONFIG_DIR / "supervisor.json"
|
|
51
|
+
LOG_DIR = CONFIG_DIR / "logs"
|
|
52
|
+
|
|
53
|
+
# A service that has run this long without exiting is considered stable —
|
|
54
|
+
# its failure/backoff count resets so a single crash after weeks of uptime
|
|
55
|
+
# doesn't get treated like a crash loop.
|
|
56
|
+
STABLE_UPTIME_SECONDS = 60.0
|
|
57
|
+
BACKOFF_BASE_SECONDS = 2.0
|
|
58
|
+
BACKOFF_MAX_SECONDS = 60.0
|
|
59
|
+
LOG_ROTATE_BYTES = 5 * 1024 * 1024 # rotate a service's log past 5MB
|
|
60
|
+
POLL_INTERVAL_SECONDS = 2.0
|
|
61
|
+
STATUS_REPORT_INTERVAL_SECONDS = 10.0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _load_robopark_identity() -> Optional[tuple[str, str, str]]:
|
|
65
|
+
"""Reuse preview_agent.py's own enrollment files (same ~/.robopark/ dir)
|
|
66
|
+
so the supervisor can report status to the scheduler without needing
|
|
67
|
+
separate credentials. Returns (device_id, scheduler_url, token) or None
|
|
68
|
+
if this robot hasn't been enrolled yet."""
|
|
69
|
+
cfg_file = CONFIG_DIR / "preview_agent.json"
|
|
70
|
+
token_file = CONFIG_DIR / "device_token"
|
|
71
|
+
if not cfg_file.exists() or not token_file.exists():
|
|
72
|
+
return None
|
|
73
|
+
try:
|
|
74
|
+
cfg = json.loads(cfg_file.read_text(encoding="utf8"))
|
|
75
|
+
device_id = cfg.get("device_id")
|
|
76
|
+
scheduler_url = cfg.get("scheduler_url", "http://localhost:8080")
|
|
77
|
+
token = token_file.read_text(encoding="utf8").strip()
|
|
78
|
+
if not device_id or not token:
|
|
79
|
+
return None
|
|
80
|
+
return device_id, scheduler_url, token
|
|
81
|
+
except Exception:
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _report_status(states: "list[ServiceState]", identity: tuple[str, str, str]) -> list[dict]:
|
|
86
|
+
"""POST current service status; returns any pending remote-control
|
|
87
|
+
commands the scheduler had queued for this device (e.g. an operator
|
|
88
|
+
clicking "restart" in the dashboard) -- delivered on this same
|
|
89
|
+
request/response cycle rather than a separate poll."""
|
|
90
|
+
device_id, scheduler_url, token = identity
|
|
91
|
+
try:
|
|
92
|
+
import httpx
|
|
93
|
+
except ImportError:
|
|
94
|
+
logger.debug("httpx not installed -- skipping status report (see requirements-robot.txt)")
|
|
95
|
+
return []
|
|
96
|
+
payload = {"services": [s.status_dict() for s in states]}
|
|
97
|
+
url = f"{scheduler_url.rstrip('/')}/api/devices/{device_id}/supervisor-status"
|
|
98
|
+
try:
|
|
99
|
+
resp = httpx.post(url, json=payload, headers={"Authorization": f"Bearer {token}"}, timeout=5.0)
|
|
100
|
+
resp.raise_for_status()
|
|
101
|
+
return resp.json().get("commands", [])
|
|
102
|
+
except Exception as e:
|
|
103
|
+
logger.debug(f"status report failed (scheduler unreachable?): {e}")
|
|
104
|
+
return []
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass
|
|
108
|
+
class ServiceSpec:
|
|
109
|
+
name: str
|
|
110
|
+
enabled: bool
|
|
111
|
+
command: list[str]
|
|
112
|
+
cwd: Optional[str] = None
|
|
113
|
+
env: Optional[dict] = None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass
|
|
117
|
+
class ServiceState:
|
|
118
|
+
spec: ServiceSpec
|
|
119
|
+
proc: Optional[subprocess.Popen] = None
|
|
120
|
+
log_file: Optional[object] = None
|
|
121
|
+
started_at: float = 0.0
|
|
122
|
+
failure_count: int = 0
|
|
123
|
+
next_restart_at: float = 0.0
|
|
124
|
+
last_exit_code: Optional[int] = None
|
|
125
|
+
stopped: bool = False # true once the supervisor is shutting down
|
|
126
|
+
|
|
127
|
+
def status_dict(self) -> dict:
|
|
128
|
+
running = self.proc is not None
|
|
129
|
+
return {
|
|
130
|
+
"name": self.spec.name,
|
|
131
|
+
"enabled": self.spec.enabled,
|
|
132
|
+
"running": running,
|
|
133
|
+
"pid": self.proc.pid if running else None,
|
|
134
|
+
"uptime_seconds": (time.monotonic() - self.started_at) if running else None,
|
|
135
|
+
"failure_count": self.failure_count,
|
|
136
|
+
"last_exit_code": self.last_exit_code,
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _load_config(path: Path) -> tuple[list[ServiceSpec], Path]:
|
|
141
|
+
if not path.exists():
|
|
142
|
+
raise SystemExit(
|
|
143
|
+
f"No config at {path}. Copy supervisor.example.json there and "
|
|
144
|
+
f"edit it, or pass --config PATH."
|
|
145
|
+
)
|
|
146
|
+
data = json.loads(path.read_text(encoding="utf8"))
|
|
147
|
+
services = [
|
|
148
|
+
ServiceSpec(
|
|
149
|
+
name=s["name"],
|
|
150
|
+
enabled=bool(s.get("enabled", False)),
|
|
151
|
+
command=list(s["command"]),
|
|
152
|
+
cwd=s.get("cwd"),
|
|
153
|
+
env=s.get("env"),
|
|
154
|
+
)
|
|
155
|
+
for s in data.get("services", [])
|
|
156
|
+
]
|
|
157
|
+
log_dir = Path(data.get("log_dir", str(LOG_DIR))).expanduser()
|
|
158
|
+
return services, log_dir
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _open_log(log_dir: Path, name: str):
|
|
162
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
163
|
+
path = log_dir / f"{name}.log"
|
|
164
|
+
if path.exists() and path.stat().st_size > LOG_ROTATE_BYTES:
|
|
165
|
+
rotated = log_dir / f"{name}.log.1"
|
|
166
|
+
try:
|
|
167
|
+
rotated.unlink(missing_ok=True)
|
|
168
|
+
path.rename(rotated)
|
|
169
|
+
except OSError as e:
|
|
170
|
+
logger.warning(f"log rotate failed for {name}: {e}")
|
|
171
|
+
return open(path, "a", encoding="utf8", buffering=1)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _spawn(state: ServiceState, log_dir: Path) -> None:
|
|
175
|
+
spec = state.spec
|
|
176
|
+
state.log_file = _open_log(log_dir, spec.name)
|
|
177
|
+
env = os.environ.copy()
|
|
178
|
+
if spec.env:
|
|
179
|
+
env.update(spec.env)
|
|
180
|
+
banner = f"\n=== supervisor: starting {spec.name} at {time.strftime('%Y-%m-%d %H:%M:%S')} ===\n"
|
|
181
|
+
state.log_file.write(banner)
|
|
182
|
+
try:
|
|
183
|
+
state.proc = subprocess.Popen(
|
|
184
|
+
spec.command,
|
|
185
|
+
cwd=spec.cwd,
|
|
186
|
+
env=env,
|
|
187
|
+
stdout=state.log_file,
|
|
188
|
+
stderr=subprocess.STDOUT,
|
|
189
|
+
stdin=subprocess.DEVNULL,
|
|
190
|
+
)
|
|
191
|
+
state.started_at = time.monotonic()
|
|
192
|
+
logger.info(f"started {spec.name} (pid={state.proc.pid}): {' '.join(spec.command)}")
|
|
193
|
+
except Exception as e:
|
|
194
|
+
logger.error(f"failed to start {spec.name}: {e}")
|
|
195
|
+
state.proc = None
|
|
196
|
+
state.failure_count += 1
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _schedule_restart(state: ServiceState) -> None:
|
|
200
|
+
backoff = min(BACKOFF_BASE_SECONDS * (2 ** state.failure_count), BACKOFF_MAX_SECONDS)
|
|
201
|
+
state.next_restart_at = time.monotonic() + backoff
|
|
202
|
+
logger.warning(f"{state.spec.name} exited — restarting in {backoff:.0f}s (failure #{state.failure_count})")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _terminate(state: ServiceState) -> None:
|
|
206
|
+
if not state.proc:
|
|
207
|
+
return
|
|
208
|
+
try:
|
|
209
|
+
state.proc.terminate()
|
|
210
|
+
try:
|
|
211
|
+
state.proc.wait(timeout=5)
|
|
212
|
+
except subprocess.TimeoutExpired:
|
|
213
|
+
logger.warning(f"{state.spec.name} did not exit in time, killing")
|
|
214
|
+
state.proc.kill()
|
|
215
|
+
state.proc.wait(timeout=5)
|
|
216
|
+
except Exception as e:
|
|
217
|
+
logger.error(f"error stopping {state.spec.name}: {e}")
|
|
218
|
+
finally:
|
|
219
|
+
if state.log_file:
|
|
220
|
+
state.log_file.close()
|
|
221
|
+
state.log_file = None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _execute_command(state: ServiceState, action: str) -> None:
|
|
225
|
+
"""Operator-initiated action from the dashboard. Supports:
|
|
226
|
+
- "restart" (original): terminate if running, the main loop
|
|
227
|
+
respawns it on the next tick.
|
|
228
|
+
- "start": spawn the service if it's not already running. Used
|
|
229
|
+
after a deliberate stop.
|
|
230
|
+
- "stop": graceful terminate and mark `stopped=True` so the main
|
|
231
|
+
loop does NOT auto-restart it. Re-enable with "start".
|
|
232
|
+
Anything else is ignored with a warning."""
|
|
233
|
+
if action == "restart":
|
|
234
|
+
# clear any deliberate-stop so the next main-loop tick is allowed
|
|
235
|
+
# to respawn it
|
|
236
|
+
state.stopped = False
|
|
237
|
+
if state.proc is not None:
|
|
238
|
+
_terminate(state)
|
|
239
|
+
state.proc = None
|
|
240
|
+
state.failure_count = 0
|
|
241
|
+
state.next_restart_at = 0.0
|
|
242
|
+
return
|
|
243
|
+
if action == "start":
|
|
244
|
+
state.stopped = False
|
|
245
|
+
state.failure_count = 0
|
|
246
|
+
state.next_restart_at = 0.0
|
|
247
|
+
if state.proc is None:
|
|
248
|
+
_spawn(state, _CURRENT_LOG_DIR or LOG_DIR)
|
|
249
|
+
return
|
|
250
|
+
if action == "stop":
|
|
251
|
+
if state.proc is not None:
|
|
252
|
+
_terminate(state)
|
|
253
|
+
state.proc = None
|
|
254
|
+
state.stopped = True
|
|
255
|
+
return
|
|
256
|
+
logger.warning(f"ignoring unknown remote command {action!r} for {state.spec.name}")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# ── Operator shell (C1 gap-fill) ──
|
|
260
|
+
# Allows the dashboard to read service logs and run a small set of
|
|
261
|
+
# pre-approved diagnostic commands on the robot. Results are POSTed back
|
|
262
|
+
# to the scheduler (POST /api/devices/{id}/supervisor-output) which
|
|
263
|
+
# caches them per (device, command-id) so the dashboard can poll the
|
|
264
|
+
# result without holding a long-lived connection to the robot.
|
|
265
|
+
#
|
|
266
|
+
# SECURITY: the allowlist below is the only thing that gets executed.
|
|
267
|
+
# Operators cannot pass arbitrary commands; the parser rejects anything
|
|
268
|
+
# not on this list with a 400-equivalent ("command not allowed").
|
|
269
|
+
|
|
270
|
+
import secrets as _secrets # noqa: E402
|
|
271
|
+
|
|
272
|
+
# A list of allow-listed command specs. Each entry has a fixed argv;
|
|
273
|
+
# placeholders in <angle brackets> get substituted from the request.
|
|
274
|
+
# A request that doesn't match any entry is rejected.
|
|
275
|
+
SHELL_ALLOWLIST = [
|
|
276
|
+
{"name": "uptime", "argv": ["uptime"]},
|
|
277
|
+
{"name": "free", "argv": ["free", "-m"]},
|
|
278
|
+
{"name": "df", "argv": ["df", "-h"]},
|
|
279
|
+
{"name": "ps", "argv": ["ps", "aux"]},
|
|
280
|
+
{"name": "uname", "argv": ["uname", "-a"]},
|
|
281
|
+
{"name": "date", "argv": ["date"]},
|
|
282
|
+
{"name": "whoami", "argv": ["whoami"]},
|
|
283
|
+
{"name": "hostname", "argv": ["hostname"]},
|
|
284
|
+
{"name": "ip", "argv": ["ip", "addr"]},
|
|
285
|
+
{"name": "ss", "argv": ["ss", "-tlnp"]},
|
|
286
|
+
{"name": "os-release", "argv": ["cat", "/etc/os-release"]},
|
|
287
|
+
{"name": "ls-logs", "argv": ["ls", "-la", "<dir>"]},
|
|
288
|
+
{"name": "systemctl-status", "argv": ["systemctl", "status", "<name>", "--no-pager", "-n", "30"]},
|
|
289
|
+
{"name": "journalctl", "argv": ["journalctl", "-u", "<name>", "-n", "200", "--no-pager"]},
|
|
290
|
+
{"name": "tail", "argv": ["tail", "-n", "<n>", "<path>"]},
|
|
291
|
+
]
|
|
292
|
+
|
|
293
|
+
# Cap output size per command to avoid an operator accidentally filling
|
|
294
|
+
# the supervisor output buffer with 100MB of `ps aux`.
|
|
295
|
+
SHELL_OUTPUT_MAX_BYTES = 64 * 1024
|
|
296
|
+
SHELL_RUN_TIMEOUT_SECONDS = 8.0
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _run_shell_command(name: str, params: dict) -> dict:
|
|
300
|
+
"""Resolve `name` against the allowlist, substitute params, and run.
|
|
301
|
+
|
|
302
|
+
Returns a dict {ok, exit_code, stdout, stderr, error}. The caller
|
|
303
|
+
POSTs this to the scheduler as the supervisor-output payload."""
|
|
304
|
+
spec = None
|
|
305
|
+
for s in SHELL_ALLOWLIST:
|
|
306
|
+
if s["name"] == name:
|
|
307
|
+
spec = s
|
|
308
|
+
break
|
|
309
|
+
if spec is None:
|
|
310
|
+
return {"ok": False, "error": f"command {name!r} is not in the allowlist"}
|
|
311
|
+
argv = []
|
|
312
|
+
for piece in spec["argv"]:
|
|
313
|
+
if piece.startswith("<") and piece.endswith(">"):
|
|
314
|
+
key = piece[1:-1]
|
|
315
|
+
val = params.get(key)
|
|
316
|
+
if val is None or not isinstance(val, str) or not val.strip():
|
|
317
|
+
return {"ok": False, "error": f"missing required parameter {key!r} for command {name!r}"}
|
|
318
|
+
# Reject anything that smells like a shell metachar — keep it
|
|
319
|
+
# strictly to filenames / unit names. Service names are owned
|
|
320
|
+
# by supervisor.json on this robot, not by the network.
|
|
321
|
+
if any(c in val for c in ("\x00", "\n", "\r")):
|
|
322
|
+
return {"ok": False, "error": "invalid characters in parameter"}
|
|
323
|
+
argv.append(val)
|
|
324
|
+
else:
|
|
325
|
+
argv.append(piece)
|
|
326
|
+
try:
|
|
327
|
+
proc = subprocess.run(
|
|
328
|
+
argv,
|
|
329
|
+
capture_output=True,
|
|
330
|
+
text=True,
|
|
331
|
+
timeout=SHELL_RUN_TIMEOUT_SECONDS,
|
|
332
|
+
check=False,
|
|
333
|
+
)
|
|
334
|
+
out = (proc.stdout or "")[:SHELL_OUTPUT_MAX_BYTES]
|
|
335
|
+
err = (proc.stderr or "")[:SHELL_OUTPUT_MAX_BYTES]
|
|
336
|
+
return {
|
|
337
|
+
"ok": True,
|
|
338
|
+
"exit_code": proc.returncode,
|
|
339
|
+
"stdout": out,
|
|
340
|
+
"stderr": err,
|
|
341
|
+
"truncated": len(proc.stdout or "") > SHELL_OUTPUT_MAX_BYTES or len(proc.stderr or "") > SHELL_OUTPUT_MAX_BYTES,
|
|
342
|
+
}
|
|
343
|
+
except subprocess.TimeoutExpired:
|
|
344
|
+
return {"ok": False, "error": f"command timed out after {SHELL_RUN_TIMEOUT_SECONDS}s"}
|
|
345
|
+
except FileNotFoundError as e:
|
|
346
|
+
return {"ok": False, "error": f"command not found: {e}"}
|
|
347
|
+
except Exception as e:
|
|
348
|
+
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _tail_log_file(path: Path, lines: int) -> dict:
|
|
352
|
+
"""Return the last `lines` lines of a log file as a string.
|
|
353
|
+
|
|
354
|
+
Resolves `path` against LOG_DIR if it's relative; rejects anything
|
|
355
|
+
that tries to escape (../) for safety, even though the operator
|
|
356
|
+
can already read anything on the robot via the allowlist."""
|
|
357
|
+
try:
|
|
358
|
+
lines = max(1, min(int(lines), 2000))
|
|
359
|
+
except (TypeError, ValueError):
|
|
360
|
+
lines = 200
|
|
361
|
+
if not path.is_absolute():
|
|
362
|
+
path = (LOG_DIR / path).resolve()
|
|
363
|
+
# Belt-and-suspenders: don't let `..` traversal escape the log dir
|
|
364
|
+
# (the dashboard only ever sends a service name, but defend anyway).
|
|
365
|
+
try:
|
|
366
|
+
path.relative_to(LOG_DIR.resolve())
|
|
367
|
+
except ValueError:
|
|
368
|
+
return {"ok": False, "error": "log path is outside the supervisor log directory"}
|
|
369
|
+
if not path.exists():
|
|
370
|
+
return {"ok": False, "error": f"no such log file: {path}"}
|
|
371
|
+
try:
|
|
372
|
+
# Read the tail efficiently: seek to ~32KB from the end and split.
|
|
373
|
+
size = path.stat().st_size
|
|
374
|
+
chunk = min(size, 64 * 1024)
|
|
375
|
+
with path.open("rb") as f:
|
|
376
|
+
if size > chunk:
|
|
377
|
+
f.seek(size - chunk)
|
|
378
|
+
data = f.read().decode("utf-8", errors="replace")
|
|
379
|
+
all_lines = data.splitlines()
|
|
380
|
+
tail = all_lines[-lines:]
|
|
381
|
+
return {"ok": True, "path": str(path), "lines": len(tail), "total_lines": len(all_lines), "content": "\n".join(tail)}
|
|
382
|
+
except Exception as e:
|
|
383
|
+
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _post_supervisor_output(identity, kind: str, service: Optional[str], payload: dict, request_id: Optional[str] = None) -> None:
|
|
387
|
+
"""Send the result of a tail_logs or shell_run back to the scheduler.
|
|
388
|
+
|
|
389
|
+
Uses the same httpx call style as the status report itself; no
|
|
390
|
+
retry, no queue — if the scheduler is down we drop the result.
|
|
391
|
+
The dashboard times out and reports a clear error in that case."""
|
|
392
|
+
device_id, scheduler_url, token = identity
|
|
393
|
+
try:
|
|
394
|
+
import httpx
|
|
395
|
+
except ImportError:
|
|
396
|
+
return
|
|
397
|
+
url = f"{scheduler_url.rstrip('/')}/api/devices/{device_id}/supervisor-output"
|
|
398
|
+
body = {"kind": kind, "service": service, "payload": payload, "request_id": request_id}
|
|
399
|
+
try:
|
|
400
|
+
httpx.post(url, json=body, headers={"Authorization": f"Bearer {token}"}, timeout=5.0)
|
|
401
|
+
except Exception as e:
|
|
402
|
+
logger.debug(f"supervisor-output POST failed: {e}")
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _handle_shell_command(identity, cmd: dict) -> None:
|
|
406
|
+
"""Process a single shell/log request returned by the scheduler.
|
|
407
|
+
|
|
408
|
+
`cmd` is {id, kind: "shell_run"|"tail_logs"|"speaker_test", service,
|
|
409
|
+
params}. Runs the request, posts the result back, and returns."""
|
|
410
|
+
kind = cmd.get("kind", "shell_run")
|
|
411
|
+
service = cmd.get("service")
|
|
412
|
+
request_id = cmd.get("id")
|
|
413
|
+
params = cmd.get("params") or {}
|
|
414
|
+
if kind == "tail_logs":
|
|
415
|
+
# params: {lines: N, path?: override path}
|
|
416
|
+
path_param = params.get("path")
|
|
417
|
+
if path_param:
|
|
418
|
+
log_path = Path(path_param)
|
|
419
|
+
elif service:
|
|
420
|
+
log_path = (_CURRENT_LOG_DIR or LOG_DIR) / f"{service}.log"
|
|
421
|
+
else:
|
|
422
|
+
_post_supervisor_output(identity, kind, service, {"ok": False, "error": "tail_logs requires service or path"}, request_id)
|
|
423
|
+
return
|
|
424
|
+
result = _tail_log_file(log_path, int(params.get("lines", 200)))
|
|
425
|
+
elif kind == "shell_run":
|
|
426
|
+
result = _run_shell_command(params.get("name", ""), params)
|
|
427
|
+
elif kind == "speaker_test":
|
|
428
|
+
result = _speaker_roundtrip_test(params)
|
|
429
|
+
else:
|
|
430
|
+
result = {"ok": False, "error": f"unknown shell command kind: {kind!r}"}
|
|
431
|
+
_post_supervisor_output(identity, kind, service, result, request_id)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
# ── Speaker roundtrip test (C2 gap-fill) ──
|
|
435
|
+
# Plays a short tone through the robot's configured output device while
|
|
436
|
+
# simultaneously recording from the configured input device. Returns
|
|
437
|
+
# {played, recorded_rms, peak_db, duration_ms, output_device,
|
|
438
|
+
# input_device, error}.
|
|
439
|
+
#
|
|
440
|
+
# Used by the dashboard's "Test speaker" button. Helps catch the
|
|
441
|
+
# classic failure modes: speaker muted, wrong output device, mic
|
|
442
|
+
# pointing the wrong way, USB audio device unplugged. The result is
|
|
443
|
+
# a quick PASS/FAIL plus a peak-dB readout that the operator can
|
|
444
|
+
# read at a glance.
|
|
445
|
+
|
|
446
|
+
# Default test tone parameters — overridden by per-request params
|
|
447
|
+
SPEAKER_TEST_FREQUENCY_HZ = 1000.0
|
|
448
|
+
SPEAKER_TEST_DURATION_S = 0.6
|
|
449
|
+
SPEAKER_TEST_SAMPLE_RATE = 48000
|
|
450
|
+
SPEAKER_TEST_AMPLITUDE = 0.6 # 0..1; conservative so it doesn't clip
|
|
451
|
+
SPEAKER_TEST_OUTPUT_DEVICE = None # None = use ROBOPARK_AUDIO_OUTPUT / default
|
|
452
|
+
SPEAKER_TEST_INPUT_DEVICE = None
|
|
453
|
+
# Peak dB threshold: anything below this is reported as "no signal
|
|
454
|
+
# detected" (PASS means the robot heard the tone back; FAIL means
|
|
455
|
+
# the mic didn't pick it up). The default (-30 dB) is conservative
|
|
456
|
+
# for a quiet indoor environment; can be overridden per request.
|
|
457
|
+
SPEAKER_TEST_PEAK_DB_THRESHOLD = -30.0
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _resolve_audio_device(pa, selected: str, kind: str) -> Optional[int]:
|
|
461
|
+
"""Resolve a free-form device name/index to a PyAudio device index.
|
|
462
|
+
|
|
463
|
+
kind is "input" or "output". None / "default" picks the WASAPI
|
|
464
|
+
default device (per the same logic preview_agent.py uses for the
|
|
465
|
+
mic). If the name doesn't match any device, returns None and lets
|
|
466
|
+
PyAudio pick its global default — that may still work, but the
|
|
467
|
+
result is recorded so the operator can see it."""
|
|
468
|
+
if selected is None:
|
|
469
|
+
selected = "default"
|
|
470
|
+
s = str(selected).strip()
|
|
471
|
+
if s == "" or s.lower() == "default":
|
|
472
|
+
try:
|
|
473
|
+
wasapi = pa.get_host_api_info_by_type(pyaudio.paWASAPI)
|
|
474
|
+
key = "defaultInputDevice" if kind == "input" else "defaultOutputDevice"
|
|
475
|
+
idx = wasapi.get(key)
|
|
476
|
+
if idx is not None and idx >= 0:
|
|
477
|
+
return idx
|
|
478
|
+
except Exception:
|
|
479
|
+
pass
|
|
480
|
+
return None
|
|
481
|
+
if s.isdigit():
|
|
482
|
+
return int(s)
|
|
483
|
+
needle = s.lower()
|
|
484
|
+
want_channels = 1 if kind == "input" else 0 # input: must have >0
|
|
485
|
+
for i in range(pa.get_device_count()):
|
|
486
|
+
info = pa.get_device_info_by_index(i)
|
|
487
|
+
if kind == "output" and info.get("maxOutputChannels", 0) <= 0:
|
|
488
|
+
continue
|
|
489
|
+
if kind == "input" and info.get("maxInputChannels", 0) <= 0:
|
|
490
|
+
continue
|
|
491
|
+
if needle in str(info.get("name", "")).lower():
|
|
492
|
+
return i
|
|
493
|
+
return None
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _speaker_roundtrip_test(params: dict) -> dict:
|
|
497
|
+
"""Play a test tone + record the mic simultaneously; return metrics.
|
|
498
|
+
|
|
499
|
+
params may include {frequency, duration, amplitude, output, input,
|
|
500
|
+
threshold_db}. Anything missing falls back to the module constants."""
|
|
501
|
+
try:
|
|
502
|
+
import pyaudio
|
|
503
|
+
except ImportError as e:
|
|
504
|
+
return {"ok": False, "error": f"pyaudio not installed on this robot: {e}"}
|
|
505
|
+
freq = float(params.get("frequency", SPEAKER_TEST_FREQUENCY_HZ))
|
|
506
|
+
dur = float(params.get("duration", SPEAKER_TEST_DURATION_S))
|
|
507
|
+
amp = float(params.get("amplitude", SPEAKER_TEST_AMPLITUDE))
|
|
508
|
+
threshold_db = float(params.get("threshold_db", SPEAKER_TEST_PEAK_DB_THRESHOLD))
|
|
509
|
+
out_name = params.get("output", os.environ.get("ROBOPARK_AUDIO_OUTPUT") or SPEAKER_TEST_OUTPUT_DEVICE)
|
|
510
|
+
in_name = params.get("input", os.environ.get("ROBOPARK_AUDIO_INPUT") or SPEAKER_TEST_INPUT_DEVICE)
|
|
511
|
+
sample_rate = int(params.get("sample_rate", SPEAKER_TEST_SAMPLE_RATE))
|
|
512
|
+
if not (50.0 <= freq <= 8000.0):
|
|
513
|
+
return {"ok": False, "error": f"frequency {freq}Hz out of allowed range (50..8000)"}
|
|
514
|
+
if not (0.1 <= dur <= 3.0):
|
|
515
|
+
return {"ok": False, "error": f"duration {dur}s out of allowed range (0.1..3.0)"}
|
|
516
|
+
if not (0.05 <= amp <= 1.0):
|
|
517
|
+
return {"ok": False, "error": f"amplitude {amp} out of allowed range (0.05..1.0)"}
|
|
518
|
+
n_samples = int(sample_rate * dur)
|
|
519
|
+
if n_samples < 480:
|
|
520
|
+
return {"ok": False, "error": "duration too short"}
|
|
521
|
+
|
|
522
|
+
pa = pyaudio.PyAudio()
|
|
523
|
+
out_idx = _resolve_audio_device(pa, out_name, "output")
|
|
524
|
+
in_idx = _resolve_audio_device(pa, in_name, "input")
|
|
525
|
+
def _dev_name(idx):
|
|
526
|
+
if idx is None: return "(default)"
|
|
527
|
+
try: return pa.get_device_info_by_index(idx).get("name", str(idx))
|
|
528
|
+
except Exception: return str(idx)
|
|
529
|
+
out_label = _dev_name(out_idx)
|
|
530
|
+
in_label = _dev_name(in_idx)
|
|
531
|
+
|
|
532
|
+
frames = bytearray()
|
|
533
|
+
peak_value = int(32767 * amp)
|
|
534
|
+
for n in range(n_samples):
|
|
535
|
+
env = min(1.0, n / (sample_rate * 0.004), (n_samples - n) / (sample_rate * 0.008))
|
|
536
|
+
value = int(peak_value * env * math.sin(2 * math.pi * freq * n / sample_rate))
|
|
537
|
+
frames.extend(struct.pack("<hh", value, value))
|
|
538
|
+
raw = bytes(frames)
|
|
539
|
+
|
|
540
|
+
recorded_peak = 0
|
|
541
|
+
recorded_rms = 0.0
|
|
542
|
+
err = None
|
|
543
|
+
t0 = time.monotonic()
|
|
544
|
+
try:
|
|
545
|
+
out_stream = pa.open(
|
|
546
|
+
format=pyaudio.paInt16,
|
|
547
|
+
channels=2,
|
|
548
|
+
rate=sample_rate,
|
|
549
|
+
output=True,
|
|
550
|
+
output_device_index=out_idx,
|
|
551
|
+
)
|
|
552
|
+
chunk = 1024
|
|
553
|
+
in_stream = pa.open(
|
|
554
|
+
format=pyaudio.paInt16,
|
|
555
|
+
channels=1,
|
|
556
|
+
rate=sample_rate,
|
|
557
|
+
input=True,
|
|
558
|
+
input_device_index=in_idx,
|
|
559
|
+
frames_per_buffer=chunk,
|
|
560
|
+
)
|
|
561
|
+
in_stream.start_stream()
|
|
562
|
+
chunk_bytes = chunk * 2 * 2
|
|
563
|
+
pos = 0
|
|
564
|
+
peak_acc = 0
|
|
565
|
+
rms_acc = 0.0
|
|
566
|
+
n_samp = 0
|
|
567
|
+
deadline = time.monotonic() + dur + 1.5
|
|
568
|
+
while pos < len(raw) and time.monotonic() < deadline:
|
|
569
|
+
end = min(pos + chunk_bytes, len(raw))
|
|
570
|
+
out_stream.write(raw[pos:end])
|
|
571
|
+
pos = end
|
|
572
|
+
try:
|
|
573
|
+
avail = in_stream.get_read_available()
|
|
574
|
+
if avail and avail > 0:
|
|
575
|
+
data = in_stream.read(avail, exception_on_overflow=False)
|
|
576
|
+
for s in range(0, len(data) - 1, 2):
|
|
577
|
+
v = int.from_bytes(data[s:s+2], "little", signed=True)
|
|
578
|
+
a = abs(v)
|
|
579
|
+
if a > peak_acc: peak_acc = a
|
|
580
|
+
rms_acc += v * v
|
|
581
|
+
n_samp += 1
|
|
582
|
+
except Exception:
|
|
583
|
+
pass
|
|
584
|
+
out_stream.stop_stream(); out_stream.close()
|
|
585
|
+
try:
|
|
586
|
+
tail = in_stream.read(n_samples, exception_on_overflow=False)
|
|
587
|
+
for s in range(0, len(tail) - 1, 2):
|
|
588
|
+
v = int.from_bytes(tail[s:s+2], "little", signed=True)
|
|
589
|
+
a = abs(v)
|
|
590
|
+
if a > peak_acc: peak_acc = a
|
|
591
|
+
rms_acc += v * v
|
|
592
|
+
n_samp += 1
|
|
593
|
+
except Exception:
|
|
594
|
+
pass
|
|
595
|
+
in_stream.stop_stream(); in_stream.close()
|
|
596
|
+
recorded_peak = peak_acc
|
|
597
|
+
recorded_rms = (rms_acc / max(1, n_samp)) ** 0.5
|
|
598
|
+
except Exception as e:
|
|
599
|
+
err = f"{type(e).__name__}: {e}"
|
|
600
|
+
finally:
|
|
601
|
+
try: pa.terminate()
|
|
602
|
+
except Exception: pass
|
|
603
|
+
duration_ms = int((time.monotonic() - t0) * 1000)
|
|
604
|
+
if err:
|
|
605
|
+
return {"ok": False, "error": err, "duration_ms": duration_ms,
|
|
606
|
+
"output_device": out_label, "input_device": in_label,
|
|
607
|
+
"frequency": freq, "duration": dur}
|
|
608
|
+
if recorded_peak > 0:
|
|
609
|
+
peak_db = 20.0 * math.log10(recorded_peak / 32767.0)
|
|
610
|
+
else:
|
|
611
|
+
peak_db = -120.0
|
|
612
|
+
if recorded_rms > 0:
|
|
613
|
+
rms_db = 20.0 * math.log10(recorded_rms / 32767.0)
|
|
614
|
+
else:
|
|
615
|
+
rms_db = -120.0
|
|
616
|
+
pass_ = peak_db >= threshold_db
|
|
617
|
+
return {
|
|
618
|
+
"ok": True,
|
|
619
|
+
"pass": pass_,
|
|
620
|
+
"frequency": freq,
|
|
621
|
+
"duration": dur,
|
|
622
|
+
"duration_ms": duration_ms,
|
|
623
|
+
"sample_rate": sample_rate,
|
|
624
|
+
"amplitude": amp,
|
|
625
|
+
"output_device": out_label,
|
|
626
|
+
"input_device": in_label,
|
|
627
|
+
"recorded_peak": int(recorded_peak),
|
|
628
|
+
"recorded_peak_db": round(peak_db, 1),
|
|
629
|
+
"recorded_rms": round(recorded_rms, 1),
|
|
630
|
+
"recorded_rms_db": round(rms_db, 1),
|
|
631
|
+
"threshold_db": threshold_db,
|
|
632
|
+
"diagnostic": ("speaker + mic round-trip OK" if pass_ else
|
|
633
|
+
"no signal detected — check that the speaker is on, the mic isn't muted, and the right output/input devices are selected"),
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def run(config_path: Path) -> None:
|
|
638
|
+
global _CURRENT_LOG_DIR
|
|
639
|
+
services, log_dir = _load_config(config_path)
|
|
640
|
+
_CURRENT_LOG_DIR = log_dir
|
|
641
|
+
enabled = [s for s in services if s.enabled]
|
|
642
|
+
if not enabled:
|
|
643
|
+
raise SystemExit(f"No enabled services in {config_path} — nothing to supervise.")
|
|
644
|
+
|
|
645
|
+
states = [ServiceState(spec=s) for s in enabled]
|
|
646
|
+
for state in states:
|
|
647
|
+
_spawn(state, log_dir)
|
|
648
|
+
|
|
649
|
+
shutdown = {"flag": False}
|
|
650
|
+
|
|
651
|
+
def _on_signal(signum, frame):
|
|
652
|
+
logger.info(f"received signal {signum}, shutting down all services…")
|
|
653
|
+
shutdown["flag"] = True
|
|
654
|
+
|
|
655
|
+
signal.signal(signal.SIGINT, _on_signal)
|
|
656
|
+
signal.signal(signal.SIGTERM, _on_signal)
|
|
657
|
+
|
|
658
|
+
logger.info(f"supervising {len(states)} service(s): {', '.join(s.spec.name for s in states)}")
|
|
659
|
+
|
|
660
|
+
last_status_report_at = 0.0
|
|
661
|
+
try:
|
|
662
|
+
while not shutdown["flag"]:
|
|
663
|
+
now = time.monotonic()
|
|
664
|
+
for state in states:
|
|
665
|
+
if state.proc is None:
|
|
666
|
+
if not state.stopped and now >= state.next_restart_at:
|
|
667
|
+
_spawn(state, log_dir)
|
|
668
|
+
continue
|
|
669
|
+
ret = state.proc.poll()
|
|
670
|
+
if ret is None:
|
|
671
|
+
continue # still running
|
|
672
|
+
# Exited. A long uptime before exit resets the backoff —
|
|
673
|
+
# otherwise a service that's crash-looping keeps backing off.
|
|
674
|
+
uptime = now - state.started_at
|
|
675
|
+
state.last_exit_code = ret
|
|
676
|
+
if uptime >= STABLE_UPTIME_SECONDS:
|
|
677
|
+
state.failure_count = 0
|
|
678
|
+
else:
|
|
679
|
+
state.failure_count += 1
|
|
680
|
+
if state.log_file:
|
|
681
|
+
state.log_file.write(f"=== supervisor: {state.spec.name} exited with code {ret} (uptime {uptime:.0f}s) ===\n")
|
|
682
|
+
state.log_file.close()
|
|
683
|
+
state.log_file = None
|
|
684
|
+
state.proc = None
|
|
685
|
+
_schedule_restart(state)
|
|
686
|
+
if now - last_status_report_at >= STATUS_REPORT_INTERVAL_SECONDS:
|
|
687
|
+
last_status_report_at = now
|
|
688
|
+
identity = _load_robopark_identity()
|
|
689
|
+
if identity:
|
|
690
|
+
commands = _report_status(states, identity)
|
|
691
|
+
by_name = {s.spec.name: s for s in states}
|
|
692
|
+
for cmd in commands:
|
|
693
|
+
kind = cmd.get("kind", "supervisor_action")
|
|
694
|
+
target = by_name.get(cmd.get("service_name"))
|
|
695
|
+
if kind == "supervisor_action" and target:
|
|
696
|
+
_execute_command(target, cmd.get("action", "restart"))
|
|
697
|
+
elif kind == "supervisor_action":
|
|
698
|
+
logger.warning(f"remote command for unknown service {cmd.get('service_name')!r} ignored")
|
|
699
|
+
elif kind in ("shell_run", "tail_logs", "speaker_test"):
|
|
700
|
+
# run on a background thread so we don't block
|
|
701
|
+
# the 2s poll loop on a slow command
|
|
702
|
+
import threading
|
|
703
|
+
threading.Thread(target=_handle_shell_command, args=(identity, cmd), daemon=True).start()
|
|
704
|
+
else:
|
|
705
|
+
logger.warning(f"unknown remote command kind: {kind!r}")
|
|
706
|
+
else:
|
|
707
|
+
logger.debug("not enrolled yet (no ~/.robopark/preview_agent.json + device_token) -- skipping status report")
|
|
708
|
+
time.sleep(POLL_INTERVAL_SECONDS)
|
|
709
|
+
finally:
|
|
710
|
+
for state in states:
|
|
711
|
+
_terminate(state)
|
|
712
|
+
logger.info("all services stopped, exiting")
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def main() -> None:
|
|
716
|
+
# stdout/stderr are fully buffered (not line-buffered) once redirected to
|
|
717
|
+
# a file or pipe -- which is exactly how Task Scheduler/systemd run this.
|
|
718
|
+
# Without this, restart/crash log lines can sit unflushed for minutes.
|
|
719
|
+
sys.stdout.reconfigure(line_buffering=True)
|
|
720
|
+
sys.stderr.reconfigure(line_buffering=True)
|
|
721
|
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
|
722
|
+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
723
|
+
parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG_FILE, help="path to supervisor.json")
|
|
724
|
+
args = parser.parse_args()
|
|
725
|
+
run(args.config)
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
if __name__ == "__main__":
|
|
729
|
+
main()
|