robopark 3.1.2 → 3.3.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 (101) hide show
  1. package/README.md +66 -95
  2. package/bin/robopark.js +9 -2
  3. package/conversation/elevenlabs_agent.py +1617 -1617
  4. package/conversation/supervisor_store.py +189 -189
  5. package/dist/{kernel/config-schema.js → config-schema.js} +1 -0
  6. package/dist/config-schema.js.map +1 -0
  7. package/dist/robopark/add-robot.js +1 -0
  8. package/dist/robopark/add-robot.js.map +1 -0
  9. package/dist/robopark/agent-ctl.js +1 -0
  10. package/dist/robopark/agent-ctl.js.map +1 -0
  11. package/dist/robopark/auto-start.js +51 -73
  12. package/dist/robopark/auto-start.js.map +1 -0
  13. package/dist/robopark/conversation.js +1 -0
  14. package/dist/robopark/conversation.js.map +1 -0
  15. package/dist/robopark/discovery.js +1 -0
  16. package/dist/robopark/discovery.js.map +1 -0
  17. package/dist/robopark/doctor.js +1 -0
  18. package/dist/robopark/doctor.js.map +1 -0
  19. package/dist/robopark/enroll.js +1 -0
  20. package/dist/robopark/enroll.js.map +1 -0
  21. package/dist/robopark/llm-set.js +1 -0
  22. package/dist/robopark/llm-set.js.map +1 -0
  23. package/dist/robopark/motor-control.js +1 -0
  24. package/dist/robopark/motor-control.js.map +1 -0
  25. package/dist/robopark/preview-agent-launcher.js +1 -0
  26. package/dist/robopark/preview-agent-launcher.js.map +1 -0
  27. package/dist/robopark/probe.js +16 -15
  28. package/dist/robopark/probe.js.map +1 -0
  29. package/dist/robopark/profile.js +1 -0
  30. package/dist/robopark/profile.js.map +1 -0
  31. package/dist/robopark/python-env.js +1 -7
  32. package/dist/robopark/python-env.js.map +1 -0
  33. package/dist/robopark/robot-runtime.js +1 -0
  34. package/dist/robopark/robot-runtime.js.map +1 -0
  35. package/dist/robopark/scan.js +1 -0
  36. package/dist/robopark/scan.js.map +1 -0
  37. package/dist/robopark/screen-control.js +1 -0
  38. package/dist/robopark/screen-control.js.map +1 -0
  39. package/dist/robopark/secrets.js +1 -0
  40. package/dist/robopark/secrets.js.map +1 -0
  41. package/dist/robopark/serve.js +4 -6
  42. package/dist/robopark/serve.js.map +1 -0
  43. package/dist/robopark/server-add.js +1 -0
  44. package/dist/robopark/server-add.js.map +1 -0
  45. package/dist/robopark/setup-livekit.js +53 -52
  46. package/dist/robopark/setup-livekit.js.map +1 -0
  47. package/dist/robopark/setup.js +1 -0
  48. package/dist/robopark/setup.js.map +1 -0
  49. package/dist/robopark/stop-all.js +1 -0
  50. package/dist/robopark/stop-all.js.map +1 -0
  51. package/dist/robopark/verify.js +1 -0
  52. package/dist/robopark/verify.js.map +1 -0
  53. package/dist/robopark/vision-agent-launcher.js +1 -0
  54. package/dist/robopark/vision-agent-launcher.js.map +1 -0
  55. package/dist/robopark/vision-control.js +1 -0
  56. package/dist/robopark/vision-control.js.map +1 -0
  57. package/dist/robopark-cli.js +7 -125
  58. package/dist/robopark-cli.js.map +1 -0
  59. package/package.json +16 -25
  60. package/pi-client/_install_steps.sh +29 -29
  61. package/pi-client/client.py +76 -76
  62. package/pi-client/install.sh +40 -40
  63. package/pi-client/join_convo.sh +54 -54
  64. package/pi-client/livekit_bridge.py +24 -24
  65. package/pi-client/motor_bridge.py +10 -10
  66. package/scheduler/__pycache__/main.cpython-312.pyc +0 -0
  67. package/scheduler/main.py +196 -17
  68. package/scheduler/media_lock.py +57 -57
  69. package/scheduler/preview_agent.py +1122 -1122
  70. package/scheduler/production_config.json +73 -113
  71. package/scheduler/scripts/install-robot-supervisor-linux.sh +33 -33
  72. package/{static → scheduler/static}/vendor/elevenlabs-client.iife.js +24199 -24199
  73. package/scheduler/static/voice-join.html +177 -0
  74. package/screen/screen_runtime.py +75 -75
  75. package/vision/app_pi_clean.py +297 -297
  76. package/vision/audio_server_pi.py +24 -24
  77. package/vision/install.sh +34 -34
  78. package/vision/motor_server.py +275 -275
  79. package/vision/requirements_camera.txt +6 -6
  80. package/vision/requirements_motor.txt +4 -4
  81. package/vision/requirements_pi_unified.txt +2 -2
  82. package/vision/requirements_vision_agent.txt +19 -19
  83. package/vision/run.sh +244 -244
  84. package/vision/services/services.sh +12 -12
  85. package/dist/kernel/types.js +0 -7
  86. package/dist/robopark/access.js +0 -99
  87. package/dist/robopark/control-center-html.js +0 -9191
  88. package/dist/robopark/control-server.js +0 -148
  89. package/dist/robopark/deployment-commands.js +0 -47
  90. package/dist/robopark/standalone.js +0 -562
  91. package/dist/robopark/voice-join-html.js +0 -57
  92. package/scripts/windows-shim-fix.cjs +0 -58
  93. package/static/robopark/barracuda.png +0 -0
  94. package/static/robopark/bmw.png +0 -0
  95. package/static/robopark/jaguar.png +0 -0
  96. package/static/robopark/magnus.jpg +0 -0
  97. package/static/robopark/panda.png +0 -0
  98. package/static/robopark/tesla.png +0 -0
  99. package/static/robopark/titan.jpg +0 -0
  100. package/static/robopark/volt.png +0 -0
  101. package/static/vendor/elevenlabs-client.LICENSE +0 -21
@@ -3,42 +3,42 @@
3
3
 
4
4
  from __future__ import annotations
5
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
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
18
  import time
19
- import urllib.parse
20
- import urllib.request
21
- import uuid
22
- from collections import deque
19
+ import urllib.parse
20
+ import urllib.request
21
+ import uuid
22
+ from collections import deque
23
23
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
24
24
  from pathlib import Path
25
25
  from typing import Callable
26
26
 
27
27
  import pyaudio
28
28
  from elevenlabs.client import ElevenLabs
29
- from elevenlabs.conversational_ai.conversation import AudioInterface, ClientTools, Conversation
29
+ from elevenlabs.conversational_ai.conversation import AudioInterface, ClientTools, Conversation
30
+
31
+ from supervisor_store import SupervisorStore
30
32
 
31
- from supervisor_store import SupervisorStore
32
33
 
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
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
42
 
43
43
 
44
44
  def log(message: str) -> None:
@@ -96,95 +96,95 @@ def find_device(pa: pyaudio.PyAudio, selector: str, direction: str) -> tuple[int
96
96
  return matches[0]
97
97
 
98
98
 
99
- def candidate_rates(info: dict) -> list[int]:
99
+ def candidate_rates(info: dict) -> list[int]:
100
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
- }
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
188
 
189
189
 
190
190
  class NativeAudioInterface(AudioInterface):
@@ -193,99 +193,99 @@ class NativeAudioInterface(AudioInterface):
193
193
  def __init__(self, input_selector: str, output_selector: str) -> None:
194
194
  self.input_selector = input_selector
195
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
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
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
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
210
  self.output_rate = SDK_RATE
211
211
  self.output_channels = 1
212
212
  self.input_rate_state = None
213
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
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
289
  except Exception as exc:
290
290
  errors.append(f"{rate}Hz: {exc}")
291
291
  raise RuntimeError(f"microphone open failed ({'; '.join(errors)})")
@@ -312,174 +312,174 @@ class NativeAudioInterface(AudioInterface):
312
312
  errors.append(f"{rate}Hz/{channels}ch: {exc}")
313
313
  raise RuntimeError(f"speaker open failed ({'; '.join(errors)})")
314
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)
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
337
  self.out_stream = self._open_output(output_index, output_info)
338
338
  except Exception:
339
339
  self._close_streams()
340
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():
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
454
  try:
455
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
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
483
 
484
484
  def interrupt(self):
485
485
  while True:
@@ -487,22 +487,22 @@ class NativeAudioInterface(AudioInterface):
487
487
  self.output_queue.get_nowait()
488
488
  except queue.Empty:
489
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):
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
506
  if stream is not None:
507
507
  try:
508
508
  stream.stop_stream()
@@ -523,26 +523,26 @@ class NativeAudioInterface(AudioInterface):
523
523
 
524
524
  def stop(self):
525
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
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
546
 
547
547
 
548
548
  class VoiceTrigger:
@@ -620,7 +620,7 @@ class VoiceTrigger:
620
620
  log(f"voice trigger unavailable: {exc}")
621
621
 
622
622
 
623
- class MotorRegistry:
623
+ class MotorRegistry:
624
624
  def __init__(self, motors: dict[str, int], active_high: bool, default_pulse_ms: int) -> None:
625
625
  self.motors = motors
626
626
  self.active_high = active_high
@@ -685,859 +685,859 @@ class MotorRegistry:
685
685
  device.close()
686
686
  except Exception:
687
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
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
907
  self.shutdown = threading.Event()
908
908
  self.trigger_event = threading.Event()
909
909
  self.trigger_reason = "unknown"
910
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
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
947
  self.voice_trigger = VoiceTrigger(
948
948
  config["audio_input"], int(config["voice_threshold"]), self.trigger
949
949
  )
950
- self.httpd: ThreadingHTTPServer | None = None
951
- self.http_thread: threading.Thread | None = None
952
- self.motor_registry = MotorRegistry(
950
+ self.httpd: ThreadingHTTPServer | None = None
951
+ self.http_thread: threading.Thread | None = None
952
+ self.motor_registry = MotorRegistry(
953
953
  config.get("motors") or {},
954
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}"}
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
1315
 
1316
1316
  def start_motion_server(self) -> None:
1317
1317
  runtime = self
1318
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:
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
1331
  body = json.dumps(payload).encode()
1332
1332
  self.send_response(status)
1333
1333
  self.send_header("Content-Type", "application/json")
1334
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]
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
1541
  length = int(self.headers.get("Content-Length", "0") or 0)
1542
1542
  try:
1543
1543
  body = json.loads(self.rfile.read(length) or b"{}")
@@ -1549,229 +1549,229 @@ class Runtime:
1549
1549
  if not runtime.config["motion_enabled"]:
1550
1550
  self._reply(404, {"ok": False, "error": "motion trigger is disabled"})
1551
1551
  return
1552
- admission = runtime.trigger("motion")
1553
- self._reply(202, {
1554
- "ok": True,
1555
- "queued": admission["accepted"],
1556
- **admission,
1557
- })
1552
+ admission = runtime.trigger("motion")
1553
+ self._reply(202, {
1554
+ "ok": True,
1555
+ "queued": admission["accepted"],
1556
+ **admission,
1557
+ })
1558
1558
 
1559
1559
  def log_message(self, format, *args):
1560
1560
  return
1561
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}")
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
1709
 
1710
1710
  def on_agent(text: str) -> None:
1711
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}")
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
1721
 
1722
1722
  def on_end() -> None:
1723
1723
  ended.set()
1724
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")
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
1775
  if self.motor_registry.names():
1776
1776
  deadline = time.monotonic() + 10
1777
1777
  while getattr(conversation, "_ws", None) is None and time.monotonic() < deadline:
@@ -1788,187 +1788,187 @@ class Runtime:
1788
1788
  "tool returns ok=true."
1789
1789
  )
1790
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)
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
1800
  if thread is not None and not thread.is_alive():
1801
1801
  break
1802
- if self.config["max_session"] > 0 and now - started >= self.config["max_session"]:
1802
+ if self.config["max_session"] > 0 and now - started >= self.config["max_session"]:
1803
1803
  log("maximum session timeout reached")
1804
1804
  break
1805
1805
  if (
1806
- self.config["idle_timeout"] > 0
1807
- and now - last_activity[0] >= self.config["idle_timeout"]
1806
+ self.config["idle_timeout"] > 0
1807
+ and now - last_activity[0] >= self.config["idle_timeout"]
1808
1808
  and now >= agent_busy_until[0]
1809
1809
  and audio.started.is_set()
1810
1810
  ):
1811
1811
  log("conversation idle timeout reached")
1812
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()
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
1951
  self.voice_trigger.stop()
1952
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:
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
1965
  parser = argparse.ArgumentParser()
1966
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")
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
1972
  runtime = Runtime(config)
1973
1973
 
1974
1974
  def stop(_signum, _frame):