robopark 2.8.35 → 3.0.0

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.
Files changed (68) hide show
  1. package/README.md +88 -63
  2. package/bin/robopark.js +7 -17
  3. package/conversation/elevenlabs_agent.py +1985 -0
  4. package/conversation/requirements.txt +3 -0
  5. package/conversation/supervisor_store.py +189 -0
  6. package/dist/kernel/config-schema.js +37 -0
  7. package/dist/kernel/types.js +7 -0
  8. package/dist/robopark/access.js +99 -0
  9. package/dist/robopark/add-robot.js +188 -0
  10. package/dist/robopark/agent-ctl.js +305 -0
  11. package/dist/robopark/auto-start.js +289 -0
  12. package/dist/robopark/conversation.js +505 -0
  13. package/dist/robopark/deployment-commands.js +47 -0
  14. package/dist/robopark/discovery.js +180 -0
  15. package/dist/robopark/doctor.js +175 -0
  16. package/dist/robopark/enroll.js +68 -0
  17. package/dist/robopark/llm-set.js +87 -0
  18. package/dist/robopark/motor-control.js +195 -0
  19. package/dist/robopark/preview-agent-launcher.js +77 -0
  20. package/dist/robopark/probe.js +138 -0
  21. package/dist/robopark/profile.js +69 -0
  22. package/dist/robopark/python-env.js +162 -0
  23. package/dist/robopark/robot-runtime.js +489 -0
  24. package/dist/robopark/scan.js +97 -0
  25. package/dist/robopark/screen-control.js +55 -0
  26. package/dist/robopark/secrets.js +41 -0
  27. package/dist/robopark/serve.js +285 -0
  28. package/dist/robopark/server-add.js +114 -0
  29. package/dist/robopark/setup-livekit.js +300 -0
  30. package/dist/robopark/setup.js +286 -0
  31. package/dist/robopark/standalone.js +466 -0
  32. package/dist/robopark/stop-all.js +141 -0
  33. package/dist/robopark/verify.js +192 -0
  34. package/dist/robopark/vision-agent-launcher.js +98 -0
  35. package/dist/robopark/vision-control.js +81 -0
  36. package/dist/robopark-cli.js +799 -0
  37. package/package.json +21 -5
  38. package/pi-client/_install_steps.sh +29 -29
  39. package/pi-client/client.py +61 -2
  40. package/pi-client/install.sh +40 -40
  41. package/pi-client/join_convo.sh +54 -54
  42. package/pi-client/livekit_bridge.py +16 -7
  43. package/pi-client/motor_bridge.py +6 -3
  44. package/scheduler/fleet_config.json +75 -0
  45. package/scheduler/main.py +4505 -135
  46. package/scheduler/media_lock.py +57 -0
  47. package/scheduler/preview_agent.py +1465 -87
  48. package/scheduler/production_config.json +139 -0
  49. package/scheduler/robot_supervisor.py +1705 -0
  50. package/scheduler/scripts/install-robot-supervisor-linux.sh +33 -0
  51. package/scheduler/scripts/install-robot-supervisor-windows.ps1 +49 -0
  52. package/scheduler/scripts/robopark-supervisor.service +20 -0
  53. package/scheduler/scripts/start-scheduler-local.ps1 +50 -0
  54. package/scheduler/supervisor.example.json +26 -0
  55. package/scheduler/vision_motion_trigger.py +101 -0
  56. package/screen/screen_runtime.py +75 -0
  57. package/vision/app_pi_clean.py +253 -16
  58. package/vision/audio_server_pi.py +19 -0
  59. package/vision/install.sh +34 -34
  60. package/vision/motor_server.py +224 -61
  61. package/vision/requirements_camera.txt +6 -0
  62. package/vision/requirements_motor.txt +4 -0
  63. package/vision/requirements_pi_unified.txt +1 -0
  64. package/vision/requirements_vision_agent.txt +19 -0
  65. package/vision/run.sh +244 -244
  66. package/vision/services/services.sh +12 -12
  67. package/scheduler/__pycache__/main.cpython-312.pyc +0 -0
  68. package/scheduler/__pycache__/preview_agent.cpython-312.pyc +0 -0
@@ -0,0 +1,1705 @@
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 base64
35
+ import csv
36
+ import json
37
+ import logging
38
+ import math
39
+ import os
40
+ import re
41
+ import signal
42
+ import struct
43
+ import subprocess
44
+ import sys
45
+ import time
46
+ import urllib.error
47
+ import urllib.request
48
+ from dataclasses import dataclass, field
49
+ from pathlib import Path
50
+ from typing import Optional
51
+
52
+ logger = logging.getLogger("robopark.supervisor")
53
+
54
+ CONFIG_DIR = Path.home() / ".robopark"
55
+ DEFAULT_CONFIG_FILE = CONFIG_DIR / "supervisor.json"
56
+ LOG_DIR = CONFIG_DIR / "logs"
57
+
58
+ # A service that has run this long without exiting is considered stable —
59
+ # its failure/backoff count resets so a single crash after weeks of uptime
60
+ # doesn't get treated like a crash loop.
61
+ STABLE_UPTIME_SECONDS = 60.0
62
+ BACKOFF_BASE_SECONDS = 2.0
63
+ BACKOFF_MAX_SECONDS = 60.0
64
+ LOG_ROTATE_BYTES = 5 * 1024 * 1024 # rotate a service's log past 5MB
65
+ POLL_INTERVAL_SECONDS = 2.0
66
+ STATUS_REPORT_INTERVAL_SECONDS = 10.0
67
+
68
+
69
+ def _scheduler_headers(token: str) -> dict[str, str]:
70
+ """Authenticate the enrolled device and, when present, the mesh proxy."""
71
+ headers = {"Authorization": f"Bearer {token}"}
72
+ mesh_token = os.getenv("ROBOPARK_MESH_TOKEN", "").strip()
73
+ if mesh_token:
74
+ headers["X-RoboPark-Mesh-Token"] = mesh_token
75
+ return headers
76
+
77
+
78
+ def _motor_headers() -> dict[str, str]:
79
+ token = (
80
+ os.getenv("ROBOPARK_MOTOR_TOKEN", "").strip()
81
+ or os.getenv("ROBOPARK_MESH_TOKEN", "").strip()
82
+ )
83
+ return {"X-RoboPark-Motor-Token": token} if token else {}
84
+
85
+
86
+ def _load_robopark_identity() -> Optional[tuple[str, str, str]]:
87
+ """Reuse preview_agent.py's own enrollment files (same ~/.robopark/ dir)
88
+ so the supervisor can report status to the scheduler without needing
89
+ separate credentials. Returns (device_id, scheduler_url, token) or None
90
+ if this robot hasn't been enrolled yet."""
91
+ cfg_file = CONFIG_DIR / "preview_agent.json"
92
+ token_file = CONFIG_DIR / "device_token"
93
+ if not cfg_file.exists() or not token_file.exists():
94
+ return None
95
+ try:
96
+ cfg = json.loads(cfg_file.read_text(encoding="utf8"))
97
+ device_id = cfg.get("device_id")
98
+ scheduler_url = cfg.get("scheduler_url", "http://localhost:8080")
99
+ token = token_file.read_text(encoding="utf8").strip()
100
+ if not device_id or not token:
101
+ return None
102
+ return device_id, scheduler_url, token
103
+ except Exception:
104
+ return None
105
+
106
+
107
+ def _systemd_service_statuses() -> list[dict]:
108
+ """Report persistent RoboPark units even in command-only mode."""
109
+ if sys.platform == "win32":
110
+ try:
111
+ listed = subprocess.run(
112
+ ["schtasks", "/Query", "/FO", "CSV", "/NH"],
113
+ capture_output=True, text=True, timeout=10, check=False,
114
+ )
115
+ except Exception as exc:
116
+ logger.debug("could not enumerate Windows tasks: %s", exc)
117
+ return []
118
+ rows = []
119
+ for columns in csv.reader(listed.stdout.splitlines()):
120
+ task = columns[0].lstrip("\\") if columns else ""
121
+ if not task.lower().startswith("robopark-"):
122
+ continue
123
+ status = columns[2].strip().lower() if len(columns) > 2 else "unknown"
124
+ rows.append({
125
+ "name": task,
126
+ "enabled": status != "disabled",
127
+ "running": status == "running",
128
+ "pid": None,
129
+ "uptime_seconds": None,
130
+ "failure_count": 0,
131
+ "last_exit_code": None,
132
+ })
133
+ return rows
134
+ if sys.platform != "linux":
135
+ return []
136
+ try:
137
+ listed = subprocess.run(
138
+ ["systemctl", "list-unit-files", "robopark-*.service", "--no-legend", "--no-pager"],
139
+ capture_output=True, text=True, timeout=5, check=False,
140
+ )
141
+ except Exception as exc:
142
+ logger.debug("could not enumerate systemd services: %s", exc)
143
+ return []
144
+ rows = []
145
+ for line in listed.stdout.splitlines():
146
+ columns = line.strip().split()
147
+ unit = columns[0] if columns else ""
148
+ if not re.fullmatch(r"robopark-[a-z0-9-]+\.service", unit):
149
+ continue
150
+ show = subprocess.run(
151
+ ["systemctl", "show", unit, "--property=ActiveState,MainPID,ExecMainStatus", "--value"],
152
+ capture_output=True, text=True, timeout=5, check=False,
153
+ )
154
+ values = show.stdout.splitlines()
155
+ active = values[0].strip() if values else "unknown"
156
+ pid = int(values[1]) if len(values) > 1 and values[1].isdigit() and int(values[1]) else None
157
+ exit_code = int(values[2]) if len(values) > 2 and values[2].lstrip("-").isdigit() else None
158
+ rows.append({
159
+ "name": unit,
160
+ "enabled": any(value.startswith("enabled") for value in columns[1:]),
161
+ "running": active == "active",
162
+ "pid": pid,
163
+ "uptime_seconds": None,
164
+ "failure_count": 0,
165
+ "last_exit_code": exit_code,
166
+ })
167
+ return rows
168
+
169
+
170
+ def _report_status(states: "list[ServiceState]", identity: tuple[str, str, str]) -> list[dict]:
171
+ """POST current service status; returns any pending remote-control
172
+ commands the scheduler had queued for this device (e.g. an operator
173
+ clicking "restart" in the dashboard) -- delivered on this same
174
+ request/response cycle rather than a separate poll."""
175
+ device_id, scheduler_url, token = identity
176
+ try:
177
+ import httpx
178
+ except ImportError:
179
+ logger.debug("httpx not installed -- skipping status report (see requirements-robot.txt)")
180
+ return []
181
+ services = [s.status_dict() for s in states]
182
+ known = {service["name"] for service in services}
183
+ services.extend(service for service in _systemd_service_statuses() if service["name"] not in known)
184
+ payload = {"services": services}
185
+ url = f"{scheduler_url.rstrip('/')}/api/devices/{device_id}/supervisor-status"
186
+ try:
187
+ resp = httpx.post(url, json=payload, headers=_scheduler_headers(token), timeout=5.0)
188
+ resp.raise_for_status()
189
+ return resp.json().get("commands", [])
190
+ except Exception as e:
191
+ logger.debug(f"status report failed (scheduler unreachable?): {e}")
192
+ return []
193
+
194
+
195
+ @dataclass
196
+ class ServiceSpec:
197
+ name: str
198
+ enabled: bool
199
+ command: list[str]
200
+ cwd: Optional[str] = None
201
+ env: Optional[dict] = None
202
+
203
+
204
+ @dataclass
205
+ class ServiceState:
206
+ spec: ServiceSpec
207
+ proc: Optional[subprocess.Popen] = None
208
+ log_file: Optional[object] = None
209
+ started_at: float = 0.0
210
+ failure_count: int = 0
211
+ next_restart_at: float = 0.0
212
+ last_exit_code: Optional[int] = None
213
+ stopped: bool = False # true once the supervisor is shutting down
214
+
215
+ def status_dict(self) -> dict:
216
+ running = self.proc is not None
217
+ return {
218
+ "name": self.spec.name,
219
+ "enabled": self.spec.enabled,
220
+ "running": running,
221
+ "pid": self.proc.pid if running else None,
222
+ "uptime_seconds": (time.monotonic() - self.started_at) if running else None,
223
+ "failure_count": self.failure_count,
224
+ "last_exit_code": self.last_exit_code,
225
+ }
226
+
227
+
228
+ def _load_config(path: Path) -> tuple[list[ServiceSpec], Path]:
229
+ if not path.exists():
230
+ raise SystemExit(
231
+ f"No config at {path}. Copy supervisor.example.json there and "
232
+ f"edit it, or pass --config PATH."
233
+ )
234
+ data = json.loads(path.read_text(encoding="utf8"))
235
+ services = [
236
+ ServiceSpec(
237
+ name=s["name"],
238
+ enabled=bool(s.get("enabled", False)),
239
+ command=list(s["command"]),
240
+ cwd=s.get("cwd"),
241
+ env=s.get("env"),
242
+ )
243
+ for s in data.get("services", [])
244
+ ]
245
+ log_dir = Path(data.get("log_dir", str(LOG_DIR))).expanduser()
246
+ return services, log_dir
247
+
248
+
249
+ def _open_log(log_dir: Path, name: str):
250
+ log_dir.mkdir(parents=True, exist_ok=True)
251
+ path = log_dir / f"{name}.log"
252
+ if path.exists() and path.stat().st_size > LOG_ROTATE_BYTES:
253
+ rotated = log_dir / f"{name}.log.1"
254
+ try:
255
+ rotated.unlink(missing_ok=True)
256
+ path.rename(rotated)
257
+ except OSError as e:
258
+ logger.warning(f"log rotate failed for {name}: {e}")
259
+ return open(path, "a", encoding="utf8", buffering=1)
260
+
261
+
262
+ def _spawn(state: ServiceState, log_dir: Path) -> None:
263
+ spec = state.spec
264
+ state.log_file = _open_log(log_dir, spec.name)
265
+ env = os.environ.copy()
266
+ if spec.env:
267
+ env.update(spec.env)
268
+ banner = f"\n=== supervisor: starting {spec.name} at {time.strftime('%Y-%m-%d %H:%M:%S')} ===\n"
269
+ state.log_file.write(banner)
270
+ try:
271
+ state.proc = subprocess.Popen(
272
+ spec.command,
273
+ cwd=spec.cwd,
274
+ env=env,
275
+ stdout=state.log_file,
276
+ stderr=subprocess.STDOUT,
277
+ stdin=subprocess.DEVNULL,
278
+ )
279
+ state.started_at = time.monotonic()
280
+ logger.info(f"started {spec.name} (pid={state.proc.pid}): {' '.join(spec.command)}")
281
+ except Exception as e:
282
+ logger.error(f"failed to start {spec.name}: {e}")
283
+ state.proc = None
284
+ state.failure_count += 1
285
+
286
+
287
+ def _schedule_restart(state: ServiceState) -> None:
288
+ backoff = min(BACKOFF_BASE_SECONDS * (2 ** state.failure_count), BACKOFF_MAX_SECONDS)
289
+ state.next_restart_at = time.monotonic() + backoff
290
+ logger.warning(f"{state.spec.name} exited — restarting in {backoff:.0f}s (failure #{state.failure_count})")
291
+
292
+
293
+ def _terminate(state: ServiceState) -> None:
294
+ if not state.proc:
295
+ return
296
+ try:
297
+ state.proc.terminate()
298
+ try:
299
+ state.proc.wait(timeout=5)
300
+ except subprocess.TimeoutExpired:
301
+ logger.warning(f"{state.spec.name} did not exit in time, killing")
302
+ state.proc.kill()
303
+ state.proc.wait(timeout=5)
304
+ except Exception as e:
305
+ logger.error(f"error stopping {state.spec.name}: {e}")
306
+ finally:
307
+ if state.log_file:
308
+ state.log_file.close()
309
+ state.log_file = None
310
+
311
+
312
+ def _execute_command(state: ServiceState, action: str) -> None:
313
+ """Operator-initiated action from the dashboard. Supports:
314
+ - "restart" (original): terminate if running, the main loop
315
+ respawns it on the next tick.
316
+ - "start": spawn the service if it's not already running. Used
317
+ after a deliberate stop.
318
+ - "stop": graceful terminate and mark `stopped=True` so the main
319
+ loop does NOT auto-restart it. Re-enable with "start".
320
+ Anything else is ignored with a warning."""
321
+ if action == "restart":
322
+ # clear any deliberate-stop so the next main-loop tick is allowed
323
+ # to respawn it
324
+ state.stopped = False
325
+ if state.proc is not None:
326
+ _terminate(state)
327
+ state.proc = None
328
+ state.failure_count = 0
329
+ state.next_restart_at = 0.0
330
+ return
331
+ if action == "start":
332
+ state.stopped = False
333
+ state.failure_count = 0
334
+ state.next_restart_at = 0.0
335
+ if state.proc is None:
336
+ _spawn(state, _CURRENT_LOG_DIR or LOG_DIR)
337
+ return
338
+ if action == "stop":
339
+ if state.proc is not None:
340
+ _terminate(state)
341
+ state.proc = None
342
+ state.stopped = True
343
+ return
344
+ logger.warning(f"ignoring unknown remote command {action!r} for {state.spec.name}")
345
+
346
+
347
+ def _execute_systemd_action(service_name: str, action: str) -> None:
348
+ """Control allowlisted RoboPark systemd units or Windows scheduled tasks."""
349
+ if action not in {"start", "stop", "restart"}:
350
+ logger.warning("rejected unsupported system service action %r %r", service_name, action)
351
+ return
352
+ aliases = {
353
+ "runtime": "robopark-robot-runtime-*.service",
354
+ "mesh": "robopark-robot-runtime-*.service",
355
+ "vision": "robopark-robot-runtime-*.service",
356
+ "preview": "robopark-robot-runtime-*.service",
357
+ "conversation": "robopark-robot-conversation-*.service",
358
+ "motor": "robopark-robot-motor-*.service",
359
+ "screen": "robopark-robot-screen-*.service",
360
+ }
361
+ if sys.platform == "win32":
362
+ prefixes = {
363
+ "runtime": "RoboPark-robot-runtime-",
364
+ "mesh": "RoboPark-robot-runtime-",
365
+ "vision": "RoboPark-robot-runtime-",
366
+ "preview": "RoboPark-robot-runtime-",
367
+ "conversation": "RoboPark-robot-conversation-",
368
+ "motor": "RoboPark-robot-motor-",
369
+ "screen": "RoboPark-robot-screen-",
370
+ }
371
+ prefix = prefixes.get(service_name)
372
+ if not prefix:
373
+ logger.warning("rejected non-RoboPark Windows service %r", service_name)
374
+ return
375
+ try:
376
+ listed = subprocess.run(
377
+ ["schtasks", "/Query", "/FO", "CSV", "/NH"],
378
+ capture_output=True, text=True, timeout=10, check=False,
379
+ )
380
+ tasks = []
381
+ for row in csv.reader(listed.stdout.splitlines()):
382
+ task = row[0].lstrip("\\") if row else ""
383
+ if task.lower().startswith(prefix.lower()):
384
+ tasks.append(task)
385
+ for task in tasks:
386
+ if action in {"stop", "restart"}:
387
+ subprocess.run(["schtasks", "/End", "/TN", task], capture_output=True, timeout=20, check=False)
388
+ if action in {"start", "restart"}:
389
+ result = subprocess.run(["schtasks", "/Run", "/TN", task], capture_output=True, text=True, timeout=20, check=False)
390
+ if result.returncode:
391
+ logger.error("schtasks %s %s failed: %s", action, task, (result.stderr or result.stdout).strip())
392
+ if not tasks:
393
+ logger.warning("no installed Windows task matches service %r", service_name)
394
+ except Exception as exc:
395
+ logger.error("Windows service action failed: %s", exc)
396
+ return
397
+ if sys.platform != "linux":
398
+ logger.warning("service actions are unsupported on %s", sys.platform)
399
+ return
400
+ pattern = aliases.get(service_name, service_name)
401
+ if not re.fullmatch(r"robopark-[a-z0-9*-]+\.service", pattern):
402
+ logger.warning("rejected non-RoboPark service %r", service_name)
403
+ return
404
+ try:
405
+ listed = subprocess.run(
406
+ ["systemctl", "list-unit-files", pattern, "--no-legend", "--no-pager"],
407
+ capture_output=True, text=True, timeout=5, check=False,
408
+ )
409
+ units = []
410
+ for line in listed.stdout.splitlines():
411
+ unit = line.strip().split()[0] if line.strip() else ""
412
+ if re.fullmatch(r"robopark-[a-z0-9-]+\.service", unit):
413
+ units.append(unit)
414
+ if not units:
415
+ logger.warning("no installed unit matches service %r", service_name)
416
+ return
417
+ for unit in units:
418
+ result = subprocess.run(
419
+ ["systemctl", action, unit], capture_output=True, text=True, timeout=20, check=False,
420
+ )
421
+ if result.returncode:
422
+ logger.error("systemctl %s %s failed: %s", action, unit, (result.stderr or result.stdout).strip())
423
+ else:
424
+ logger.info("systemctl %s %s completed", action, unit)
425
+ except Exception as exc:
426
+ logger.error("system service action failed: %s", exc)
427
+
428
+
429
+ # ── Operator shell (C1 gap-fill) ──
430
+ # Allows the dashboard to read service logs and run a small set of
431
+ # pre-approved diagnostic commands on the robot. Results are POSTed back
432
+ # to the scheduler (POST /api/devices/{id}/supervisor-output) which
433
+ # caches them per (device, command-id) so the dashboard can poll the
434
+ # result without holding a long-lived connection to the robot.
435
+ #
436
+ # SECURITY: the allowlist below is the only thing that gets executed.
437
+ # Operators cannot pass arbitrary commands; the parser rejects anything
438
+ # not on this list with a 400-equivalent ("command not allowed").
439
+
440
+ import secrets as _secrets # noqa: E402
441
+
442
+ # A list of allow-listed command specs. Each entry has a fixed argv;
443
+ # placeholders in <angle brackets> get substituted from the request.
444
+ # A request that doesn't match any entry is rejected.
445
+ SHELL_ALLOWLIST = [
446
+ {"name": "uptime", "argv": ["uptime"]},
447
+ {"name": "free", "argv": ["free", "-m"]},
448
+ {"name": "df", "argv": ["df", "-h"]},
449
+ {"name": "ps", "argv": ["ps", "aux"]},
450
+ {"name": "uname", "argv": ["uname", "-a"]},
451
+ {"name": "date", "argv": ["date"]},
452
+ {"name": "whoami", "argv": ["whoami"]},
453
+ {"name": "hostname", "argv": ["hostname"]},
454
+ {"name": "ip", "argv": ["ip", "addr"]},
455
+ {"name": "ss", "argv": ["ss", "-tlnp"]},
456
+ {"name": "os-release", "argv": ["cat", "/etc/os-release"]},
457
+ {"name": "ls-logs", "argv": ["ls", "-la", "<dir>"]},
458
+ {"name": "systemctl-status", "argv": ["systemctl", "status", "<name>", "--no-pager", "-n", "30"]},
459
+ {"name": "journalctl", "argv": ["journalctl", "-u", "<name>", "-n", "200", "--no-pager"]},
460
+ {"name": "tail", "argv": ["tail", "-n", "<n>", "<path>"]},
461
+ ]
462
+
463
+ # Cap output size per command to avoid an operator accidentally filling
464
+ # the supervisor output buffer with 100MB of `ps aux`.
465
+ SHELL_OUTPUT_MAX_BYTES = 64 * 1024
466
+ JOURNAL_OUTPUT_MAX_BYTES = 512 * 1024
467
+ SHELL_RUN_TIMEOUT_SECONDS = 8.0
468
+
469
+ VOICE_ENGINE_COMMANDS = {
470
+ "voice-engine-status",
471
+ "voice-engine-switch",
472
+ "voice-engine-restart",
473
+ "voice-engine-stop",
474
+ "voice-engine-trigger",
475
+ "voice-engine-dispose",
476
+ "voice-engine-recover",
477
+ "voice-engine-audio-status",
478
+ "voice-engine-audio-release",
479
+ "voice-engine-audio-recover",
480
+ "conversation-journal-backfill",
481
+ }
482
+
483
+
484
+ def _systemctl(action: str, unit: str) -> tuple[bool, str]:
485
+ try:
486
+ proc = subprocess.run(
487
+ ["systemctl", action, unit],
488
+ capture_output=True,
489
+ text=True,
490
+ timeout=20,
491
+ check=False,
492
+ )
493
+ detail = (proc.stderr or proc.stdout or "").strip()
494
+ return proc.returncode == 0, detail
495
+ except (FileNotFoundError, subprocess.TimeoutExpired, OSError) as exc:
496
+ return False, f"{type(exc).__name__}: {exc}"
497
+
498
+
499
+ def _user_systemctl(action: str, unit: str) -> tuple[bool, str]:
500
+ """Control a kiosk user unit, including from a root-owned supervisor."""
501
+ try:
502
+ identities: list[tuple[int, Optional[str]]] = [(os.getuid(), None)]
503
+ if os.getuid() == 0:
504
+ import pwd
505
+
506
+ for service_path in Path("/home").glob(f"*/.config/systemd/user/{unit}"):
507
+ username = service_path.parts[2]
508
+ identity = (pwd.getpwnam(username).pw_uid, username)
509
+ if identity not in identities:
510
+ identities.append(identity)
511
+ details = []
512
+ for uid, username in identities:
513
+ env = {
514
+ **os.environ,
515
+ "XDG_RUNTIME_DIR": f"/run/user/{uid}",
516
+ "DBUS_SESSION_BUS_ADDRESS": f"unix:path=/run/user/{uid}/bus",
517
+ }
518
+ argv = ["systemctl", "--user", action, unit]
519
+ if username:
520
+ argv = ["runuser", "-u", username, "--", *argv]
521
+ proc = subprocess.run(
522
+ argv,
523
+ capture_output=True,
524
+ text=True,
525
+ timeout=20,
526
+ check=False,
527
+ env=env,
528
+ )
529
+ detail = (proc.stderr or proc.stdout or "").strip()
530
+ if proc.returncode == 0:
531
+ return True, detail
532
+ if detail:
533
+ details.append(detail)
534
+ return False, "; ".join(details)
535
+ except (AttributeError, FileNotFoundError, subprocess.TimeoutExpired, OSError) as exc:
536
+ return False, f"{type(exc).__name__}: {exc}"
537
+
538
+
539
+ def _conversation_endpoint(port: int, method: str = "GET") -> tuple[bool, dict]:
540
+ request = urllib.request.Request(
541
+ f"http://127.0.0.1:{port}/",
542
+ method=method,
543
+ headers={"Content-Type": "application/json"},
544
+ )
545
+ try:
546
+ with urllib.request.urlopen(request, timeout=3) as response:
547
+ payload = json.loads(response.read().decode("utf-8") or "{}")
548
+ return 200 <= response.status < 300, payload
549
+ except Exception as exc:
550
+ return False, {"error": f"{type(exc).__name__}: {exc}"}
551
+
552
+
553
+ def _audio_device_owners() -> list[dict]:
554
+ """Return processes with an open ALSA device, using procfs as ground truth."""
555
+ owners: list[dict] = []
556
+ proc_root = Path("/proc")
557
+ if not proc_root.exists():
558
+ return owners
559
+ for process_dir in proc_root.iterdir():
560
+ if not process_dir.name.isdigit():
561
+ continue
562
+ devices: set[str] = set()
563
+ try:
564
+ for descriptor in (process_dir / "fd").iterdir():
565
+ try:
566
+ target = os.readlink(descriptor)
567
+ except OSError:
568
+ continue
569
+ if target.startswith("/dev/snd/"):
570
+ devices.add(target)
571
+ except (FileNotFoundError, PermissionError, ProcessLookupError):
572
+ continue
573
+ if not devices:
574
+ continue
575
+ try:
576
+ command = (process_dir / "cmdline").read_bytes().replace(b"\0", b" ").decode("utf-8", "replace").strip()
577
+ except (FileNotFoundError, PermissionError, ProcessLookupError, OSError):
578
+ command = ""
579
+ try:
580
+ process_name = (process_dir / "comm").read_text(encoding="utf-8", errors="replace").strip()
581
+ except (FileNotFoundError, PermissionError, ProcessLookupError, OSError):
582
+ process_name = "unknown"
583
+ try:
584
+ cgroup = (process_dir / "cgroup").read_text(encoding="utf-8", errors="replace")
585
+ service = next(
586
+ (part for part in cgroup.replace("\n", "/").split("/") if part.endswith(".service")),
587
+ None,
588
+ )
589
+ except (FileNotFoundError, PermissionError, ProcessLookupError, OSError):
590
+ service = None
591
+ owners.append({
592
+ "pid": int(process_dir.name),
593
+ "process": process_name,
594
+ "command": command[:512],
595
+ "service": service,
596
+ "devices": sorted(devices),
597
+ })
598
+ return sorted(owners, key=lambda item: item["pid"])
599
+
600
+
601
+ def _audio_owner_groups(owners: list[dict]) -> list[str]:
602
+ """Collapse parent/child media processes belonging to one service owner."""
603
+ groups = {
604
+ str(owner.get("service") or f"pid:{owner.get('pid', 'unknown')}")
605
+ for owner in owners
606
+ }
607
+ return sorted(groups)
608
+
609
+
610
+ def _wait_for_conversation_endpoint(port: int, timeout: float = 30.0) -> tuple[bool, dict]:
611
+ deadline = time.monotonic() + timeout
612
+ last: dict = {"error": "conversation endpoint did not become ready"}
613
+ while time.monotonic() < deadline:
614
+ ready, payload = _conversation_endpoint(port)
615
+ if ready:
616
+ return True, payload
617
+ last = payload
618
+ time.sleep(0.5)
619
+ return False, last
620
+
621
+
622
+ def _conversation_management_api(port: int, path: str, method: str = "GET",
623
+ payload: Optional[dict] = None) -> tuple[bool, dict]:
624
+ """Call the robot-local supervisor without touching media or systemd."""
625
+ request = urllib.request.Request(
626
+ f"http://127.0.0.1:{port}{path}",
627
+ data=(json.dumps(payload).encode("utf-8") if payload is not None else None),
628
+ headers={"Content-Type": "application/json"},
629
+ method=method,
630
+ )
631
+ try:
632
+ with urllib.request.urlopen(request, timeout=5) as response:
633
+ body = response.read().decode("utf-8")
634
+ return True, json.loads(body) if body else {}
635
+ except urllib.error.HTTPError as exc:
636
+ try:
637
+ detail = json.loads(exc.read().decode("utf-8") or "{}")
638
+ except Exception:
639
+ detail = {"error": str(exc)}
640
+ return False, detail
641
+ except Exception as exc:
642
+ return False, {"error": str(exc)}
643
+
644
+
645
+ def _voice_engine_command(name: str, params: dict) -> dict:
646
+ """Manage one robot's mutually exclusive RoboVoice/ElevenLabs owner."""
647
+ import re
648
+
649
+ robot = str(params.get("robot", "")).strip()
650
+ engine = str(params.get("engine", "elevenlabs")).strip().lower()
651
+ try:
652
+ port = int(params.get("port", 5060))
653
+ except (TypeError, ValueError):
654
+ return {"ok": False, "error": "invalid motion port"}
655
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,63}", robot):
656
+ return {"ok": False, "error": "invalid robot name"}
657
+ if engine not in {"robovoice", "elevenlabs"}:
658
+ return {"ok": False, "error": "engine must be robovoice or elevenlabs"}
659
+ if not 1024 <= port <= 65535:
660
+ return {"ok": False, "error": "motion port must be between 1024 and 65535"}
661
+
662
+ conversation_unit = f"robopark-robot-conversation-{robot}.service"
663
+ robovoice_unit = f"robopark-robot-runtime-{robot}.service"
664
+ kiosk_unit = "robopark-kiosk.service"
665
+
666
+ if name == "conversation-journal-backfill":
667
+ units = [conversation_unit, robovoice_unit]
668
+ chunks = []
669
+ for unit in units:
670
+ try:
671
+ proc = subprocess.run(
672
+ ["journalctl", "-u", unit, "--since", "3 days ago", "--output", "short-iso", "--no-pager"],
673
+ capture_output=True,
674
+ text=True,
675
+ timeout=SHELL_RUN_TIMEOUT_SECONDS,
676
+ check=False,
677
+ )
678
+ if proc.stdout:
679
+ chunks.append(f"# unit={unit}\n{proc.stdout}")
680
+ except (FileNotFoundError, subprocess.TimeoutExpired, OSError) as exc:
681
+ chunks.append(f"# unit={unit} error={type(exc).__name__}: {exc}")
682
+ output = "\n".join(chunks)
683
+ return {
684
+ "ok": True,
685
+ "exit_code": 0,
686
+ "stdout": output[:JOURNAL_OUTPUT_MAX_BYTES],
687
+ "stderr": "",
688
+ "truncated": len(output) > JOURNAL_OUTPUT_MAX_BYTES,
689
+ }
690
+
691
+ if name == "voice-engine-audio-status":
692
+ owners = _audio_device_owners()
693
+ return {
694
+ "ok": True,
695
+ "exit_code": 0,
696
+ "stdout": json.dumps({
697
+ "audio_owners": owners,
698
+ "audio_owner_groups": _audio_owner_groups(owners),
699
+ "owner_count": len(owners),
700
+ }),
701
+ "stderr": "",
702
+ }
703
+
704
+ if name in {"voice-engine-audio-release", "voice-engine-audio-recover"}:
705
+ if not bool(params.get("force")):
706
+ return {
707
+ "ok": False, "exit_code": 1, "stdout": "",
708
+ "stderr": "audio release is destructive and requires force=true",
709
+ }
710
+ ok, health = _conversation_management_api(port, "/health")
711
+ if ok and health.get("session_active"):
712
+ return {
713
+ "ok": False, "exit_code": 1, "stdout": json.dumps(health),
714
+ "stderr": "active sessions cannot release their media lease",
715
+ }
716
+ # The management plane never kills arbitrary ALSA owners. Return exact
717
+ # ownership so the operator can resolve the owning service explicitly.
718
+ return {
719
+ "ok": bool(ok and health.get("media_lease_available")),
720
+ "exit_code": 0 if ok and health.get("media_lease_available") else 1,
721
+ "stdout": json.dumps(health),
722
+ "stderr": "foreign media owner must be stopped explicitly" if ok else health.get("error", "endpoint unavailable"),
723
+ }
724
+
725
+ if name == "voice-engine-switch":
726
+ return {"ok": False, "error": "engine changes must be staged through voice configuration"}
727
+ elif name == "voice-engine-restart":
728
+ if engine == "elevenlabs":
729
+ ok, payload = _conversation_management_api(port, "/restart-when-idle", "POST", {})
730
+ detail = payload.get("error")
731
+ else:
732
+ return {"ok": False, "error": "RoboVoice restart requires an explicit engine transition"}
733
+ if not ok:
734
+ return {"ok": False, "error": detail or f"could not restart {engine} engine"}
735
+ elif name == "voice-engine-stop":
736
+ if engine == "elevenlabs":
737
+ ok, payload = _conversation_management_api(port, "/stop", "POST", {})
738
+ detail = payload.get("error")
739
+ else:
740
+ return {"ok": False, "error": "RoboVoice stop requires an explicit engine transition"}
741
+ if not ok:
742
+ return {"ok": False, "error": detail or f"could not stop {engine} engine"}
743
+ elif name == "voice-engine-trigger":
744
+ if engine != "elevenlabs":
745
+ return {"ok": False, "error": "RoboVoice sessions are triggered through LiveKit"}
746
+ ok, payload = _conversation_management_api(port, "/trigger", "POST", {"reason": "dashboard"})
747
+ return {"ok": ok, "exit_code": 0 if ok else 1, "stdout": json.dumps(payload), "stderr": ""}
748
+ elif name in {"voice-engine-dispose", "voice-engine-recover"}:
749
+ if engine != "elevenlabs":
750
+ return {"ok": False, "error": "session disposal is only available for ElevenLabs"}
751
+ path = "/restart?force=true" if name == "voice-engine-recover" else "/stop"
752
+ if name == "voice-engine-recover" and not bool(params.get("force")):
753
+ path = "/restart-when-idle"
754
+ ok, payload = _conversation_management_api(port, path, "POST", {})
755
+ return {"ok": ok, "exit_code": 0 if ok else 1, "stdout": json.dumps(payload), "stderr": payload.get("error", "")}
756
+
757
+ health_ready, health = _conversation_management_api(port, "/health")
758
+ state_ready, state = _conversation_management_api(port, "/state")
759
+ config_ready, configuration = _conversation_management_api(port, "/configuration")
760
+ conversation_active, _ = _systemctl("is-active", conversation_unit)
761
+ robovoice_runtime_active, _ = _systemctl("is-active", robovoice_unit)
762
+ robovoice_kiosk_active, _ = _user_systemctl("is-active", kiosk_unit)
763
+ robovoice_active = robovoice_runtime_active or robovoice_kiosk_active
764
+ endpoint_ready, endpoint = (state_ready, state)
765
+ observed = "conflict" if conversation_active and robovoice_active else (
766
+ "elevenlabs" if conversation_active else "robovoice" if robovoice_active else "none"
767
+ )
768
+ audio_owners = _audio_device_owners()
769
+ status = {
770
+ "desired_engine": engine,
771
+ "observed_engine": observed,
772
+ "conversation_service_active": conversation_active,
773
+ "robovoice_service_active": robovoice_active,
774
+ "robovoice_runtime_active": robovoice_runtime_active,
775
+ "robovoice_kiosk_active": robovoice_kiosk_active,
776
+ "endpoint_ready": endpoint_ready,
777
+ "endpoint": endpoint if endpoint_ready else None,
778
+ "endpoint_error": None if endpoint_ready else endpoint.get("error"),
779
+ "health": health if health_ready else None,
780
+ "configuration": configuration if config_ready else None,
781
+ "audio_owners": audio_owners,
782
+ "audio_owner_groups": _audio_owner_groups(audio_owners),
783
+ }
784
+ audio_conflict = bool((health.get("media_lease") or {}).get("conflicts")) if health_ready else len(status["audio_owner_groups"]) > 1
785
+ status["audio_conflict"] = audio_conflict
786
+ healthy = observed == engine and not audio_conflict and (engine != "elevenlabs" or endpoint_ready)
787
+ return {
788
+ "ok": healthy,
789
+ "exit_code": 0 if healthy else 1,
790
+ "stdout": json.dumps(status),
791
+ "stderr": "" if healthy else (
792
+ "multiple independent processes own ALSA devices"
793
+ if audio_conflict else "desired and observed voice engines do not match"
794
+ ),
795
+ }
796
+
797
+
798
+ def _run_shell_command(name: str, params: dict) -> dict:
799
+ """Resolve `name` against the allowlist, substitute params, and run.
800
+
801
+ Returns a dict {ok, exit_code, stdout, stderr, error}. The caller
802
+ POSTs this to the scheduler as the supervisor-output payload."""
803
+ if name in VOICE_ENGINE_COMMANDS:
804
+ return _voice_engine_command(name, params)
805
+
806
+ spec = None
807
+ for s in SHELL_ALLOWLIST:
808
+ if s["name"] == name:
809
+ spec = s
810
+ break
811
+ if spec is None:
812
+ return {"ok": False, "error": f"command {name!r} is not in the allowlist"}
813
+ argv = []
814
+ for piece in spec["argv"]:
815
+ if piece.startswith("<") and piece.endswith(">"):
816
+ key = piece[1:-1]
817
+ val = params.get(key)
818
+ if val is None or not isinstance(val, str) or not val.strip():
819
+ return {"ok": False, "error": f"missing required parameter {key!r} for command {name!r}"}
820
+ # Reject anything that smells like a shell metachar — keep it
821
+ # strictly to filenames / unit names. Service names are owned
822
+ # by supervisor.json on this robot, not by the network.
823
+ if any(c in val for c in ("\x00", "\n", "\r")):
824
+ return {"ok": False, "error": "invalid characters in parameter"}
825
+ argv.append(val)
826
+ else:
827
+ argv.append(piece)
828
+ try:
829
+ proc = subprocess.run(
830
+ argv,
831
+ capture_output=True,
832
+ text=True,
833
+ timeout=SHELL_RUN_TIMEOUT_SECONDS,
834
+ check=False,
835
+ )
836
+ out = (proc.stdout or "")[:SHELL_OUTPUT_MAX_BYTES]
837
+ err = (proc.stderr or "")[:SHELL_OUTPUT_MAX_BYTES]
838
+ return {
839
+ "ok": True,
840
+ "exit_code": proc.returncode,
841
+ "stdout": out,
842
+ "stderr": err,
843
+ "truncated": len(proc.stdout or "") > SHELL_OUTPUT_MAX_BYTES or len(proc.stderr or "") > SHELL_OUTPUT_MAX_BYTES,
844
+ }
845
+ except subprocess.TimeoutExpired:
846
+ return {"ok": False, "error": f"command timed out after {SHELL_RUN_TIMEOUT_SECONDS}s"}
847
+ except FileNotFoundError as e:
848
+ return {"ok": False, "error": f"command not found: {e}"}
849
+ except Exception as e:
850
+ return {"ok": False, "error": f"{type(e).__name__}: {e}"}
851
+
852
+
853
+ def _tail_log_file(path: Path, lines: int) -> dict:
854
+ """Return the last `lines` lines of a log file as a string.
855
+
856
+ Resolves `path` against LOG_DIR if it's relative; rejects anything
857
+ that tries to escape (../) for safety, even though the operator
858
+ can already read anything on the robot via the allowlist."""
859
+ try:
860
+ lines = max(1, min(int(lines), 2000))
861
+ except (TypeError, ValueError):
862
+ lines = 200
863
+ if not path.is_absolute():
864
+ path = (LOG_DIR / path).resolve()
865
+ # Belt-and-suspenders: don't let `..` traversal escape the log dir
866
+ # (the dashboard only ever sends a service name, but defend anyway).
867
+ try:
868
+ path.relative_to(LOG_DIR.resolve())
869
+ except ValueError:
870
+ return {"ok": False, "error": "log path is outside the supervisor log directory"}
871
+ if not path.exists():
872
+ return {"ok": False, "error": f"no such log file: {path}"}
873
+ try:
874
+ # Read the tail efficiently: seek to ~32KB from the end and split.
875
+ size = path.stat().st_size
876
+ chunk = min(size, 64 * 1024)
877
+ with path.open("rb") as f:
878
+ if size > chunk:
879
+ f.seek(size - chunk)
880
+ data = f.read().decode("utf-8", errors="replace")
881
+ all_lines = data.splitlines()
882
+ tail = all_lines[-lines:]
883
+ return {"ok": True, "path": str(path), "lines": len(tail), "total_lines": len(all_lines), "content": "\n".join(tail)}
884
+ except Exception as e:
885
+ return {"ok": False, "error": f"{type(e).__name__}: {e}"}
886
+
887
+
888
+ def _post_supervisor_output(identity, kind: str, service: Optional[str], payload: dict, request_id: Optional[str] = None) -> None:
889
+ """Send the result of a tail_logs or shell_run back to the scheduler.
890
+
891
+ Uses the same httpx call style as the status report itself; no
892
+ retry, no queue — if the scheduler is down we drop the result.
893
+ The dashboard times out and reports a clear error in that case."""
894
+ device_id, scheduler_url, token = identity
895
+ try:
896
+ import httpx
897
+ except ImportError:
898
+ return
899
+ url = f"{scheduler_url.rstrip('/')}/api/devices/{device_id}/supervisor-output"
900
+ body = {"kind": kind, "service": service, "payload": payload, "request_id": request_id}
901
+ try:
902
+ httpx.post(url, json=body, headers=_scheduler_headers(token), timeout=5.0)
903
+ except Exception as e:
904
+ logger.debug(f"supervisor-output POST failed: {e}")
905
+
906
+
907
+ def _handle_shell_command(identity, cmd: dict) -> None:
908
+ """Process a single shell/log request returned by the scheduler.
909
+
910
+ `cmd` is {id, kind: "shell_run"|"tail_logs"|"speaker_test", service,
911
+ params}. Runs the request, posts the result back, and returns."""
912
+ kind = cmd.get("kind", "shell_run")
913
+ service = cmd.get("service")
914
+ request_id = cmd.get("id")
915
+ params = cmd.get("params") or {}
916
+ if kind == "tail_logs":
917
+ # params: {lines: N, path?: override path}
918
+ path_param = params.get("path")
919
+ if path_param:
920
+ log_path = Path(path_param)
921
+ elif service:
922
+ log_path = (_CURRENT_LOG_DIR or LOG_DIR) / f"{service}.log"
923
+ else:
924
+ _post_supervisor_output(identity, kind, service, {"ok": False, "error": "tail_logs requires service or path"}, request_id)
925
+ return
926
+ result = _tail_log_file(log_path, int(params.get("lines", 200)))
927
+ elif kind == "shell_run":
928
+ result = _run_shell_command(params.get("name", ""), params)
929
+ elif kind == "speaker_test":
930
+ result = _speaker_roundtrip_test(params)
931
+ elif kind == "motor_discover":
932
+ result = _discover_motor_registry(params)
933
+ elif kind == "motor_sequence":
934
+ result = _run_motor_sequence(params)
935
+ else:
936
+ result = {"ok": False, "error": f"unknown shell command kind: {kind!r}"}
937
+ _post_supervisor_output(identity, kind, service, result, request_id)
938
+
939
+
940
+ def _discover_motor_registry(params: dict) -> dict:
941
+ """Read the robot-local motor registry without touching any GPIO output."""
942
+ import httpx
943
+ import re
944
+
945
+ base = str(params.get("motor_server_url") or "http://127.0.0.1:8001").rstrip("/")
946
+ if not (base.startswith("http://127.0.0.1:") or base.startswith("http://localhost:")):
947
+ return {"ok": False, "error": "motor server must be robot-local"}
948
+ try:
949
+ with httpx.Client(timeout=5.0, headers=_motor_headers()) as client:
950
+ response = client.get(f"{base}/list-motors")
951
+ response.raise_for_status()
952
+ raw_motors = response.json().get("motors", [])
953
+ registry, used_ids, used_gpios = [], set(), set()
954
+ for index, raw in enumerate(raw_motors[:32]):
955
+ gpio = int(raw.get("gpio", -1))
956
+ if gpio < 2 or gpio > 27 or gpio in used_gpios:
957
+ continue
958
+ name = str(raw.get("name") or f"Relay {index + 1}").strip()[:60]
959
+ base_id = re.sub(r"[^a-z0-9_-]+", "-", name.lower()).strip("-") or f"relay-{index + 1}"
960
+ motor_id, suffix = base_id[:32], 2
961
+ while motor_id in used_ids:
962
+ tail = f"-{suffix}"
963
+ motor_id = f"{base_id[:32-len(tail)]}{tail}"
964
+ suffix += 1
965
+ used_ids.add(motor_id)
966
+ used_gpios.add(gpio)
967
+ registry.append({
968
+ "id": motor_id,
969
+ "name": name,
970
+ "gpio": gpio,
971
+ "active_high": bool(raw.get("active_high", True)),
972
+ "max_duration_ms": 3000,
973
+ })
974
+ return {"ok": True, "registry": registry, "count": len(registry), "motor_server_url": base}
975
+ except Exception as exc:
976
+ return {"ok": False, "error": f"motor registry discovery failed: {type(exc).__name__}: {exc}"}
977
+
978
+
979
+ def _run_motor_sequence(params: dict) -> dict:
980
+ """Run one validated scheduler sequence against the robot-local motor API."""
981
+ import httpx
982
+ base = str(params.get("motor_server_url") or "http://127.0.0.1:8001").rstrip("/")
983
+ if not (base.startswith("http://127.0.0.1:") or base.startswith("http://localhost:")):
984
+ return {"ok": False, "error": "motor server must be robot-local"}
985
+ registry = {str(item.get("id")): item for item in (params.get("registry") or [])}
986
+ steps = params.get("steps") or []
987
+ started = time.monotonic()
988
+ completed = []
989
+ gpio_log = []
990
+ try:
991
+ with httpx.Client(timeout=8.0, headers=_motor_headers()) as client:
992
+ if params.get("stop_all"):
993
+ response = client.post(f"{base}/stop-motors", json={})
994
+ response.raise_for_status()
995
+ return {"ok": True, "stopped": True, "sequence_id": "emergency-stop"}
996
+ existing = client.get(f"{base}/list-motors").json().get("motors", [])
997
+ existing_names = {str(item.get("name")) for item in existing}
998
+ for motor_id, motor in registry.items():
999
+ body = {"name": motor_id, "gpio": int(motor["gpio"]), "active_high": bool(motor.get("active_high", True))}
1000
+ response = (client.put(f"{base}/update-motor/{motor_id}", json=body)
1001
+ if motor_id in existing_names else client.post(f"{base}/add-motor", json=body))
1002
+ response.raise_for_status()
1003
+ if params.get("test_all"):
1004
+ duration_ms = max(50, min(1000, int(params.get("duration_ms", 300))))
1005
+ pause_ms = max(0, min(2000, int(params.get("pause_ms", 200))))
1006
+ response = client.post(f"{base}/test", json={
1007
+ "seconds_on": duration_ms / 1000.0,
1008
+ "seconds_pause": pause_ms / 1000.0,
1009
+ "pins": [int(motor["gpio"]) for motor in registry.values()],
1010
+ })
1011
+ response.raise_for_status()
1012
+ deadline = time.monotonic() + len(registry) * ((duration_ms + pause_ms) / 1000.0) + 3.0
1013
+ last_test_action = None
1014
+ while time.monotonic() < deadline:
1015
+ status = client.get(f"{base}/status").json()
1016
+ action = str(status.get("last_action") or "")
1017
+ if action.startswith("test:gpio:") and action != last_test_action:
1018
+ try:
1019
+ observed_gpio = int(action.rsplit(":", 1)[-1])
1020
+ except ValueError:
1021
+ observed_gpio = -1
1022
+ if observed_gpio >= 2:
1023
+ gpio_log.append({
1024
+ "gpio": observed_gpio,
1025
+ "status": "active_observed",
1026
+ "elapsed_ms": int((time.monotonic() - started) * 1000),
1027
+ })
1028
+ last_test_action = action
1029
+ if status.get("status") == "idle":
1030
+ if status.get("error"):
1031
+ raise RuntimeError(f"registered GPIO test failed: {status['error']}")
1032
+ completed = [{"motor_id": motor_id, "gpio": int(motor["gpio"])}
1033
+ for motor_id, motor in registry.items()]
1034
+ break
1035
+ time.sleep(0.05)
1036
+ else:
1037
+ raise TimeoutError("registered GPIO test did not return to idle")
1038
+ for index, step in enumerate(steps):
1039
+ motor_id = str(step.get("motor_id"))
1040
+ motor = registry.get(motor_id)
1041
+ if not motor:
1042
+ raise ValueError(f"step {index + 1} references unknown motor {motor_id}")
1043
+ delay_ms = max(0, min(30000, int(step.get("delay_ms", 0))))
1044
+ duration_ms = max(50, min(int(motor.get("max_duration_ms", 3000)), int(step.get("duration_ms", 500))))
1045
+ if delay_ms:
1046
+ time.sleep(delay_ms / 1000.0)
1047
+ response = client.post(f"{base}/trigger-motor", json={"motor_name": motor_id, "seconds": duration_ms / 1000.0})
1048
+ response.raise_for_status()
1049
+ deadline = time.monotonic() + duration_ms / 1000.0 + 2.0
1050
+ while time.monotonic() < deadline:
1051
+ status_response = client.get(f"{base}/status")
1052
+ status_response.raise_for_status()
1053
+ status = status_response.json()
1054
+ if status.get("status") == "idle":
1055
+ if status.get("error"):
1056
+ raise RuntimeError(f"motor {motor_id} failed: {status['error']}")
1057
+ break
1058
+ time.sleep(0.05)
1059
+ else:
1060
+ raise TimeoutError(f"motor {motor_id} did not return to idle")
1061
+ completed.append({"motor_id": motor_id, "gpio": int(motor["gpio"]), "duration_ms": duration_ms})
1062
+ except Exception as exc:
1063
+ try:
1064
+ httpx.post(f"{base}/stop-motors", json={}, headers=_motor_headers(), timeout=3.0)
1065
+ except Exception:
1066
+ pass
1067
+ return {"ok": False, "error": f"{type(exc).__name__}: {exc}", "completed_steps": completed,
1068
+ "gpio_log": gpio_log,
1069
+ "duration_ms": int((time.monotonic() - started) * 1000), "session_id": params.get("session_id")}
1070
+ return {"ok": True, "pass": True, "sequence_id": params.get("sequence_id"), "completed_steps": completed,
1071
+ "gpio_log": gpio_log,
1072
+ "duration_ms": int((time.monotonic() - started) * 1000), "motor_server_url": base,
1073
+ "session_id": params.get("session_id")}
1074
+
1075
+
1076
+ # ── Speaker roundtrip test (C2 gap-fill) ──
1077
+ # Plays a short tone through the robot's configured output device while
1078
+ # simultaneously recording from the configured input device. Returns
1079
+ # {played, recorded_rms, peak_db, duration_ms, output_device,
1080
+ # input_device, error}.
1081
+ #
1082
+ # Used by the dashboard's "Test speaker" button. Helps catch the
1083
+ # classic failure modes: speaker muted, wrong output device, mic
1084
+ # pointing the wrong way, USB audio device unplugged. The result is
1085
+ # a quick PASS/FAIL plus a peak-dB readout that the operator can
1086
+ # read at a glance.
1087
+
1088
+ # Default test tone parameters — overridden by per-request params
1089
+ SPEAKER_TEST_FREQUENCY_HZ = 1000.0
1090
+ SPEAKER_TEST_DURATION_S = 0.6
1091
+ SPEAKER_TEST_SAMPLE_RATE = 48000
1092
+ SPEAKER_TEST_AMPLITUDE = 0.6 # 0..1; conservative so it doesn't clip
1093
+ SPEAKER_TEST_OUTPUT_DEVICE = None # None = use ROBOPARK_AUDIO_OUTPUT / default
1094
+ SPEAKER_TEST_INPUT_DEVICE = None
1095
+ # Peak dB threshold: anything below this is reported as "no signal
1096
+ # detected" (PASS means the robot heard the tone back; FAIL means
1097
+ # the mic didn't pick it up). The default (-30 dB) is conservative
1098
+ # for a quiet indoor environment; can be overridden per request.
1099
+ SPEAKER_TEST_PEAK_DB_THRESHOLD = -30.0
1100
+
1101
+
1102
+ def _resolve_audio_device(pa, selected: str, kind: str) -> Optional[int]:
1103
+ """Resolve a free-form device name/index to a PyAudio device index.
1104
+
1105
+ kind is "input" or "output". None / "default" picks the WASAPI
1106
+ default device (per the same logic preview_agent.py uses for the
1107
+ mic). If the name doesn't match any device, returns None and lets
1108
+ PyAudio pick its global default — that may still work, but the
1109
+ result is recorded so the operator can see it."""
1110
+ if selected is None:
1111
+ selected = "default"
1112
+ s = str(selected).strip()
1113
+ if s == "" or s.lower() == "default":
1114
+ try:
1115
+ info = (pa.get_default_input_device_info() if kind == "input"
1116
+ else pa.get_default_output_device_info())
1117
+ idx = info.get("index")
1118
+ if idx is not None and idx >= 0:
1119
+ return int(idx)
1120
+ except Exception:
1121
+ pass
1122
+ return None
1123
+ if s.isdigit():
1124
+ return int(s)
1125
+ needle = s.lower()
1126
+ want_channels = 1 if kind == "input" else 0 # input: must have >0
1127
+ for i in range(pa.get_device_count()):
1128
+ info = pa.get_device_info_by_index(i)
1129
+ if kind == "output" and info.get("maxOutputChannels", 0) <= 0:
1130
+ continue
1131
+ if kind == "input" and info.get("maxInputChannels", 0) <= 0:
1132
+ continue
1133
+ if needle in str(info.get("name", "")).lower():
1134
+ return i
1135
+ return None
1136
+
1137
+
1138
+ def _audio_rate_candidates(info: dict, requested_rate: int) -> list[int]:
1139
+ """Return practical PCM rates with the device native rate first."""
1140
+ rates = []
1141
+ for value in (
1142
+ info.get("defaultSampleRate"),
1143
+ requested_rate,
1144
+ 48000,
1145
+ 44100,
1146
+ 32000,
1147
+ 24000,
1148
+ 16000,
1149
+ ):
1150
+ try:
1151
+ rate = int(round(float(value)))
1152
+ except (TypeError, ValueError):
1153
+ continue
1154
+ if rate > 0 and rate not in rates:
1155
+ rates.append(rate)
1156
+ return rates
1157
+
1158
+
1159
+ def _resample_pcm16_mono(raw: bytes, source_rate: int, target_rate: int) -> bytes:
1160
+ """Linearly resample little-endian mono PCM16 without optional DSP deps."""
1161
+ if source_rate == target_rate or not raw:
1162
+ return raw
1163
+ source_count = len(raw) // 2
1164
+ target_count = max(1, int(round(source_count * target_rate / source_rate)))
1165
+ source = struct.unpack(f"<{source_count}h", raw[:source_count * 2])
1166
+ if source_count == 1:
1167
+ return struct.pack("<h", source[0]) * target_count
1168
+ scale = (source_count - 1) / max(1, target_count - 1)
1169
+ result = bytearray(target_count * 2)
1170
+ for index in range(target_count):
1171
+ position = index * scale
1172
+ left = int(position)
1173
+ right = min(left + 1, source_count - 1)
1174
+ fraction = position - left
1175
+ value = int(round(source[left] + (source[right] - source[left]) * fraction))
1176
+ struct.pack_into("<h", result, index * 2, value)
1177
+ return bytes(result)
1178
+
1179
+
1180
+ def _open_pcm_stream(pa, pyaudio_module, kind: str, device_index: Optional[int],
1181
+ requested_rate: int):
1182
+ """Open a PCM16 stream using the first format the device accepts."""
1183
+ try:
1184
+ if device_index is None:
1185
+ info = (pa.get_default_output_device_info() if kind == "output"
1186
+ else pa.get_default_input_device_info())
1187
+ device_index = int(info.get("index"))
1188
+ else:
1189
+ info = pa.get_device_info_by_index(device_index)
1190
+ except Exception as exc:
1191
+ raise OSError(f"selected {kind} device is unavailable: {exc}") from exc
1192
+
1193
+ channel_key = "maxOutputChannels" if kind == "output" else "maxInputChannels"
1194
+ max_channels = int(info.get(channel_key, 0) or 0)
1195
+ if max_channels < 1:
1196
+ raise OSError(f"{info.get('name', device_index)} has no {kind} channels")
1197
+ channels = [2, 1] if kind == "output" and max_channels >= 2 else [1]
1198
+ errors = []
1199
+ for rate in _audio_rate_candidates(info, requested_rate):
1200
+ for channel_count in channels:
1201
+ kwargs = {
1202
+ "format": pyaudio_module.paInt16,
1203
+ "channels": channel_count,
1204
+ "rate": rate,
1205
+ kind: True,
1206
+ f"{kind}_device_index": device_index,
1207
+ }
1208
+ if kind == "input":
1209
+ kwargs["frames_per_buffer"] = 1024
1210
+ try:
1211
+ stream = pa.open(**kwargs)
1212
+ return stream, rate, channel_count, info
1213
+ except Exception as exc:
1214
+ errors.append(f"{rate}Hz/{channel_count}ch: {exc}")
1215
+ attempts = "; ".join(errors[-4:])
1216
+ raise OSError(
1217
+ f"{info.get('name', device_index)} supports no usable PCM16 {kind} format"
1218
+ + (f" ({attempts})" if attempts else "")
1219
+ )
1220
+
1221
+
1222
+ def _linux_aplay_pcm16(raw: bytes, selected_output: str, sample_rate: int,
1223
+ channels: int = 1) -> tuple[bool, str]:
1224
+ """Play PCM through the same ALSA plughw path proven during Pi setup.
1225
+
1226
+ PortAudio and RoboVision enumerate devices in different index spaces. The
1227
+ inventory name contains the stable ALSA hw tuple, so use it directly and
1228
+ let ALSA's plug layer adapt the cached TTS sample rate/channel count.
1229
+ """
1230
+ import re
1231
+
1232
+ match = re.search(r"hw:(\d+),(\d+)", str(selected_output), re.IGNORECASE)
1233
+ if not match:
1234
+ return False, "selected output has no ALSA hw address"
1235
+ alsa_device = f"plughw:{match.group(1)},{match.group(2)}"
1236
+ command = [
1237
+ "aplay", "-q", "-D", alsa_device, "-t", "raw", "-f", "S16_LE",
1238
+ "-r", str(sample_rate), "-c", str(channels),
1239
+ ]
1240
+ last_error = "aplay failed"
1241
+ # ALSA can retain an exclusive USB handle briefly after the LiveKit
1242
+ # playback stream closes. Retry for a bounded period instead of declaring
1243
+ # a valid device unsupported on the first EBUSY response.
1244
+ try:
1245
+ from media_lock import media_lock
1246
+ with media_lock("speaker", timeout=8.0):
1247
+ for attempt in range(5):
1248
+ if attempt:
1249
+ time.sleep(0.5)
1250
+ try:
1251
+ completed = subprocess.run(
1252
+ command,
1253
+ input=raw,
1254
+ stdout=subprocess.DEVNULL,
1255
+ stderr=subprocess.PIPE,
1256
+ timeout=max(5.0, len(raw) / max(1, sample_rate * channels * 2) + 3.0),
1257
+ check=False,
1258
+ )
1259
+ except Exception as exc:
1260
+ last_error = f"{type(exc).__name__}: {exc}"
1261
+ continue
1262
+ if completed.returncode == 0:
1263
+ return True, alsa_device
1264
+ last_error = completed.stderr.decode("utf-8", errors="replace").strip() or f"aplay exited {completed.returncode}"
1265
+ except TimeoutError as exc:
1266
+ return False, str(exc)
1267
+ return False, f"{alsa_device}: {last_error}"
1268
+
1269
+
1270
+ def _linux_mic_groundtruth(selected_input: str, duration: float = 4.0) -> dict:
1271
+ """Capture the fleet USB mic through the exact onsite-proven ALSA path."""
1272
+ import re
1273
+
1274
+ match = re.search(r"hw:(\d+),(\d+)", str(selected_input), re.IGNORECASE)
1275
+ if not match:
1276
+ return {"ok": False, "error": "selected input has no ALSA hw address"}
1277
+ alsa_device = f"plughw:{match.group(1)},{match.group(2)}"
1278
+ seconds = max(1, min(10, int(round(duration))))
1279
+ command = [
1280
+ "arecord", "-q", "-D", alsa_device, "-t", "raw", "-f", "S16_LE",
1281
+ "-r", "48000", "-c", "1", "-d", str(seconds),
1282
+ ]
1283
+ started = time.monotonic()
1284
+ try:
1285
+ from media_lock import media_lock
1286
+ with media_lock("microphone", timeout=8.0):
1287
+ completed = subprocess.run(
1288
+ command,
1289
+ stdout=subprocess.PIPE,
1290
+ stderr=subprocess.PIPE,
1291
+ timeout=seconds + 4.0,
1292
+ check=False,
1293
+ )
1294
+ except Exception as exc:
1295
+ return {
1296
+ "ok": False,
1297
+ "mode": "mic_groundtruth",
1298
+ "error": f"{type(exc).__name__}: {exc}",
1299
+ "input_device": alsa_device,
1300
+ "duration_ms": int((time.monotonic() - started) * 1000),
1301
+ }
1302
+ raw = completed.stdout or b""
1303
+ stderr = completed.stderr.decode("utf-8", errors="replace").strip()
1304
+ if completed.returncode != 0 or len(raw) < 2:
1305
+ return {
1306
+ "ok": False,
1307
+ "mode": "mic_groundtruth",
1308
+ "error": stderr or f"arecord exited {completed.returncode}",
1309
+ "exit_code": completed.returncode,
1310
+ "bytes": len(raw),
1311
+ "input_device": alsa_device,
1312
+ "command": " ".join(command),
1313
+ "duration_ms": int((time.monotonic() - started) * 1000),
1314
+ }
1315
+ sample_count = len(raw) // 2
1316
+ peak = 0
1317
+ square_sum = 0
1318
+ nonzero = 0
1319
+ for (value,) in struct.iter_unpack("<h", raw[:sample_count * 2]):
1320
+ magnitude = abs(value)
1321
+ peak = max(peak, magnitude)
1322
+ square_sum += value * value
1323
+ if value:
1324
+ nonzero += 1
1325
+ rms = (square_sum / sample_count) ** 0.5
1326
+ peak_db = round(20.0 * math.log10(max(1, peak) / 32767.0), 1)
1327
+ rms_db = round(20.0 * math.log10(max(1.0, rms) / 32767.0), 1)
1328
+ return {
1329
+ "ok": True,
1330
+ "pass": nonzero > 0 and peak > 0,
1331
+ "mode": "mic_groundtruth",
1332
+ "exit_code": completed.returncode,
1333
+ "bytes": len(raw),
1334
+ "samples": sample_count,
1335
+ "nonzero_samples": nonzero,
1336
+ "nonzero_percent": round(nonzero * 100.0 / sample_count, 3),
1337
+ "recorded_peak": peak,
1338
+ "recorded_peak_db": peak_db,
1339
+ "recorded_rms": round(rms, 2),
1340
+ "recorded_rms_db": rms_db,
1341
+ "sample_rate": 48000,
1342
+ "channels": 1,
1343
+ "input_device": alsa_device,
1344
+ "command": " ".join(command),
1345
+ "stderr": stderr,
1346
+ "duration_ms": int((time.monotonic() - started) * 1000),
1347
+ "diagnostic": "ground truth captured through the onsite-proven ALSA arecord path",
1348
+ }
1349
+
1350
+
1351
+ def _speaker_roundtrip_test(params: dict) -> dict:
1352
+ """Play a test tone + record the mic simultaneously; return metrics.
1353
+
1354
+ params may include {frequency, duration, amplitude, output, input,
1355
+ threshold_db}. Anything missing falls back to the module constants."""
1356
+ mode = str(params.get("mode", "tone")).lower()
1357
+ playback_only = bool(params.get("playback_only", mode == "tts"))
1358
+ freq = float(params.get("frequency", SPEAKER_TEST_FREQUENCY_HZ))
1359
+ dur = float(params.get("duration", SPEAKER_TEST_DURATION_S))
1360
+ amp = float(params.get("amplitude", SPEAKER_TEST_AMPLITUDE))
1361
+ threshold_db = float(params.get("threshold_db", SPEAKER_TEST_PEAK_DB_THRESHOLD))
1362
+ # RoboVision inventory indices belong to sounddevice and may not match
1363
+ # PyAudio's index space. Prefer the reported hardware name for lookup.
1364
+ out_name = params.get("output_name") or params.get("output", os.environ.get("ROBOPARK_AUDIO_OUTPUT") or SPEAKER_TEST_OUTPUT_DEVICE)
1365
+ in_name = params.get("input_name") or params.get("input", os.environ.get("ROBOPARK_AUDIO_INPUT") or SPEAKER_TEST_INPUT_DEVICE)
1366
+ source_rate = int(params.get("sample_rate", SPEAKER_TEST_SAMPLE_RATE))
1367
+ if mode == "mic_groundtruth":
1368
+ if not sys.platform.startswith("linux"):
1369
+ return {"ok": False, "error": "mic_groundtruth requires Linux ALSA"}
1370
+ return _linux_mic_groundtruth(str(in_name), float(params.get("duration", 4.0)))
1371
+ if mode not in ("tone", "tts"):
1372
+ return {"ok": False, "error": f"unsupported speaker test mode: {mode}"}
1373
+ try:
1374
+ import pyaudio
1375
+ except ImportError as e:
1376
+ return {"ok": False, "error": f"pyaudio not installed on this robot: {e}"}
1377
+ if mode == "tone" and not (50.0 <= freq <= 8000.0):
1378
+ return {"ok": False, "error": f"frequency {freq}Hz out of allowed range (50..8000)"}
1379
+ if not (0.1 <= dur <= 3.0):
1380
+ return {"ok": False, "error": f"duration {dur}s out of allowed range (0.1..3.0)"}
1381
+ if not (0.05 <= amp <= 1.0):
1382
+ return {"ok": False, "error": f"amplitude {amp} out of allowed range (0.05..1.0)"}
1383
+ mono_pcm = None
1384
+ if mode == "tts":
1385
+ try:
1386
+ mono_pcm = base64.b64decode(params.get("audio_pcm_base64") or "", validate=True)
1387
+ except Exception as e:
1388
+ return {"ok": False, "error": f"invalid cached TTS audio: {e}"}
1389
+ if len(mono_pcm) < 480 or len(mono_pcm) % 2:
1390
+ return {"ok": False, "error": "cached TTS audio is empty or malformed"}
1391
+ dur = len(mono_pcm) / 2 / source_rate
1392
+ if dur > 10.0:
1393
+ return {"ok": False, "error": "cached TTS audio exceeds the 10 second test limit"}
1394
+ n_samples = int(source_rate * dur)
1395
+ if n_samples < 480:
1396
+ return {"ok": False, "error": "duration too short"}
1397
+
1398
+ # Cached character voice is output-only. On Linux, bypass PortAudio's
1399
+ # unrelated index space and use the exact ALSA plughw endpoint selected by
1400
+ # RoboVision. This is the same path used by the successful onsite test.
1401
+ if playback_only and mono_pcm is not None and sys.platform.startswith("linux"):
1402
+ t0 = time.monotonic()
1403
+ played, detail = _linux_aplay_pcm16(mono_pcm, str(out_name), source_rate)
1404
+ duration_ms = int((time.monotonic() - t0) * 1000)
1405
+ if not played:
1406
+ return {
1407
+ "ok": False,
1408
+ "error": f"ALSA playback failed: {detail}",
1409
+ "duration_ms": duration_ms,
1410
+ "output_device": str(out_name),
1411
+ "input_device": "not opened (speaker-only test)",
1412
+ "duration": dur,
1413
+ }
1414
+ return {
1415
+ "ok": True,
1416
+ "pass": True,
1417
+ "played": True,
1418
+ "playback_only": True,
1419
+ "mode": mode,
1420
+ "text": params.get("text"),
1421
+ "cache_hit": bool(params.get("cache_hit")),
1422
+ "tts_provider": params.get("tts_provider"),
1423
+ "tts_voice": params.get("tts_voice"),
1424
+ "duration": dur,
1425
+ "duration_ms": duration_ms,
1426
+ "sample_rate": source_rate,
1427
+ "output_sample_rate": source_rate,
1428
+ "output_channels": 1,
1429
+ "output_device": detail,
1430
+ "input_device": "not opened (speaker-only test)",
1431
+ "diagnostic": "cached voice played through the selected ALSA plughw endpoint",
1432
+ }
1433
+
1434
+ pa = pyaudio.PyAudio()
1435
+ out_idx = _resolve_audio_device(pa, out_name, "output")
1436
+ in_idx = None if playback_only else _resolve_audio_device(pa, in_name, "input")
1437
+ def _dev_name(idx):
1438
+ if idx is None: return "(default)"
1439
+ try: return pa.get_device_info_by_index(idx).get("name", str(idx))
1440
+ except Exception: return str(idx)
1441
+ out_label = _dev_name(out_idx)
1442
+ in_label = "not opened (speaker-only test)" if playback_only else _dev_name(in_idx)
1443
+
1444
+ recorded_peak = 0
1445
+ recorded_rms = 0.0
1446
+ err = None
1447
+ output_rate = None
1448
+ input_rate = None
1449
+ output_channels = None
1450
+ input_channels = None
1451
+ t0 = time.monotonic()
1452
+ out_stream = None
1453
+ in_stream = None
1454
+ try:
1455
+ out_stream, output_rate, output_channels, out_info = _open_pcm_stream(
1456
+ pa, pyaudio, "output", out_idx, source_rate
1457
+ )
1458
+ out_label = str(out_info.get("name", out_label))
1459
+ if not playback_only:
1460
+ in_stream, input_rate, input_channels, in_info = _open_pcm_stream(
1461
+ pa, pyaudio, "input", in_idx, source_rate
1462
+ )
1463
+ in_label = str(in_info.get("name", in_label))
1464
+
1465
+ if mono_pcm is not None:
1466
+ output_mono = _resample_pcm16_mono(mono_pcm, source_rate, output_rate)
1467
+ else:
1468
+ output_samples = int(output_rate * dur)
1469
+ frames = bytearray(output_samples * 2)
1470
+ peak_value = int(32767 * amp)
1471
+ for n in range(output_samples):
1472
+ env = min(1.0, n / (output_rate * 0.004),
1473
+ (output_samples - n) / (output_rate * 0.008))
1474
+ value = int(peak_value * env * math.sin(2 * math.pi * freq * n / output_rate))
1475
+ struct.pack_into("<h", frames, n * 2, value)
1476
+ output_mono = bytes(frames)
1477
+ if output_channels == 2:
1478
+ frames = bytearray(len(output_mono) * 2)
1479
+ for pos in range(0, len(output_mono), 2):
1480
+ frames[pos * 2:pos * 2 + 4] = output_mono[pos:pos + 2] * 2
1481
+ raw = bytes(frames)
1482
+ else:
1483
+ raw = output_mono
1484
+
1485
+ chunk = 1024
1486
+ if in_stream is not None:
1487
+ in_stream.start_stream()
1488
+ chunk_bytes = chunk * 2 * output_channels
1489
+ pos = 0
1490
+ peak_acc = 0
1491
+ rms_acc = 0.0
1492
+ n_samp = 0
1493
+ deadline = time.monotonic() + dur + 1.5
1494
+ while pos < len(raw) and time.monotonic() < deadline:
1495
+ end = min(pos + chunk_bytes, len(raw))
1496
+ out_stream.write(raw[pos:end])
1497
+ pos = end
1498
+ try:
1499
+ if in_stream is None:
1500
+ continue
1501
+ avail = in_stream.get_read_available()
1502
+ if avail and avail > 0:
1503
+ data = in_stream.read(avail, exception_on_overflow=False)
1504
+ for s in range(0, len(data) - 1, 2):
1505
+ v = int.from_bytes(data[s:s+2], "little", signed=True)
1506
+ a = abs(v)
1507
+ if a > peak_acc: peak_acc = a
1508
+ rms_acc += v * v
1509
+ n_samp += 1
1510
+ except Exception:
1511
+ pass
1512
+ out_stream.stop_stream(); out_stream.close(); out_stream = None
1513
+ if in_stream is not None:
1514
+ try:
1515
+ tail_frames = min(int(input_rate * 0.35), max(0, in_stream.get_read_available()))
1516
+ tail = in_stream.read(tail_frames, exception_on_overflow=False) if tail_frames else b""
1517
+ for s in range(0, len(tail) - 1, 2):
1518
+ v = int.from_bytes(tail[s:s+2], "little", signed=True)
1519
+ a = abs(v)
1520
+ if a > peak_acc: peak_acc = a
1521
+ rms_acc += v * v
1522
+ n_samp += 1
1523
+ except Exception:
1524
+ pass
1525
+ if in_stream is not None:
1526
+ in_stream.stop_stream(); in_stream.close(); in_stream = None
1527
+ recorded_peak = peak_acc
1528
+ recorded_rms = (rms_acc / max(1, n_samp)) ** 0.5
1529
+ except Exception as e:
1530
+ err = f"{type(e).__name__}: {e}"
1531
+ finally:
1532
+ for stream in (out_stream, in_stream):
1533
+ if stream is not None:
1534
+ try: stream.stop_stream()
1535
+ except Exception: pass
1536
+ try: stream.close()
1537
+ except Exception: pass
1538
+ try: pa.terminate()
1539
+ except Exception: pass
1540
+ duration_ms = int((time.monotonic() - t0) * 1000)
1541
+ if err:
1542
+ return {"ok": False, "error": err, "duration_ms": duration_ms,
1543
+ "output_device": out_label, "input_device": in_label,
1544
+ "frequency": freq, "duration": dur}
1545
+ if playback_only:
1546
+ return {
1547
+ "ok": True,
1548
+ "pass": True,
1549
+ "played": True,
1550
+ "playback_only": True,
1551
+ "mode": mode,
1552
+ "text": params.get("text"),
1553
+ "cache_hit": bool(params.get("cache_hit")),
1554
+ "tts_provider": params.get("tts_provider"),
1555
+ "tts_voice": params.get("tts_voice"),
1556
+ "duration": dur,
1557
+ "duration_ms": duration_ms,
1558
+ "sample_rate": source_rate,
1559
+ "output_sample_rate": output_rate,
1560
+ "output_channels": output_channels,
1561
+ "output_device": out_label,
1562
+ "input_device": in_label,
1563
+ "diagnostic": "speaker playback completed; microphone was intentionally not opened",
1564
+ }
1565
+ if recorded_peak > 0:
1566
+ peak_db = 20.0 * math.log10(recorded_peak / 32767.0)
1567
+ else:
1568
+ peak_db = -120.0
1569
+ if recorded_rms > 0:
1570
+ rms_db = 20.0 * math.log10(recorded_rms / 32767.0)
1571
+ else:
1572
+ rms_db = -120.0
1573
+ pass_ = peak_db >= threshold_db
1574
+ return {
1575
+ "ok": True,
1576
+ "pass": pass_,
1577
+ "mode": mode,
1578
+ "text": params.get("text"),
1579
+ "cache_hit": bool(params.get("cache_hit")),
1580
+ "tts_provider": params.get("tts_provider"),
1581
+ "tts_voice": params.get("tts_voice"),
1582
+ "frequency": freq,
1583
+ "duration": dur,
1584
+ "duration_ms": duration_ms,
1585
+ "sample_rate": source_rate,
1586
+ "output_sample_rate": output_rate,
1587
+ "input_sample_rate": input_rate,
1588
+ "output_channels": output_channels,
1589
+ "input_channels": input_channels,
1590
+ "amplitude": amp,
1591
+ "output_device": out_label,
1592
+ "input_device": in_label,
1593
+ "recorded_peak": int(recorded_peak),
1594
+ "recorded_peak_db": round(peak_db, 1),
1595
+ "recorded_rms": round(recorded_rms, 1),
1596
+ "recorded_rms_db": round(rms_db, 1),
1597
+ "threshold_db": threshold_db,
1598
+ "diagnostic": ("speaker + mic round-trip OK" if pass_ else
1599
+ "no signal detected — check that the speaker is on, the mic isn't muted, and the right output/input devices are selected"),
1600
+ }
1601
+
1602
+
1603
+ def run(config_path: Path, commands_only: bool = False) -> None:
1604
+ global _CURRENT_LOG_DIR
1605
+ if commands_only:
1606
+ # Unified robot-runtime already owns preview, vision, audio and motors.
1607
+ # Do not require a legacy supervisor config or launch duplicate owners.
1608
+ services, log_dir = [], LOG_DIR
1609
+ else:
1610
+ services, log_dir = _load_config(config_path)
1611
+ _CURRENT_LOG_DIR = log_dir
1612
+ enabled = [s for s in services if s.enabled]
1613
+ if not enabled and not commands_only:
1614
+ raise SystemExit(f"No enabled services in {config_path} — nothing to supervise.")
1615
+
1616
+ states = [ServiceState(spec=s) for s in enabled]
1617
+ for state in states:
1618
+ _spawn(state, log_dir)
1619
+
1620
+ shutdown = {"flag": False}
1621
+
1622
+ def _on_signal(signum, frame):
1623
+ logger.info(f"received signal {signum}, shutting down all services…")
1624
+ shutdown["flag"] = True
1625
+
1626
+ signal.signal(signal.SIGINT, _on_signal)
1627
+ signal.signal(signal.SIGTERM, _on_signal)
1628
+
1629
+ if commands_only:
1630
+ logger.info("command-only supervisor active; waiting for scheduler requests")
1631
+ else:
1632
+ logger.info(f"supervising {len(states)} service(s): {', '.join(s.spec.name for s in states)}")
1633
+
1634
+ last_status_report_at = 0.0
1635
+ try:
1636
+ while not shutdown["flag"]:
1637
+ now = time.monotonic()
1638
+ for state in states:
1639
+ if state.proc is None:
1640
+ if not state.stopped and now >= state.next_restart_at:
1641
+ _spawn(state, log_dir)
1642
+ continue
1643
+ ret = state.proc.poll()
1644
+ if ret is None:
1645
+ continue # still running
1646
+ # Exited. A long uptime before exit resets the backoff —
1647
+ # otherwise a service that's crash-looping keeps backing off.
1648
+ uptime = now - state.started_at
1649
+ state.last_exit_code = ret
1650
+ if uptime >= STABLE_UPTIME_SECONDS:
1651
+ state.failure_count = 0
1652
+ else:
1653
+ state.failure_count += 1
1654
+ if state.log_file:
1655
+ state.log_file.write(f"=== supervisor: {state.spec.name} exited with code {ret} (uptime {uptime:.0f}s) ===\n")
1656
+ state.log_file.close()
1657
+ state.log_file = None
1658
+ state.proc = None
1659
+ _schedule_restart(state)
1660
+ if now - last_status_report_at >= STATUS_REPORT_INTERVAL_SECONDS:
1661
+ last_status_report_at = now
1662
+ identity = _load_robopark_identity()
1663
+ if identity:
1664
+ commands = _report_status(states, identity)
1665
+ by_name = {s.spec.name: s for s in states}
1666
+ for cmd in commands:
1667
+ kind = cmd.get("kind", "supervisor_action")
1668
+ target = by_name.get(cmd.get("service_name"))
1669
+ if kind == "supervisor_action" and target:
1670
+ _execute_command(target, cmd.get("action", "restart"))
1671
+ elif kind == "supervisor_action":
1672
+ _execute_systemd_action(cmd.get("service_name", ""), cmd.get("action", "restart"))
1673
+ elif kind in ("shell_run", "tail_logs", "speaker_test", "motor_discover", "motor_sequence"):
1674
+ # run on a background thread so we don't block
1675
+ # the 2s poll loop on a slow command
1676
+ import threading
1677
+ threading.Thread(target=_handle_shell_command, args=(identity, cmd), daemon=True).start()
1678
+ else:
1679
+ logger.warning(f"unknown remote command kind: {kind!r}")
1680
+ else:
1681
+ logger.debug("not enrolled yet (no ~/.robopark/preview_agent.json + device_token) -- skipping status report")
1682
+ time.sleep(POLL_INTERVAL_SECONDS)
1683
+ finally:
1684
+ for state in states:
1685
+ _terminate(state)
1686
+ logger.info("all services stopped, exiting")
1687
+
1688
+
1689
+ def main() -> None:
1690
+ # stdout/stderr are fully buffered (not line-buffered) once redirected to
1691
+ # a file or pipe -- which is exactly how Task Scheduler/systemd run this.
1692
+ # Without this, restart/crash log lines can sit unflushed for minutes.
1693
+ sys.stdout.reconfigure(line_buffering=True)
1694
+ sys.stderr.reconfigure(line_buffering=True)
1695
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
1696
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
1697
+ parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG_FILE, help="path to supervisor.json")
1698
+ parser.add_argument("--commands-only", action="store_true",
1699
+ help="consume remote commands without launching duplicate robot services")
1700
+ args = parser.parse_args()
1701
+ run(args.config, commands_only=args.commands_only)
1702
+
1703
+
1704
+ if __name__ == "__main__":
1705
+ main()