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,1985 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Direct motion/voice-triggered ElevenLabs conversation runtime for a robot."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import audioop
|
|
8
|
+
import hmac
|
|
9
|
+
import json
|
|
10
|
+
import math
|
|
11
|
+
import os
|
|
12
|
+
import queue
|
|
13
|
+
import re
|
|
14
|
+
import signal
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
import urllib.parse
|
|
20
|
+
import urllib.request
|
|
21
|
+
import uuid
|
|
22
|
+
from collections import deque
|
|
23
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Callable
|
|
26
|
+
|
|
27
|
+
import pyaudio
|
|
28
|
+
from elevenlabs.client import ElevenLabs
|
|
29
|
+
from elevenlabs.conversational_ai.conversation import AudioInterface, ClientTools, Conversation
|
|
30
|
+
|
|
31
|
+
from supervisor_store import SupervisorStore
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
SDK_RATE = 16000
|
|
35
|
+
SAMPLE_WIDTH = 2
|
|
36
|
+
TRIGGER_CHUNK = 2048
|
|
37
|
+
OUTPUT_QUEUE_CHUNKS = 256
|
|
38
|
+
SESSION_STOP_GRACE_SECONDS = 3.0
|
|
39
|
+
SESSION_FORCE_CLOSE_SECONDS = 2.0
|
|
40
|
+
SPEAKER_ECHO_TAIL_SECONDS = 0.7
|
|
41
|
+
PENDING_MOTION_TTL_SECONDS = 15.0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def log(message: str) -> None:
|
|
45
|
+
print(f"[conversation] {message}", flush=True)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def pcm_rms(data: bytes) -> int:
|
|
49
|
+
if not data:
|
|
50
|
+
return 0
|
|
51
|
+
count = len(data) // SAMPLE_WIDTH
|
|
52
|
+
if count == 0:
|
|
53
|
+
return 0
|
|
54
|
+
total = 0
|
|
55
|
+
for index in range(0, count * SAMPLE_WIDTH, SAMPLE_WIDTH):
|
|
56
|
+
sample = int.from_bytes(data[index:index + SAMPLE_WIDTH], "little", signed=True)
|
|
57
|
+
total += sample * sample
|
|
58
|
+
return int(math.sqrt(total / count))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def find_device(pa: pyaudio.PyAudio, selector: str, direction: str) -> tuple[int, dict]:
|
|
62
|
+
required_channels = "maxInputChannels" if direction == "input" else "maxOutputChannels"
|
|
63
|
+
candidates: list[tuple[int, dict]] = []
|
|
64
|
+
for index in range(pa.get_device_count()):
|
|
65
|
+
info = pa.get_device_info_by_index(index)
|
|
66
|
+
if int(info.get(required_channels, 0)) > 0:
|
|
67
|
+
candidates.append((index, info))
|
|
68
|
+
if not candidates:
|
|
69
|
+
raise RuntimeError(f"no audio {direction} devices found")
|
|
70
|
+
|
|
71
|
+
value = str(selector).strip()
|
|
72
|
+
if value.casefold() in {"default", "system default"}:
|
|
73
|
+
try:
|
|
74
|
+
info = (
|
|
75
|
+
pa.get_default_input_device_info()
|
|
76
|
+
if direction == "input"
|
|
77
|
+
else pa.get_default_output_device_info()
|
|
78
|
+
)
|
|
79
|
+
return int(info["index"]), info
|
|
80
|
+
except Exception as exc:
|
|
81
|
+
raise RuntimeError(f"default audio {direction} device is unavailable: {exc}") from exc
|
|
82
|
+
if value.isdigit():
|
|
83
|
+
wanted = int(value)
|
|
84
|
+
for index, info in candidates:
|
|
85
|
+
if index == wanted:
|
|
86
|
+
return index, info
|
|
87
|
+
raise RuntimeError(f"audio {direction} device index {wanted} is unavailable")
|
|
88
|
+
|
|
89
|
+
normalized = value.casefold()
|
|
90
|
+
exact = [(index, info) for index, info in candidates if str(info["name"]).casefold() == normalized]
|
|
91
|
+
partial = [(index, info) for index, info in candidates if normalized in str(info["name"]).casefold()]
|
|
92
|
+
matches = exact or partial
|
|
93
|
+
if not matches:
|
|
94
|
+
available = ", ".join(f"{index}:{info['name']}" for index, info in candidates)
|
|
95
|
+
raise RuntimeError(f"audio {direction} device '{selector}' not found; available: {available}")
|
|
96
|
+
return matches[0]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def candidate_rates(info: dict) -> list[int]:
|
|
100
|
+
values = [int(float(info.get("defaultSampleRate", 0))), 48000, 44100, 32000, 16000]
|
|
101
|
+
return list(dict.fromkeys(rate for rate in values if rate > 0))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def stable_audio_label(selector: str) -> str:
|
|
105
|
+
"""Remove the boot-order ALSA suffix while preserving product identity."""
|
|
106
|
+
return re.sub(r"\s*\((?:plug)?hw:\d+,\d+\)\s*$", "", str(selector), flags=re.I).strip()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def resolve_alsa_capture(selector: str) -> str | None:
|
|
110
|
+
"""Resolve a logical USB microphone label to its current ALSA card."""
|
|
111
|
+
if os.name != "posix" or not shutil.which("arecord"):
|
|
112
|
+
return None
|
|
113
|
+
label = stable_audio_label(selector)
|
|
114
|
+
parts = [part.strip() for part in label.split(":") if part.strip() not in {"", "-"}]
|
|
115
|
+
try:
|
|
116
|
+
output = subprocess.run(
|
|
117
|
+
["arecord", "-l"], capture_output=True, text=True, timeout=3, check=False
|
|
118
|
+
).stdout
|
|
119
|
+
except Exception:
|
|
120
|
+
return None
|
|
121
|
+
candidates = []
|
|
122
|
+
for line in output.splitlines():
|
|
123
|
+
match = re.match(r"^card\s+(\d+):.*device\s+(\d+):", line.strip(), re.I)
|
|
124
|
+
if not match:
|
|
125
|
+
continue
|
|
126
|
+
folded = line.casefold()
|
|
127
|
+
if parts and not all(part.casefold() in folded for part in parts):
|
|
128
|
+
continue
|
|
129
|
+
# Exact case is a useful discriminator for the fleet's two generic
|
|
130
|
+
# USB products ("Usb Audio Device" mic vs "USB Audio Device" output).
|
|
131
|
+
case_matches = sum(part in line for part in parts)
|
|
132
|
+
candidates.append((case_matches, f"plughw:{match.group(1)},{match.group(2)}"))
|
|
133
|
+
if not candidates:
|
|
134
|
+
return None
|
|
135
|
+
candidates.sort(reverse=True)
|
|
136
|
+
return candidates[0][1]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def audio_inventory() -> dict:
|
|
140
|
+
pa = pyaudio.PyAudio()
|
|
141
|
+
try:
|
|
142
|
+
inputs = []
|
|
143
|
+
outputs = []
|
|
144
|
+
for index in range(pa.get_device_count()):
|
|
145
|
+
info = pa.get_device_info_by_index(index)
|
|
146
|
+
row = {
|
|
147
|
+
"index": index,
|
|
148
|
+
"name": str(info.get("name", "unknown")),
|
|
149
|
+
"rate": int(float(info.get("defaultSampleRate", 0))),
|
|
150
|
+
}
|
|
151
|
+
if int(info.get("maxInputChannels", 0)) > 0:
|
|
152
|
+
inputs.append({**row, "channels": int(info["maxInputChannels"])})
|
|
153
|
+
if int(info.get("maxOutputChannels", 0)) > 0:
|
|
154
|
+
outputs.append({**row, "channels": int(info["maxOutputChannels"])})
|
|
155
|
+
return {"inputs": inputs, "outputs": outputs}
|
|
156
|
+
finally:
|
|
157
|
+
pa.terminate()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def media_lease_status() -> dict:
|
|
161
|
+
"""Report ALSA owners without opening or terminating any media device."""
|
|
162
|
+
owners: dict[int, dict] = {}
|
|
163
|
+
if os.name == "posix" and Path("/proc").exists():
|
|
164
|
+
for process_dir in Path("/proc").glob("[0-9]*"):
|
|
165
|
+
try:
|
|
166
|
+
pid = int(process_dir.name)
|
|
167
|
+
devices = sorted({
|
|
168
|
+
os.readlink(fd)
|
|
169
|
+
for fd in (process_dir / "fd").iterdir()
|
|
170
|
+
if os.readlink(fd).startswith("/dev/snd/")
|
|
171
|
+
})
|
|
172
|
+
if not devices:
|
|
173
|
+
continue
|
|
174
|
+
command = (process_dir / "cmdline").read_bytes().replace(b"\0", b" ").decode(
|
|
175
|
+
"utf-8", errors="replace"
|
|
176
|
+
).strip()
|
|
177
|
+
owners[pid] = {"pid": pid, "command": command, "devices": devices}
|
|
178
|
+
except (FileNotFoundError, PermissionError, ProcessLookupError, OSError, ValueError):
|
|
179
|
+
continue
|
|
180
|
+
rows = list(owners.values())
|
|
181
|
+
foreign = [row for row in rows if row["pid"] != os.getpid()]
|
|
182
|
+
return {
|
|
183
|
+
"authoritative_pid": os.getpid(),
|
|
184
|
+
"available": not foreign,
|
|
185
|
+
"owners": rows,
|
|
186
|
+
"conflicts": foreign,
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class NativeAudioInterface(AudioInterface):
|
|
191
|
+
"""PyAudio interface with deterministic devices and native-rate conversion."""
|
|
192
|
+
|
|
193
|
+
def __init__(self, input_selector: str, output_selector: str) -> None:
|
|
194
|
+
self.input_selector = input_selector
|
|
195
|
+
self.output_selector = output_selector
|
|
196
|
+
self.pa: pyaudio.PyAudio | None = None
|
|
197
|
+
self.in_stream = None
|
|
198
|
+
self.input_process: subprocess.Popen | None = None
|
|
199
|
+
self.input_backend = "pyaudio"
|
|
200
|
+
self.input_name = input_selector
|
|
201
|
+
self.output_name = output_selector
|
|
202
|
+
self.out_stream = None
|
|
203
|
+
self.input_callback: Callable[[bytes], None] | None = None
|
|
204
|
+
self.output_queue: queue.Queue[bytes] = queue.Queue(maxsize=OUTPUT_QUEUE_CHUNKS)
|
|
205
|
+
self.stop_event = threading.Event()
|
|
206
|
+
self.input_thread: threading.Thread | None = None
|
|
207
|
+
self.output_thread: threading.Thread | None = None
|
|
208
|
+
self.input_frames_per_buffer = 320
|
|
209
|
+
self.input_rate = SDK_RATE
|
|
210
|
+
self.output_rate = SDK_RATE
|
|
211
|
+
self.output_channels = 1
|
|
212
|
+
self.input_rate_state = None
|
|
213
|
+
self.output_rate_state = None
|
|
214
|
+
self.lock = threading.Lock()
|
|
215
|
+
self.started = threading.Event()
|
|
216
|
+
self.output_chunks = 0
|
|
217
|
+
self.output_bytes = 0
|
|
218
|
+
self.input_chunks = 0
|
|
219
|
+
self.forwarded_input_chunks = 0
|
|
220
|
+
self.input_peak = 0
|
|
221
|
+
self.suppressed_input_chunks = 0
|
|
222
|
+
self.input_read_errors = 0
|
|
223
|
+
self.last_input_error: str | None = None
|
|
224
|
+
self.output_write_errors = 0
|
|
225
|
+
self.last_output_error: str | None = None
|
|
226
|
+
self.fatal_error = threading.Event()
|
|
227
|
+
self.fatal_error_message: str | None = None
|
|
228
|
+
self.suppress_input_until = 0.0
|
|
229
|
+
self.echo_lock = threading.Lock()
|
|
230
|
+
self.echo_gate_announced = False
|
|
231
|
+
|
|
232
|
+
def _suppress_input_for(self, seconds: float) -> None:
|
|
233
|
+
with self.echo_lock:
|
|
234
|
+
self.suppress_input_until = max(
|
|
235
|
+
self.suppress_input_until,
|
|
236
|
+
time.monotonic() + max(0.0, seconds),
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
def _input_is_suppressed(self) -> bool:
|
|
240
|
+
with self.echo_lock:
|
|
241
|
+
return time.monotonic() < self.suppress_input_until
|
|
242
|
+
|
|
243
|
+
def _open_input(self, index: int, info: dict, alsa_device: str | None = None):
|
|
244
|
+
alsa_match = re.search(r"\((?:plug)?hw:(\d+),(\d+)\)", str(info.get("name", "")), re.I)
|
|
245
|
+
if alsa_device is None and alsa_match:
|
|
246
|
+
alsa_device = f"plughw:{alsa_match.group(1)},{alsa_match.group(2)}"
|
|
247
|
+
if os.name == "posix" and alsa_device and shutil.which("arecord"):
|
|
248
|
+
device = alsa_device
|
|
249
|
+
command = [
|
|
250
|
+
"arecord", "-q", "-D", device, "-t", "raw", "-f", "S16_LE",
|
|
251
|
+
"-r", "48000", "-c", "1", "--period-size", "960",
|
|
252
|
+
]
|
|
253
|
+
process = subprocess.Popen(
|
|
254
|
+
command,
|
|
255
|
+
stdout=subprocess.PIPE,
|
|
256
|
+
stderr=subprocess.DEVNULL,
|
|
257
|
+
bufsize=0,
|
|
258
|
+
)
|
|
259
|
+
time.sleep(0.15)
|
|
260
|
+
if process.poll() is not None:
|
|
261
|
+
raise RuntimeError(f"microphone arecord failed on {device} (exit {process.returncode})")
|
|
262
|
+
self.input_process = process
|
|
263
|
+
self.input_backend = f"arecord {device}"
|
|
264
|
+
# This is the same onsite-proven path used by mic ground-truth
|
|
265
|
+
# tests. Several fleet USB interfaces expose near-silent samples
|
|
266
|
+
# when ALSA is asked to capture at 16 kHz directly. Capture at
|
|
267
|
+
# 48 kHz and resample to the ElevenLabs 16 kHz SDK format below.
|
|
268
|
+
self.input_rate = 48000
|
|
269
|
+
self.input_frames_per_buffer = int(self.input_rate / 50)
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
errors = []
|
|
273
|
+
for rate in candidate_rates(info):
|
|
274
|
+
try:
|
|
275
|
+
frames_per_buffer = max(256, int(rate / 50))
|
|
276
|
+
stream = self.pa.open(
|
|
277
|
+
format=pyaudio.paInt16,
|
|
278
|
+
channels=1,
|
|
279
|
+
rate=rate,
|
|
280
|
+
input=True,
|
|
281
|
+
input_device_index=index,
|
|
282
|
+
frames_per_buffer=frames_per_buffer,
|
|
283
|
+
start=True,
|
|
284
|
+
)
|
|
285
|
+
self.input_rate = rate
|
|
286
|
+
self.input_frames_per_buffer = frames_per_buffer
|
|
287
|
+
self.input_backend = "pyaudio"
|
|
288
|
+
return stream
|
|
289
|
+
except Exception as exc:
|
|
290
|
+
errors.append(f"{rate}Hz: {exc}")
|
|
291
|
+
raise RuntimeError(f"microphone open failed ({'; '.join(errors)})")
|
|
292
|
+
|
|
293
|
+
def _open_output(self, index: int, info: dict):
|
|
294
|
+
errors = []
|
|
295
|
+
max_channels = max(1, int(info.get("maxOutputChannels", 1)))
|
|
296
|
+
for channels in ([2, 1] if max_channels >= 2 else [1]):
|
|
297
|
+
for rate in candidate_rates(info):
|
|
298
|
+
try:
|
|
299
|
+
stream = self.pa.open(
|
|
300
|
+
format=pyaudio.paInt16,
|
|
301
|
+
channels=channels,
|
|
302
|
+
rate=rate,
|
|
303
|
+
output=True,
|
|
304
|
+
output_device_index=index,
|
|
305
|
+
frames_per_buffer=max(256, int(rate / 16)),
|
|
306
|
+
start=True,
|
|
307
|
+
)
|
|
308
|
+
self.output_rate = rate
|
|
309
|
+
self.output_channels = channels
|
|
310
|
+
return stream
|
|
311
|
+
except Exception as exc:
|
|
312
|
+
errors.append(f"{rate}Hz/{channels}ch: {exc}")
|
|
313
|
+
raise RuntimeError(f"speaker open failed ({'; '.join(errors)})")
|
|
314
|
+
|
|
315
|
+
def start(self, input_callback: Callable[[bytes], None]):
|
|
316
|
+
with self.lock:
|
|
317
|
+
if self.started.is_set():
|
|
318
|
+
return
|
|
319
|
+
self.input_callback = input_callback
|
|
320
|
+
self.stop_event.clear()
|
|
321
|
+
self.pa = pyaudio.PyAudio()
|
|
322
|
+
input_label = stable_audio_label(self.input_selector)
|
|
323
|
+
resolved_capture = resolve_alsa_capture(input_label)
|
|
324
|
+
if resolved_capture:
|
|
325
|
+
input_index = -1
|
|
326
|
+
input_info = {"name": input_label}
|
|
327
|
+
log(f"microphone identity resolved: {input_label} -> {resolved_capture}")
|
|
328
|
+
else:
|
|
329
|
+
input_index, input_info = find_device(self.pa, input_label, "input")
|
|
330
|
+
output_index, output_info = find_device(
|
|
331
|
+
self.pa, stable_audio_label(self.output_selector), "output"
|
|
332
|
+
)
|
|
333
|
+
self.input_name = str(input_info["name"])
|
|
334
|
+
self.output_name = str(output_info["name"])
|
|
335
|
+
try:
|
|
336
|
+
self.in_stream = self._open_input(input_index, input_info, resolved_capture)
|
|
337
|
+
self.out_stream = self._open_output(output_index, output_info)
|
|
338
|
+
except Exception:
|
|
339
|
+
self._close_streams()
|
|
340
|
+
raise
|
|
341
|
+
self.output_thread = threading.Thread(
|
|
342
|
+
target=self._output_loop, name="robot-speaker", daemon=True
|
|
343
|
+
)
|
|
344
|
+
self.input_thread = threading.Thread(
|
|
345
|
+
target=self._input_loop, name="robot-microphone", daemon=True
|
|
346
|
+
)
|
|
347
|
+
self.output_thread.start()
|
|
348
|
+
self.input_thread.start()
|
|
349
|
+
self.started.set()
|
|
350
|
+
log(
|
|
351
|
+
f"audio active: mic {input_info['name']} at {self.input_rate}Hz; "
|
|
352
|
+
f"capture {self.input_backend}; speaker {output_info['name']} "
|
|
353
|
+
f"at {self.output_rate}Hz/{self.output_channels}ch"
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
def status(self) -> dict:
|
|
357
|
+
return {
|
|
358
|
+
"active": self.started.is_set() and not self.stop_event.is_set(),
|
|
359
|
+
"input": self.input_name,
|
|
360
|
+
"input_backend": self.input_backend,
|
|
361
|
+
"input_rate": self.input_rate,
|
|
362
|
+
"input_chunks": self.input_chunks,
|
|
363
|
+
"forwarded_input_chunks": self.forwarded_input_chunks,
|
|
364
|
+
"suppressed_input_chunks": self.suppressed_input_chunks,
|
|
365
|
+
"input_peak": self.input_peak,
|
|
366
|
+
"input_read_errors": self.input_read_errors,
|
|
367
|
+
"last_input_error": self.last_input_error,
|
|
368
|
+
"output": self.output_name,
|
|
369
|
+
"output_rate": self.output_rate,
|
|
370
|
+
"output_channels": self.output_channels,
|
|
371
|
+
"output_chunks": self.output_chunks,
|
|
372
|
+
"output_bytes": self.output_bytes,
|
|
373
|
+
"output_write_errors": self.output_write_errors,
|
|
374
|
+
"last_output_error": self.last_output_error,
|
|
375
|
+
"fatal_error": self.fatal_error_message,
|
|
376
|
+
"echo_gate_active": self._input_is_suppressed(),
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
def _input_loop(self) -> None:
|
|
380
|
+
read_errors = 0
|
|
381
|
+
while not self.stop_event.is_set():
|
|
382
|
+
try:
|
|
383
|
+
if self.input_process is not None:
|
|
384
|
+
data = self.input_process.stdout.read(self.input_frames_per_buffer * SAMPLE_WIDTH)
|
|
385
|
+
if not data and self.input_process.poll() is not None:
|
|
386
|
+
if self.stop_event.is_set():
|
|
387
|
+
break
|
|
388
|
+
raise RuntimeError(f"arecord exited with code {self.input_process.returncode}")
|
|
389
|
+
else:
|
|
390
|
+
data = self.in_stream.read(
|
|
391
|
+
self.input_frames_per_buffer,
|
|
392
|
+
exception_on_overflow=False,
|
|
393
|
+
)
|
|
394
|
+
read_errors = 0
|
|
395
|
+
except Exception as exc:
|
|
396
|
+
read_errors += 1
|
|
397
|
+
self.input_read_errors += 1
|
|
398
|
+
self.last_input_error = str(exc)
|
|
399
|
+
if read_errors <= 3 or read_errors % 50 == 0:
|
|
400
|
+
log(f"microphone read failed ({read_errors}): {exc}")
|
|
401
|
+
if read_errors >= 3:
|
|
402
|
+
self.fatal_error_message = f"microphone capture failed repeatedly: {exc}"
|
|
403
|
+
self.fatal_error.set()
|
|
404
|
+
break
|
|
405
|
+
time.sleep(0.02)
|
|
406
|
+
continue
|
|
407
|
+
if not data:
|
|
408
|
+
continue
|
|
409
|
+
self.input_chunks += 1
|
|
410
|
+
try:
|
|
411
|
+
self.input_peak = max(self.input_peak, audioop.max(data, SAMPLE_WIDTH))
|
|
412
|
+
except Exception:
|
|
413
|
+
pass
|
|
414
|
+
if self._input_is_suppressed() or not self.output_queue.empty():
|
|
415
|
+
self.suppressed_input_chunks += 1
|
|
416
|
+
continue
|
|
417
|
+
try:
|
|
418
|
+
converted, self.input_rate_state = audioop.ratecv(
|
|
419
|
+
data, SAMPLE_WIDTH, 1, self.input_rate, SDK_RATE, self.input_rate_state
|
|
420
|
+
)
|
|
421
|
+
if converted and self.input_callback:
|
|
422
|
+
self.input_callback(converted)
|
|
423
|
+
self.forwarded_input_chunks += 1
|
|
424
|
+
except Exception as exc:
|
|
425
|
+
log(f"microphone callback failed: {exc}")
|
|
426
|
+
|
|
427
|
+
def output(self, audio: bytes):
|
|
428
|
+
if self.stop_event.is_set() or not audio:
|
|
429
|
+
return
|
|
430
|
+
# ElevenLabs delivers mono PCM16 at SDK_RATE. Gate capture for the
|
|
431
|
+
# queued playback duration plus room/speaker decay so the agent cannot
|
|
432
|
+
# transcribe its own response and recursively answer itself.
|
|
433
|
+
self._suppress_input_for((len(audio) / (SDK_RATE * SAMPLE_WIDTH)) + SPEAKER_ECHO_TAIL_SECONDS)
|
|
434
|
+
if not self.echo_gate_announced:
|
|
435
|
+
self.echo_gate_announced = True
|
|
436
|
+
log("speaker echo gate active; microphone STT paused during playback")
|
|
437
|
+
try:
|
|
438
|
+
self.output_queue.put_nowait(audio)
|
|
439
|
+
except queue.Full:
|
|
440
|
+
# Bound latency and memory if the hardware stalls. Fresh speech is
|
|
441
|
+
# more useful than replaying stale buffered audio after recovery.
|
|
442
|
+
try:
|
|
443
|
+
self.output_queue.get_nowait()
|
|
444
|
+
except queue.Empty:
|
|
445
|
+
pass
|
|
446
|
+
try:
|
|
447
|
+
self.output_queue.put_nowait(audio)
|
|
448
|
+
except queue.Full:
|
|
449
|
+
pass
|
|
450
|
+
|
|
451
|
+
def _output_loop(self) -> None:
|
|
452
|
+
write_errors = 0
|
|
453
|
+
while not self.stop_event.is_set():
|
|
454
|
+
try:
|
|
455
|
+
data = self.output_queue.get(timeout=0.2)
|
|
456
|
+
except queue.Empty:
|
|
457
|
+
continue
|
|
458
|
+
try:
|
|
459
|
+
self._suppress_input_for(
|
|
460
|
+
(len(data) / (SDK_RATE * SAMPLE_WIDTH)) + SPEAKER_ECHO_TAIL_SECONDS
|
|
461
|
+
)
|
|
462
|
+
converted, self.output_rate_state = audioop.ratecv(
|
|
463
|
+
data, SAMPLE_WIDTH, 1, SDK_RATE, self.output_rate, self.output_rate_state
|
|
464
|
+
)
|
|
465
|
+
if self.output_channels == 2:
|
|
466
|
+
converted = audioop.tostereo(converted, SAMPLE_WIDTH, 1, 1)
|
|
467
|
+
self.out_stream.write(converted, exception_on_underflow=False)
|
|
468
|
+
write_errors = 0
|
|
469
|
+
self._suppress_input_for(SPEAKER_ECHO_TAIL_SECONDS)
|
|
470
|
+
self.output_chunks += 1
|
|
471
|
+
self.output_bytes += len(data)
|
|
472
|
+
if self.output_chunks == 1:
|
|
473
|
+
log("speaker received first agent audio frame")
|
|
474
|
+
except Exception as exc:
|
|
475
|
+
write_errors += 1
|
|
476
|
+
self.output_write_errors += 1
|
|
477
|
+
self.last_output_error = str(exc)
|
|
478
|
+
log(f"speaker write failed: {exc}")
|
|
479
|
+
if write_errors >= 3:
|
|
480
|
+
self.fatal_error_message = f"speaker playback failed repeatedly: {exc}"
|
|
481
|
+
self.fatal_error.set()
|
|
482
|
+
break
|
|
483
|
+
|
|
484
|
+
def interrupt(self):
|
|
485
|
+
while True:
|
|
486
|
+
try:
|
|
487
|
+
self.output_queue.get_nowait()
|
|
488
|
+
except queue.Empty:
|
|
489
|
+
break
|
|
490
|
+
self.output_rate_state = None
|
|
491
|
+
self._suppress_input_for(SPEAKER_ECHO_TAIL_SECONDS)
|
|
492
|
+
log("agent playback interrupted")
|
|
493
|
+
|
|
494
|
+
def _close_streams(self) -> None:
|
|
495
|
+
if self.input_process is not None:
|
|
496
|
+
try:
|
|
497
|
+
self.input_process.terminate()
|
|
498
|
+
self.input_process.wait(timeout=1)
|
|
499
|
+
except Exception:
|
|
500
|
+
try:
|
|
501
|
+
self.input_process.kill()
|
|
502
|
+
except Exception:
|
|
503
|
+
pass
|
|
504
|
+
self.input_process = None
|
|
505
|
+
for stream in (self.in_stream, self.out_stream):
|
|
506
|
+
if stream is not None:
|
|
507
|
+
try:
|
|
508
|
+
stream.stop_stream()
|
|
509
|
+
except Exception:
|
|
510
|
+
pass
|
|
511
|
+
try:
|
|
512
|
+
stream.close()
|
|
513
|
+
except Exception:
|
|
514
|
+
pass
|
|
515
|
+
self.in_stream = None
|
|
516
|
+
self.out_stream = None
|
|
517
|
+
if self.pa is not None:
|
|
518
|
+
try:
|
|
519
|
+
self.pa.terminate()
|
|
520
|
+
except Exception:
|
|
521
|
+
pass
|
|
522
|
+
self.pa = None
|
|
523
|
+
|
|
524
|
+
def stop(self):
|
|
525
|
+
with self.lock:
|
|
526
|
+
if self.stop_event.is_set() and not self.started.is_set():
|
|
527
|
+
return
|
|
528
|
+
self.stop_event.set()
|
|
529
|
+
if self.input_process is not None:
|
|
530
|
+
try:
|
|
531
|
+
self.input_process.terminate()
|
|
532
|
+
except Exception:
|
|
533
|
+
pass
|
|
534
|
+
if self.input_thread and self.input_thread is not threading.current_thread():
|
|
535
|
+
self.input_thread.join(timeout=2)
|
|
536
|
+
if self.output_thread and self.output_thread is not threading.current_thread():
|
|
537
|
+
self.output_thread.join(timeout=2)
|
|
538
|
+
self._close_streams()
|
|
539
|
+
self.started.clear()
|
|
540
|
+
self.input_callback = None
|
|
541
|
+
while True:
|
|
542
|
+
try:
|
|
543
|
+
self.output_queue.get_nowait()
|
|
544
|
+
except queue.Empty:
|
|
545
|
+
break
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
class VoiceTrigger:
|
|
549
|
+
def __init__(self, selector: str, threshold: int, trigger: Callable[[str], None]) -> None:
|
|
550
|
+
self.selector = selector
|
|
551
|
+
self.threshold = threshold
|
|
552
|
+
self.trigger = trigger
|
|
553
|
+
self.stop_event = threading.Event()
|
|
554
|
+
self.thread: threading.Thread | None = None
|
|
555
|
+
self.stream = None
|
|
556
|
+
self.pa: pyaudio.PyAudio | None = None
|
|
557
|
+
|
|
558
|
+
def start(self) -> None:
|
|
559
|
+
if self.thread and self.thread.is_alive():
|
|
560
|
+
return
|
|
561
|
+
self.stop_event.clear()
|
|
562
|
+
self.thread = threading.Thread(target=self._run, name="voice-trigger", daemon=True)
|
|
563
|
+
self.thread.start()
|
|
564
|
+
|
|
565
|
+
def stop(self) -> None:
|
|
566
|
+
self.stop_event.set()
|
|
567
|
+
if self.stream is not None:
|
|
568
|
+
try:
|
|
569
|
+
self.stream.stop_stream()
|
|
570
|
+
self.stream.close()
|
|
571
|
+
except Exception:
|
|
572
|
+
pass
|
|
573
|
+
self.stream = None
|
|
574
|
+
if self.thread and self.thread is not threading.current_thread():
|
|
575
|
+
self.thread.join(timeout=2)
|
|
576
|
+
if self.pa is not None:
|
|
577
|
+
try:
|
|
578
|
+
self.pa.terminate()
|
|
579
|
+
except Exception:
|
|
580
|
+
pass
|
|
581
|
+
self.pa = None
|
|
582
|
+
|
|
583
|
+
def _run(self) -> None:
|
|
584
|
+
try:
|
|
585
|
+
self.pa = pyaudio.PyAudio()
|
|
586
|
+
index, info = find_device(self.pa, self.selector, "input")
|
|
587
|
+
last_error = None
|
|
588
|
+
for rate in candidate_rates(info):
|
|
589
|
+
try:
|
|
590
|
+
self.stream = self.pa.open(
|
|
591
|
+
format=pyaudio.paInt16,
|
|
592
|
+
channels=1,
|
|
593
|
+
rate=rate,
|
|
594
|
+
input=True,
|
|
595
|
+
input_device_index=index,
|
|
596
|
+
frames_per_buffer=TRIGGER_CHUNK,
|
|
597
|
+
start=True,
|
|
598
|
+
)
|
|
599
|
+
log(f"voice trigger armed on {info['name']} at {rate}Hz")
|
|
600
|
+
break
|
|
601
|
+
except Exception as exc:
|
|
602
|
+
last_error = exc
|
|
603
|
+
if self.stream is None:
|
|
604
|
+
raise RuntimeError(f"could not open trigger microphone: {last_error}")
|
|
605
|
+
consecutive = 0
|
|
606
|
+
warmup = 3
|
|
607
|
+
while not self.stop_event.is_set():
|
|
608
|
+
data = self.stream.read(TRIGGER_CHUNK, exception_on_overflow=False)
|
|
609
|
+
if warmup:
|
|
610
|
+
warmup -= 1
|
|
611
|
+
continue
|
|
612
|
+
level = pcm_rms(data)
|
|
613
|
+
consecutive = consecutive + 1 if level >= self.threshold else 0
|
|
614
|
+
if consecutive >= 3:
|
|
615
|
+
log(f"voice trigger detected (RMS {level})")
|
|
616
|
+
self.trigger("voice")
|
|
617
|
+
return
|
|
618
|
+
except Exception as exc:
|
|
619
|
+
if not self.stop_event.is_set():
|
|
620
|
+
log(f"voice trigger unavailable: {exc}")
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
class MotorRegistry:
|
|
624
|
+
def __init__(self, motors: dict[str, int], active_high: bool, default_pulse_ms: int) -> None:
|
|
625
|
+
self.motors = motors
|
|
626
|
+
self.active_high = active_high
|
|
627
|
+
self.default_pulse_ms = default_pulse_ms
|
|
628
|
+
self.devices = {}
|
|
629
|
+
self.lock = threading.Lock()
|
|
630
|
+
|
|
631
|
+
def names(self) -> list[str]:
|
|
632
|
+
return sorted(self.motors)
|
|
633
|
+
|
|
634
|
+
def _device(self, name: str):
|
|
635
|
+
normalized = str(name).strip().lower().replace("_", "-")
|
|
636
|
+
if normalized not in self.motors:
|
|
637
|
+
raise ValueError(
|
|
638
|
+
f"unknown motor '{name}'; registered motors: {', '.join(self.names()) or 'none'}"
|
|
639
|
+
)
|
|
640
|
+
if normalized not in self.devices:
|
|
641
|
+
from gpiozero import OutputDevice
|
|
642
|
+
|
|
643
|
+
self.devices[normalized] = OutputDevice(
|
|
644
|
+
self.motors[normalized],
|
|
645
|
+
active_high=self.active_high,
|
|
646
|
+
initial_value=False,
|
|
647
|
+
)
|
|
648
|
+
return normalized, self.devices[normalized]
|
|
649
|
+
|
|
650
|
+
def execute(self, parameters: dict) -> dict:
|
|
651
|
+
name = parameters.get("name") or parameters.get("motor")
|
|
652
|
+
action = str(parameters.get("action") or "pulse").strip().lower()
|
|
653
|
+
requested_ms = parameters.get("duration_ms", self.default_pulse_ms)
|
|
654
|
+
try:
|
|
655
|
+
duration_ms = int(requested_ms)
|
|
656
|
+
except (TypeError, ValueError):
|
|
657
|
+
duration_ms = self.default_pulse_ms
|
|
658
|
+
duration_ms = max(50, min(duration_ms, 3000))
|
|
659
|
+
with self.lock:
|
|
660
|
+
normalized, device = self._device(name)
|
|
661
|
+
pin = self.motors[normalized]
|
|
662
|
+
if action == "off":
|
|
663
|
+
device.off()
|
|
664
|
+
log(f"motor {normalized} GPIO{pin}: OFF")
|
|
665
|
+
elif action in {"pulse", "on", "activate", "move"}:
|
|
666
|
+
log(f"motor {normalized} GPIO{pin}: ON for {duration_ms}ms")
|
|
667
|
+
device.on()
|
|
668
|
+
time.sleep(duration_ms / 1000)
|
|
669
|
+
device.off()
|
|
670
|
+
log(f"motor {normalized} GPIO{pin}: OFF")
|
|
671
|
+
else:
|
|
672
|
+
raise ValueError("action must be pulse, on, activate, move, or off")
|
|
673
|
+
return {
|
|
674
|
+
"ok": True,
|
|
675
|
+
"motor": normalized,
|
|
676
|
+
"gpio": pin,
|
|
677
|
+
"action": action,
|
|
678
|
+
"duration_ms": 0 if action == "off" else duration_ms,
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
def close(self) -> None:
|
|
682
|
+
for device in self.devices.values():
|
|
683
|
+
try:
|
|
684
|
+
device.off()
|
|
685
|
+
device.close()
|
|
686
|
+
except Exception:
|
|
687
|
+
pass
|
|
688
|
+
self.devices.clear()
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
class SchedulerReporter:
|
|
692
|
+
"""Best-effort central oversight for robot-local ElevenLabs calls."""
|
|
693
|
+
|
|
694
|
+
def __init__(self, config: dict, store: SupervisorStore) -> None:
|
|
695
|
+
self.base_url = str(config.get("scheduler_url") or "").rstrip("/")
|
|
696
|
+
self.device_id = str(config.get("scheduler_device_id") or "").strip()
|
|
697
|
+
self.device_token = str(config.get("scheduler_device_token") or "").strip()
|
|
698
|
+
if not (self.base_url and self.device_id and self.device_token):
|
|
699
|
+
try:
|
|
700
|
+
config_dir = Path.home() / ".robopark"
|
|
701
|
+
preview = json.loads((config_dir / "preview_agent.json").read_text(encoding="utf-8"))
|
|
702
|
+
self.base_url = str(preview.get("scheduler_url") or self.base_url).rstrip("/")
|
|
703
|
+
self.device_id = str(preview.get("device_id") or self.device_id).strip()
|
|
704
|
+
token_path = config_dir / "device_token"
|
|
705
|
+
self.device_token = (
|
|
706
|
+
token_path.read_text(encoding="utf-8").strip()
|
|
707
|
+
if token_path.exists()
|
|
708
|
+
else str(preview.get("device_token") or self.device_token).strip()
|
|
709
|
+
)
|
|
710
|
+
except Exception:
|
|
711
|
+
pass
|
|
712
|
+
self.agent_id = str(config.get("agent_id") or "").strip()
|
|
713
|
+
self.branch_id = str(config.get("branch_id") or "").strip() or None
|
|
714
|
+
self.session_id: str | None = None
|
|
715
|
+
self.event_token: str | None = None
|
|
716
|
+
self.sequence = 0
|
|
717
|
+
self.lock = threading.Lock()
|
|
718
|
+
self.store = store
|
|
719
|
+
self.command_handler: Callable[[dict], dict] | None = None
|
|
720
|
+
self.state_provider: Callable[[], dict] | None = None
|
|
721
|
+
self.stop_event = threading.Event()
|
|
722
|
+
self.jobs: queue.Queue[tuple[str, dict, dict] | None] = queue.Queue(maxsize=256)
|
|
723
|
+
self.worker: threading.Thread | None = None
|
|
724
|
+
|
|
725
|
+
@property
|
|
726
|
+
def enabled(self) -> bool:
|
|
727
|
+
return bool(self.base_url and self.device_id and self.device_token)
|
|
728
|
+
|
|
729
|
+
def _post(self, path: str, payload: dict, headers: dict | None = None) -> dict:
|
|
730
|
+
request = urllib.request.Request(
|
|
731
|
+
f"{self.base_url}{path}",
|
|
732
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
733
|
+
headers={"Content-Type": "application/json", **(headers or {})},
|
|
734
|
+
method="POST",
|
|
735
|
+
)
|
|
736
|
+
with urllib.request.urlopen(request, timeout=4) as response:
|
|
737
|
+
body = response.read().decode("utf-8")
|
|
738
|
+
return json.loads(body) if body else {}
|
|
739
|
+
|
|
740
|
+
def _get(self, path: str, headers: dict | None = None) -> dict:
|
|
741
|
+
request = urllib.request.Request(
|
|
742
|
+
f"{self.base_url}{path}", headers=headers or {}, method="GET"
|
|
743
|
+
)
|
|
744
|
+
with urllib.request.urlopen(request, timeout=4) as response:
|
|
745
|
+
body = response.read().decode("utf-8")
|
|
746
|
+
return json.loads(body) if body else {}
|
|
747
|
+
|
|
748
|
+
def _sync_durable_state(self) -> None:
|
|
749
|
+
if not self.enabled:
|
|
750
|
+
return
|
|
751
|
+
headers = {"Authorization": f"Bearer {self.device_token}"}
|
|
752
|
+
if self.state_provider is not None:
|
|
753
|
+
self._post(
|
|
754
|
+
f"/api/devices/{urllib.parse.quote(self.device_id, safe='')}/voice-runtime-state",
|
|
755
|
+
self.state_provider(), headers,
|
|
756
|
+
)
|
|
757
|
+
events = self.store.events_after(0, 250)
|
|
758
|
+
if events:
|
|
759
|
+
result = self._post(
|
|
760
|
+
f"/api/devices/{urllib.parse.quote(self.device_id, safe='')}/telemetry/events",
|
|
761
|
+
{"events": events},
|
|
762
|
+
headers,
|
|
763
|
+
)
|
|
764
|
+
acknowledged = int(result.get("acknowledged_sequence") or 0)
|
|
765
|
+
if acknowledged:
|
|
766
|
+
self.store.acknowledge(acknowledged)
|
|
767
|
+
commands = self._get(
|
|
768
|
+
f"/api/devices/{urllib.parse.quote(self.device_id, safe='')}/commands/pending",
|
|
769
|
+
headers,
|
|
770
|
+
).get("commands") or []
|
|
771
|
+
for command in commands:
|
|
772
|
+
command_id = str(command.get("command_id") or "")
|
|
773
|
+
if not command_id:
|
|
774
|
+
continue
|
|
775
|
+
previous = self.store.command_result(command_id)
|
|
776
|
+
if previous:
|
|
777
|
+
self._post(
|
|
778
|
+
f"/api/devices/{urllib.parse.quote(self.device_id, safe='')}/commands/{command_id}/ack",
|
|
779
|
+
{"status": previous["status"], "result": previous["result"]},
|
|
780
|
+
headers,
|
|
781
|
+
)
|
|
782
|
+
continue
|
|
783
|
+
self._post(
|
|
784
|
+
f"/api/devices/{urllib.parse.quote(self.device_id, safe='')}/commands/{command_id}/ack",
|
|
785
|
+
{"status": "acknowledged", "result": {}}, headers,
|
|
786
|
+
)
|
|
787
|
+
self._post(
|
|
788
|
+
f"/api/devices/{urllib.parse.quote(self.device_id, safe='')}/commands/{command_id}/ack",
|
|
789
|
+
{"status": "running", "result": {}}, headers,
|
|
790
|
+
)
|
|
791
|
+
try:
|
|
792
|
+
if self.command_handler is None:
|
|
793
|
+
raise RuntimeError("local command handler is unavailable")
|
|
794
|
+
result = self.command_handler(command)
|
|
795
|
+
status = "succeeded" if result.get("ok") else "failed"
|
|
796
|
+
except Exception as exc:
|
|
797
|
+
result = {"ok": False, "error": str(exc)}
|
|
798
|
+
status = "failed"
|
|
799
|
+
self.store.complete_command(command_id, status, result)
|
|
800
|
+
self._post(
|
|
801
|
+
f"/api/devices/{urllib.parse.quote(self.device_id, safe='')}/commands/{command_id}/ack",
|
|
802
|
+
{"status": status, "result": result}, headers,
|
|
803
|
+
)
|
|
804
|
+
|
|
805
|
+
def _run(self) -> None:
|
|
806
|
+
while not self.stop_event.is_set():
|
|
807
|
+
try:
|
|
808
|
+
job = self.jobs.get(timeout=2)
|
|
809
|
+
except queue.Empty:
|
|
810
|
+
job = ()
|
|
811
|
+
if job is None:
|
|
812
|
+
return
|
|
813
|
+
if job:
|
|
814
|
+
path, payload, headers = job
|
|
815
|
+
try:
|
|
816
|
+
self._post(path, payload, headers)
|
|
817
|
+
except Exception as exc:
|
|
818
|
+
log(f"central oversight write failed; event remains local: {exc}")
|
|
819
|
+
try:
|
|
820
|
+
self._sync_durable_state()
|
|
821
|
+
except Exception:
|
|
822
|
+
# Gateway connectivity never changes local voice readiness.
|
|
823
|
+
pass
|
|
824
|
+
|
|
825
|
+
def start_worker(self) -> None:
|
|
826
|
+
if self.worker is None or not self.worker.is_alive():
|
|
827
|
+
self.worker = threading.Thread(target=self._run, name="management-sync", daemon=True)
|
|
828
|
+
self.worker.start()
|
|
829
|
+
|
|
830
|
+
def _enqueue(self, path: str, payload: dict, headers: dict) -> None:
|
|
831
|
+
if not self.enabled:
|
|
832
|
+
return
|
|
833
|
+
self.start_worker()
|
|
834
|
+
try:
|
|
835
|
+
self.jobs.put_nowait((path, payload, headers))
|
|
836
|
+
except queue.Full:
|
|
837
|
+
log("central oversight queue full; dropping telemetry event")
|
|
838
|
+
|
|
839
|
+
def start(self, reason: str) -> None:
|
|
840
|
+
if not self.enabled:
|
|
841
|
+
return
|
|
842
|
+
with self.lock:
|
|
843
|
+
self.session_id = None
|
|
844
|
+
self.event_token = None
|
|
845
|
+
self.sequence = 0
|
|
846
|
+
try:
|
|
847
|
+
result = self._post(
|
|
848
|
+
f"/api/devices/{urllib.parse.quote(self.device_id, safe='')}/direct-voice/sessions",
|
|
849
|
+
{"agent_id": self.agent_id, "branch_id": self.branch_id, "trigger_reason": reason},
|
|
850
|
+
{"Authorization": f"Bearer {self.device_token}"},
|
|
851
|
+
)
|
|
852
|
+
with self.lock:
|
|
853
|
+
self.session_id = str(result.get("session_id") or "") or None
|
|
854
|
+
self.event_token = str(result.get("event_token") or "") or None
|
|
855
|
+
if self.session_id:
|
|
856
|
+
log(f"central oversight session registered: {self.session_id}")
|
|
857
|
+
except Exception as exc:
|
|
858
|
+
log(f"central oversight unavailable; continuing locally: {exc}")
|
|
859
|
+
|
|
860
|
+
def event(self, stage: str, status: str, message: str, details: dict | None = None) -> None:
|
|
861
|
+
pipeline_stage = {
|
|
862
|
+
"session_starting": "voice_worker",
|
|
863
|
+
"provider_connected": "voice_worker",
|
|
864
|
+
"user_transcript": "stt_listening",
|
|
865
|
+
"agent_response": "llm_response",
|
|
866
|
+
"session_error": "voice_worker",
|
|
867
|
+
"session_disposed": "session_ended",
|
|
868
|
+
}.get(stage)
|
|
869
|
+
if pipeline_stage is None:
|
|
870
|
+
return
|
|
871
|
+
pipeline_status = "running" if status == "active" else status
|
|
872
|
+
with self.lock:
|
|
873
|
+
session_id, token = self.session_id, self.event_token
|
|
874
|
+
if not session_id or not token:
|
|
875
|
+
return
|
|
876
|
+
self._enqueue(
|
|
877
|
+
f"/api/sessions/{urllib.parse.quote(session_id, safe='')}/pipeline-events",
|
|
878
|
+
{"stage": pipeline_stage, "status": pipeline_status, "message": message, "source": "robot_hardware", "details": details or {}},
|
|
879
|
+
{"X-RoboPark-Session-Token": token},
|
|
880
|
+
)
|
|
881
|
+
|
|
882
|
+
def turn(self, role: str, text: str) -> None:
|
|
883
|
+
# Transcript delivery is exclusively cursor-based through the durable
|
|
884
|
+
# spool. Posting it here as well produced duplicate historical turns.
|
|
885
|
+
return
|
|
886
|
+
|
|
887
|
+
def end(self, reason: str, duration: float, error: str | None) -> None:
|
|
888
|
+
with self.lock:
|
|
889
|
+
session_id = self.session_id
|
|
890
|
+
self.session_id = None
|
|
891
|
+
self.event_token = None
|
|
892
|
+
if not session_id or not self.enabled:
|
|
893
|
+
return
|
|
894
|
+
self._enqueue(
|
|
895
|
+
f"/api/devices/{urllib.parse.quote(self.device_id, safe='')}/direct-voice/sessions/{urllib.parse.quote(session_id, safe='')}/end",
|
|
896
|
+
{"reason": reason, "duration_seconds": duration, "error": error},
|
|
897
|
+
{"Authorization": f"Bearer {self.device_token}"},
|
|
898
|
+
)
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
class Runtime:
|
|
902
|
+
def __init__(self, config: dict) -> None:
|
|
903
|
+
self.config = config
|
|
904
|
+
# Session disposal is local and deterministic. Recycling the systemd
|
|
905
|
+
# worker after every timeout caused port races and stale ALSA owners.
|
|
906
|
+
self.config["recycle_after_session"] = False
|
|
907
|
+
self.shutdown = threading.Event()
|
|
908
|
+
self.trigger_event = threading.Event()
|
|
909
|
+
self.trigger_reason = "unknown"
|
|
910
|
+
self.state_lock = threading.Lock()
|
|
911
|
+
self.active = False
|
|
912
|
+
self.last_session_end = 0.0
|
|
913
|
+
self.session_started_at = 0.0
|
|
914
|
+
self.sessions_started = 0
|
|
915
|
+
self.sessions_completed = 0
|
|
916
|
+
self.triggers_accepted = 0
|
|
917
|
+
self.triggers_rejected = 0
|
|
918
|
+
self.pending_motion_at = 0.0
|
|
919
|
+
self.last_error: str | None = None
|
|
920
|
+
self.telemetry_last_error: str | None = None
|
|
921
|
+
self.vision_routed = False
|
|
922
|
+
self.vision_last_error: str | None = None
|
|
923
|
+
self.vision_process: subprocess.Popen | None = None
|
|
924
|
+
self.vision_process_started = False
|
|
925
|
+
self.vision_restarts = 0
|
|
926
|
+
self.recycle_required = False
|
|
927
|
+
self.force_stop_event = threading.Event()
|
|
928
|
+
self.paused = False
|
|
929
|
+
self.apply_when_idle_requested = False
|
|
930
|
+
self.restart_when_idle_requested = False
|
|
931
|
+
self.stop_when_idle_requested = False
|
|
932
|
+
self.local_session_id: str | None = None
|
|
933
|
+
self.lifecycle_events: deque[dict] = deque(maxlen=80)
|
|
934
|
+
self.store = SupervisorStore(
|
|
935
|
+
str(config["config_path"]),
|
|
936
|
+
str(config.get("scheduler_device_id") or config.get("robot_name") or "robot"),
|
|
937
|
+
)
|
|
938
|
+
self.paused = bool(self.store.get_state("paused", False))
|
|
939
|
+
self._initialize_configuration_state()
|
|
940
|
+
self.scheduler_reporter = SchedulerReporter(config, self.store)
|
|
941
|
+
self.scheduler_reporter.command_handler = self.handle_management_command
|
|
942
|
+
self.scheduler_reporter.state_provider = self.reconciliation_state
|
|
943
|
+
self.last_session: dict | None = None
|
|
944
|
+
self.last_trigger_reason: str | None = None
|
|
945
|
+
self.audio_devices = audio_inventory()
|
|
946
|
+
self.current_audio: NativeAudioInterface | None = None
|
|
947
|
+
self.voice_trigger = VoiceTrigger(
|
|
948
|
+
config["audio_input"], int(config["voice_threshold"]), self.trigger
|
|
949
|
+
)
|
|
950
|
+
self.httpd: ThreadingHTTPServer | None = None
|
|
951
|
+
self.http_thread: threading.Thread | None = None
|
|
952
|
+
self.motor_registry = MotorRegistry(
|
|
953
|
+
config.get("motors") or {},
|
|
954
|
+
bool(config.get("motor_active_high")),
|
|
955
|
+
int(config.get("motor_pulse_ms") or 300),
|
|
956
|
+
)
|
|
957
|
+
self._event("runtime_ready", "ElevenLabs runtime initialized")
|
|
958
|
+
self.scheduler_reporter.start_worker()
|
|
959
|
+
|
|
960
|
+
def _initialize_configuration_state(self) -> None:
|
|
961
|
+
applied = self.store.get_state("applied_configuration")
|
|
962
|
+
if not applied:
|
|
963
|
+
applied = self._configuration_snapshot(
|
|
964
|
+
int(self.config.get("applied_revision") or self.config.get("desired_revision") or 1)
|
|
965
|
+
)
|
|
966
|
+
self.store.set_state("applied_configuration", applied)
|
|
967
|
+
self.store.set_state("last_known_good_configuration", applied)
|
|
968
|
+
self.config["applied_revision"] = int(applied.get("applied_revision") or 1)
|
|
969
|
+
self.config["desired_revision"] = int(
|
|
970
|
+
self.store.get_state("desired_revision", self.config["applied_revision"])
|
|
971
|
+
)
|
|
972
|
+
|
|
973
|
+
def _configuration_snapshot(self, revision: int | None = None) -> dict:
|
|
974
|
+
applied_revision = int(revision or self.config.get("applied_revision") or 1)
|
|
975
|
+
desired_revision = int(self.config.get("desired_revision") or applied_revision)
|
|
976
|
+
return {
|
|
977
|
+
"voice_engine_default": str(self.config.get("voice_engine_default") or "elevenlabs"),
|
|
978
|
+
"robovoice_enabled": bool(self.config.get("robovoice_enabled", True)),
|
|
979
|
+
"allow_session_override": bool(self.config.get("allow_session_override", True)),
|
|
980
|
+
"apply_changes_when_idle": bool(self.config.get("apply_changes_when_idle", True)),
|
|
981
|
+
"desired_revision": desired_revision,
|
|
982
|
+
"applied_revision": applied_revision,
|
|
983
|
+
"character_id": str(self.config.get("character_id") or self.config.get("robot_name") or ""),
|
|
984
|
+
"elevenlabs": {
|
|
985
|
+
"agent_id": str(self.config.get("agent_id") or ""),
|
|
986
|
+
"branch_id": str(self.config.get("branch_id") or "") or None,
|
|
987
|
+
},
|
|
988
|
+
"robovoice": {"server_id": self.config.get("robovoice_server_id")},
|
|
989
|
+
"audio": {
|
|
990
|
+
"input_identity": str(self.config.get("audio_input") or ""),
|
|
991
|
+
"output_identity": str(self.config.get("audio_output") or ""),
|
|
992
|
+
},
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
def configuration(self) -> dict:
|
|
996
|
+
applied = self.store.get_state("applied_configuration", self._configuration_snapshot())
|
|
997
|
+
staged = self.store.get_state("staged_configuration")
|
|
998
|
+
desired_revision = int(self.store.get_state("desired_revision", applied.get("applied_revision", 1)))
|
|
999
|
+
applied["desired_revision"] = desired_revision
|
|
1000
|
+
return {
|
|
1001
|
+
**applied,
|
|
1002
|
+
"desired_revision": desired_revision,
|
|
1003
|
+
"applied_revision": int(applied.get("applied_revision") or 1),
|
|
1004
|
+
"application_state": (
|
|
1005
|
+
"waiting_for_idle" if staged and self.active else
|
|
1006
|
+
"staged" if staged else "applied"
|
|
1007
|
+
),
|
|
1008
|
+
"staged_configuration": staged,
|
|
1009
|
+
"last_known_good_revision": int(
|
|
1010
|
+
(self.store.get_state("last_known_good_configuration") or applied).get("applied_revision") or 1
|
|
1011
|
+
),
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
@staticmethod
|
|
1015
|
+
def _validate_staged_configuration(payload: dict) -> dict:
|
|
1016
|
+
forbidden = {"api_key", "token", "secret", "password", "device_token"}
|
|
1017
|
+
|
|
1018
|
+
def inspect(value, path="configuration"):
|
|
1019
|
+
if isinstance(value, dict):
|
|
1020
|
+
for key, child in value.items():
|
|
1021
|
+
if any(part in str(key).casefold() for part in forbidden):
|
|
1022
|
+
raise ValueError(f"credentials are not accepted in {path}")
|
|
1023
|
+
inspect(child, f"{path}.{key}")
|
|
1024
|
+
elif isinstance(value, list):
|
|
1025
|
+
for child in value:
|
|
1026
|
+
inspect(child, path)
|
|
1027
|
+
|
|
1028
|
+
inspect(payload)
|
|
1029
|
+
engine = str(payload.get("voice_engine_default") or "elevenlabs").casefold()
|
|
1030
|
+
if engine not in {"elevenlabs", "robovoice"}:
|
|
1031
|
+
raise ValueError("voice_engine_default must be elevenlabs or robovoice")
|
|
1032
|
+
elevenlabs = payload.get("elevenlabs") or {}
|
|
1033
|
+
if engine == "elevenlabs" and not str(elevenlabs.get("agent_id") or "").strip():
|
|
1034
|
+
raise ValueError("elevenlabs.agent_id is required")
|
|
1035
|
+
audio = payload.get("audio") or {}
|
|
1036
|
+
if not str(audio.get("input_identity") or "").strip():
|
|
1037
|
+
raise ValueError("audio.input_identity is required")
|
|
1038
|
+
if not str(audio.get("output_identity") or "").strip():
|
|
1039
|
+
raise ValueError("audio.output_identity is required")
|
|
1040
|
+
return payload
|
|
1041
|
+
|
|
1042
|
+
def stage_configuration(self, payload: dict) -> dict:
|
|
1043
|
+
current = self.configuration()
|
|
1044
|
+
merged = {
|
|
1045
|
+
key: value for key, value in current.items()
|
|
1046
|
+
if key not in {"application_state", "staged_configuration", "last_known_good_revision"}
|
|
1047
|
+
}
|
|
1048
|
+
for key in (
|
|
1049
|
+
"voice_engine_default", "robovoice_enabled", "allow_session_override",
|
|
1050
|
+
"apply_changes_when_idle", "character_id", "elevenlabs", "robovoice", "audio",
|
|
1051
|
+
):
|
|
1052
|
+
if key in payload:
|
|
1053
|
+
merged[key] = payload[key]
|
|
1054
|
+
revision = max(
|
|
1055
|
+
int(payload.get("desired_revision") or 0),
|
|
1056
|
+
int(current["desired_revision"]) + 1,
|
|
1057
|
+
)
|
|
1058
|
+
merged["desired_revision"] = revision
|
|
1059
|
+
merged["applied_revision"] = int(current["applied_revision"])
|
|
1060
|
+
self._validate_staged_configuration(merged)
|
|
1061
|
+
self.store.set_state("desired_revision", revision)
|
|
1062
|
+
self.store.set_state("staged_configuration", merged)
|
|
1063
|
+
self.config["desired_revision"] = revision
|
|
1064
|
+
self._event("configuration_staged", f"Configuration revision {revision} staged", "pending")
|
|
1065
|
+
return self.configuration()
|
|
1066
|
+
|
|
1067
|
+
def apply_staged_configuration(self) -> dict:
|
|
1068
|
+
staged = self.store.get_state("staged_configuration")
|
|
1069
|
+
if not staged:
|
|
1070
|
+
return {"ok": True, "configuration": self.configuration(), "changed": False}
|
|
1071
|
+
if self.active:
|
|
1072
|
+
self.apply_when_idle_requested = True
|
|
1073
|
+
return {"ok": True, "waiting_for_idle": True, "configuration": self.configuration()}
|
|
1074
|
+
previous = self._configuration_snapshot(int(self.config.get("applied_revision") or 1))
|
|
1075
|
+
try:
|
|
1076
|
+
self._validate_staged_configuration(staged)
|
|
1077
|
+
audio = staged["audio"]
|
|
1078
|
+
elevenlabs = staged.get("elevenlabs") or {}
|
|
1079
|
+
if staged["voice_engine_default"] == "elevenlabs":
|
|
1080
|
+
pa = pyaudio.PyAudio()
|
|
1081
|
+
try:
|
|
1082
|
+
find_device(pa, audio["input_identity"], "input")
|
|
1083
|
+
find_device(pa, audio["output_identity"], "output")
|
|
1084
|
+
finally:
|
|
1085
|
+
pa.terminate()
|
|
1086
|
+
self.config.update({
|
|
1087
|
+
"voice_engine_default": staged["voice_engine_default"],
|
|
1088
|
+
"robovoice_enabled": bool(staged.get("robovoice_enabled", True)),
|
|
1089
|
+
"allow_session_override": bool(staged.get("allow_session_override", True)),
|
|
1090
|
+
"apply_changes_when_idle": bool(staged.get("apply_changes_when_idle", True)),
|
|
1091
|
+
"character_id": staged.get("character_id"),
|
|
1092
|
+
"agent_id": elevenlabs.get("agent_id") or self.config.get("agent_id"),
|
|
1093
|
+
"branch_id": elevenlabs.get("branch_id"),
|
|
1094
|
+
"audio_input": audio["input_identity"],
|
|
1095
|
+
"audio_output": audio["output_identity"],
|
|
1096
|
+
"desired_revision": int(staged["desired_revision"]),
|
|
1097
|
+
"applied_revision": int(staged["desired_revision"]),
|
|
1098
|
+
})
|
|
1099
|
+
Path(self.config["config_path"]).write_text(
|
|
1100
|
+
json.dumps(self.config, indent=2) + "\n", encoding="utf-8"
|
|
1101
|
+
)
|
|
1102
|
+
applied = self._configuration_snapshot(self.config["applied_revision"])
|
|
1103
|
+
applied["desired_revision"] = self.config["applied_revision"]
|
|
1104
|
+
self.store.set_state("last_known_good_configuration", previous)
|
|
1105
|
+
self.store.set_state("applied_configuration", applied)
|
|
1106
|
+
self.store.set_state("desired_revision", self.config["applied_revision"])
|
|
1107
|
+
self.store.set_state("staged_configuration", None)
|
|
1108
|
+
self.audio_devices = audio_inventory()
|
|
1109
|
+
self.restart_when_idle_requested = True
|
|
1110
|
+
self._event("configuration_applied", f"Configuration revision {self.config['applied_revision']} applied")
|
|
1111
|
+
return {"ok": True, "changed": True, "configuration": self.configuration()}
|
|
1112
|
+
except Exception as exc:
|
|
1113
|
+
self.store.set_state("applied_configuration", previous)
|
|
1114
|
+
self._event("configuration_rollback", f"Staged configuration rejected: {exc}", "failed")
|
|
1115
|
+
return {"ok": False, "error": str(exc), "rolled_back": True, "configuration": self.configuration()}
|
|
1116
|
+
|
|
1117
|
+
def _event(self, stage: str, message: str, status: str = "ok", **details) -> None:
|
|
1118
|
+
event = {
|
|
1119
|
+
"timestamp": time.time(),
|
|
1120
|
+
"stage": stage,
|
|
1121
|
+
"status": status,
|
|
1122
|
+
"message": message,
|
|
1123
|
+
**details,
|
|
1124
|
+
}
|
|
1125
|
+
self.lifecycle_events.append(event)
|
|
1126
|
+
self._spool_event(f"lifecycle.{stage}", event)
|
|
1127
|
+
self.scheduler_reporter.event(stage, status, message, details)
|
|
1128
|
+
|
|
1129
|
+
def _spool_event(self, event_type: str, payload: dict) -> None:
|
|
1130
|
+
try:
|
|
1131
|
+
self.store.append_event(event_type, payload, self.local_session_id)
|
|
1132
|
+
except Exception as exc:
|
|
1133
|
+
# Observability storage can degrade, but it cannot break media or
|
|
1134
|
+
# provider callbacks. Health exposes the failure for management.
|
|
1135
|
+
self.telemetry_last_error = str(exc)
|
|
1136
|
+
log(f"telemetry spool write failed; voice continues locally: {exc}")
|
|
1137
|
+
|
|
1138
|
+
def status(self) -> dict:
|
|
1139
|
+
with self.state_lock:
|
|
1140
|
+
cooldown_remaining = max(
|
|
1141
|
+
0.0,
|
|
1142
|
+
float(self.config["cooldown"]) - (time.monotonic() - self.last_session_end),
|
|
1143
|
+
) if self.last_session_end else 0.0
|
|
1144
|
+
return {
|
|
1145
|
+
"engine": "elevenlabs",
|
|
1146
|
+
"effective_engine": self.configuration()["voice_engine_default"],
|
|
1147
|
+
"engine_source": "robot",
|
|
1148
|
+
"fallback_available": bool(self.config.get("robovoice_enabled", True)),
|
|
1149
|
+
"robot_name": self.config.get("robot_name"),
|
|
1150
|
+
"agent_id": self.config.get("agent_id"),
|
|
1151
|
+
"branch_id": self.config.get("branch_id"),
|
|
1152
|
+
"central_oversight": {
|
|
1153
|
+
"enabled": self.scheduler_reporter.enabled,
|
|
1154
|
+
"session_id": self.scheduler_reporter.session_id,
|
|
1155
|
+
"queued_writes": self.scheduler_reporter.jobs.qsize(),
|
|
1156
|
+
"telemetry": self.store.stats(),
|
|
1157
|
+
},
|
|
1158
|
+
"configuration": self.configuration(),
|
|
1159
|
+
"paused": self.paused,
|
|
1160
|
+
"active": self.active,
|
|
1161
|
+
"queued": self.trigger_event.is_set(),
|
|
1162
|
+
"state": (
|
|
1163
|
+
"active" if self.active else
|
|
1164
|
+
("queued" if self.trigger_event.is_set() else
|
|
1165
|
+
("cooldown" if cooldown_remaining else "armed"))
|
|
1166
|
+
),
|
|
1167
|
+
"cooldown_remaining_ms": int(cooldown_remaining * 1000),
|
|
1168
|
+
"sessions_started": self.sessions_started,
|
|
1169
|
+
"sessions_completed": self.sessions_completed,
|
|
1170
|
+
"triggers_accepted": self.triggers_accepted,
|
|
1171
|
+
"triggers_rejected": self.triggers_rejected,
|
|
1172
|
+
"pending_motion": bool(
|
|
1173
|
+
self.pending_motion_at
|
|
1174
|
+
and time.monotonic() - self.pending_motion_at <= PENDING_MOTION_TTL_SECONDS
|
|
1175
|
+
),
|
|
1176
|
+
"last_error": self.last_error,
|
|
1177
|
+
"telemetry_last_error": self.telemetry_last_error,
|
|
1178
|
+
"last_trigger_reason": self.last_trigger_reason,
|
|
1179
|
+
"session_started_at": self.session_started_at,
|
|
1180
|
+
"session_age_seconds": (
|
|
1181
|
+
round(time.monotonic() - self.session_started_at, 1)
|
|
1182
|
+
if self.active and self.session_started_at else 0
|
|
1183
|
+
),
|
|
1184
|
+
"last_session": self.last_session,
|
|
1185
|
+
"lifecycle_events": list(self.lifecycle_events),
|
|
1186
|
+
"vision_routed": self.vision_routed,
|
|
1187
|
+
"vision_last_error": self.vision_last_error,
|
|
1188
|
+
"vision_managed": bool(self.config.get("manage_vision")),
|
|
1189
|
+
"vision_process_running": bool(
|
|
1190
|
+
self.vision_process is not None and self.vision_process.poll() is None
|
|
1191
|
+
),
|
|
1192
|
+
"vision_restarts": self.vision_restarts,
|
|
1193
|
+
"configured_audio": {
|
|
1194
|
+
"input": self.config["audio_input"],
|
|
1195
|
+
"output": self.config["audio_output"],
|
|
1196
|
+
},
|
|
1197
|
+
"active_audio": self.current_audio.status() if self.current_audio else None,
|
|
1198
|
+
"audio_devices": self.audio_devices,
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
def reconciliation_state(self) -> dict:
|
|
1202
|
+
configuration = self.configuration()
|
|
1203
|
+
return {
|
|
1204
|
+
"applied_revision": int(configuration["applied_revision"]),
|
|
1205
|
+
"desired_revision": int(configuration["desired_revision"]),
|
|
1206
|
+
"effective_engine": "elevenlabs",
|
|
1207
|
+
"state": "active" if self.active else "paused" if self.paused else "armed",
|
|
1208
|
+
"session": ({
|
|
1209
|
+
"session_id": self.local_session_id,
|
|
1210
|
+
"trigger_reason": self.last_trigger_reason,
|
|
1211
|
+
} if self.active else None),
|
|
1212
|
+
"health": {
|
|
1213
|
+
"service_running": True,
|
|
1214
|
+
"endpoint_ready": self.http_thread is None or self.http_thread.is_alive(),
|
|
1215
|
+
"session_active": self.active,
|
|
1216
|
+
"telemetry_connected": self.scheduler_reporter.enabled,
|
|
1217
|
+
},
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
def trigger(self, reason: str) -> dict:
|
|
1221
|
+
with self.state_lock:
|
|
1222
|
+
if self.shutdown.is_set():
|
|
1223
|
+
self.triggers_rejected += 1
|
|
1224
|
+
self._event("trigger_rejected", "Runtime is shutting down", "failed", reason=reason)
|
|
1225
|
+
return {"accepted": False, "reason": "shutting_down"}
|
|
1226
|
+
if self.paused:
|
|
1227
|
+
self.triggers_rejected += 1
|
|
1228
|
+
return {"accepted": False, "reason": "stopped"}
|
|
1229
|
+
if self.active:
|
|
1230
|
+
if reason == "motion":
|
|
1231
|
+
self.pending_motion_at = time.monotonic()
|
|
1232
|
+
self.triggers_rejected += 1
|
|
1233
|
+
self._event("trigger_rejected", "Session active; motion retained", "pending", reason=reason)
|
|
1234
|
+
return {"accepted": False, "reason": "active", "pending": reason == "motion"}
|
|
1235
|
+
cooldown_remaining = (
|
|
1236
|
+
float(self.config["cooldown"]) - (time.monotonic() - self.last_session_end)
|
|
1237
|
+
if self.last_session_end else 0.0
|
|
1238
|
+
)
|
|
1239
|
+
if cooldown_remaining > 0:
|
|
1240
|
+
if reason == "motion":
|
|
1241
|
+
self.pending_motion_at = time.monotonic()
|
|
1242
|
+
self.triggers_rejected += 1
|
|
1243
|
+
self._event("trigger_rejected", "Cooldown active; motion retained", "pending", reason=reason)
|
|
1244
|
+
return {
|
|
1245
|
+
"accepted": False,
|
|
1246
|
+
"reason": "cooldown",
|
|
1247
|
+
"pending": reason == "motion",
|
|
1248
|
+
"retry_after_ms": int(cooldown_remaining * 1000),
|
|
1249
|
+
}
|
|
1250
|
+
if self.trigger_event.is_set():
|
|
1251
|
+
self.triggers_rejected += 1
|
|
1252
|
+
self._event("trigger_rejected", "A trigger is already queued", "pending", reason=reason)
|
|
1253
|
+
return {"accepted": False, "reason": "already_queued"}
|
|
1254
|
+
self.trigger_reason = reason
|
|
1255
|
+
self.last_trigger_reason = reason
|
|
1256
|
+
self.trigger_event.set()
|
|
1257
|
+
self.triggers_accepted += 1
|
|
1258
|
+
self._event("trigger_accepted", f"{reason} trigger accepted", "ok", reason=reason)
|
|
1259
|
+
return {"accepted": True, "reason": reason}
|
|
1260
|
+
|
|
1261
|
+
def handle_management_command(self, command: dict) -> dict:
|
|
1262
|
+
operation = str(command.get("operation") or "")
|
|
1263
|
+
payload = command.get("payload") or {}
|
|
1264
|
+
force = bool(command.get("force"))
|
|
1265
|
+
if operation == "voice.trigger":
|
|
1266
|
+
self.paused = False
|
|
1267
|
+
self.store.set_state("paused", False)
|
|
1268
|
+
return {"ok": True, **self.trigger(str(payload.get("reason") or "gateway"))}
|
|
1269
|
+
if operation == "voice.configuration.stage":
|
|
1270
|
+
return {"ok": True, "configuration": self.stage_configuration(payload)}
|
|
1271
|
+
if operation == "voice.configuration.apply_when_idle":
|
|
1272
|
+
self.apply_when_idle_requested = True
|
|
1273
|
+
return {"ok": True, "waiting_for_idle": self.active}
|
|
1274
|
+
if operation == "voice.restart_when_idle":
|
|
1275
|
+
self.restart_when_idle_requested = True
|
|
1276
|
+
return {"ok": True, "waiting_for_idle": self.active}
|
|
1277
|
+
if operation == "voice.stop_when_idle":
|
|
1278
|
+
self.stop_when_idle_requested = True
|
|
1279
|
+
return {"ok": True, "waiting_for_idle": self.active}
|
|
1280
|
+
if operation == "voice.configuration.rollback":
|
|
1281
|
+
previous = self.store.get_state("last_known_good_configuration")
|
|
1282
|
+
if not previous:
|
|
1283
|
+
return {"ok": False, "error": "no last-known-good configuration exists"}
|
|
1284
|
+
previous["desired_revision"] = int(self.configuration()["desired_revision"]) + 1
|
|
1285
|
+
self.store.set_state("staged_configuration", previous)
|
|
1286
|
+
self.store.set_state("desired_revision", previous["desired_revision"])
|
|
1287
|
+
self.apply_when_idle_requested = True
|
|
1288
|
+
return {"ok": True, "waiting_for_idle": self.active}
|
|
1289
|
+
if operation == "voice.media.release_stale":
|
|
1290
|
+
if not force:
|
|
1291
|
+
return {"ok": False, "error": "force authorization is required"}
|
|
1292
|
+
lease = media_lease_status()
|
|
1293
|
+
if self.active:
|
|
1294
|
+
return {"ok": False, "error": "active session owns the media lease", "media_lease": lease}
|
|
1295
|
+
if lease["conflicts"]:
|
|
1296
|
+
return {
|
|
1297
|
+
"ok": False,
|
|
1298
|
+
"error": "foreign live process owns ALSA; stop that named service explicitly",
|
|
1299
|
+
"media_lease": lease,
|
|
1300
|
+
}
|
|
1301
|
+
return {"ok": True, "released": True, "media_lease": lease}
|
|
1302
|
+
if operation in {"voice.restart", "voice.stop", "voice.configuration.apply"}:
|
|
1303
|
+
if not force:
|
|
1304
|
+
return {"ok": False, "error": "force authorization is required"}
|
|
1305
|
+
if operation == "voice.restart":
|
|
1306
|
+
self.restart_when_idle_requested = True
|
|
1307
|
+
elif operation == "voice.stop":
|
|
1308
|
+
self.stop_when_idle_requested = True
|
|
1309
|
+
else:
|
|
1310
|
+
self.apply_when_idle_requested = True
|
|
1311
|
+
if self.active:
|
|
1312
|
+
self.force_stop_event.set()
|
|
1313
|
+
return {"ok": True, "disposing": self.active}
|
|
1314
|
+
return {"ok": False, "error": f"unsupported operation: {operation}"}
|
|
1315
|
+
|
|
1316
|
+
def start_motion_server(self) -> None:
|
|
1317
|
+
runtime = self
|
|
1318
|
+
|
|
1319
|
+
class Handler(BaseHTTPRequestHandler):
|
|
1320
|
+
def _remote(self) -> bool:
|
|
1321
|
+
return self.client_address[0] not in {"127.0.0.1", "::1"}
|
|
1322
|
+
|
|
1323
|
+
def _authorized(self) -> bool:
|
|
1324
|
+
if not self._remote():
|
|
1325
|
+
return True
|
|
1326
|
+
supplied = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query).get("token", [""])[0]
|
|
1327
|
+
expected = str(runtime.config.get("tailscale_status_token") or "")
|
|
1328
|
+
return bool(expected) and hmac.compare_digest(supplied, expected)
|
|
1329
|
+
|
|
1330
|
+
def _reply(self, status: int, payload: dict) -> None:
|
|
1331
|
+
body = json.dumps(payload).encode()
|
|
1332
|
+
self.send_response(status)
|
|
1333
|
+
self.send_header("Content-Type", "application/json")
|
|
1334
|
+
self.send_header("Content-Length", str(len(body)))
|
|
1335
|
+
self.end_headers()
|
|
1336
|
+
self.wfile.write(body)
|
|
1337
|
+
|
|
1338
|
+
def _body(self) -> dict:
|
|
1339
|
+
length = int(self.headers.get("Content-Length", "0") or 0)
|
|
1340
|
+
return json.loads(self.rfile.read(length) or b"{}")
|
|
1341
|
+
|
|
1342
|
+
def _force(self) -> bool:
|
|
1343
|
+
value = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query).get("force", [""])[0]
|
|
1344
|
+
return value.casefold() in {"1", "true", "yes"}
|
|
1345
|
+
|
|
1346
|
+
def _html(self) -> None:
|
|
1347
|
+
body = """<!doctype html><meta name=viewport content='width=device-width'><title>RoboPark audio</title><style>body{font:15px ui-monospace,monospace;background:#111;color:#eee;max-width:900px;margin:30px auto;padding:0 16px}h1{color:#ffd86b}select,button{font:inherit;padding:9px;margin:4px;background:#222;color:#fff;border:1px solid #555;border-radius:6px}pre{white-space:pre-wrap;background:#1b1b1b;padding:18px;border-radius:10px}</style><h1>RoboPark active devices</h1><label>Microphone <select id=i></select></label><label>Speaker <select id=o></select></label><button onclick='save()'>Apply and restart</button><pre id=s>loading...</pre><script>const q=location.search;let first=true;async function p(){let r=await fetch('/status'+q),d=await r.json();document.querySelector('#s').textContent=JSON.stringify(d,null,2);if(first&&d.audio_devices){first=false;for(const [id,key] of [['i','inputs'],['o','outputs']]){let e=document.querySelector('#'+id),selected=id==='i'?d.configured_audio.input:d.configured_audio.output;for(const x of d.audio_devices[key])e.add(new Option(x.name,x.name,x.name===selected,x.name===selected))}}}async function save(){let body={input:document.querySelector('#i').value,output:document.querySelector('#o').value};let r=await fetch('/devices'+q,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)});alert(JSON.stringify(await r.json()))}p();setInterval(p,2000)</script>""".encode()
|
|
1348
|
+
self.send_response(200)
|
|
1349
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
1350
|
+
self.send_header("Content-Length", str(len(body)))
|
|
1351
|
+
self.end_headers()
|
|
1352
|
+
self.wfile.write(body)
|
|
1353
|
+
|
|
1354
|
+
def do_GET(self):
|
|
1355
|
+
if not self._authorized():
|
|
1356
|
+
self._reply(403, {"ok": False, "error": "invalid or missing device-status token"})
|
|
1357
|
+
return
|
|
1358
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
1359
|
+
path = parsed.path
|
|
1360
|
+
if path == "/" and "text/html" in self.headers.get("Accept", ""):
|
|
1361
|
+
self._html()
|
|
1362
|
+
return
|
|
1363
|
+
state = runtime.status()
|
|
1364
|
+
if path == "/health":
|
|
1365
|
+
configured = state["configured_audio"]
|
|
1366
|
+
input_names = {row["name"] for row in runtime.audio_devices["inputs"]}
|
|
1367
|
+
output_names = {row["name"] for row in runtime.audio_devices["outputs"]}
|
|
1368
|
+
lease = media_lease_status()
|
|
1369
|
+
self._reply(200, {
|
|
1370
|
+
"ok": True,
|
|
1371
|
+
"voice_ready": (
|
|
1372
|
+
configured["input"] in input_names
|
|
1373
|
+
and configured["output"] in output_names
|
|
1374
|
+
and lease["available"]
|
|
1375
|
+
and not runtime.paused
|
|
1376
|
+
),
|
|
1377
|
+
"service_running": True,
|
|
1378
|
+
"endpoint_ready": True,
|
|
1379
|
+
"audio_input_resolved": configured["input"] in input_names,
|
|
1380
|
+
"audio_output_resolved": configured["output"] in output_names,
|
|
1381
|
+
"media_lease_available": lease["available"],
|
|
1382
|
+
"provider_reachable": runtime.last_error is None,
|
|
1383
|
+
"configuration_valid": runtime.store.get_state("staged_configuration") is None,
|
|
1384
|
+
"trigger_armed": not runtime.paused,
|
|
1385
|
+
"session_active": runtime.active,
|
|
1386
|
+
"telemetry_connected": runtime.scheduler_reporter.enabled,
|
|
1387
|
+
"telemetry_mode": "connected" if runtime.scheduler_reporter.enabled else "buffering_locally",
|
|
1388
|
+
"media_lease": lease,
|
|
1389
|
+
})
|
|
1390
|
+
return
|
|
1391
|
+
if path == "/configuration":
|
|
1392
|
+
self._reply(200, {"ok": True, **runtime.configuration()})
|
|
1393
|
+
return
|
|
1394
|
+
if path == "/sessions/current":
|
|
1395
|
+
self._reply(200, {
|
|
1396
|
+
"ok": True,
|
|
1397
|
+
"active": runtime.active,
|
|
1398
|
+
"session": ({
|
|
1399
|
+
"session_id": runtime.local_session_id,
|
|
1400
|
+
"started_at": runtime.session_started_at,
|
|
1401
|
+
"trigger_reason": runtime.last_trigger_reason,
|
|
1402
|
+
"engine": "elevenlabs",
|
|
1403
|
+
"agent_id": runtime.config.get("agent_id"),
|
|
1404
|
+
} if runtime.active else None),
|
|
1405
|
+
"last_session": runtime.last_session,
|
|
1406
|
+
})
|
|
1407
|
+
return
|
|
1408
|
+
if path == "/events":
|
|
1409
|
+
query = urllib.parse.parse_qs(parsed.query)
|
|
1410
|
+
after = int(query.get("after", ["0"])[0] or 0)
|
|
1411
|
+
limit = int(query.get("limit", ["250"])[0] or 250)
|
|
1412
|
+
events = runtime.store.events_after(after, limit)
|
|
1413
|
+
self._reply(200, {
|
|
1414
|
+
"ok": True,
|
|
1415
|
+
"events": events,
|
|
1416
|
+
"next_cursor": events[-1]["sequence"] if events else after,
|
|
1417
|
+
"spool": runtime.store.stats(),
|
|
1418
|
+
})
|
|
1419
|
+
return
|
|
1420
|
+
if path not in {"/", "/status", "/state"}:
|
|
1421
|
+
self._reply(404, {"ok": False, "error": "unknown management endpoint"})
|
|
1422
|
+
return
|
|
1423
|
+
self._reply(200, {
|
|
1424
|
+
"ok": True,
|
|
1425
|
+
"armed": True,
|
|
1426
|
+
"motors": runtime.motor_registry.motors,
|
|
1427
|
+
**state,
|
|
1428
|
+
})
|
|
1429
|
+
|
|
1430
|
+
def do_POST(self):
|
|
1431
|
+
parsed_path = urllib.parse.urlparse(self.path)
|
|
1432
|
+
path = parsed_path.path
|
|
1433
|
+
if parsed_path.path == "/devices" and self._authorized():
|
|
1434
|
+
length = int(self.headers.get("Content-Length", "0") or 0)
|
|
1435
|
+
try:
|
|
1436
|
+
payload = json.loads(self.rfile.read(length) or b"{}")
|
|
1437
|
+
selected_input = str(payload.get("input") or "").strip()
|
|
1438
|
+
selected_output = str(payload.get("output") or "").strip()
|
|
1439
|
+
valid_inputs = {row["name"] for row in runtime.audio_devices["inputs"]}
|
|
1440
|
+
valid_outputs = {row["name"] for row in runtime.audio_devices["outputs"]}
|
|
1441
|
+
if selected_input not in valid_inputs or selected_output not in valid_outputs:
|
|
1442
|
+
raise ValueError("selection must use devices currently registered by PyAudio")
|
|
1443
|
+
runtime.config["audio_input"] = selected_input
|
|
1444
|
+
runtime.config["audio_output"] = selected_output
|
|
1445
|
+
Path(runtime.config["config_path"]).write_text(
|
|
1446
|
+
json.dumps(runtime.config, indent=2) + "\n", encoding="utf-8"
|
|
1447
|
+
)
|
|
1448
|
+
self._reply(202, {"ok": True, "input": selected_input, "output": selected_output, "restarting": True})
|
|
1449
|
+
threading.Timer(0.2, runtime.shutdown.set).start()
|
|
1450
|
+
except Exception as exc:
|
|
1451
|
+
self._reply(400, {"ok": False, "error": str(exc)})
|
|
1452
|
+
return
|
|
1453
|
+
if self._remote():
|
|
1454
|
+
self._reply(403, {"ok": False, "error": "tailnet status endpoint is read-only"})
|
|
1455
|
+
return
|
|
1456
|
+
if path == "/trigger":
|
|
1457
|
+
if not runtime.config["motion_enabled"]:
|
|
1458
|
+
self._reply(409, {"ok": False, "error": "motion trigger is disabled"})
|
|
1459
|
+
return
|
|
1460
|
+
body = self._body()
|
|
1461
|
+
runtime.paused = False
|
|
1462
|
+
runtime.store.set_state("paused", False)
|
|
1463
|
+
admission = runtime.trigger(str(body.get("reason") or "management_test"))
|
|
1464
|
+
self._reply(202 if admission["accepted"] else 409, {"ok": admission["accepted"], **admission})
|
|
1465
|
+
return
|
|
1466
|
+
if path == "/configuration/stage":
|
|
1467
|
+
try:
|
|
1468
|
+
self._reply(202, {"ok": True, **runtime.stage_configuration(self._body())})
|
|
1469
|
+
except Exception as exc:
|
|
1470
|
+
self._reply(400, {"ok": False, "error": str(exc)})
|
|
1471
|
+
return
|
|
1472
|
+
if path == "/configuration/apply-when-idle":
|
|
1473
|
+
runtime.apply_when_idle_requested = True
|
|
1474
|
+
result = runtime.apply_staged_configuration() if not runtime.active else {
|
|
1475
|
+
"ok": True, "waiting_for_idle": True, "configuration": runtime.configuration()
|
|
1476
|
+
}
|
|
1477
|
+
self._reply(202, result)
|
|
1478
|
+
return
|
|
1479
|
+
if path == "/configuration/apply":
|
|
1480
|
+
if not self._force():
|
|
1481
|
+
self._reply(409, {"ok": False, "error": "force=true is required"})
|
|
1482
|
+
return
|
|
1483
|
+
runtime.apply_when_idle_requested = True
|
|
1484
|
+
if runtime.active:
|
|
1485
|
+
runtime.force_stop_event.set()
|
|
1486
|
+
self._reply(202, {"ok": True, "disposing": runtime.active, "configuration": runtime.configuration()})
|
|
1487
|
+
return
|
|
1488
|
+
if path == "/restart-when-idle":
|
|
1489
|
+
runtime.restart_when_idle_requested = True
|
|
1490
|
+
runtime._event("restart_requested", "Restart scheduled for idle", "pending")
|
|
1491
|
+
self._reply(202, {"ok": True, "waiting_for_idle": runtime.active})
|
|
1492
|
+
return
|
|
1493
|
+
if path == "/restart":
|
|
1494
|
+
if not self._force():
|
|
1495
|
+
self._reply(409, {"ok": False, "error": "force=true is required"})
|
|
1496
|
+
return
|
|
1497
|
+
runtime.restart_when_idle_requested = True
|
|
1498
|
+
if runtime.active:
|
|
1499
|
+
runtime.force_stop_event.set()
|
|
1500
|
+
self._reply(202, {"ok": True, "disposing": runtime.active})
|
|
1501
|
+
return
|
|
1502
|
+
if path == "/stop":
|
|
1503
|
+
runtime.stop_when_idle_requested = True
|
|
1504
|
+
if self._force() and runtime.active:
|
|
1505
|
+
runtime.force_stop_event.set()
|
|
1506
|
+
elif runtime.active:
|
|
1507
|
+
self._reply(202, {"ok": True, "waiting_for_idle": True})
|
|
1508
|
+
return
|
|
1509
|
+
runtime.paused = True
|
|
1510
|
+
runtime.store.set_state("paused", True)
|
|
1511
|
+
runtime.trigger_event.clear()
|
|
1512
|
+
runtime.voice_trigger.stop()
|
|
1513
|
+
runtime._event("runtime_stopped", "Voice triggers paused", "pending")
|
|
1514
|
+
self._reply(202, {"ok": True, "paused": True})
|
|
1515
|
+
return
|
|
1516
|
+
if path == "/events/ack":
|
|
1517
|
+
body = self._body()
|
|
1518
|
+
cursor = runtime.store.acknowledge(int(body.get("sequence") or 0))
|
|
1519
|
+
self._reply(200, {"ok": True, "acknowledged_sequence": cursor})
|
|
1520
|
+
return
|
|
1521
|
+
if path in {"/session/stop", "/recover"}:
|
|
1522
|
+
if not self._force():
|
|
1523
|
+
runtime.stop_when_idle_requested = True
|
|
1524
|
+
self._reply(202, {"ok": True, "waiting_for_idle": runtime.active})
|
|
1525
|
+
return
|
|
1526
|
+
runtime.force_stop_event.set()
|
|
1527
|
+
if runtime.current_audio is not None:
|
|
1528
|
+
runtime.current_audio.stop()
|
|
1529
|
+
runtime.pending_motion_at = 0.0
|
|
1530
|
+
runtime.trigger_event.clear()
|
|
1531
|
+
stage = "recovery_requested" if path == "/recover" else "dispose_requested"
|
|
1532
|
+
runtime._event(stage, "Audio released and session disposal requested", "pending")
|
|
1533
|
+
self._reply(202, {
|
|
1534
|
+
"ok": True,
|
|
1535
|
+
"disposing": runtime.active,
|
|
1536
|
+
"audio_released": True,
|
|
1537
|
+
})
|
|
1538
|
+
return
|
|
1539
|
+
if path.startswith("/motors/"):
|
|
1540
|
+
name = path.split("/", 2)[2]
|
|
1541
|
+
length = int(self.headers.get("Content-Length", "0") or 0)
|
|
1542
|
+
try:
|
|
1543
|
+
body = json.loads(self.rfile.read(length) or b"{}")
|
|
1544
|
+
body["name"] = name
|
|
1545
|
+
self._reply(200, runtime.motor_registry.execute(body))
|
|
1546
|
+
except Exception as exc:
|
|
1547
|
+
self._reply(400, {"ok": False, "error": str(exc)})
|
|
1548
|
+
return
|
|
1549
|
+
if not runtime.config["motion_enabled"]:
|
|
1550
|
+
self._reply(404, {"ok": False, "error": "motion trigger is disabled"})
|
|
1551
|
+
return
|
|
1552
|
+
admission = runtime.trigger("motion")
|
|
1553
|
+
self._reply(202, {
|
|
1554
|
+
"ok": True,
|
|
1555
|
+
"queued": admission["accepted"],
|
|
1556
|
+
**admission,
|
|
1557
|
+
})
|
|
1558
|
+
|
|
1559
|
+
def log_message(self, format, *args):
|
|
1560
|
+
return
|
|
1561
|
+
|
|
1562
|
+
ThreadingHTTPServer.allow_reuse_address = True
|
|
1563
|
+
ThreadingHTTPServer.daemon_threads = True
|
|
1564
|
+
self.httpd = ThreadingHTTPServer(
|
|
1565
|
+
(self.config.get("status_bind_host") or self.config["motion_host"], int(self.config["motion_port"])), Handler
|
|
1566
|
+
)
|
|
1567
|
+
self.http_thread = threading.Thread(
|
|
1568
|
+
target=self.httpd.serve_forever,
|
|
1569
|
+
name="motion-webhook",
|
|
1570
|
+
daemon=True,
|
|
1571
|
+
)
|
|
1572
|
+
self.http_thread.start()
|
|
1573
|
+
log(f"robot control endpoint armed at http://{self.config['motion_host']}:{self.config['motion_port']}/")
|
|
1574
|
+
if self.config.get("tailscale_status_ip"):
|
|
1575
|
+
log(
|
|
1576
|
+
f"tailnet device selector: http://{self.config['tailscale_status_ip']}:"
|
|
1577
|
+
f"{self.config['motion_port']}/?token={self.config['tailscale_status_token']}"
|
|
1578
|
+
)
|
|
1579
|
+
if self.config.get("vision_url") and self.config["motion_enabled"]:
|
|
1580
|
+
threading.Thread(
|
|
1581
|
+
target=self._maintain_vision_route,
|
|
1582
|
+
name="robovision-router",
|
|
1583
|
+
daemon=True,
|
|
1584
|
+
).start()
|
|
1585
|
+
|
|
1586
|
+
def _maintain_vision_route(self) -> None:
|
|
1587
|
+
target = f"http://127.0.0.1:{self.config['motion_port']}/"
|
|
1588
|
+
payload = json.dumps({"url": target}).encode()
|
|
1589
|
+
arm_payload = json.dumps({"active": True}).encode()
|
|
1590
|
+
last_result = None
|
|
1591
|
+
while not self.shutdown.is_set():
|
|
1592
|
+
try:
|
|
1593
|
+
for endpoint, body in (
|
|
1594
|
+
("/api/motion/webhook", payload),
|
|
1595
|
+
("/api/motion/toggle", arm_payload),
|
|
1596
|
+
):
|
|
1597
|
+
request = urllib.request.Request(
|
|
1598
|
+
f"{self.config['vision_url']}{endpoint}",
|
|
1599
|
+
data=body,
|
|
1600
|
+
headers={"Content-Type": "application/json"},
|
|
1601
|
+
method="POST",
|
|
1602
|
+
)
|
|
1603
|
+
with urllib.request.urlopen(request, timeout=3) as response:
|
|
1604
|
+
if response.status >= 300:
|
|
1605
|
+
raise RuntimeError(f"{endpoint} returned HTTP {response.status}")
|
|
1606
|
+
with self.state_lock:
|
|
1607
|
+
self.vision_routed = True
|
|
1608
|
+
self.vision_last_error = None
|
|
1609
|
+
if last_result is not True:
|
|
1610
|
+
log(f"RoboVision motion armed and routed to {target}")
|
|
1611
|
+
last_result = True
|
|
1612
|
+
# Reassert frequently enough to recover from a replaced camera
|
|
1613
|
+
# process without waiting for an operator or a full service restart.
|
|
1614
|
+
delay = 5
|
|
1615
|
+
except Exception as exc:
|
|
1616
|
+
with self.state_lock:
|
|
1617
|
+
self.vision_routed = False
|
|
1618
|
+
self.vision_last_error = str(exc)
|
|
1619
|
+
if last_result is not False:
|
|
1620
|
+
log(f"RoboVision webhook configuration failed; retrying: {exc}")
|
|
1621
|
+
last_result = False
|
|
1622
|
+
if self.config.get("manage_vision"):
|
|
1623
|
+
self._ensure_vision_process()
|
|
1624
|
+
delay = 2
|
|
1625
|
+
self.shutdown.wait(delay)
|
|
1626
|
+
|
|
1627
|
+
def _ensure_vision_process(self) -> None:
|
|
1628
|
+
process = self.vision_process
|
|
1629
|
+
if process is not None and process.poll() is None:
|
|
1630
|
+
return
|
|
1631
|
+
if process is not None:
|
|
1632
|
+
log(f"managed RoboVision exited with code {process.returncode}; restarting")
|
|
1633
|
+
script = self.config.get("vision_script")
|
|
1634
|
+
python = self.config.get("vision_python")
|
|
1635
|
+
if not script or not python:
|
|
1636
|
+
with self.state_lock:
|
|
1637
|
+
self.vision_last_error = "managed RoboVision runtime path is missing"
|
|
1638
|
+
return
|
|
1639
|
+
target = f"http://127.0.0.1:{self.config['motion_port']}/"
|
|
1640
|
+
env = os.environ.copy()
|
|
1641
|
+
env["PYTHONUNBUFFERED"] = "1"
|
|
1642
|
+
env["ROBOPARK_CAMERA_DEVICE"] = str(self.config.get("camera_device") or "auto")
|
|
1643
|
+
try:
|
|
1644
|
+
self.vision_process = subprocess.Popen(
|
|
1645
|
+
[
|
|
1646
|
+
str(python),
|
|
1647
|
+
str(script),
|
|
1648
|
+
"--port", str(self.config["vision_port"]),
|
|
1649
|
+
"--motion-webhook-url", target,
|
|
1650
|
+
"--motion-active",
|
|
1651
|
+
],
|
|
1652
|
+
env=env,
|
|
1653
|
+
)
|
|
1654
|
+
self.vision_process_started = True
|
|
1655
|
+
self.vision_restarts += 1
|
|
1656
|
+
log(
|
|
1657
|
+
f"managed RoboVision started (pid {self.vision_process.pid}) on "
|
|
1658
|
+
f"{self.config['vision_url']} with camera {env['ROBOPARK_CAMERA_DEVICE']}"
|
|
1659
|
+
)
|
|
1660
|
+
except Exception as exc:
|
|
1661
|
+
with self.state_lock:
|
|
1662
|
+
self.vision_last_error = str(exc)
|
|
1663
|
+
log(f"managed RoboVision start failed; retrying: {exc}")
|
|
1664
|
+
|
|
1665
|
+
def _stop_vision_process(self) -> None:
|
|
1666
|
+
process = self.vision_process
|
|
1667
|
+
if not self.vision_process_started or process is None or process.poll() is not None:
|
|
1668
|
+
return
|
|
1669
|
+
process.terminate()
|
|
1670
|
+
try:
|
|
1671
|
+
process.wait(timeout=5)
|
|
1672
|
+
except subprocess.TimeoutExpired:
|
|
1673
|
+
log("forcing managed RoboVision process closed")
|
|
1674
|
+
process.kill()
|
|
1675
|
+
process.wait(timeout=2)
|
|
1676
|
+
log("managed RoboVision stopped")
|
|
1677
|
+
|
|
1678
|
+
def run_session(self, reason: str) -> None:
|
|
1679
|
+
with self.state_lock:
|
|
1680
|
+
self.active = True
|
|
1681
|
+
self.session_started_at = time.monotonic()
|
|
1682
|
+
self.sessions_started += 1
|
|
1683
|
+
session_number = self.sessions_started
|
|
1684
|
+
self.last_error = None
|
|
1685
|
+
self.force_stop_event.clear()
|
|
1686
|
+
self.local_session_id = f"session_{uuid.uuid4().hex}"
|
|
1687
|
+
self.scheduler_reporter.start(reason)
|
|
1688
|
+
if self.scheduler_reporter.session_id:
|
|
1689
|
+
self.local_session_id = self.scheduler_reporter.session_id
|
|
1690
|
+
self._event("session_starting", f"Starting ElevenLabs session #{session_number}", "active", reason=reason)
|
|
1691
|
+
self.voice_trigger.stop()
|
|
1692
|
+
audio = NativeAudioInterface(self.config["audio_input"], self.config["audio_output"])
|
|
1693
|
+
self.current_audio = audio
|
|
1694
|
+
last_activity = [time.monotonic()]
|
|
1695
|
+
agent_busy_until = [0.0]
|
|
1696
|
+
ended = threading.Event()
|
|
1697
|
+
conversation = None
|
|
1698
|
+
client_tools = None
|
|
1699
|
+
|
|
1700
|
+
def on_user(text: str) -> None:
|
|
1701
|
+
last_activity[0] = time.monotonic()
|
|
1702
|
+
self._event("user_transcript", text or "No speech decoded", "ok" if text.strip(" .") else "failed")
|
|
1703
|
+
self.scheduler_reporter.turn("user", text)
|
|
1704
|
+
self._spool_event(
|
|
1705
|
+
"transcript.final",
|
|
1706
|
+
{"role": "user", "text": text, "is_final": True, "engine": "elevenlabs"},
|
|
1707
|
+
)
|
|
1708
|
+
log(f"user: {text}")
|
|
1709
|
+
|
|
1710
|
+
def on_agent(text: str) -> None:
|
|
1711
|
+
now = time.monotonic()
|
|
1712
|
+
last_activity[0] = now
|
|
1713
|
+
agent_busy_until[0] = now + max(1.5, len(text.split()) / 2.2)
|
|
1714
|
+
self._event("agent_response", text or "Empty agent response", "ok" if text.strip() else "failed")
|
|
1715
|
+
self.scheduler_reporter.turn("assistant", text)
|
|
1716
|
+
self._spool_event(
|
|
1717
|
+
"transcript.final",
|
|
1718
|
+
{"role": "assistant", "text": text, "is_final": True, "engine": "elevenlabs"},
|
|
1719
|
+
)
|
|
1720
|
+
log(f"agent: {text}")
|
|
1721
|
+
|
|
1722
|
+
def on_end() -> None:
|
|
1723
|
+
ended.set()
|
|
1724
|
+
|
|
1725
|
+
started = time.monotonic()
|
|
1726
|
+
log(f"starting ElevenLabs session #{session_number} ({reason})")
|
|
1727
|
+
try:
|
|
1728
|
+
client = ElevenLabs(api_key=self.config.get("api_key"))
|
|
1729
|
+
client_tools = ClientTools()
|
|
1730
|
+
if self.motor_registry.names():
|
|
1731
|
+
client_tools.register("robotMotor", self.motor_registry.execute)
|
|
1732
|
+
log(f"agent tool robotMotor registered for: {', '.join(self.motor_registry.names())}")
|
|
1733
|
+
conversation = Conversation(
|
|
1734
|
+
client=client,
|
|
1735
|
+
agent_id=self.config["agent_id"],
|
|
1736
|
+
requires_auth=bool(self.config["requires_auth"]),
|
|
1737
|
+
audio_interface=audio,
|
|
1738
|
+
callback_user_transcript=on_user,
|
|
1739
|
+
callback_agent_response=on_agent,
|
|
1740
|
+
callback_end_session=on_end,
|
|
1741
|
+
client_tools=client_tools,
|
|
1742
|
+
)
|
|
1743
|
+
branch_id = str(self.config.get("branch_id") or "").strip()
|
|
1744
|
+
if branch_id:
|
|
1745
|
+
if self.config.get("requires_auth"):
|
|
1746
|
+
query = urllib.parse.urlencode({
|
|
1747
|
+
"agent_id": self.config["agent_id"],
|
|
1748
|
+
"branch_id": branch_id,
|
|
1749
|
+
})
|
|
1750
|
+
request = urllib.request.Request(
|
|
1751
|
+
f"https://api.elevenlabs.io/v1/convai/conversation/get-signed-url?{query}",
|
|
1752
|
+
headers={
|
|
1753
|
+
"Accept": "application/json",
|
|
1754
|
+
"xi-api-key": str(self.config.get("api_key") or ""),
|
|
1755
|
+
},
|
|
1756
|
+
)
|
|
1757
|
+
with urllib.request.urlopen(request, timeout=15) as response:
|
|
1758
|
+
signed_url = json.loads(response.read().decode("utf-8"))["signed_url"]
|
|
1759
|
+
conversation._get_signed_url = lambda: signed_url
|
|
1760
|
+
else:
|
|
1761
|
+
original_get_wss_url = conversation._get_wss_url
|
|
1762
|
+
|
|
1763
|
+
def branch_wss_url():
|
|
1764
|
+
parsed = urllib.parse.urlparse(original_get_wss_url())
|
|
1765
|
+
params = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True)
|
|
1766
|
+
params.append(("branch_id", branch_id))
|
|
1767
|
+
return urllib.parse.urlunparse(
|
|
1768
|
+
parsed._replace(query=urllib.parse.urlencode(params))
|
|
1769
|
+
)
|
|
1770
|
+
|
|
1771
|
+
conversation._get_wss_url = branch_wss_url
|
|
1772
|
+
log(f"pinned ElevenLabs branch {branch_id}")
|
|
1773
|
+
conversation.start_session()
|
|
1774
|
+
self._event("provider_connected", "ElevenLabs websocket connected", "active")
|
|
1775
|
+
if self.motor_registry.names():
|
|
1776
|
+
deadline = time.monotonic() + 10
|
|
1777
|
+
while getattr(conversation, "_ws", None) is None and time.monotonic() < deadline:
|
|
1778
|
+
time.sleep(0.1)
|
|
1779
|
+
if getattr(conversation, "_ws", None) is not None:
|
|
1780
|
+
conversation.send_contextual_update(
|
|
1781
|
+
"MANDATORY ROBOT CONTROL POLICY: When the user asks to move, turn, "
|
|
1782
|
+
"activate, test, or switch a physical part, call robotMotor before "
|
|
1783
|
+
"responding. Do not merely describe or claim movement. "
|
|
1784
|
+
"This robot exposes the robotMotor client tool. "
|
|
1785
|
+
f"Registered motor names: {', '.join(self.motor_registry.names())}. "
|
|
1786
|
+
"Only use these exact names. Use action pulse by default. Activations "
|
|
1787
|
+
"are automatically time-bounded, and only confirm movement after the "
|
|
1788
|
+
"tool returns ok=true."
|
|
1789
|
+
)
|
|
1790
|
+
log("registered motor names sent to agent context")
|
|
1791
|
+
while not self.shutdown.is_set() and not self.force_stop_event.is_set() and not ended.wait(0.25):
|
|
1792
|
+
now = time.monotonic()
|
|
1793
|
+
if audio.fatal_error.is_set():
|
|
1794
|
+
self.recycle_required = True
|
|
1795
|
+
raise RuntimeError(audio.fatal_error_message or "fatal audio device failure")
|
|
1796
|
+
if audio.started.is_set() and now - started >= 8 and audio.input_chunks == 0:
|
|
1797
|
+
self.recycle_required = True
|
|
1798
|
+
raise RuntimeError("microphone capture produced no PCM within 8 seconds")
|
|
1799
|
+
thread = getattr(conversation, "_thread", None)
|
|
1800
|
+
if thread is not None and not thread.is_alive():
|
|
1801
|
+
break
|
|
1802
|
+
if self.config["max_session"] > 0 and now - started >= self.config["max_session"]:
|
|
1803
|
+
log("maximum session timeout reached")
|
|
1804
|
+
break
|
|
1805
|
+
if (
|
|
1806
|
+
self.config["idle_timeout"] > 0
|
|
1807
|
+
and now - last_activity[0] >= self.config["idle_timeout"]
|
|
1808
|
+
and now >= agent_busy_until[0]
|
|
1809
|
+
and audio.started.is_set()
|
|
1810
|
+
):
|
|
1811
|
+
log("conversation idle timeout reached")
|
|
1812
|
+
break
|
|
1813
|
+
except Exception as exc:
|
|
1814
|
+
with self.state_lock:
|
|
1815
|
+
self.last_error = str(exc)
|
|
1816
|
+
log(f"conversation failed: {exc}")
|
|
1817
|
+
self._event("session_error", str(exc), "failed")
|
|
1818
|
+
finally:
|
|
1819
|
+
ws = getattr(conversation, "_ws", None) if conversation is not None else None
|
|
1820
|
+
thread = getattr(conversation, "_thread", None) if conversation is not None else None
|
|
1821
|
+
if conversation is not None:
|
|
1822
|
+
try:
|
|
1823
|
+
conversation.end_session()
|
|
1824
|
+
except Exception as exc:
|
|
1825
|
+
log(f"conversation end request failed: {exc}")
|
|
1826
|
+
audio.stop()
|
|
1827
|
+
self.current_audio = None
|
|
1828
|
+
if thread is not None and thread is not threading.current_thread():
|
|
1829
|
+
thread.join(timeout=SESSION_STOP_GRACE_SECONDS)
|
|
1830
|
+
if thread.is_alive() and ws is not None:
|
|
1831
|
+
log("forcing stale ElevenLabs websocket closed")
|
|
1832
|
+
try:
|
|
1833
|
+
ws.close()
|
|
1834
|
+
except Exception:
|
|
1835
|
+
pass
|
|
1836
|
+
thread.join(timeout=SESSION_FORCE_CLOSE_SECONDS)
|
|
1837
|
+
if thread.is_alive():
|
|
1838
|
+
log("ElevenLabs worker did not exit before disposal deadline")
|
|
1839
|
+
self.recycle_required = True
|
|
1840
|
+
elif conversation is None and client_tools is not None:
|
|
1841
|
+
try:
|
|
1842
|
+
client_tools.stop()
|
|
1843
|
+
except Exception:
|
|
1844
|
+
pass
|
|
1845
|
+
with self.state_lock:
|
|
1846
|
+
self.active = False
|
|
1847
|
+
self.last_session_end = time.monotonic()
|
|
1848
|
+
self.session_started_at = 0.0
|
|
1849
|
+
self.sessions_completed += 1
|
|
1850
|
+
elapsed = time.monotonic() - started
|
|
1851
|
+
self.last_session = {
|
|
1852
|
+
"number": session_number,
|
|
1853
|
+
"reason": reason,
|
|
1854
|
+
"duration_seconds": round(elapsed, 1),
|
|
1855
|
+
"ended_at": time.time(),
|
|
1856
|
+
"error": self.last_error,
|
|
1857
|
+
"audio": audio.status(),
|
|
1858
|
+
}
|
|
1859
|
+
self._event(
|
|
1860
|
+
"session_disposed",
|
|
1861
|
+
f"Session #{session_number} disposed and media released",
|
|
1862
|
+
"failed" if self.last_error else "ok",
|
|
1863
|
+
duration_seconds=round(elapsed, 1),
|
|
1864
|
+
)
|
|
1865
|
+
self.scheduler_reporter.end(
|
|
1866
|
+
"error" if self.last_error else ("operator_stop" if self.force_stop_event.is_set() else "completed"),
|
|
1867
|
+
elapsed,
|
|
1868
|
+
self.last_error,
|
|
1869
|
+
)
|
|
1870
|
+
self.local_session_id = None
|
|
1871
|
+
log(
|
|
1872
|
+
f"session #{session_number} disposed in {elapsed:.1f}s; "
|
|
1873
|
+
f"speaker frames={audio.output_chunks}, bytes={audio.output_bytes}; "
|
|
1874
|
+
f"mic chunks={audio.input_chunks}, forwarded={audio.forwarded_input_chunks}, "
|
|
1875
|
+
f"peak={audio.input_peak}, echo-suppressed={audio.suppressed_input_chunks}; "
|
|
1876
|
+
"triggers will re-arm after cooldown"
|
|
1877
|
+
)
|
|
1878
|
+
|
|
1879
|
+
def _process_idle_requests(self) -> None:
|
|
1880
|
+
if self.active:
|
|
1881
|
+
return
|
|
1882
|
+
if self.apply_when_idle_requested:
|
|
1883
|
+
result = self.apply_staged_configuration()
|
|
1884
|
+
if result.get("ok") and not result.get("waiting_for_idle"):
|
|
1885
|
+
self.apply_when_idle_requested = False
|
|
1886
|
+
if self.stop_when_idle_requested:
|
|
1887
|
+
self.stop_when_idle_requested = False
|
|
1888
|
+
self.paused = True
|
|
1889
|
+
self.store.set_state("paused", True)
|
|
1890
|
+
self.trigger_event.clear()
|
|
1891
|
+
self.voice_trigger.stop()
|
|
1892
|
+
self._event("runtime_stopped", "Voice triggers paused after session disposal", "pending")
|
|
1893
|
+
if self.restart_when_idle_requested:
|
|
1894
|
+
self.restart_when_idle_requested = False
|
|
1895
|
+
self._event("restart_begin", "Clean worker restart beginning", "pending")
|
|
1896
|
+
self.recycle_required = True
|
|
1897
|
+
self.shutdown.set()
|
|
1898
|
+
self.trigger_event.set()
|
|
1899
|
+
|
|
1900
|
+
def run(self) -> None:
|
|
1901
|
+
if self.config.get("always_on") or self.config["motion_enabled"] or self.motor_registry.names():
|
|
1902
|
+
self.start_motion_server()
|
|
1903
|
+
if self.config["voice_trigger_enabled"] and not self.paused:
|
|
1904
|
+
self.voice_trigger.start()
|
|
1905
|
+
if self.config.get("always_on"):
|
|
1906
|
+
self.trigger_reason = "always-on"
|
|
1907
|
+
self.trigger_event.set()
|
|
1908
|
+
log(f"ready: robot={self.config['robot_name']} agent={self.config['agent_id']}")
|
|
1909
|
+
while not self.shutdown.is_set():
|
|
1910
|
+
self._process_idle_requests()
|
|
1911
|
+
if self.shutdown.is_set():
|
|
1912
|
+
break
|
|
1913
|
+
if self.http_thread is not None and not self.http_thread.is_alive():
|
|
1914
|
+
log("robot control endpoint stopped unexpectedly; recycling runtime")
|
|
1915
|
+
self.recycle_required = True
|
|
1916
|
+
break
|
|
1917
|
+
if not self.trigger_event.wait(0.5):
|
|
1918
|
+
continue
|
|
1919
|
+
if self.shutdown.is_set():
|
|
1920
|
+
break
|
|
1921
|
+
self.trigger_event.clear()
|
|
1922
|
+
reason = self.trigger_reason
|
|
1923
|
+
self.run_session(reason)
|
|
1924
|
+
self._process_idle_requests()
|
|
1925
|
+
if self.recycle_required:
|
|
1926
|
+
log("recycling runtime for clean media state")
|
|
1927
|
+
break
|
|
1928
|
+
while not self.shutdown.is_set() and (
|
|
1929
|
+
time.monotonic() - self.last_session_end < self.config["cooldown"]
|
|
1930
|
+
):
|
|
1931
|
+
time.sleep(0.2)
|
|
1932
|
+
with self.state_lock:
|
|
1933
|
+
pending_age = (
|
|
1934
|
+
time.monotonic() - self.pending_motion_at
|
|
1935
|
+
if self.pending_motion_at else float("inf")
|
|
1936
|
+
)
|
|
1937
|
+
if pending_age <= PENDING_MOTION_TTL_SECONDS and not self.shutdown.is_set():
|
|
1938
|
+
self.pending_motion_at = 0.0
|
|
1939
|
+
self.trigger_reason = "motion-continuation"
|
|
1940
|
+
self.trigger_event.set()
|
|
1941
|
+
self.triggers_accepted += 1
|
|
1942
|
+
log("recent motion promoted to a continuation session")
|
|
1943
|
+
elif self.pending_motion_at:
|
|
1944
|
+
self.pending_motion_at = 0.0
|
|
1945
|
+
if self.config.get("always_on") and not self.shutdown.is_set():
|
|
1946
|
+
self.trigger_reason = "always-on-reconnect"
|
|
1947
|
+
self.trigger_event.set()
|
|
1948
|
+
log("always-on session queued for reconnect")
|
|
1949
|
+
if self.config["voice_trigger_enabled"] and not self.shutdown.is_set():
|
|
1950
|
+
self.voice_trigger.start()
|
|
1951
|
+
self.voice_trigger.stop()
|
|
1952
|
+
self.motor_registry.close()
|
|
1953
|
+
if self.httpd is not None:
|
|
1954
|
+
self.httpd.shutdown()
|
|
1955
|
+
self.httpd.server_close()
|
|
1956
|
+
self._stop_vision_process()
|
|
1957
|
+
self.scheduler_reporter.stop_event.set()
|
|
1958
|
+
try:
|
|
1959
|
+
self.scheduler_reporter.jobs.put_nowait(None)
|
|
1960
|
+
except queue.Full:
|
|
1961
|
+
pass
|
|
1962
|
+
|
|
1963
|
+
|
|
1964
|
+
def main() -> None:
|
|
1965
|
+
parser = argparse.ArgumentParser()
|
|
1966
|
+
parser.add_argument("--config", required=True)
|
|
1967
|
+
args = parser.parse_args()
|
|
1968
|
+
config = json.loads(Path(args.config).read_text(encoding="utf-8"))
|
|
1969
|
+
config["config_path"] = str(Path(args.config).resolve())
|
|
1970
|
+
if not config.get("always_on") and not config.get("motion_enabled") and not config.get("voice_trigger_enabled"):
|
|
1971
|
+
raise SystemExit("at least one trigger must be enabled")
|
|
1972
|
+
runtime = Runtime(config)
|
|
1973
|
+
|
|
1974
|
+
def stop(_signum, _frame):
|
|
1975
|
+
log("shutdown requested")
|
|
1976
|
+
runtime.shutdown.set()
|
|
1977
|
+
runtime.trigger_event.set()
|
|
1978
|
+
|
|
1979
|
+
signal.signal(signal.SIGTERM, stop)
|
|
1980
|
+
signal.signal(signal.SIGINT, stop)
|
|
1981
|
+
runtime.run()
|
|
1982
|
+
|
|
1983
|
+
|
|
1984
|
+
if __name__ == "__main__":
|
|
1985
|
+
main()
|