infinicode 2.8.96 → 2.8.98
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 +471 -54
- package/dist/robopark/python-env.d.ts +1 -0
- package/dist/robopark/python-env.js +3 -0
- package/dist/robopark/robot-runtime.js +1 -1
- package/dist/robopark/vision-agent-launcher.js +19 -3
- package/docs/ROBOPARK_PRODUCTION_MEMORY.md +25 -0
- package/package.json +1 -1
- package/packages/robopark/scheduler/main.py +278 -22
- package/packages/robopark/scheduler/preview_agent.py +53 -18
- package/packages/robopark/scheduler/robot_supervisor.py +151 -9
- package/packages/robopark/vision/motor_server.py +204 -88
- package/packages/robopark/vision/requirements_vision_agent.txt +1 -0
|
@@ -97,7 +97,7 @@ def _resolve_inventory_audio(items: list, selected: object, preferred: str) -> s
|
|
|
97
97
|
return str(match.get("name")) if match and match.get("name") else selected_text
|
|
98
98
|
|
|
99
99
|
|
|
100
|
-
def _normalize_livekit_url(url: Optional[str]) -> Optional[str]:
|
|
100
|
+
def _normalize_livekit_url(url: Optional[str]) -> Optional[str]:
|
|
101
101
|
"""Avoid Windows localhost IPv6/IPv4 ambiguity for local LiveKit."""
|
|
102
102
|
if not url:
|
|
103
103
|
return url
|
|
@@ -105,9 +105,54 @@ def _normalize_livekit_url(url: Optional[str]) -> Optional[str]:
|
|
|
105
105
|
prefix = f"{scheme}://localhost"
|
|
106
106
|
if url.startswith(prefix):
|
|
107
107
|
return f"{scheme}://127.0.0.1" + url[len(prefix):]
|
|
108
|
-
return url
|
|
109
|
-
|
|
110
|
-
|
|
108
|
+
return url
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _pcm16_scale_and_peak(data: bytes, gain: float) -> tuple[bytes, int]:
|
|
112
|
+
"""Apply gain and measure peak without audioop (removed in Python 3.13)."""
|
|
113
|
+
from array import array
|
|
114
|
+
|
|
115
|
+
samples = array("h")
|
|
116
|
+
samples.frombytes(data[:len(data) - (len(data) % 2)])
|
|
117
|
+
if sys.byteorder != "little":
|
|
118
|
+
samples.byteswap()
|
|
119
|
+
peak = 0
|
|
120
|
+
for index, value in enumerate(samples):
|
|
121
|
+
scaled = max(-32768, min(32767, int(value * gain))) if gain != 1.0 else value
|
|
122
|
+
samples[index] = scaled
|
|
123
|
+
peak = max(peak, abs(scaled))
|
|
124
|
+
if sys.byteorder != "little":
|
|
125
|
+
samples.byteswap()
|
|
126
|
+
return samples.tobytes(), peak
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _pcm16_resample_mono(data: bytes, source_rate: int, target_rate: int) -> bytes:
|
|
130
|
+
"""Linearly resample a PCM16 mono chunk using only the standard library."""
|
|
131
|
+
from array import array
|
|
132
|
+
|
|
133
|
+
if source_rate == target_rate or len(data) < 4:
|
|
134
|
+
return data
|
|
135
|
+
source = array("h")
|
|
136
|
+
source.frombytes(data[:len(data) - (len(data) % 2)])
|
|
137
|
+
if sys.byteorder != "little":
|
|
138
|
+
source.byteswap()
|
|
139
|
+
target_count = max(1, round(len(source) * target_rate / source_rate))
|
|
140
|
+
target = array("h", [0]) * target_count
|
|
141
|
+
scale = source_rate / target_rate
|
|
142
|
+
last = len(source) - 1
|
|
143
|
+
for index in range(target_count):
|
|
144
|
+
position = min(last, index * scale)
|
|
145
|
+
left = int(position)
|
|
146
|
+
right = min(last, left + 1)
|
|
147
|
+
fraction = position - left
|
|
148
|
+
target[index] = max(-32768, min(32767, round(
|
|
149
|
+
source[left] + (source[right] - source[left]) * fraction
|
|
150
|
+
)))
|
|
151
|
+
if sys.byteorder != "little":
|
|
152
|
+
target.byteswap()
|
|
153
|
+
return target.tobytes()
|
|
154
|
+
|
|
155
|
+
|
|
111
156
|
def _play_audio_effect(selected_output: str | None, effect: str) -> None:
|
|
112
157
|
"""Play a short local cue without involving the voice pipeline."""
|
|
113
158
|
sample_rate = 48000
|
|
@@ -1733,7 +1778,6 @@ class LiveKitPublisher:
|
|
|
1733
1778
|
samples_per_frame = int(48000 * frame_ms / 1000)
|
|
1734
1779
|
samples_per_batch = int(48000 * BATCH_MS / 1000)
|
|
1735
1780
|
bytes_per_frame = samples_per_frame * 2 # int16 mono
|
|
1736
|
-
import audioop
|
|
1737
1781
|
mic_gain = max(1.0, min(float(os.getenv("ROBOPARK_MIC_GAIN", "4.0")), 12.0))
|
|
1738
1782
|
_diag_peak = 0
|
|
1739
1783
|
_diag_count = 0
|
|
@@ -1751,8 +1795,7 @@ class LiveKitPublisher:
|
|
|
1751
1795
|
_pub_start = time.monotonic()
|
|
1752
1796
|
for off in range(0, len(data) - bytes_per_frame + 1, bytes_per_frame):
|
|
1753
1797
|
chunk = data[off:off + bytes_per_frame]
|
|
1754
|
-
|
|
1755
|
-
chunk = audioop.mul(chunk, 2, mic_gain)
|
|
1798
|
+
chunk, p = _pcm16_scale_and_peak(chunk, mic_gain)
|
|
1756
1799
|
frame = self.rtc.AudioFrame(
|
|
1757
1800
|
data=chunk, sample_rate=48000, num_channels=1, samples_per_channel=samples_per_frame,
|
|
1758
1801
|
)
|
|
@@ -1763,8 +1806,7 @@ class LiveKitPublisher:
|
|
|
1763
1806
|
"microphone_published", "ok",
|
|
1764
1807
|
"Microphone track published with live PCM frames",
|
|
1765
1808
|
)
|
|
1766
|
-
|
|
1767
|
-
_diag_peak = max(_diag_peak, p)
|
|
1809
|
+
_diag_peak = max(_diag_peak, p)
|
|
1768
1810
|
_diag_count += 1
|
|
1769
1811
|
_diag_read_ms += (_t1 - _t0) * 1000
|
|
1770
1812
|
_diag_publish_ms += (time.monotonic() - _pub_start) * 1000
|
|
@@ -1971,7 +2013,6 @@ class AlsaAudioCapture(AudioCapture):
|
|
|
1971
2013
|
self.media_guard = media_lock("microphone", timeout=1.5).acquire()
|
|
1972
2014
|
self.process = None
|
|
1973
2015
|
self.source_rate = 48000
|
|
1974
|
-
self.rate_state = None
|
|
1975
2016
|
errors = []
|
|
1976
2017
|
# The fleet USB microphone normally accepts 48 kHz through ALSA's
|
|
1977
2018
|
# plug layer. Some firmware revisions expose only native 44.1 kHz;
|
|
@@ -2006,10 +2047,7 @@ class AlsaAudioCapture(AudioCapture):
|
|
|
2006
2047
|
self.source_rate = source_rate
|
|
2007
2048
|
self.alsa_device = alsa_device
|
|
2008
2049
|
if source_rate != 48000:
|
|
2009
|
-
|
|
2010
|
-
first, self.rate_state = audioop.ratecv(
|
|
2011
|
-
first, 2, 1, source_rate, 48000, self.rate_state
|
|
2012
|
-
)
|
|
2050
|
+
first = _pcm16_resample_mono(first, source_rate, 48000)
|
|
2013
2051
|
self.buffer.extend(first)
|
|
2014
2052
|
break
|
|
2015
2053
|
except Exception as exc:
|
|
@@ -2027,7 +2065,6 @@ class AlsaAudioCapture(AudioCapture):
|
|
|
2027
2065
|
raise OSError(f"ALSA capture failed on plug{match.group(1)} ({'; '.join(errors)})")
|
|
2028
2066
|
|
|
2029
2067
|
def read(self, samples_per_frame: int):
|
|
2030
|
-
import audioop
|
|
2031
2068
|
import os
|
|
2032
2069
|
from livekit import rtc
|
|
2033
2070
|
|
|
@@ -2041,9 +2078,7 @@ class AlsaAudioCapture(AudioCapture):
|
|
|
2041
2078
|
detail = self.process.stderr.read().decode("utf-8", errors="replace").strip()
|
|
2042
2079
|
raise OSError(f"ALSA capture stopped on {self.alsa_device}: {detail or 'no PCM data'}")
|
|
2043
2080
|
if self.source_rate != 48000:
|
|
2044
|
-
chunk, self.
|
|
2045
|
-
chunk, 2, 1, self.source_rate, 48000, self.rate_state
|
|
2046
|
-
)
|
|
2081
|
+
chunk = _pcm16_resample_mono(chunk, self.source_rate, 48000)
|
|
2047
2082
|
self.buffer.extend(chunk)
|
|
2048
2083
|
data = bytes(self.buffer[:bytes_needed])
|
|
2049
2084
|
del self.buffer[:bytes_needed]
|
|
@@ -403,7 +403,7 @@ def _post_supervisor_output(identity, kind: str, service: Optional[str], payload
|
|
|
403
403
|
logger.debug(f"supervisor-output POST failed: {e}")
|
|
404
404
|
|
|
405
405
|
|
|
406
|
-
def _handle_shell_command(identity, cmd: dict) -> None:
|
|
406
|
+
def _handle_shell_command(identity, cmd: dict) -> None:
|
|
407
407
|
"""Process a single shell/log request returned by the scheduler.
|
|
408
408
|
|
|
409
409
|
`cmd` is {id, kind: "shell_run"|"tail_logs"|"speaker_test", service,
|
|
@@ -425,11 +425,68 @@ def _handle_shell_command(identity, cmd: dict) -> None:
|
|
|
425
425
|
result = _tail_log_file(log_path, int(params.get("lines", 200)))
|
|
426
426
|
elif kind == "shell_run":
|
|
427
427
|
result = _run_shell_command(params.get("name", ""), params)
|
|
428
|
-
elif kind == "speaker_test":
|
|
429
|
-
result = _speaker_roundtrip_test(params)
|
|
428
|
+
elif kind == "speaker_test":
|
|
429
|
+
result = _speaker_roundtrip_test(params)
|
|
430
|
+
elif kind == "motor_sequence":
|
|
431
|
+
result = _run_motor_sequence(params)
|
|
430
432
|
else:
|
|
431
433
|
result = {"ok": False, "error": f"unknown shell command kind: {kind!r}"}
|
|
432
|
-
_post_supervisor_output(identity, kind, service, result, request_id)
|
|
434
|
+
_post_supervisor_output(identity, kind, service, result, request_id)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _run_motor_sequence(params: dict) -> dict:
|
|
438
|
+
"""Run one validated scheduler sequence against the robot-local motor API."""
|
|
439
|
+
import httpx
|
|
440
|
+
base = str(params.get("motor_server_url") or "http://127.0.0.1:8001").rstrip("/")
|
|
441
|
+
if not (base.startswith("http://127.0.0.1:") or base.startswith("http://localhost:")):
|
|
442
|
+
return {"ok": False, "error": "motor server must be robot-local"}
|
|
443
|
+
registry = {str(item.get("id")): item for item in (params.get("registry") or [])}
|
|
444
|
+
steps = params.get("steps") or []
|
|
445
|
+
started = time.monotonic()
|
|
446
|
+
completed = []
|
|
447
|
+
try:
|
|
448
|
+
with httpx.Client(timeout=8.0) as client:
|
|
449
|
+
existing = client.get(f"{base}/list-motors").json().get("motors", [])
|
|
450
|
+
existing_names = {str(item.get("name")) for item in existing}
|
|
451
|
+
for motor_id, motor in registry.items():
|
|
452
|
+
body = {"name": motor_id, "gpio": int(motor["gpio"]), "active_high": bool(motor.get("active_high", True))}
|
|
453
|
+
response = (client.put(f"{base}/update-motor/{motor_id}", json=body)
|
|
454
|
+
if motor_id in existing_names else client.post(f"{base}/add-motor", json=body))
|
|
455
|
+
response.raise_for_status()
|
|
456
|
+
for index, step in enumerate(steps):
|
|
457
|
+
motor_id = str(step.get("motor_id"))
|
|
458
|
+
motor = registry.get(motor_id)
|
|
459
|
+
if not motor:
|
|
460
|
+
raise ValueError(f"step {index + 1} references unknown motor {motor_id}")
|
|
461
|
+
delay_ms = max(0, min(30000, int(step.get("delay_ms", 0))))
|
|
462
|
+
duration_ms = max(50, min(int(motor.get("max_duration_ms", 3000)), int(step.get("duration_ms", 500))))
|
|
463
|
+
if delay_ms:
|
|
464
|
+
time.sleep(delay_ms / 1000.0)
|
|
465
|
+
response = client.post(f"{base}/trigger-motor", json={"motor_name": motor_id, "seconds": duration_ms / 1000.0})
|
|
466
|
+
response.raise_for_status()
|
|
467
|
+
deadline = time.monotonic() + duration_ms / 1000.0 + 2.0
|
|
468
|
+
while time.monotonic() < deadline:
|
|
469
|
+
status_response = client.get(f"{base}/status")
|
|
470
|
+
status_response.raise_for_status()
|
|
471
|
+
status = status_response.json()
|
|
472
|
+
if status.get("status") == "idle":
|
|
473
|
+
if status.get("error"):
|
|
474
|
+
raise RuntimeError(f"motor {motor_id} failed: {status['error']}")
|
|
475
|
+
break
|
|
476
|
+
time.sleep(0.05)
|
|
477
|
+
else:
|
|
478
|
+
raise TimeoutError(f"motor {motor_id} did not return to idle")
|
|
479
|
+
completed.append({"motor_id": motor_id, "duration_ms": duration_ms})
|
|
480
|
+
except Exception as exc:
|
|
481
|
+
try:
|
|
482
|
+
httpx.post(f"{base}/stop-motors", json={}, timeout=3.0)
|
|
483
|
+
except Exception:
|
|
484
|
+
pass
|
|
485
|
+
return {"ok": False, "error": f"{type(exc).__name__}: {exc}", "completed_steps": completed,
|
|
486
|
+
"duration_ms": int((time.monotonic() - started) * 1000), "session_id": params.get("session_id")}
|
|
487
|
+
return {"ok": True, "pass": True, "sequence_id": params.get("sequence_id"), "completed_steps": completed,
|
|
488
|
+
"duration_ms": int((time.monotonic() - started) * 1000), "motor_server_url": base,
|
|
489
|
+
"session_id": params.get("session_id")}
|
|
433
490
|
|
|
434
491
|
|
|
435
492
|
# ── Speaker roundtrip test (C2 gap-fill) ──
|
|
@@ -626,15 +683,92 @@ def _linux_aplay_pcm16(raw: bytes, selected_output: str, sample_rate: int,
|
|
|
626
683
|
return False, f"{alsa_device}: {last_error}"
|
|
627
684
|
|
|
628
685
|
|
|
686
|
+
def _linux_mic_groundtruth(selected_input: str, duration: float = 4.0) -> dict:
|
|
687
|
+
"""Capture the fleet USB mic through the exact onsite-proven ALSA path."""
|
|
688
|
+
import re
|
|
689
|
+
|
|
690
|
+
match = re.search(r"hw:(\d+),(\d+)", str(selected_input), re.IGNORECASE)
|
|
691
|
+
if not match:
|
|
692
|
+
return {"ok": False, "error": "selected input has no ALSA hw address"}
|
|
693
|
+
alsa_device = f"plughw:{match.group(1)},{match.group(2)}"
|
|
694
|
+
seconds = max(1, min(10, int(round(duration))))
|
|
695
|
+
command = [
|
|
696
|
+
"arecord", "-q", "-D", alsa_device, "-t", "raw", "-f", "S16_LE",
|
|
697
|
+
"-r", "48000", "-c", "1", "-d", str(seconds),
|
|
698
|
+
]
|
|
699
|
+
started = time.monotonic()
|
|
700
|
+
try:
|
|
701
|
+
from media_lock import media_lock
|
|
702
|
+
with media_lock("microphone", timeout=8.0):
|
|
703
|
+
completed = subprocess.run(
|
|
704
|
+
command,
|
|
705
|
+
stdout=subprocess.PIPE,
|
|
706
|
+
stderr=subprocess.PIPE,
|
|
707
|
+
timeout=seconds + 4.0,
|
|
708
|
+
check=False,
|
|
709
|
+
)
|
|
710
|
+
except Exception as exc:
|
|
711
|
+
return {
|
|
712
|
+
"ok": False,
|
|
713
|
+
"mode": "mic_groundtruth",
|
|
714
|
+
"error": f"{type(exc).__name__}: {exc}",
|
|
715
|
+
"input_device": alsa_device,
|
|
716
|
+
"duration_ms": int((time.monotonic() - started) * 1000),
|
|
717
|
+
}
|
|
718
|
+
raw = completed.stdout or b""
|
|
719
|
+
stderr = completed.stderr.decode("utf-8", errors="replace").strip()
|
|
720
|
+
if completed.returncode != 0 or len(raw) < 2:
|
|
721
|
+
return {
|
|
722
|
+
"ok": False,
|
|
723
|
+
"mode": "mic_groundtruth",
|
|
724
|
+
"error": stderr or f"arecord exited {completed.returncode}",
|
|
725
|
+
"exit_code": completed.returncode,
|
|
726
|
+
"bytes": len(raw),
|
|
727
|
+
"input_device": alsa_device,
|
|
728
|
+
"command": " ".join(command),
|
|
729
|
+
"duration_ms": int((time.monotonic() - started) * 1000),
|
|
730
|
+
}
|
|
731
|
+
sample_count = len(raw) // 2
|
|
732
|
+
peak = 0
|
|
733
|
+
square_sum = 0
|
|
734
|
+
nonzero = 0
|
|
735
|
+
for (value,) in struct.iter_unpack("<h", raw[:sample_count * 2]):
|
|
736
|
+
magnitude = abs(value)
|
|
737
|
+
peak = max(peak, magnitude)
|
|
738
|
+
square_sum += value * value
|
|
739
|
+
if value:
|
|
740
|
+
nonzero += 1
|
|
741
|
+
rms = (square_sum / sample_count) ** 0.5
|
|
742
|
+
peak_db = round(20.0 * math.log10(max(1, peak) / 32767.0), 1)
|
|
743
|
+
rms_db = round(20.0 * math.log10(max(1.0, rms) / 32767.0), 1)
|
|
744
|
+
return {
|
|
745
|
+
"ok": True,
|
|
746
|
+
"pass": nonzero > 0 and peak > 0,
|
|
747
|
+
"mode": "mic_groundtruth",
|
|
748
|
+
"exit_code": completed.returncode,
|
|
749
|
+
"bytes": len(raw),
|
|
750
|
+
"samples": sample_count,
|
|
751
|
+
"nonzero_samples": nonzero,
|
|
752
|
+
"nonzero_percent": round(nonzero * 100.0 / sample_count, 3),
|
|
753
|
+
"recorded_peak": peak,
|
|
754
|
+
"recorded_peak_db": peak_db,
|
|
755
|
+
"recorded_rms": round(rms, 2),
|
|
756
|
+
"recorded_rms_db": rms_db,
|
|
757
|
+
"sample_rate": 48000,
|
|
758
|
+
"channels": 1,
|
|
759
|
+
"input_device": alsa_device,
|
|
760
|
+
"command": " ".join(command),
|
|
761
|
+
"stderr": stderr,
|
|
762
|
+
"duration_ms": int((time.monotonic() - started) * 1000),
|
|
763
|
+
"diagnostic": "ground truth captured through the onsite-proven ALSA arecord path",
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
|
|
629
767
|
def _speaker_roundtrip_test(params: dict) -> dict:
|
|
630
768
|
"""Play a test tone + record the mic simultaneously; return metrics.
|
|
631
769
|
|
|
632
770
|
params may include {frequency, duration, amplitude, output, input,
|
|
633
771
|
threshold_db}. Anything missing falls back to the module constants."""
|
|
634
|
-
try:
|
|
635
|
-
import pyaudio
|
|
636
|
-
except ImportError as e:
|
|
637
|
-
return {"ok": False, "error": f"pyaudio not installed on this robot: {e}"}
|
|
638
772
|
mode = str(params.get("mode", "tone")).lower()
|
|
639
773
|
playback_only = bool(params.get("playback_only", mode == "tts"))
|
|
640
774
|
freq = float(params.get("frequency", SPEAKER_TEST_FREQUENCY_HZ))
|
|
@@ -646,8 +780,16 @@ def _speaker_roundtrip_test(params: dict) -> dict:
|
|
|
646
780
|
out_name = params.get("output_name") or params.get("output", os.environ.get("ROBOPARK_AUDIO_OUTPUT") or SPEAKER_TEST_OUTPUT_DEVICE)
|
|
647
781
|
in_name = params.get("input_name") or params.get("input", os.environ.get("ROBOPARK_AUDIO_INPUT") or SPEAKER_TEST_INPUT_DEVICE)
|
|
648
782
|
source_rate = int(params.get("sample_rate", SPEAKER_TEST_SAMPLE_RATE))
|
|
783
|
+
if mode == "mic_groundtruth":
|
|
784
|
+
if not sys.platform.startswith("linux"):
|
|
785
|
+
return {"ok": False, "error": "mic_groundtruth requires Linux ALSA"}
|
|
786
|
+
return _linux_mic_groundtruth(str(in_name), float(params.get("duration", 4.0)))
|
|
649
787
|
if mode not in ("tone", "tts"):
|
|
650
788
|
return {"ok": False, "error": f"unsupported speaker test mode: {mode}"}
|
|
789
|
+
try:
|
|
790
|
+
import pyaudio
|
|
791
|
+
except ImportError as e:
|
|
792
|
+
return {"ok": False, "error": f"pyaudio not installed on this robot: {e}"}
|
|
651
793
|
if mode == "tone" and not (50.0 <= freq <= 8000.0):
|
|
652
794
|
return {"ok": False, "error": f"frequency {freq}Hz out of allowed range (50..8000)"}
|
|
653
795
|
if not (0.1 <= dur <= 3.0):
|
|
@@ -936,7 +1078,7 @@ def run(config_path: Path) -> None:
|
|
|
936
1078
|
_execute_command(target, cmd.get("action", "restart"))
|
|
937
1079
|
elif kind == "supervisor_action":
|
|
938
1080
|
logger.warning(f"remote command for unknown service {cmd.get('service_name')!r} ignored")
|
|
939
|
-
elif kind in ("shell_run", "tail_logs", "speaker_test"):
|
|
1081
|
+
elif kind in ("shell_run", "tail_logs", "speaker_test", "motor_sequence"):
|
|
940
1082
|
# run on a background thread so we don't block
|
|
941
1083
|
# the 2s poll loop on a slow command
|
|
942
1084
|
import threading
|