infinicode 2.8.37 → 2.8.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/kernel/federation/dashboard-html.d.ts +1 -1
- package/dist/kernel/federation/dashboard-html.js +1041 -28
- package/dist/robopark/add-robot.d.ts +2 -0
- package/dist/robopark/add-robot.js +9 -0
- package/dist/robopark/secrets.d.ts +8 -0
- package/dist/robopark/secrets.js +41 -0
- package/dist/robopark/setup.d.ts +3 -0
- package/dist/robopark/setup.js +9 -2
- package/dist/robopark-cli.js +22 -0
- package/package.json +1 -1
- package/packages/robopark/scheduler/main.py +770 -114
- package/packages/robopark/scheduler/robot_supervisor.py +424 -15
|
@@ -33,8 +33,10 @@ from __future__ import annotations
|
|
|
33
33
|
import argparse
|
|
34
34
|
import json
|
|
35
35
|
import logging
|
|
36
|
+
import math
|
|
36
37
|
import os
|
|
37
38
|
import signal
|
|
39
|
+
import struct
|
|
38
40
|
import subprocess
|
|
39
41
|
import sys
|
|
40
42
|
import time
|
|
@@ -220,23 +222,422 @@ def _terminate(state: ServiceState) -> None:
|
|
|
220
222
|
|
|
221
223
|
|
|
222
224
|
def _execute_command(state: ServiceState, action: str) -> None:
|
|
223
|
-
"""Operator-initiated action from the dashboard.
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
225
|
+
"""Operator-initiated action from the dashboard. Supports:
|
|
226
|
+
- "restart" (original): terminate if running, the main loop
|
|
227
|
+
respawns it on the next tick.
|
|
228
|
+
- "start": spawn the service if it's not already running. Used
|
|
229
|
+
after a deliberate stop.
|
|
230
|
+
- "stop": graceful terminate and mark `stopped=True` so the main
|
|
231
|
+
loop does NOT auto-restart it. Re-enable with "start".
|
|
232
|
+
Anything else is ignored with a warning."""
|
|
233
|
+
if action == "restart":
|
|
234
|
+
# clear any deliberate-stop so the next main-loop tick is allowed
|
|
235
|
+
# to respawn it
|
|
236
|
+
state.stopped = False
|
|
237
|
+
if state.proc is not None:
|
|
238
|
+
_terminate(state)
|
|
239
|
+
state.proc = None
|
|
240
|
+
state.failure_count = 0
|
|
241
|
+
state.next_restart_at = 0.0
|
|
229
242
|
return
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
state.
|
|
234
|
-
|
|
235
|
-
|
|
243
|
+
if action == "start":
|
|
244
|
+
state.stopped = False
|
|
245
|
+
state.failure_count = 0
|
|
246
|
+
state.next_restart_at = 0.0
|
|
247
|
+
if state.proc is None:
|
|
248
|
+
_spawn(state, _CURRENT_LOG_DIR or LOG_DIR)
|
|
249
|
+
return
|
|
250
|
+
if action == "stop":
|
|
251
|
+
if state.proc is not None:
|
|
252
|
+
_terminate(state)
|
|
253
|
+
state.proc = None
|
|
254
|
+
state.stopped = True
|
|
255
|
+
return
|
|
256
|
+
logger.warning(f"ignoring unknown remote command {action!r} for {state.spec.name}")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# ── Operator shell (C1 gap-fill) ──
|
|
260
|
+
# Allows the dashboard to read service logs and run a small set of
|
|
261
|
+
# pre-approved diagnostic commands on the robot. Results are POSTed back
|
|
262
|
+
# to the scheduler (POST /api/devices/{id}/supervisor-output) which
|
|
263
|
+
# caches them per (device, command-id) so the dashboard can poll the
|
|
264
|
+
# result without holding a long-lived connection to the robot.
|
|
265
|
+
#
|
|
266
|
+
# SECURITY: the allowlist below is the only thing that gets executed.
|
|
267
|
+
# Operators cannot pass arbitrary commands; the parser rejects anything
|
|
268
|
+
# not on this list with a 400-equivalent ("command not allowed").
|
|
269
|
+
|
|
270
|
+
import secrets as _secrets # noqa: E402
|
|
271
|
+
|
|
272
|
+
# A list of allow-listed command specs. Each entry has a fixed argv;
|
|
273
|
+
# placeholders in <angle brackets> get substituted from the request.
|
|
274
|
+
# A request that doesn't match any entry is rejected.
|
|
275
|
+
SHELL_ALLOWLIST = [
|
|
276
|
+
{"name": "uptime", "argv": ["uptime"]},
|
|
277
|
+
{"name": "free", "argv": ["free", "-m"]},
|
|
278
|
+
{"name": "df", "argv": ["df", "-h"]},
|
|
279
|
+
{"name": "ps", "argv": ["ps", "aux"]},
|
|
280
|
+
{"name": "uname", "argv": ["uname", "-a"]},
|
|
281
|
+
{"name": "date", "argv": ["date"]},
|
|
282
|
+
{"name": "whoami", "argv": ["whoami"]},
|
|
283
|
+
{"name": "hostname", "argv": ["hostname"]},
|
|
284
|
+
{"name": "ip", "argv": ["ip", "addr"]},
|
|
285
|
+
{"name": "ss", "argv": ["ss", "-tlnp"]},
|
|
286
|
+
{"name": "os-release", "argv": ["cat", "/etc/os-release"]},
|
|
287
|
+
{"name": "ls-logs", "argv": ["ls", "-la", "<dir>"]},
|
|
288
|
+
{"name": "systemctl-status", "argv": ["systemctl", "status", "<name>", "--no-pager", "-n", "30"]},
|
|
289
|
+
{"name": "journalctl", "argv": ["journalctl", "-u", "<name>", "-n", "200", "--no-pager"]},
|
|
290
|
+
{"name": "tail", "argv": ["tail", "-n", "<n>", "<path>"]},
|
|
291
|
+
]
|
|
292
|
+
|
|
293
|
+
# Cap output size per command to avoid an operator accidentally filling
|
|
294
|
+
# the supervisor output buffer with 100MB of `ps aux`.
|
|
295
|
+
SHELL_OUTPUT_MAX_BYTES = 64 * 1024
|
|
296
|
+
SHELL_RUN_TIMEOUT_SECONDS = 8.0
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _run_shell_command(name: str, params: dict) -> dict:
|
|
300
|
+
"""Resolve `name` against the allowlist, substitute params, and run.
|
|
301
|
+
|
|
302
|
+
Returns a dict {ok, exit_code, stdout, stderr, error}. The caller
|
|
303
|
+
POSTs this to the scheduler as the supervisor-output payload."""
|
|
304
|
+
spec = None
|
|
305
|
+
for s in SHELL_ALLOWLIST:
|
|
306
|
+
if s["name"] == name:
|
|
307
|
+
spec = s
|
|
308
|
+
break
|
|
309
|
+
if spec is None:
|
|
310
|
+
return {"ok": False, "error": f"command {name!r} is not in the allowlist"}
|
|
311
|
+
argv = []
|
|
312
|
+
for piece in spec["argv"]:
|
|
313
|
+
if piece.startswith("<") and piece.endswith(">"):
|
|
314
|
+
key = piece[1:-1]
|
|
315
|
+
val = params.get(key)
|
|
316
|
+
if val is None or not isinstance(val, str) or not val.strip():
|
|
317
|
+
return {"ok": False, "error": f"missing required parameter {key!r} for command {name!r}"}
|
|
318
|
+
# Reject anything that smells like a shell metachar — keep it
|
|
319
|
+
# strictly to filenames / unit names. Service names are owned
|
|
320
|
+
# by supervisor.json on this robot, not by the network.
|
|
321
|
+
if any(c in val for c in ("\x00", "\n", "\r")):
|
|
322
|
+
return {"ok": False, "error": "invalid characters in parameter"}
|
|
323
|
+
argv.append(val)
|
|
324
|
+
else:
|
|
325
|
+
argv.append(piece)
|
|
326
|
+
try:
|
|
327
|
+
proc = subprocess.run(
|
|
328
|
+
argv,
|
|
329
|
+
capture_output=True,
|
|
330
|
+
text=True,
|
|
331
|
+
timeout=SHELL_RUN_TIMEOUT_SECONDS,
|
|
332
|
+
check=False,
|
|
333
|
+
)
|
|
334
|
+
out = (proc.stdout or "")[:SHELL_OUTPUT_MAX_BYTES]
|
|
335
|
+
err = (proc.stderr or "")[:SHELL_OUTPUT_MAX_BYTES]
|
|
336
|
+
return {
|
|
337
|
+
"ok": True,
|
|
338
|
+
"exit_code": proc.returncode,
|
|
339
|
+
"stdout": out,
|
|
340
|
+
"stderr": err,
|
|
341
|
+
"truncated": len(proc.stdout or "") > SHELL_OUTPUT_MAX_BYTES or len(proc.stderr or "") > SHELL_OUTPUT_MAX_BYTES,
|
|
342
|
+
}
|
|
343
|
+
except subprocess.TimeoutExpired:
|
|
344
|
+
return {"ok": False, "error": f"command timed out after {SHELL_RUN_TIMEOUT_SECONDS}s"}
|
|
345
|
+
except FileNotFoundError as e:
|
|
346
|
+
return {"ok": False, "error": f"command not found: {e}"}
|
|
347
|
+
except Exception as e:
|
|
348
|
+
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _tail_log_file(path: Path, lines: int) -> dict:
|
|
352
|
+
"""Return the last `lines` lines of a log file as a string.
|
|
353
|
+
|
|
354
|
+
Resolves `path` against LOG_DIR if it's relative; rejects anything
|
|
355
|
+
that tries to escape (../) for safety, even though the operator
|
|
356
|
+
can already read anything on the robot via the allowlist."""
|
|
357
|
+
try:
|
|
358
|
+
lines = max(1, min(int(lines), 2000))
|
|
359
|
+
except (TypeError, ValueError):
|
|
360
|
+
lines = 200
|
|
361
|
+
if not path.is_absolute():
|
|
362
|
+
path = (LOG_DIR / path).resolve()
|
|
363
|
+
# Belt-and-suspenders: don't let `..` traversal escape the log dir
|
|
364
|
+
# (the dashboard only ever sends a service name, but defend anyway).
|
|
365
|
+
try:
|
|
366
|
+
path.relative_to(LOG_DIR.resolve())
|
|
367
|
+
except ValueError:
|
|
368
|
+
return {"ok": False, "error": "log path is outside the supervisor log directory"}
|
|
369
|
+
if not path.exists():
|
|
370
|
+
return {"ok": False, "error": f"no such log file: {path}"}
|
|
371
|
+
try:
|
|
372
|
+
# Read the tail efficiently: seek to ~32KB from the end and split.
|
|
373
|
+
size = path.stat().st_size
|
|
374
|
+
chunk = min(size, 64 * 1024)
|
|
375
|
+
with path.open("rb") as f:
|
|
376
|
+
if size > chunk:
|
|
377
|
+
f.seek(size - chunk)
|
|
378
|
+
data = f.read().decode("utf-8", errors="replace")
|
|
379
|
+
all_lines = data.splitlines()
|
|
380
|
+
tail = all_lines[-lines:]
|
|
381
|
+
return {"ok": True, "path": str(path), "lines": len(tail), "total_lines": len(all_lines), "content": "\n".join(tail)}
|
|
382
|
+
except Exception as e:
|
|
383
|
+
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _post_supervisor_output(identity, kind: str, service: Optional[str], payload: dict, request_id: Optional[str] = None) -> None:
|
|
387
|
+
"""Send the result of a tail_logs or shell_run back to the scheduler.
|
|
388
|
+
|
|
389
|
+
Uses the same httpx call style as the status report itself; no
|
|
390
|
+
retry, no queue — if the scheduler is down we drop the result.
|
|
391
|
+
The dashboard times out and reports a clear error in that case."""
|
|
392
|
+
device_id, scheduler_url, token = identity
|
|
393
|
+
try:
|
|
394
|
+
import httpx
|
|
395
|
+
except ImportError:
|
|
396
|
+
return
|
|
397
|
+
url = f"{scheduler_url.rstrip('/')}/api/devices/{device_id}/supervisor-output"
|
|
398
|
+
body = {"kind": kind, "service": service, "payload": payload, "request_id": request_id}
|
|
399
|
+
try:
|
|
400
|
+
httpx.post(url, json=body, headers={"Authorization": f"Bearer {token}"}, timeout=5.0)
|
|
401
|
+
except Exception as e:
|
|
402
|
+
logger.debug(f"supervisor-output POST failed: {e}")
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _handle_shell_command(identity, cmd: dict) -> None:
|
|
406
|
+
"""Process a single shell/log request returned by the scheduler.
|
|
407
|
+
|
|
408
|
+
`cmd` is {id, kind: "shell_run"|"tail_logs"|"speaker_test", service,
|
|
409
|
+
params}. Runs the request, posts the result back, and returns."""
|
|
410
|
+
kind = cmd.get("kind", "shell_run")
|
|
411
|
+
service = cmd.get("service")
|
|
412
|
+
request_id = cmd.get("id")
|
|
413
|
+
params = cmd.get("params") or {}
|
|
414
|
+
if kind == "tail_logs":
|
|
415
|
+
# params: {lines: N, path?: override path}
|
|
416
|
+
path_param = params.get("path")
|
|
417
|
+
if path_param:
|
|
418
|
+
log_path = Path(path_param)
|
|
419
|
+
elif service:
|
|
420
|
+
log_path = (_CURRENT_LOG_DIR or LOG_DIR) / f"{service}.log"
|
|
421
|
+
else:
|
|
422
|
+
_post_supervisor_output(identity, kind, service, {"ok": False, "error": "tail_logs requires service or path"}, request_id)
|
|
423
|
+
return
|
|
424
|
+
result = _tail_log_file(log_path, int(params.get("lines", 200)))
|
|
425
|
+
elif kind == "shell_run":
|
|
426
|
+
result = _run_shell_command(params.get("name", ""), params)
|
|
427
|
+
elif kind == "speaker_test":
|
|
428
|
+
result = _speaker_roundtrip_test(params)
|
|
429
|
+
else:
|
|
430
|
+
result = {"ok": False, "error": f"unknown shell command kind: {kind!r}"}
|
|
431
|
+
_post_supervisor_output(identity, kind, service, result, request_id)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
# ── Speaker roundtrip test (C2 gap-fill) ──
|
|
435
|
+
# Plays a short tone through the robot's configured output device while
|
|
436
|
+
# simultaneously recording from the configured input device. Returns
|
|
437
|
+
# {played, recorded_rms, peak_db, duration_ms, output_device,
|
|
438
|
+
# input_device, error}.
|
|
439
|
+
#
|
|
440
|
+
# Used by the dashboard's "Test speaker" button. Helps catch the
|
|
441
|
+
# classic failure modes: speaker muted, wrong output device, mic
|
|
442
|
+
# pointing the wrong way, USB audio device unplugged. The result is
|
|
443
|
+
# a quick PASS/FAIL plus a peak-dB readout that the operator can
|
|
444
|
+
# read at a glance.
|
|
445
|
+
|
|
446
|
+
# Default test tone parameters — overridden by per-request params
|
|
447
|
+
SPEAKER_TEST_FREQUENCY_HZ = 1000.0
|
|
448
|
+
SPEAKER_TEST_DURATION_S = 0.6
|
|
449
|
+
SPEAKER_TEST_SAMPLE_RATE = 48000
|
|
450
|
+
SPEAKER_TEST_AMPLITUDE = 0.6 # 0..1; conservative so it doesn't clip
|
|
451
|
+
SPEAKER_TEST_OUTPUT_DEVICE = None # None = use ROBOPARK_AUDIO_OUTPUT / default
|
|
452
|
+
SPEAKER_TEST_INPUT_DEVICE = None
|
|
453
|
+
# Peak dB threshold: anything below this is reported as "no signal
|
|
454
|
+
# detected" (PASS means the robot heard the tone back; FAIL means
|
|
455
|
+
# the mic didn't pick it up). The default (-30 dB) is conservative
|
|
456
|
+
# for a quiet indoor environment; can be overridden per request.
|
|
457
|
+
SPEAKER_TEST_PEAK_DB_THRESHOLD = -30.0
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _resolve_audio_device(pa, selected: str, kind: str) -> Optional[int]:
|
|
461
|
+
"""Resolve a free-form device name/index to a PyAudio device index.
|
|
462
|
+
|
|
463
|
+
kind is "input" or "output". None / "default" picks the WASAPI
|
|
464
|
+
default device (per the same logic preview_agent.py uses for the
|
|
465
|
+
mic). If the name doesn't match any device, returns None and lets
|
|
466
|
+
PyAudio pick its global default — that may still work, but the
|
|
467
|
+
result is recorded so the operator can see it."""
|
|
468
|
+
if selected is None:
|
|
469
|
+
selected = "default"
|
|
470
|
+
s = str(selected).strip()
|
|
471
|
+
if s == "" or s.lower() == "default":
|
|
472
|
+
try:
|
|
473
|
+
wasapi = pa.get_host_api_info_by_type(pyaudio.paWASAPI)
|
|
474
|
+
key = "defaultInputDevice" if kind == "input" else "defaultOutputDevice"
|
|
475
|
+
idx = wasapi.get(key)
|
|
476
|
+
if idx is not None and idx >= 0:
|
|
477
|
+
return idx
|
|
478
|
+
except Exception:
|
|
479
|
+
pass
|
|
480
|
+
return None
|
|
481
|
+
if s.isdigit():
|
|
482
|
+
return int(s)
|
|
483
|
+
needle = s.lower()
|
|
484
|
+
want_channels = 1 if kind == "input" else 0 # input: must have >0
|
|
485
|
+
for i in range(pa.get_device_count()):
|
|
486
|
+
info = pa.get_device_info_by_index(i)
|
|
487
|
+
if kind == "output" and info.get("maxOutputChannels", 0) <= 0:
|
|
488
|
+
continue
|
|
489
|
+
if kind == "input" and info.get("maxInputChannels", 0) <= 0:
|
|
490
|
+
continue
|
|
491
|
+
if needle in str(info.get("name", "")).lower():
|
|
492
|
+
return i
|
|
493
|
+
return None
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _speaker_roundtrip_test(params: dict) -> dict:
|
|
497
|
+
"""Play a test tone + record the mic simultaneously; return metrics.
|
|
498
|
+
|
|
499
|
+
params may include {frequency, duration, amplitude, output, input,
|
|
500
|
+
threshold_db}. Anything missing falls back to the module constants."""
|
|
501
|
+
try:
|
|
502
|
+
import pyaudio
|
|
503
|
+
except ImportError as e:
|
|
504
|
+
return {"ok": False, "error": f"pyaudio not installed on this robot: {e}"}
|
|
505
|
+
freq = float(params.get("frequency", SPEAKER_TEST_FREQUENCY_HZ))
|
|
506
|
+
dur = float(params.get("duration", SPEAKER_TEST_DURATION_S))
|
|
507
|
+
amp = float(params.get("amplitude", SPEAKER_TEST_AMPLITUDE))
|
|
508
|
+
threshold_db = float(params.get("threshold_db", SPEAKER_TEST_PEAK_DB_THRESHOLD))
|
|
509
|
+
out_name = params.get("output", os.environ.get("ROBOPARK_AUDIO_OUTPUT") or SPEAKER_TEST_OUTPUT_DEVICE)
|
|
510
|
+
in_name = params.get("input", os.environ.get("ROBOPARK_AUDIO_INPUT") or SPEAKER_TEST_INPUT_DEVICE)
|
|
511
|
+
sample_rate = int(params.get("sample_rate", SPEAKER_TEST_SAMPLE_RATE))
|
|
512
|
+
if not (50.0 <= freq <= 8000.0):
|
|
513
|
+
return {"ok": False, "error": f"frequency {freq}Hz out of allowed range (50..8000)"}
|
|
514
|
+
if not (0.1 <= dur <= 3.0):
|
|
515
|
+
return {"ok": False, "error": f"duration {dur}s out of allowed range (0.1..3.0)"}
|
|
516
|
+
if not (0.05 <= amp <= 1.0):
|
|
517
|
+
return {"ok": False, "error": f"amplitude {amp} out of allowed range (0.05..1.0)"}
|
|
518
|
+
n_samples = int(sample_rate * dur)
|
|
519
|
+
if n_samples < 480:
|
|
520
|
+
return {"ok": False, "error": "duration too short"}
|
|
521
|
+
|
|
522
|
+
pa = pyaudio.PyAudio()
|
|
523
|
+
out_idx = _resolve_audio_device(pa, out_name, "output")
|
|
524
|
+
in_idx = _resolve_audio_device(pa, in_name, "input")
|
|
525
|
+
def _dev_name(idx):
|
|
526
|
+
if idx is None: return "(default)"
|
|
527
|
+
try: return pa.get_device_info_by_index(idx).get("name", str(idx))
|
|
528
|
+
except Exception: return str(idx)
|
|
529
|
+
out_label = _dev_name(out_idx)
|
|
530
|
+
in_label = _dev_name(in_idx)
|
|
531
|
+
|
|
532
|
+
frames = bytearray()
|
|
533
|
+
peak_value = int(32767 * amp)
|
|
534
|
+
for n in range(n_samples):
|
|
535
|
+
env = min(1.0, n / (sample_rate * 0.004), (n_samples - n) / (sample_rate * 0.008))
|
|
536
|
+
value = int(peak_value * env * math.sin(2 * math.pi * freq * n / sample_rate))
|
|
537
|
+
frames.extend(struct.pack("<hh", value, value))
|
|
538
|
+
raw = bytes(frames)
|
|
539
|
+
|
|
540
|
+
recorded_peak = 0
|
|
541
|
+
recorded_rms = 0.0
|
|
542
|
+
err = None
|
|
543
|
+
t0 = time.monotonic()
|
|
544
|
+
try:
|
|
545
|
+
out_stream = pa.open(
|
|
546
|
+
format=pyaudio.paInt16,
|
|
547
|
+
channels=2,
|
|
548
|
+
rate=sample_rate,
|
|
549
|
+
output=True,
|
|
550
|
+
output_device_index=out_idx,
|
|
551
|
+
)
|
|
552
|
+
chunk = 1024
|
|
553
|
+
in_stream = pa.open(
|
|
554
|
+
format=pyaudio.paInt16,
|
|
555
|
+
channels=1,
|
|
556
|
+
rate=sample_rate,
|
|
557
|
+
input=True,
|
|
558
|
+
input_device_index=in_idx,
|
|
559
|
+
frames_per_buffer=chunk,
|
|
560
|
+
)
|
|
561
|
+
in_stream.start_stream()
|
|
562
|
+
chunk_bytes = chunk * 2 * 2
|
|
563
|
+
pos = 0
|
|
564
|
+
peak_acc = 0
|
|
565
|
+
rms_acc = 0.0
|
|
566
|
+
n_samp = 0
|
|
567
|
+
deadline = time.monotonic() + dur + 1.5
|
|
568
|
+
while pos < len(raw) and time.monotonic() < deadline:
|
|
569
|
+
end = min(pos + chunk_bytes, len(raw))
|
|
570
|
+
out_stream.write(raw[pos:end])
|
|
571
|
+
pos = end
|
|
572
|
+
try:
|
|
573
|
+
avail = in_stream.get_read_available()
|
|
574
|
+
if avail and avail > 0:
|
|
575
|
+
data = in_stream.read(avail, exception_on_overflow=False)
|
|
576
|
+
for s in range(0, len(data) - 1, 2):
|
|
577
|
+
v = int.from_bytes(data[s:s+2], "little", signed=True)
|
|
578
|
+
a = abs(v)
|
|
579
|
+
if a > peak_acc: peak_acc = a
|
|
580
|
+
rms_acc += v * v
|
|
581
|
+
n_samp += 1
|
|
582
|
+
except Exception:
|
|
583
|
+
pass
|
|
584
|
+
out_stream.stop_stream(); out_stream.close()
|
|
585
|
+
try:
|
|
586
|
+
tail = in_stream.read(n_samples, exception_on_overflow=False)
|
|
587
|
+
for s in range(0, len(tail) - 1, 2):
|
|
588
|
+
v = int.from_bytes(tail[s:s+2], "little", signed=True)
|
|
589
|
+
a = abs(v)
|
|
590
|
+
if a > peak_acc: peak_acc = a
|
|
591
|
+
rms_acc += v * v
|
|
592
|
+
n_samp += 1
|
|
593
|
+
except Exception:
|
|
594
|
+
pass
|
|
595
|
+
in_stream.stop_stream(); in_stream.close()
|
|
596
|
+
recorded_peak = peak_acc
|
|
597
|
+
recorded_rms = (rms_acc / max(1, n_samp)) ** 0.5
|
|
598
|
+
except Exception as e:
|
|
599
|
+
err = f"{type(e).__name__}: {e}"
|
|
600
|
+
finally:
|
|
601
|
+
try: pa.terminate()
|
|
602
|
+
except Exception: pass
|
|
603
|
+
duration_ms = int((time.monotonic() - t0) * 1000)
|
|
604
|
+
if err:
|
|
605
|
+
return {"ok": False, "error": err, "duration_ms": duration_ms,
|
|
606
|
+
"output_device": out_label, "input_device": in_label,
|
|
607
|
+
"frequency": freq, "duration": dur}
|
|
608
|
+
if recorded_peak > 0:
|
|
609
|
+
peak_db = 20.0 * math.log10(recorded_peak / 32767.0)
|
|
610
|
+
else:
|
|
611
|
+
peak_db = -120.0
|
|
612
|
+
if recorded_rms > 0:
|
|
613
|
+
rms_db = 20.0 * math.log10(recorded_rms / 32767.0)
|
|
614
|
+
else:
|
|
615
|
+
rms_db = -120.0
|
|
616
|
+
pass_ = peak_db >= threshold_db
|
|
617
|
+
return {
|
|
618
|
+
"ok": True,
|
|
619
|
+
"pass": pass_,
|
|
620
|
+
"frequency": freq,
|
|
621
|
+
"duration": dur,
|
|
622
|
+
"duration_ms": duration_ms,
|
|
623
|
+
"sample_rate": sample_rate,
|
|
624
|
+
"amplitude": amp,
|
|
625
|
+
"output_device": out_label,
|
|
626
|
+
"input_device": in_label,
|
|
627
|
+
"recorded_peak": int(recorded_peak),
|
|
628
|
+
"recorded_peak_db": round(peak_db, 1),
|
|
629
|
+
"recorded_rms": round(recorded_rms, 1),
|
|
630
|
+
"recorded_rms_db": round(rms_db, 1),
|
|
631
|
+
"threshold_db": threshold_db,
|
|
632
|
+
"diagnostic": ("speaker + mic round-trip OK" if pass_ else
|
|
633
|
+
"no signal detected — check that the speaker is on, the mic isn't muted, and the right output/input devices are selected"),
|
|
634
|
+
}
|
|
236
635
|
|
|
237
636
|
|
|
238
637
|
def run(config_path: Path) -> None:
|
|
638
|
+
global _CURRENT_LOG_DIR
|
|
239
639
|
services, log_dir = _load_config(config_path)
|
|
640
|
+
_CURRENT_LOG_DIR = log_dir
|
|
240
641
|
enabled = [s for s in services if s.enabled]
|
|
241
642
|
if not enabled:
|
|
242
643
|
raise SystemExit(f"No enabled services in {config_path} — nothing to supervise.")
|
|
@@ -262,7 +663,7 @@ def run(config_path: Path) -> None:
|
|
|
262
663
|
now = time.monotonic()
|
|
263
664
|
for state in states:
|
|
264
665
|
if state.proc is None:
|
|
265
|
-
if now >= state.next_restart_at:
|
|
666
|
+
if not state.stopped and now >= state.next_restart_at:
|
|
266
667
|
_spawn(state, log_dir)
|
|
267
668
|
continue
|
|
268
669
|
ret = state.proc.poll()
|
|
@@ -289,11 +690,19 @@ def run(config_path: Path) -> None:
|
|
|
289
690
|
commands = _report_status(states, identity)
|
|
290
691
|
by_name = {s.spec.name: s for s in states}
|
|
291
692
|
for cmd in commands:
|
|
693
|
+
kind = cmd.get("kind", "supervisor_action")
|
|
292
694
|
target = by_name.get(cmd.get("service_name"))
|
|
293
|
-
if target:
|
|
695
|
+
if kind == "supervisor_action" and target:
|
|
294
696
|
_execute_command(target, cmd.get("action", "restart"))
|
|
295
|
-
|
|
697
|
+
elif kind == "supervisor_action":
|
|
296
698
|
logger.warning(f"remote command for unknown service {cmd.get('service_name')!r} ignored")
|
|
699
|
+
elif kind in ("shell_run", "tail_logs", "speaker_test"):
|
|
700
|
+
# run on a background thread so we don't block
|
|
701
|
+
# the 2s poll loop on a slow command
|
|
702
|
+
import threading
|
|
703
|
+
threading.Thread(target=_handle_shell_command, args=(identity, cmd), daemon=True).start()
|
|
704
|
+
else:
|
|
705
|
+
logger.warning(f"unknown remote command kind: {kind!r}")
|
|
297
706
|
else:
|
|
298
707
|
logger.debug("not enrolled yet (no ~/.robopark/preview_agent.json + device_token) -- skipping status report")
|
|
299
708
|
time.sleep(POLL_INTERVAL_SECONDS)
|