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.
@@ -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. Currently only
224
- "restart" -- terminate if running (the main loop's own restart logic
225
- then respawns it), and clear any crash backoff since this isn't a
226
- crash, it's a deliberate request that should take effect right away."""
227
- if action != "restart":
228
- logger.warning(f"ignoring unknown remote command {action!r} for {state.spec.name}")
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
- logger.info(f"remote restart requested for {state.spec.name}")
231
- if state.proc is not None:
232
- _terminate(state)
233
- state.proc = None
234
- state.failure_count = 0
235
- state.next_restart_at = 0.0 # due immediately -- the main loop respawns it next tick
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
- else:
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)