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.
- package/README.md +88 -63
- package/bin/robopark.js +7 -17
- package/conversation/elevenlabs_agent.py +1985 -0
- package/conversation/requirements.txt +3 -0
- package/conversation/supervisor_store.py +189 -0
- package/dist/kernel/config-schema.js +37 -0
- package/dist/kernel/types.js +7 -0
- package/dist/robopark/access.js +99 -0
- package/dist/robopark/add-robot.js +188 -0
- package/dist/robopark/agent-ctl.js +305 -0
- package/dist/robopark/auto-start.js +289 -0
- package/dist/robopark/conversation.js +505 -0
- package/dist/robopark/deployment-commands.js +47 -0
- package/dist/robopark/discovery.js +180 -0
- package/dist/robopark/doctor.js +175 -0
- package/dist/robopark/enroll.js +68 -0
- package/dist/robopark/llm-set.js +87 -0
- package/dist/robopark/motor-control.js +195 -0
- package/dist/robopark/preview-agent-launcher.js +77 -0
- package/dist/robopark/probe.js +138 -0
- package/dist/robopark/profile.js +69 -0
- package/dist/robopark/python-env.js +162 -0
- package/dist/robopark/robot-runtime.js +489 -0
- package/dist/robopark/scan.js +97 -0
- package/dist/robopark/screen-control.js +55 -0
- package/dist/robopark/secrets.js +41 -0
- package/dist/robopark/serve.js +285 -0
- package/dist/robopark/server-add.js +114 -0
- package/dist/robopark/setup-livekit.js +300 -0
- package/dist/robopark/setup.js +286 -0
- package/dist/robopark/standalone.js +466 -0
- package/dist/robopark/stop-all.js +141 -0
- package/dist/robopark/verify.js +192 -0
- package/dist/robopark/vision-agent-launcher.js +98 -0
- package/dist/robopark/vision-control.js +81 -0
- package/dist/robopark-cli.js +799 -0
- package/package.json +21 -5
- package/pi-client/_install_steps.sh +29 -29
- package/pi-client/client.py +61 -2
- package/pi-client/install.sh +40 -40
- package/pi-client/join_convo.sh +54 -54
- package/pi-client/livekit_bridge.py +16 -7
- package/pi-client/motor_bridge.py +6 -3
- package/scheduler/fleet_config.json +75 -0
- package/scheduler/main.py +4505 -135
- package/scheduler/media_lock.py +57 -0
- package/scheduler/preview_agent.py +1465 -87
- package/scheduler/production_config.json +139 -0
- package/scheduler/robot_supervisor.py +1705 -0
- package/scheduler/scripts/install-robot-supervisor-linux.sh +33 -0
- package/scheduler/scripts/install-robot-supervisor-windows.ps1 +49 -0
- package/scheduler/scripts/robopark-supervisor.service +20 -0
- package/scheduler/scripts/start-scheduler-local.ps1 +50 -0
- package/scheduler/supervisor.example.json +26 -0
- package/scheduler/vision_motion_trigger.py +101 -0
- package/screen/screen_runtime.py +75 -0
- package/vision/app_pi_clean.py +253 -16
- package/vision/audio_server_pi.py +19 -0
- package/vision/install.sh +34 -34
- package/vision/motor_server.py +224 -61
- package/vision/requirements_camera.txt +6 -0
- package/vision/requirements_motor.txt +4 -0
- package/vision/requirements_pi_unified.txt +1 -0
- package/vision/requirements_vision_agent.txt +19 -0
- package/vision/run.sh +244 -244
- package/vision/services/services.sh +12 -12
- package/scheduler/__pycache__/main.cpython-312.pyc +0 -0
- 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()
|