robopark 2.8.36 → 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.
Files changed (68) hide show
  1. package/README.md +88 -63
  2. package/bin/robopark.js +7 -17
  3. package/conversation/elevenlabs_agent.py +1985 -0
  4. package/conversation/requirements.txt +3 -0
  5. package/conversation/supervisor_store.py +189 -0
  6. package/dist/kernel/config-schema.js +37 -0
  7. package/dist/kernel/types.js +7 -0
  8. package/dist/robopark/access.js +99 -0
  9. package/dist/robopark/add-robot.js +188 -0
  10. package/dist/robopark/agent-ctl.js +305 -0
  11. package/dist/robopark/auto-start.js +289 -0
  12. package/dist/robopark/conversation.js +505 -0
  13. package/dist/robopark/deployment-commands.js +47 -0
  14. package/dist/robopark/discovery.js +180 -0
  15. package/dist/robopark/doctor.js +175 -0
  16. package/dist/robopark/enroll.js +68 -0
  17. package/dist/robopark/llm-set.js +87 -0
  18. package/dist/robopark/motor-control.js +195 -0
  19. package/dist/robopark/preview-agent-launcher.js +77 -0
  20. package/dist/robopark/probe.js +138 -0
  21. package/dist/robopark/profile.js +69 -0
  22. package/dist/robopark/python-env.js +162 -0
  23. package/dist/robopark/robot-runtime.js +489 -0
  24. package/dist/robopark/scan.js +97 -0
  25. package/dist/robopark/screen-control.js +55 -0
  26. package/dist/robopark/secrets.js +41 -0
  27. package/dist/robopark/serve.js +285 -0
  28. package/dist/robopark/server-add.js +114 -0
  29. package/dist/robopark/setup-livekit.js +300 -0
  30. package/dist/robopark/setup.js +286 -0
  31. package/dist/robopark/standalone.js +466 -0
  32. package/dist/robopark/stop-all.js +141 -0
  33. package/dist/robopark/verify.js +192 -0
  34. package/dist/robopark/vision-agent-launcher.js +98 -0
  35. package/dist/robopark/vision-control.js +81 -0
  36. package/dist/robopark-cli.js +799 -0
  37. package/package.json +21 -5
  38. package/pi-client/_install_steps.sh +29 -29
  39. package/pi-client/client.py +61 -2
  40. package/pi-client/install.sh +40 -40
  41. package/pi-client/join_convo.sh +54 -54
  42. package/pi-client/livekit_bridge.py +16 -7
  43. package/pi-client/motor_bridge.py +6 -3
  44. package/scheduler/fleet_config.json +75 -0
  45. package/scheduler/main.py +4505 -135
  46. package/scheduler/media_lock.py +57 -0
  47. package/scheduler/preview_agent.py +1465 -87
  48. package/scheduler/production_config.json +139 -0
  49. package/scheduler/robot_supervisor.py +1705 -0
  50. package/scheduler/scripts/install-robot-supervisor-linux.sh +33 -0
  51. package/scheduler/scripts/install-robot-supervisor-windows.ps1 +49 -0
  52. package/scheduler/scripts/robopark-supervisor.service +20 -0
  53. package/scheduler/scripts/start-scheduler-local.ps1 +50 -0
  54. package/scheduler/supervisor.example.json +26 -0
  55. package/scheduler/vision_motion_trigger.py +101 -0
  56. package/screen/screen_runtime.py +75 -0
  57. package/vision/app_pi_clean.py +253 -16
  58. package/vision/audio_server_pi.py +19 -0
  59. package/vision/install.sh +34 -34
  60. package/vision/motor_server.py +224 -61
  61. package/vision/requirements_camera.txt +6 -0
  62. package/vision/requirements_motor.txt +4 -0
  63. package/vision/requirements_pi_unified.txt +1 -0
  64. package/vision/requirements_vision_agent.txt +19 -0
  65. package/vision/run.sh +244 -244
  66. package/vision/services/services.sh +12 -12
  67. package/scheduler/__pycache__/main.cpython-312.pyc +0 -0
  68. 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()