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.
@@ -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()