infinicode 2.8.23 → 2.8.25

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.
@@ -1,782 +1,1011 @@
1
- #!/usr/bin/env python3
2
- """
3
- RoboPark Preview Agent — runs on each robot/satellite Pi.
4
-
5
- Polls the scheduler for an active operator preview request. When active,
6
- opens the selected camera + microphone and publishes them to the returned
7
- LiveKit room. When the preview expires or is stopped, the agent leaves the
8
- room and releases the hardware.
9
-
10
- Also runs a small local HTTP listener for RoboVisionAI_PI's motion webhook
11
- (POST /api/motion/webhook target — see RoboVisionAI_PI's app_pi_clean.py).
12
- On a motion event it calls the scheduler's presence-triggered
13
- POST /api/devices/{id}/request-session and publishes into the returned room
14
- using the same LiveKitPublisher as the operator preview — this is the
15
- "scene detection" trigger point the scheduler's request-session docstring
16
- describes; RoboVisionAI_PI supplies the detection, this agent supplies the
17
- bridge to an actual session.
18
-
19
- Configuration (env / ~/.robopark/preview_agent.json):
20
- SCHEDULER_URL base URL of the RoboPark scheduler
21
- ROBOT_ID robot identity in the scheduler (defaults to hostname)
22
- DEVICE_TOKEN long-lived device token (after enrollment)
23
- ENROLLMENT_TOKEN one-time enrollment token (device token will be saved)
24
- VIDEO_DEVICE camera path/index/name (e.g. /dev/video0, 0, "PiCamera")
25
- AUDIO_DEVICE microphone name/index or "default"
26
- VIDEO_WIDTH / HEIGHT capture resolution (default 640x480)
27
- VIDEO_FPS capture fps (default 15)
28
- POLL_INTERVAL seconds between scheduler polls (default 3)
29
- HEARTBEAT_INTERVAL seconds between device heartbeats (default 30)
30
- VISION_WEBHOOK_PORT local port for the RoboVision motion webhook (default 5057, 0 disables)
31
- VISION_TRIGGER_COOLDOWN min seconds between motion-triggered sessions (default 20)
32
- VISION_SESSION_SECONDS how long a motion-triggered session holds the publisher (default 90)
33
- """
34
- from __future__ import annotations
35
-
36
- import argparse
37
- import asyncio
38
- import json
39
- import logging
40
- import os
41
- import signal
42
- import sys
43
- import threading
44
- import time
45
- from dataclasses import dataclass
46
- from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
47
- from pathlib import Path
48
- from typing import Optional
49
-
50
- import httpx
51
-
52
- logger = logging.getLogger("robopark.preview_agent")
53
-
54
- CONFIG_DIR = Path.home() / ".robopark"
55
- CONFIG_FILE = CONFIG_DIR / "preview_agent.json"
56
- TOKEN_FILE = CONFIG_DIR / "device_token"
57
-
58
- DEFAULT_VIDEO_WIDTH = 640
59
- DEFAULT_VIDEO_HEIGHT = 480
60
- DEFAULT_FPS = 15
61
- DEFAULT_POLL_INTERVAL = 3.0
62
- DEFAULT_HEARTBEAT_INTERVAL = 30.0
63
- DEFAULT_VISION_WEBHOOK_PORT = 5057
64
- DEFAULT_VISION_TRIGGER_COOLDOWN = 20.0
65
- DEFAULT_VISION_SESSION_SECONDS = 90.0
66
-
67
-
68
- def _load_config() -> dict:
69
- cfg: dict = {}
70
- if CONFIG_FILE.exists():
71
- try:
72
- cfg = json.loads(CONFIG_FILE.read_text(encoding="utf8"))
73
- except Exception as e:
74
- logger.warning(f"failed to read {CONFIG_FILE}: {e}")
75
- return cfg
76
-
77
-
78
- def _save_config(cfg: dict) -> None:
79
- CONFIG_DIR.mkdir(parents=True, exist_ok=True)
80
- CONFIG_FILE.write_text(json.dumps(cfg, indent=2), encoding="utf8")
81
-
82
-
83
- def _load_token() -> Optional[str]:
84
- if TOKEN_FILE.exists():
85
- return TOKEN_FILE.read_text(encoding="utf8").strip() or None
86
- return None
87
-
88
-
89
- def _save_token(token: str) -> None:
90
- CONFIG_DIR.mkdir(parents=True, exist_ok=True)
91
- TOKEN_FILE.write_text(token, encoding="utf8")
92
- os.chmod(TOKEN_FILE, 0o600)
93
-
94
-
95
- async def _enroll(scheduler_url: str, enrollment_token: str, robot_id: str) -> str:
96
- """Enroll this Pi with the scheduler and return the device token."""
97
- import socket
98
- payload = {
99
- "enrollment_token": enrollment_token,
100
- "name": robot_id,
101
- "lan_ip": _get_lan_ip(),
102
- }
103
- async with httpx.AsyncClient() as client:
104
- r = await client.post(
105
- f"{scheduler_url.rstrip('/')}/api/devices/enroll",
106
- json=payload,
107
- timeout=30.0,
108
- )
109
- r.raise_for_status()
110
- data = r.json()
111
- device_token = data.get("device_token")
112
- if not device_token:
113
- raise RuntimeError("enrollment did not return a device_token")
114
- _save_token(device_token)
115
- cfg = _load_config()
116
- cfg["device_id"] = data.get("device_id")
117
- cfg["scheduler_url"] = data.get("scheduler_url", scheduler_url)
118
- _save_config(cfg)
119
- logger.info(f"enrolled as device {data.get('device_id')}")
120
- return device_token
121
-
122
-
123
- def _get_lan_ip() -> Optional[str]:
124
- try:
125
- s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
126
- s.settimeout(0.5)
127
- s.connect(("10.255.255.255", 1))
128
- ip = s.getsockname()[0]
129
- s.close()
130
- return ip
131
- except Exception:
132
- return None
133
-
134
-
135
- async def _send_heartbeat(scheduler_url: str, device_id: str, token: str) -> None:
136
- try:
137
- async with httpx.AsyncClient() as client:
138
- await client.post(
139
- f"{scheduler_url.rstrip('/')}/api/devices/{device_id}/heartbeat",
140
- json={"status": "online", "ip": _get_lan_ip()},
141
- headers={"Authorization": f"Bearer {token}"},
142
- timeout=10.0,
143
- )
144
- except Exception as e:
145
- logger.debug(f"heartbeat failed: {e}")
146
-
147
-
148
- @dataclass
149
- class PreviewState:
150
- active: bool = False
151
- url: Optional[str] = None
152
- token: Optional[str] = None
153
- room: Optional[str] = None
154
-
155
-
156
- class PreviewAgent:
157
- def __init__(self, cfg: dict):
158
- self.scheduler_url = cfg.get("scheduler_url", os.getenv("SCHEDULER_URL", "http://localhost:8080"))
159
- self.robot_id = cfg.get("robot_id", os.getenv("ROBOT_ID", _hostname()))
160
- self.device_token: Optional[str] = cfg.get("device_token") or os.getenv("DEVICE_TOKEN") or _load_token()
161
- self.device_id: Optional[str] = cfg.get("device_id")
162
- self.enrollment_token: Optional[str] = cfg.get("enrollment_token")
163
- self.video_device = cfg.get("video_device", os.getenv("VIDEO_DEVICE", "auto"))
164
- self.audio_device = cfg.get("audio_device", os.getenv("AUDIO_DEVICE", "default"))
165
- self.width = int(cfg.get("video_width", os.getenv("VIDEO_WIDTH", DEFAULT_VIDEO_WIDTH)))
166
- self.height = int(cfg.get("video_height", os.getenv("VIDEO_HEIGHT", DEFAULT_VIDEO_HEIGHT)))
167
- self.fps = int(cfg.get("video_fps", os.getenv("VIDEO_FPS", DEFAULT_FPS)))
168
- self.poll_interval = float(cfg.get("poll_interval", os.getenv("POLL_INTERVAL", DEFAULT_POLL_INTERVAL)))
169
- self.heartbeat_interval = float(cfg.get("heartbeat_interval", os.getenv("HEARTBEAT_INTERVAL", DEFAULT_HEARTBEAT_INTERVAL)))
170
- self.vision_webhook_port = int(cfg.get("vision_webhook_port", os.getenv("VISION_WEBHOOK_PORT", DEFAULT_VISION_WEBHOOK_PORT)))
171
- self.vision_trigger_cooldown = float(cfg.get("vision_trigger_cooldown", os.getenv("VISION_TRIGGER_COOLDOWN", DEFAULT_VISION_TRIGGER_COOLDOWN)))
172
- self.vision_session_seconds = float(cfg.get("vision_session_seconds", os.getenv("VISION_SESSION_SECONDS", DEFAULT_VISION_SESSION_SECONDS)))
173
-
174
- self._shutdown = asyncio.Event()
175
- self._task: Optional[asyncio.Task] = None
176
- self._session: Optional[httpx.AsyncClient] = None
177
- self._current_state: PreviewState = PreviewState()
178
- self._publisher: Optional["LiveKitPublisher"] = None
179
- self._loop: Optional[asyncio.AbstractEventLoop] = None
180
- self._vision_server: Optional[ThreadingHTTPServer] = None
181
- self._last_vision_trigger: float = 0.0
182
- self._vision_active_until: float = 0.0
183
-
184
- async def run(self) -> None:
185
- enrollment_token = os.getenv("ENROLLMENT_TOKEN") or self.enrollment_token
186
- if not self.device_token and enrollment_token:
187
- self.device_token = await _enroll(self.scheduler_url, enrollment_token, self.robot_id)
188
-
189
- if not self.device_token:
190
- logger.error("no DEVICE_TOKEN and no ENROLLMENT_TOKEN; cannot poll scheduler")
191
- sys.exit(1)
192
-
193
- self._session = httpx.AsyncClient()
194
-
195
- # Must come after self._session exists _resolve_device_id() guards
196
- # on it and silently no-ops otherwise. On a fresh enroll (no cached
197
- # device_id in ~/.robopark/preview_agent.json) that made this ALWAYS
198
- # no-op, so device_id never resolved and preview/agent polling fell
199
- # back to robot_id (the display name) instead of the real device_id
200
- # for the entire sessionthe same id-mismatch bug fixed elsewhere,
201
- # recurring here only on a first-ever enroll.
202
- if not self.device_id:
203
- await self._resolve_device_id()
204
-
205
- self._loop = asyncio.get_running_loop()
206
- self._start_vision_webhook_server()
207
-
208
- tasks = [
209
- asyncio.create_task(self._poll_loop()),
210
- asyncio.create_task(self._heartbeat_loop()),
211
- ]
212
- await self._shutdown.wait()
213
- for t in tasks:
214
- t.cancel()
215
- try:
216
- await t
217
- except asyncio.CancelledError:
218
- pass
219
- if self._vision_server:
220
- self._vision_server.shutdown()
221
- await self._stop_publisher()
222
- await self._session.aclose()
223
-
224
- async def _resolve_device_id(self) -> None:
225
- """Look up our device_id from the scheduler using the token."""
226
- if not self._session or not self.device_token:
227
- return
228
- try:
229
- r = await self._session.get(
230
- f"{self.scheduler_url.rstrip('/')}/api/devices",
231
- headers={"Authorization": f"Bearer {self.device_token}"},
232
- timeout=10.0,
233
- )
234
- r.raise_for_status()
235
- for d in r.json():
236
- if d.get("name") == self.robot_id:
237
- self.device_id = d.get("id")
238
- cfg = _load_config()
239
- cfg["device_id"] = self.device_id
240
- _save_config(cfg)
241
- return
242
- except Exception as e:
243
- logger.warning(f"could not resolve device_id: {e}")
244
-
245
- async def _poll_loop(self) -> None:
246
- while not self._shutdown.is_set():
247
- try:
248
- if time.time() >= self._vision_active_until:
249
- # A motion-triggered session (if any) has ended its lease —
250
- # let the operator-preview poll reconcile the publisher again.
251
- if self._vision_active_until:
252
- self._vision_active_until = 0.0
253
- await self._stop_publisher()
254
- self._current_state = PreviewState()
255
- state = await self._fetch_preview_state()
256
- await self._apply_state(state)
257
- # else: a vision-triggered session currently owns the publisher —
258
- # don't let the (unrelated) operator-preview state tear it down.
259
- except Exception as e:
260
- logger.warning(f"poll error: {e}")
261
- try:
262
- await asyncio.wait_for(self._shutdown.wait(), timeout=self.poll_interval)
263
- except asyncio.TimeoutError:
264
- pass
265
-
266
- async def _heartbeat_loop(self) -> None:
267
- while not self._shutdown.is_set():
268
- if self.device_id and self.device_token:
269
- await _send_heartbeat(self.scheduler_url, self.device_id, self.device_token)
270
- try:
271
- await asyncio.wait_for(self._shutdown.wait(), timeout=self.heartbeat_interval)
272
- except asyncio.TimeoutError:
273
- pass
274
-
275
- async def _fetch_preview_state(self) -> PreviewState:
276
- if not self._session or not self.device_token:
277
- return PreviewState()
278
- r = await self._session.get(
279
- f"{self.scheduler_url.rstrip('/')}/api/robots/{self.device_id or self.robot_id}/preview/agent",
280
- headers={"Authorization": f"Bearer {self.device_token}"},
281
- timeout=10.0,
282
- )
283
- r.raise_for_status()
284
- data = r.json()
285
- if not data.get("active"):
286
- return PreviewState()
287
- return PreviewState(
288
- active=True,
289
- url=data.get("url"),
290
- token=data.get("token"),
291
- room=data.get("room"),
292
- )
293
-
294
- async def _apply_state(self, state: PreviewState) -> None:
295
- same = (
296
- state.active == self._current_state.active
297
- and state.room == self._current_state.room
298
- and state.token == self._current_state.token
299
- and state.url == self._current_state.url
300
- )
301
- if same:
302
- return
303
- self._current_state = state
304
- if not state.active:
305
- await self._stop_publisher()
306
- return
307
- await self._start_publisher(state)
308
-
309
- async def _start_publisher(self, state: PreviewState) -> None:
310
- await self._stop_publisher()
311
- if not state.url or not state.token or not state.room:
312
- return
313
- try:
314
- pub = LiveKitPublisher(state.url, state.token, state.room, self)
315
- await pub.start()
316
- self._publisher = pub
317
- logger.info(f"joined preview room {state.room}")
318
- except Exception as e:
319
- logger.error(f"failed to start publisher: {e}")
320
-
321
- async def _stop_publisher(self) -> None:
322
- if self._publisher:
323
- try:
324
- await self._publisher.stop()
325
- except Exception as e:
326
- logger.warning(f"publisher stop error: {e}")
327
- self._publisher = None
328
-
329
- # ---- vision (RoboVisionAI_PI motion webhook) -> presence-triggered session ----
330
-
331
- def _start_vision_webhook_server(self) -> None:
332
- if not self.vision_webhook_port:
333
- return
334
- agent = self
335
-
336
- class Handler(BaseHTTPRequestHandler):
337
- def log_message(self, fmt, *a): # noqa: A002 — quiet by default
338
- logger.debug("vision webhook: " + fmt, *a)
339
-
340
- def do_POST(self): # noqa: N802 — http.server's required method name
341
- try:
342
- length = int(self.headers.get("Content-Length", 0))
343
- body = self.rfile.read(length) if length else b"{}"
344
- payload = json.loads(body or b"{}")
345
- except Exception as e:
346
- self.send_response(400)
347
- self.end_headers()
348
- self.wfile.write(str(e).encode())
349
- return
350
- self.send_response(200)
351
- self.end_headers()
352
- self.wfile.write(b'{"ok":true}')
353
- if agent._loop:
354
- asyncio.run_coroutine_threadsafe(agent._on_vision_motion(payload), agent._loop)
355
-
356
- def do_GET(self): # noqa: N802
357
- self.send_response(200)
358
- self.end_headers()
359
- self.wfile.write(b'{"status":"ok","listening_for":"robovision motion webhook"}')
360
-
361
- try:
362
- self._vision_server = ThreadingHTTPServer(("0.0.0.0", self.vision_webhook_port), Handler)
363
- thread = threading.Thread(target=self._vision_server.serve_forever, daemon=True)
364
- thread.start()
365
- logger.info(
366
- f"vision webhook listening on :{self.vision_webhook_port} — "
367
- f"point RoboVisionAI_PI's POST /api/motion/webhook at "
368
- f"http://<this-host>:{self.vision_webhook_port}/"
369
- )
370
- except Exception as e:
371
- logger.error(f"could not start vision webhook server: {e}")
372
- self._vision_server = None
373
-
374
- async def _on_vision_motion(self, payload: dict) -> None:
375
- now = time.time()
376
- if now - self._last_vision_trigger < self.vision_trigger_cooldown:
377
- logger.debug("vision trigger suppressed (cooldown)")
378
- return
379
- self._last_vision_trigger = now
380
- if not self.device_id or not self.device_token or not self._session:
381
- logger.warning("vision motion event received but not enrolled yet — ignoring")
382
- return
383
- logger.info("motion detected by RoboVisionAI_PI — requesting a session")
384
- try:
385
- r = await self._session.post(
386
- f"{self.scheduler_url.rstrip('/')}/api/devices/{self.device_id}/request-session",
387
- headers={"Authorization": f"Bearer {self.device_token}"},
388
- timeout=10.0,
389
- )
390
- r.raise_for_status()
391
- data = r.json()
392
- except Exception as e:
393
- logger.error(f"request-session failed: {e}")
394
- return
395
- state = PreviewState(
396
- active=True,
397
- url=data.get("server_url"),
398
- token=data.get("token"),
399
- room=data.get("room_name"),
400
- )
401
- self._vision_active_until = time.time() + self.vision_session_seconds
402
- self._current_state = state
403
- await self._start_publisher(state)
404
- session_id = data.get("session_id")
405
- if session_id and self._session:
406
- try:
407
- await self._session.post(
408
- f"{self.scheduler_url.rstrip('/')}/api/sessions/{session_id}/joined",
409
- headers={"Authorization": f"Bearer {self.device_token}"},
410
- timeout=10.0,
411
- )
412
- except Exception as e:
413
- logger.debug(f"could not mark session joined: {e}")
414
-
415
- def shutdown(self) -> None:
416
- self._shutdown.set()
417
-
418
-
419
- class LiveKitPublisher:
420
- """Encapsulates LiveKit room connection, camera capture and mic capture."""
421
-
422
- def __init__(self, url: str, token: str, room_name: str, agent: PreviewAgent):
423
- from livekit import rtc
424
- self.url = url
425
- self.token = token
426
- self.room_name = room_name
427
- self.agent = agent
428
- self.rtc = rtc
429
- self.room: Optional[rtc.Room] = None
430
- self.video_source: Optional[rtc.VideoSource] = None
431
- self.video_track: Optional[rtc.LocalVideoTrack] = None
432
- self.audio_source: Optional[rtc.AudioSource] = None
433
- self.audio_track: Optional[rtc.LocalAudioTrack] = None
434
- self._stop_event = asyncio.Event()
435
- self._tasks: list[asyncio.Task] = []
436
- self._capture: Optional["VideoCapture"] = None
437
-
438
- async def start(self) -> None:
439
- self.room = self.rtc.Room()
440
- await self.room.connect(self.url, self.token)
441
-
442
- # Video
443
- self.video_source = self.rtc.VideoSource(self.agent.width, self.agent.height)
444
- self.video_track = self.rtc.LocalVideoTrack.create_video_track("camera", self.video_source)
445
- vopts = self.rtc.TrackPublishOptions()
446
- vopts.source = self.rtc.TrackSource.SOURCE_CAMERA
447
- await self.room.local_participant.publish_track(self.video_track, vopts)
448
-
449
- # Audio
450
- self.audio_source = self.rtc.AudioSource(48000, 1)
451
- self.audio_track = self.rtc.LocalAudioTrack.create_audio_track("microphone", self.audio_source)
452
- aopts = self.rtc.TrackPublishOptions()
453
- aopts.source = self.rtc.TrackSource.SOURCE_MICROPHONE
454
- await self.room.local_participant.publish_track(self.audio_track, aopts)
455
-
456
- self._capture = create_video_capture(self.agent.video_device, self.agent.width, self.agent.height, self.agent.fps)
457
- if self._capture:
458
- self._tasks.append(asyncio.create_task(self._video_loop()))
459
- if has_audio():
460
- self._tasks.append(asyncio.create_task(self._audio_loop()))
461
-
462
- async def stop(self) -> None:
463
- self._stop_event.set()
464
- for t in self._tasks:
465
- t.cancel()
466
- try:
467
- await t
468
- except asyncio.CancelledError:
469
- pass
470
- self._tasks.clear()
471
- if self._capture:
472
- self._capture.stop()
473
- self._capture = None
474
- if self.room:
475
- await self.room.disconnect()
476
- self.room = None
477
-
478
- async def _video_loop(self) -> None:
479
- assert self._capture is not None and self.video_source is not None
480
- frame_interval = 1.0 / self.agent.fps
481
- while not self._stop_event.is_set():
482
- start = time.monotonic()
483
- frame = self._capture.read()
484
- if frame is not None:
485
- self.video_source.capture_frame(frame)
486
- elapsed = time.monotonic() - start
487
- sleep_for = frame_interval - elapsed
488
- if sleep_for > 0:
489
- try:
490
- await asyncio.wait_for(self._stop_event.wait(), timeout=sleep_for)
491
- except asyncio.TimeoutError:
492
- pass
493
-
494
- async def _audio_loop(self) -> None:
495
- assert self.audio_source is not None
496
- mic = create_audio_capture(self.agent.audio_device)
497
- if mic is None:
498
- return
499
- frame_duration = 0.02 # 20ms
500
- samples_per_frame = int(48000 * frame_duration)
501
- while not self._stop_event.is_set():
502
- frame = mic.read(samples_per_frame)
503
- if frame is not None:
504
- self.audio_source.capture_frame(frame)
505
- try:
506
- await asyncio.wait_for(self._stop_event.wait(), timeout=frame_duration)
507
- except asyncio.TimeoutError:
508
- pass
509
-
510
-
511
- # -----------------------------------------------------------------------------
512
- # Video capture abstraction: V4L2/USB via OpenCV, or picamera2 on Pi.
513
- # -----------------------------------------------------------------------------
514
-
515
- class VideoCapture:
516
- def read(self) -> Optional["rtc.VideoFrame"]:
517
- raise NotImplementedError
518
-
519
- def stop(self) -> None:
520
- raise NotImplementedError
521
-
522
-
523
- class OpencvVideoCapture(VideoCapture):
524
- def __init__(self, device: int | str, width: int, height: int, fps: int):
525
- import cv2
526
- self.cv2 = cv2
527
- if isinstance(device, str) and device.startswith("/dev/video"):
528
- device = int(device.replace("/dev/video", ""))
529
- self.cap = cv2.VideoCapture(device)
530
- self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, width)
531
- self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height)
532
- self.cap.set(cv2.CAP_PROP_FPS, fps)
533
- self._ensure_frame()
534
-
535
- def _ensure_frame(self):
536
- ok, _ = self.cap.read()
537
- if not ok:
538
- raise RuntimeError("cannot read from camera")
539
-
540
- def read(self):
541
- from livekit import rtc
542
- ok, bgr = self.cap.read()
543
- if not ok or bgr is None:
544
- return None
545
- return rtc.VideoFrame(
546
- width=bgr.shape[1],
547
- height=bgr.shape[0],
548
- type=rtc.VideoBufferType.BGR,
549
- data=bgr.tobytes(),
550
- )
551
-
552
- def stop(self):
553
- self.cap.release()
554
-
555
-
556
- class Picamera2Capture(VideoCapture):
557
- def __init__(self, width: int, height: int, fps: int):
558
- from picamera2 import Picamera2
559
- self.picam = Picamera2()
560
- config = self.picam.create_video_configuration(
561
- main={"format": "RGB888", "size": (width, height)},
562
- controls={"FrameRate": fps},
563
- )
564
- self.picam.configure(config)
565
- self.picam.start()
566
- self.width = width
567
- self.height = height
568
-
569
- def read(self):
570
- from livekit import rtc
571
- arr = self.picam.capture_array()
572
- if arr is None:
573
- return None
574
- return rtc.VideoFrame(
575
- width=self.width,
576
- height=self.height,
577
- type=rtc.VideoBufferType.RGB,
578
- data=arr.tobytes(),
579
- )
580
-
581
- def stop(self):
582
- try:
583
- self.picam.stop()
584
- except Exception:
585
- pass
586
-
587
-
588
- def create_video_capture(device: str, width: int, height: int, fps: int) -> Optional[VideoCapture]:
589
- if device.lower() in ("none", "", "false", "null"):
590
- return None
591
- try:
592
- from livekit import rtc
593
- _ = rtc.VideoSource
594
- except Exception as e:
595
- logger.error(f"livekit python sdk not installed: {e}")
596
- return None
597
-
598
- # Auto-detect: prefer first V4L2 device, fall back to picamera2 if available.
599
- if device.lower() in ("auto", "default", "first"):
600
- for i in range(4):
601
- try:
602
- cap = OpencvVideoCapture(i, width, height, fps)
603
- logger.info(f"auto-selected USB camera /dev/video{i}")
604
- return cap
605
- except Exception:
606
- continue
607
- try:
608
- cap = Picamera2Capture(width, height, fps)
609
- logger.info("auto-selected Pi Camera")
610
- return cap
611
- except Exception:
612
- pass
613
- logger.error("no camera found (tried V4L2 and picamera2)")
614
- return None
615
-
616
- if device.lower() in ("picamera", "picamera2", "pi", "rpi"):
617
- return Picamera2Capture(width, height, fps)
618
-
619
- # Treat as V4L2 index or path
620
- return OpencvVideoCapture(device, width, height, fps)
621
-
622
-
623
- # -----------------------------------------------------------------------------
624
- # Audio capture abstraction: PyAudio or sounddevice -> LiveKit AudioFrame.
625
- # -----------------------------------------------------------------------------
626
-
627
- class AudioCapture:
628
- def read(self, samples_per_frame: int) -> Optional["rtc.AudioFrame"]:
629
- raise NotImplementedError
630
-
631
- def stop(self) -> None:
632
- raise NotImplementedError
633
-
634
-
635
- class PyAudioCapture(AudioCapture):
636
- def __init__(self, device: str | int | None):
637
- import pyaudio
638
- self.pa = pyaudio.PyAudio()
639
- self.device_index = self._resolve_device(device)
640
- self.buffer = bytearray()
641
- self.stream = self.pa.open(
642
- format=pyaudio.paInt16,
643
- channels=1,
644
- rate=48000,
645
- input=True,
646
- input_device_index=self.device_index,
647
- frames_per_buffer=960,
648
- stream_callback=self._callback,
649
- )
650
- self.stream.start_stream()
651
-
652
- def _resolve_device(self, device: str | int | None) -> Optional[int]:
653
- if device is None or device == "default":
654
- return None
655
- if isinstance(device, int):
656
- return device
657
- # Try exact name match
658
- for i in range(self.pa.get_device_count()):
659
- info = self.pa.get_device_info_by_index(i)
660
- if info.get("maxInputChannels", 0) > 0 and device.lower() in str(info.get("name", "")).lower():
661
- return i
662
- return None
663
-
664
- def _callback(self, in_data, frame_count, time_info, status):
665
- self.buffer.extend(in_data)
666
- return (None, pyaudio.paContinue)
667
-
668
- def read(self, samples_per_frame: int):
669
- from livekit import rtc
670
- bytes_needed = samples_per_frame * 2 # int16 mono
671
- while len(self.buffer) < bytes_needed:
672
- time.sleep(0.005)
673
- chunk = bytes(self.buffer[:bytes_needed])
674
- self.buffer = self.buffer[bytes_needed:]
675
- return rtc.AudioFrame(
676
- data=chunk,
677
- sample_rate=48000,
678
- num_channels=1,
679
- samples_per_channel=samples_per_frame,
680
- )
681
-
682
- def stop(self):
683
- if self.stream:
684
- self.stream.stop_stream()
685
- self.stream.close()
686
- self.pa.terminate()
687
-
688
-
689
- def has_audio() -> bool:
690
- try:
691
- import pyaudio # noqa: F401
692
- return True
693
- except Exception:
694
- return False
695
-
696
-
697
- def create_audio_capture(device: str) -> Optional[AudioCapture]:
698
- if device.lower() in ("none", "", "false", "null"):
699
- return None
700
- try:
701
- import pyaudio # noqa: F401
702
- return PyAudioCapture(device if device != "default" else None)
703
- except Exception as e:
704
- logger.warning(f"pyaudio not available: {e}")
705
- return None
706
-
707
-
708
- def _hostname() -> str:
709
- import socket
710
- return socket.gethostname().split(".")[0]
711
-
712
-
713
- def main() -> None:
714
- parser = argparse.ArgumentParser(description="RoboPark preview agent")
715
- parser.add_argument("--scheduler-url", default=os.getenv("SCHEDULER_URL", "http://localhost:8080"))
716
- parser.add_argument("--robot-id", default=os.getenv("ROBOT_ID", _hostname()))
717
- parser.add_argument("--device-token", default=os.getenv("DEVICE_TOKEN"))
718
- parser.add_argument("--enrollment-token", default=os.getenv("ENROLLMENT_TOKEN"))
719
- parser.add_argument("--video-device", default=os.getenv("VIDEO_DEVICE", "auto"))
720
- parser.add_argument("--audio-device", default=os.getenv("AUDIO_DEVICE", "default"))
721
- parser.add_argument("--width", type=int, default=int(os.getenv("VIDEO_WIDTH", DEFAULT_VIDEO_WIDTH)))
722
- parser.add_argument("--height", type=int, default=int(os.getenv("VIDEO_HEIGHT", DEFAULT_VIDEO_HEIGHT)))
723
- parser.add_argument("--fps", type=int, default=int(os.getenv("VIDEO_FPS", DEFAULT_FPS)))
724
- parser.add_argument("--poll-interval", type=float, default=float(os.getenv("POLL_INTERVAL", DEFAULT_POLL_INTERVAL)))
725
- parser.add_argument("--heartbeat-interval", type=float, default=float(os.getenv("HEARTBEAT_INTERVAL", DEFAULT_HEARTBEAT_INTERVAL)))
726
- parser.add_argument("--vision-webhook-port", type=int, default=int(os.getenv("VISION_WEBHOOK_PORT", DEFAULT_VISION_WEBHOOK_PORT)), help="local port for RoboVisionAI_PI's motion webhook (0 disables)")
727
- parser.add_argument("--vision-trigger-cooldown", type=float, default=float(os.getenv("VISION_TRIGGER_COOLDOWN", DEFAULT_VISION_TRIGGER_COOLDOWN)))
728
- parser.add_argument("--vision-session-seconds", type=float, default=float(os.getenv("VISION_SESSION_SECONDS", DEFAULT_VISION_SESSION_SECONDS)))
729
- parser.add_argument("--save-config", action="store_true", help="write CLI args to ~/.robopark/preview_agent.json")
730
- parser.add_argument("-v", "--verbose", action="store_true")
731
- args = parser.parse_args()
732
-
733
- logging.basicConfig(
734
- level=logging.DEBUG if args.verbose else logging.INFO,
735
- format="%(asctime)s %(levelname)s %(name)s: %(message)s",
736
- )
737
-
738
- cfg = _load_config()
739
- cfg.update({
740
- "scheduler_url": args.scheduler_url,
741
- "robot_id": args.robot_id,
742
- "video_device": args.video_device,
743
- "audio_device": args.audio_device,
744
- "video_width": args.width,
745
- "video_height": args.height,
746
- "video_fps": args.fps,
747
- "poll_interval": args.poll_interval,
748
- "heartbeat_interval": args.heartbeat_interval,
749
- "vision_webhook_port": args.vision_webhook_port,
750
- "vision_trigger_cooldown": args.vision_trigger_cooldown,
751
- "vision_session_seconds": args.vision_session_seconds,
752
- })
753
- if args.device_token:
754
- cfg["device_token"] = args.device_token
755
- _save_token(args.device_token)
756
- if args.enrollment_token:
757
- cfg["enrollment_token"] = args.enrollment_token
758
-
759
- if args.save_config:
760
- _save_config(cfg)
761
- logger.info(f"saved config to {CONFIG_FILE}")
762
-
763
- agent = PreviewAgent(cfg)
764
-
765
- loop = asyncio.new_event_loop()
766
- asyncio.set_event_loop(loop)
767
- for sig in (signal.SIGINT, signal.SIGTERM):
768
- try:
769
- loop.add_signal_handler(sig, agent.shutdown)
770
- except NotImplementedError:
771
- # add_signal_handler is POSIX-only (raises on Windows) fall back to
772
- # signal.signal, dispatched back onto the loop thread-safely.
773
- signal.signal(sig, lambda *_: loop.call_soon_threadsafe(agent.shutdown))
774
-
775
- try:
776
- loop.run_until_complete(agent.run())
777
- finally:
778
- loop.close()
779
-
780
-
781
- if __name__ == "__main__":
782
- main()
1
+ #!/usr/bin/env python3
2
+ """
3
+ RoboPark Preview Agent — runs on each robot/satellite Pi.
4
+
5
+ Polls the scheduler for an active operator preview request. When active,
6
+ opens the selected camera + microphone and publishes them to the returned
7
+ LiveKit room. When the preview expires or is stopped, the agent leaves the
8
+ room and releases the hardware.
9
+
10
+ Also runs a small local HTTP listener for RoboVisionAI_PI's motion webhook
11
+ (POST /api/motion/webhook target — see RoboVisionAI_PI's app_pi_clean.py).
12
+ On a motion event it calls the scheduler's presence-triggered
13
+ POST /api/devices/{id}/request-session and publishes into the returned room
14
+ using the same LiveKitPublisher as the operator preview — this is the
15
+ "scene detection" trigger point the scheduler's request-session docstring
16
+ describes; RoboVisionAI_PI supplies the detection, this agent supplies the
17
+ bridge to an actual session.
18
+
19
+ Configuration (env / ~/.robopark/preview_agent.json):
20
+ SCHEDULER_URL base URL of the RoboPark scheduler
21
+ ROBOT_ID robot identity in the scheduler (defaults to hostname)
22
+ DEVICE_TOKEN long-lived device token (after enrollment)
23
+ ENROLLMENT_TOKEN one-time enrollment token (device token will be saved)
24
+ VIDEO_DEVICE camera path/index/name (e.g. /dev/video0, 0, "PiCamera")
25
+ AUDIO_DEVICE microphone name/index or "default"
26
+ VIDEO_WIDTH / HEIGHT capture resolution (default 640x480)
27
+ VIDEO_FPS capture fps (default 15)
28
+ POLL_INTERVAL seconds between scheduler polls (default 3)
29
+ HEARTBEAT_INTERVAL seconds between device heartbeats (default 30)
30
+ VISION_WEBHOOK_PORT local port for the RoboVision motion webhook (default 5057, 0 disables)
31
+ VISION_TRIGGER_COOLDOWN min seconds between motion-triggered sessions (default 20)
32
+ VISION_SESSION_SECONDS HARD ceiling on how long a motion-triggered session can
33
+ hold the publisher, regardless of activity (default 90)
34
+ VISION_SILENCE_TIMEOUT how long a motion-triggered session may go without an
35
+ activity signal before it's ended early (default 15).
36
+ Activity is reported via POST /api/sessions/{id}/keepalive,
37
+ which this agent polls for via GET /api/sessions/{id}/status
38
+ on every poll tick. The actual caller of keepalive (the
39
+ voice agent, VAD-driven) lives in a different repo — see
40
+ main.py's keepalive endpoint docstring for the integration
41
+ contract. Without any caller ever hitting keepalive, a
42
+ vision session simply ends after VISION_SILENCE_TIMEOUT.
43
+ """
44
+ from __future__ import annotations
45
+
46
+ import argparse
47
+ import asyncio
48
+ import json
49
+ import logging
50
+ import os
51
+ import signal
52
+ import sys
53
+ import threading
54
+ import time
55
+ from dataclasses import dataclass
56
+ from datetime import datetime, timezone
57
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
58
+ from pathlib import Path
59
+ from typing import Optional
60
+
61
+ import httpx
62
+
63
+ logger = logging.getLogger("robopark.preview_agent")
64
+
65
+ CONFIG_DIR = Path.home() / ".robopark"
66
+ CONFIG_FILE = CONFIG_DIR / "preview_agent.json"
67
+ TOKEN_FILE = CONFIG_DIR / "device_token"
68
+
69
+ DEFAULT_VIDEO_WIDTH = 640
70
+ DEFAULT_VIDEO_HEIGHT = 480
71
+ DEFAULT_FPS = 15
72
+ DEFAULT_POLL_INTERVAL = 3.0
73
+ DEFAULT_HEARTBEAT_INTERVAL = 30.0
74
+ DEFAULT_VISION_WEBHOOK_PORT = 5057
75
+ DEFAULT_VISION_TRIGGER_COOLDOWN = 20.0
76
+ DEFAULT_VISION_SESSION_SECONDS = 90.0
77
+ # How long a motion-triggered session may go without an activity signal
78
+ # (POST /api/sessions/{id}/keepalive, called by the voice agent — a
79
+ # different repo, not this one) before preview_agent tears it down.
80
+ # vision_session_seconds remains a hard ceiling regardless of activity.
81
+ DEFAULT_VISION_SILENCE_TIMEOUT = 15.0
82
+
83
+
84
+ def _load_config() -> dict:
85
+ cfg: dict = {}
86
+ if CONFIG_FILE.exists():
87
+ try:
88
+ cfg = json.loads(CONFIG_FILE.read_text(encoding="utf8"))
89
+ except Exception as e:
90
+ logger.warning(f"failed to read {CONFIG_FILE}: {e}")
91
+ return cfg
92
+
93
+
94
+ def _save_config(cfg: dict) -> None:
95
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
96
+ CONFIG_FILE.write_text(json.dumps(cfg, indent=2), encoding="utf8")
97
+
98
+
99
+ def _load_token() -> Optional[str]:
100
+ if TOKEN_FILE.exists():
101
+ return TOKEN_FILE.read_text(encoding="utf8").strip() or None
102
+ return None
103
+
104
+
105
+ def _save_token(token: str) -> None:
106
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
107
+ TOKEN_FILE.write_text(token, encoding="utf8")
108
+ os.chmod(TOKEN_FILE, 0o600)
109
+
110
+
111
+ async def _enroll(scheduler_url: str, enrollment_token: str, robot_id: str) -> str:
112
+ """Enroll this Pi with the scheduler and return the device token."""
113
+ import socket
114
+ payload = {
115
+ "enrollment_token": enrollment_token,
116
+ "name": robot_id,
117
+ "lan_ip": _get_lan_ip(),
118
+ }
119
+ async with httpx.AsyncClient() as client:
120
+ r = await client.post(
121
+ f"{scheduler_url.rstrip('/')}/api/devices/enroll",
122
+ json=payload,
123
+ timeout=30.0,
124
+ )
125
+ r.raise_for_status()
126
+ data = r.json()
127
+ device_token = data.get("device_token")
128
+ if not device_token:
129
+ raise RuntimeError("enrollment did not return a device_token")
130
+ _save_token(device_token)
131
+ cfg = _load_config()
132
+ cfg["device_id"] = data.get("device_id")
133
+ cfg["scheduler_url"] = data.get("scheduler_url", scheduler_url)
134
+ _save_config(cfg)
135
+ logger.info(f"enrolled as device {data.get('device_id')}")
136
+ return device_token
137
+
138
+
139
+ def _get_lan_ip() -> Optional[str]:
140
+ try:
141
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
142
+ s.settimeout(0.5)
143
+ s.connect(("10.255.255.255", 1))
144
+ ip = s.getsockname()[0]
145
+ s.close()
146
+ return ip
147
+ except Exception:
148
+ return None
149
+
150
+
151
+ async def _send_heartbeat(scheduler_url: str, device_id: str, token: str) -> None:
152
+ try:
153
+ async with httpx.AsyncClient() as client:
154
+ await client.post(
155
+ f"{scheduler_url.rstrip('/')}/api/devices/{device_id}/heartbeat",
156
+ json={"status": "online", "ip": _get_lan_ip()},
157
+ headers={"Authorization": f"Bearer {token}"},
158
+ timeout=10.0,
159
+ )
160
+ except Exception as e:
161
+ logger.debug(f"heartbeat failed: {e}")
162
+
163
+
164
+ @dataclass
165
+ class PreviewState:
166
+ active: bool = False
167
+ url: Optional[str] = None
168
+ token: Optional[str] = None
169
+ room: Optional[str] = None
170
+
171
+
172
+ class PreviewAgent:
173
+ def __init__(self, cfg: dict):
174
+ self.scheduler_url = cfg.get("scheduler_url", os.getenv("SCHEDULER_URL", "http://localhost:8080"))
175
+ self.robot_id = cfg.get("robot_id", os.getenv("ROBOT_ID", _hostname()))
176
+ self.device_token: Optional[str] = cfg.get("device_token") or os.getenv("DEVICE_TOKEN") or _load_token()
177
+ self.device_id: Optional[str] = cfg.get("device_id")
178
+ self.enrollment_token: Optional[str] = cfg.get("enrollment_token")
179
+ self.video_device = cfg.get("video_device", os.getenv("VIDEO_DEVICE", "auto"))
180
+ self.audio_device = cfg.get("audio_device", os.getenv("AUDIO_DEVICE", "default"))
181
+ self.width = int(cfg.get("video_width", os.getenv("VIDEO_WIDTH", DEFAULT_VIDEO_WIDTH)))
182
+ self.height = int(cfg.get("video_height", os.getenv("VIDEO_HEIGHT", DEFAULT_VIDEO_HEIGHT)))
183
+ self.fps = int(cfg.get("video_fps", os.getenv("VIDEO_FPS", DEFAULT_FPS)))
184
+ self.poll_interval = float(cfg.get("poll_interval", os.getenv("POLL_INTERVAL", DEFAULT_POLL_INTERVAL)))
185
+ self.heartbeat_interval = float(cfg.get("heartbeat_interval", os.getenv("HEARTBEAT_INTERVAL", DEFAULT_HEARTBEAT_INTERVAL)))
186
+ self.vision_webhook_port = int(cfg.get("vision_webhook_port", os.getenv("VISION_WEBHOOK_PORT", DEFAULT_VISION_WEBHOOK_PORT)))
187
+ self.vision_trigger_cooldown = float(cfg.get("vision_trigger_cooldown", os.getenv("VISION_TRIGGER_COOLDOWN", DEFAULT_VISION_TRIGGER_COOLDOWN)))
188
+ self.vision_session_seconds = float(cfg.get("vision_session_seconds", os.getenv("VISION_SESSION_SECONDS", DEFAULT_VISION_SESSION_SECONDS)))
189
+ self.vision_silence_timeout = float(cfg.get("vision_silence_timeout", os.getenv("VISION_SILENCE_TIMEOUT", DEFAULT_VISION_SILENCE_TIMEOUT)))
190
+
191
+ self._shutdown = asyncio.Event()
192
+ self._task: Optional[asyncio.Task] = None
193
+ self._session: Optional[httpx.AsyncClient] = None
194
+ self._current_state: PreviewState = PreviewState()
195
+ self._publisher: Optional["LiveKitPublisher"] = None
196
+ self._loop: Optional[asyncio.AbstractEventLoop] = None
197
+ self._vision_server: Optional[ThreadingHTTPServer] = None
198
+ self._last_vision_trigger: float = 0.0
199
+ # Motion-triggered ("vision") session bookkeeping. Silence-timeout
200
+ # with a hard ceilingsee DEFAULT_VISION_SILENCE_TIMEOUT above and
201
+ # _check_vision_session() below for the full design.
202
+ self._vision_session_id: Optional[str] = None
203
+ self._vision_hard_deadline: float = 0.0
204
+ self._vision_last_activity: float = 0.0
205
+
206
+ async def run(self) -> None:
207
+ enrollment_token = os.getenv("ENROLLMENT_TOKEN") or self.enrollment_token
208
+ if not self.device_token and enrollment_token:
209
+ self.device_token = await _enroll(self.scheduler_url, enrollment_token, self.robot_id)
210
+
211
+ if not self.device_token:
212
+ logger.error("no DEVICE_TOKEN and no ENROLLMENT_TOKEN; cannot poll scheduler")
213
+ sys.exit(1)
214
+
215
+ self._session = httpx.AsyncClient()
216
+
217
+ # Must come after self._session exists — _resolve_device_id() guards
218
+ # on it and silently no-ops otherwise. On a fresh enroll (no cached
219
+ # device_id in ~/.robopark/preview_agent.json) that made this ALWAYS
220
+ # no-op, so device_id never resolved and preview/agent polling fell
221
+ # back to robot_id (the display name) instead of the real device_id
222
+ # for the entire session — the same id-mismatch bug fixed elsewhere,
223
+ # recurring here only on a first-ever enroll.
224
+ if not self.device_id:
225
+ await self._resolve_device_id()
226
+
227
+ self._loop = asyncio.get_running_loop()
228
+ self._start_vision_webhook_server()
229
+
230
+ tasks = [
231
+ asyncio.create_task(self._poll_loop()),
232
+ asyncio.create_task(self._heartbeat_loop()),
233
+ ]
234
+ await self._shutdown.wait()
235
+ for t in tasks:
236
+ t.cancel()
237
+ try:
238
+ await t
239
+ except asyncio.CancelledError:
240
+ pass
241
+ if self._vision_server:
242
+ self._vision_server.shutdown()
243
+ await self._stop_publisher()
244
+ await self._session.aclose()
245
+
246
+ async def _resolve_device_id(self) -> None:
247
+ """Look up our device_id from the scheduler using the token."""
248
+ if not self._session or not self.device_token:
249
+ return
250
+ try:
251
+ r = await self._session.get(
252
+ f"{self.scheduler_url.rstrip('/')}/api/devices",
253
+ headers={"Authorization": f"Bearer {self.device_token}"},
254
+ timeout=10.0,
255
+ )
256
+ r.raise_for_status()
257
+ for d in r.json():
258
+ if d.get("name") == self.robot_id:
259
+ self.device_id = d.get("id")
260
+ cfg = _load_config()
261
+ cfg["device_id"] = self.device_id
262
+ _save_config(cfg)
263
+ return
264
+ except Exception as e:
265
+ logger.warning(f"could not resolve device_id: {e}")
266
+
267
+ async def _poll_loop(self) -> None:
268
+ while not self._shutdown.is_set():
269
+ try:
270
+ if self._vision_session_id:
271
+ # A vision-triggered session currently owns the publisher.
272
+ # Decide whether it's still "active" (silence timeout vs.
273
+ # hard ceiling) instead of letting the unrelated
274
+ # operator-preview state tear it down.
275
+ await self._check_vision_session()
276
+ if not self._vision_session_id:
277
+ state = await self._fetch_preview_state()
278
+ await self._apply_state(state)
279
+ except Exception as e:
280
+ logger.warning(f"poll error: {e}")
281
+ try:
282
+ await asyncio.wait_for(self._shutdown.wait(), timeout=self.poll_interval)
283
+ except asyncio.TimeoutError:
284
+ pass
285
+
286
+ async def _check_vision_session(self) -> None:
287
+ """Decide whether the active motion-triggered session should keep
288
+ holding the publisher.
289
+
290
+ Two limits apply, whichever comes first:
291
+ 1. HARD ceiling — vision_session_seconds after the session started.
292
+ Enforced locally, no scheduler round-trip needed. Guarantees a
293
+ stuck/misbehaving caller can never hold the publisher forever.
294
+ 2. SILENCE timeout vision_silence_timeout since last_activity_at
295
+ last moved. last_activity_at lives on the scheduler and is only
296
+ ever bumped by POST /api/sessions/{id}/keepalive — a call this
297
+ agent does NOT make itself. In production that call is made by
298
+ the VOICE AGENT (a separate process/repo, VAD-driven), which is
299
+ the only thing that actually knows whether the user/agent are
300
+ still talking. We poll GET /api/sessions/{id}/status here every
301
+ poll_interval to pull the latest value.
302
+
303
+ If nothing ever calls keepalive, last_activity_at never advances past
304
+ started_at and the session ends after vision_silence_timeout — a safe
305
+ default, not a malfunction. Wiring the voice agent to call keepalive
306
+ is required to make this genuinely silence-aware; that integration is
307
+ out of scope for this repo.
308
+ """
309
+ now = time.time()
310
+ if now >= self._vision_hard_deadline:
311
+ logger.info("vision session hit its hard ceiling (vision_session_seconds) ending")
312
+ await self._end_vision_session()
313
+ return
314
+
315
+ try:
316
+ last_activity = await self._fetch_session_last_activity(self._vision_session_id)
317
+ if last_activity is not None and last_activity > self._vision_last_activity:
318
+ self._vision_last_activity = last_activity
319
+ except Exception as e:
320
+ logger.debug(f"could not refresh session activity: {e}")
321
+
322
+ if now - self._vision_last_activity >= self.vision_silence_timeout:
323
+ logger.info(
324
+ f"vision session silent for {now - self._vision_last_activity:.0f}s "
325
+ f"(>= {self.vision_silence_timeout}s timeout) — ending"
326
+ )
327
+ await self._end_vision_session()
328
+
329
+ async def _fetch_session_last_activity(self, session_id: str) -> Optional[float]:
330
+ """GET /api/sessions/{id}/status and return last_activity_at as a Unix
331
+ timestamp, or None if unavailable. Naive UTC ISO strings (as written
332
+ by main.py's datetime.utcnow().isoformat()) are interpreted as UTC
333
+ explicitly — treating them as local time would silently skew the
334
+ silence calculation on any host not running in UTC."""
335
+ if not self._session or not self.device_token:
336
+ return None
337
+ r = await self._session.get(
338
+ f"{self.scheduler_url.rstrip('/')}/api/sessions/{session_id}/status",
339
+ headers={"Authorization": f"Bearer {self.device_token}"},
340
+ timeout=10.0,
341
+ )
342
+ r.raise_for_status()
343
+ data = r.json()
344
+ ts = data.get("last_activity_at") or data.get("started_at")
345
+ if not ts:
346
+ return None
347
+ dt = datetime.fromisoformat(ts)
348
+ if dt.tzinfo is None:
349
+ dt = dt.replace(tzinfo=timezone.utc)
350
+ return dt.timestamp()
351
+
352
+ async def _end_vision_session(self) -> None:
353
+ self._vision_session_id = None
354
+ self._vision_hard_deadline = 0.0
355
+ self._vision_last_activity = 0.0
356
+ await self._stop_publisher()
357
+ self._current_state = PreviewState()
358
+
359
+ async def _heartbeat_loop(self) -> None:
360
+ while not self._shutdown.is_set():
361
+ if self.device_id and self.device_token:
362
+ await _send_heartbeat(self.scheduler_url, self.device_id, self.device_token)
363
+ try:
364
+ await asyncio.wait_for(self._shutdown.wait(), timeout=self.heartbeat_interval)
365
+ except asyncio.TimeoutError:
366
+ pass
367
+
368
+ async def _fetch_preview_state(self) -> PreviewState:
369
+ if not self._session or not self.device_token:
370
+ return PreviewState()
371
+ r = await self._session.get(
372
+ f"{self.scheduler_url.rstrip('/')}/api/robots/{self.device_id or self.robot_id}/preview/agent",
373
+ headers={"Authorization": f"Bearer {self.device_token}"},
374
+ timeout=10.0,
375
+ )
376
+ r.raise_for_status()
377
+ data = r.json()
378
+ if not data.get("active"):
379
+ return PreviewState()
380
+ return PreviewState(
381
+ active=True,
382
+ url=data.get("url"),
383
+ token=data.get("token"),
384
+ room=data.get("room"),
385
+ )
386
+
387
+ async def _apply_state(self, state: PreviewState) -> None:
388
+ same = (
389
+ state.active == self._current_state.active
390
+ and state.room == self._current_state.room
391
+ and state.token == self._current_state.token
392
+ and state.url == self._current_state.url
393
+ )
394
+ if same:
395
+ return
396
+ self._current_state = state
397
+ if not state.active:
398
+ await self._stop_publisher()
399
+ return
400
+ await self._start_publisher(state)
401
+
402
+ async def _start_publisher(self, state: PreviewState) -> None:
403
+ await self._stop_publisher()
404
+ if not state.url or not state.token or not state.room:
405
+ return
406
+ try:
407
+ pub = LiveKitPublisher(state.url, state.token, state.room, self)
408
+ await pub.start()
409
+ self._publisher = pub
410
+ logger.info(f"joined preview room {state.room}")
411
+ except Exception as e:
412
+ logger.error(f"failed to start publisher: {e}")
413
+
414
+ async def _stop_publisher(self) -> None:
415
+ if self._publisher:
416
+ try:
417
+ await self._publisher.stop()
418
+ except Exception as e:
419
+ logger.warning(f"publisher stop error: {e}")
420
+ self._publisher = None
421
+
422
+ # ---- vision (RoboVisionAI_PI motion webhook) -> presence-triggered session ----
423
+
424
+ def _start_vision_webhook_server(self) -> None:
425
+ if not self.vision_webhook_port:
426
+ return
427
+ agent = self
428
+
429
+ class Handler(BaseHTTPRequestHandler):
430
+ def log_message(self, fmt, *a): # noqa: A002 — quiet by default
431
+ logger.debug("vision webhook: " + fmt, *a)
432
+
433
+ def do_POST(self): # noqa: N802 — http.server's required method name
434
+ try:
435
+ length = int(self.headers.get("Content-Length", 0))
436
+ body = self.rfile.read(length) if length else b"{}"
437
+ payload = json.loads(body or b"{}")
438
+ except Exception as e:
439
+ self.send_response(400)
440
+ self.end_headers()
441
+ self.wfile.write(str(e).encode())
442
+ return
443
+ self.send_response(200)
444
+ self.end_headers()
445
+ self.wfile.write(b'{"ok":true}')
446
+ if agent._loop:
447
+ asyncio.run_coroutine_threadsafe(agent._on_vision_motion(payload), agent._loop)
448
+
449
+ def do_GET(self): # noqa: N802
450
+ self.send_response(200)
451
+ self.end_headers()
452
+ self.wfile.write(b'{"status":"ok","listening_for":"robovision motion webhook"}')
453
+
454
+ try:
455
+ self._vision_server = ThreadingHTTPServer(("0.0.0.0", self.vision_webhook_port), Handler)
456
+ thread = threading.Thread(target=self._vision_server.serve_forever, daemon=True)
457
+ thread.start()
458
+ logger.info(
459
+ f"vision webhook listening on :{self.vision_webhook_port} — "
460
+ f"point RoboVisionAI_PI's POST /api/motion/webhook at "
461
+ f"http://<this-host>:{self.vision_webhook_port}/"
462
+ )
463
+ except Exception as e:
464
+ logger.error(f"could not start vision webhook server: {e}")
465
+ self._vision_server = None
466
+
467
+ async def _on_vision_motion(self, payload: dict) -> None:
468
+ now = time.time()
469
+ if now - self._last_vision_trigger < self.vision_trigger_cooldown:
470
+ logger.debug("vision trigger suppressed (cooldown)")
471
+ return
472
+ self._last_vision_trigger = now
473
+ if not self.device_id or not self.device_token or not self._session:
474
+ logger.warning("vision motion event received but not enrolled yet — ignoring")
475
+ return
476
+ logger.info("motion detected by RoboVisionAI_PI — requesting a session")
477
+ try:
478
+ r = await self._session.post(
479
+ f"{self.scheduler_url.rstrip('/')}/api/devices/{self.device_id}/request-session",
480
+ headers={"Authorization": f"Bearer {self.device_token}"},
481
+ timeout=10.0,
482
+ )
483
+ r.raise_for_status()
484
+ data = r.json()
485
+ except Exception as e:
486
+ logger.error(f"request-session failed: {e}")
487
+ return
488
+ state = PreviewState(
489
+ active=True,
490
+ url=data.get("server_url"),
491
+ token=data.get("token"),
492
+ room=data.get("room_name"),
493
+ )
494
+ session_id = data.get("session_id")
495
+ now = time.time()
496
+ self._vision_session_id = session_id
497
+ self._vision_hard_deadline = now + self.vision_session_seconds
498
+ self._vision_last_activity = now
499
+ self._current_state = state
500
+ await self._start_publisher(state)
501
+ if session_id and self._session:
502
+ try:
503
+ await self._session.post(
504
+ f"{self.scheduler_url.rstrip('/')}/api/sessions/{session_id}/joined",
505
+ headers={"Authorization": f"Bearer {self.device_token}"},
506
+ timeout=10.0,
507
+ )
508
+ except Exception as e:
509
+ logger.debug(f"could not mark session joined: {e}")
510
+
511
+ def shutdown(self) -> None:
512
+ self._shutdown.set()
513
+
514
+
515
+ class LiveKitPublisher:
516
+ """Encapsulates LiveKit room connection, camera capture and mic capture."""
517
+
518
+ def __init__(self, url: str, token: str, room_name: str, agent: PreviewAgent):
519
+ from livekit import rtc
520
+ self.url = url
521
+ self.token = token
522
+ self.room_name = room_name
523
+ self.agent = agent
524
+ self.rtc = rtc
525
+ self.room: Optional[rtc.Room] = None
526
+ self.video_source: Optional[rtc.VideoSource] = None
527
+ self.video_track: Optional[rtc.LocalVideoTrack] = None
528
+ self.audio_source: Optional[rtc.AudioSource] = None
529
+ self.audio_track: Optional[rtc.LocalAudioTrack] = None
530
+ self._stop_event = asyncio.Event()
531
+ self._tasks: list[asyncio.Task] = []
532
+ self._capture: Optional["VideoCapture"] = None
533
+
534
+ async def start(self) -> None:
535
+ self.room = self.rtc.Room()
536
+ await self.room.connect(self.url, self.token)
537
+
538
+ # Video
539
+ self.video_source = self.rtc.VideoSource(self.agent.width, self.agent.height)
540
+ self.video_track = self.rtc.LocalVideoTrack.create_video_track("camera", self.video_source)
541
+ vopts = self.rtc.TrackPublishOptions()
542
+ vopts.source = self.rtc.TrackSource.SOURCE_CAMERA
543
+ await self.room.local_participant.publish_track(self.video_track, vopts)
544
+
545
+ # Audio
546
+ self.audio_source = self.rtc.AudioSource(48000, 1)
547
+ self.audio_track = self.rtc.LocalAudioTrack.create_audio_track("microphone", self.audio_source)
548
+ aopts = self.rtc.TrackPublishOptions()
549
+ aopts.source = self.rtc.TrackSource.SOURCE_MICROPHONE
550
+ await self.room.local_participant.publish_track(self.audio_track, aopts)
551
+
552
+ # Register speaker playback (subscribe to the voice agent's TTS audio
553
+ # track) BEFORE opening the camera. Camera open is a slow/occasionally
554
+ # hanging blocking call (see create_video_capture below), and since
555
+ # asyncio is single-threaded, running it inline here would stall the
556
+ # entire event loop — including receiving the agent's greeting audio
557
+ # until it finished, so a short "Hello friend!" greeting could be
558
+ # over and gone before we ever got a chance to subscribe to it.
559
+ self._playback_streams: dict = {}
560
+
561
+ def _on_track_subscribed(track, publication, participant):
562
+ logger.info(f"track_subscribed: kind={track.kind} from={participant.identity} sid={publication.sid}")
563
+ if track.kind != self.rtc.TrackKind.KIND_AUDIO:
564
+ return
565
+ if participant.identity == self.room.local_participant.identity:
566
+ return # don't play our own mic back
567
+ self._tasks.append(asyncio.create_task(self._play_remote_audio(track, publication.sid)))
568
+
569
+ self.room.on("track_subscribed", _on_track_subscribed)
570
+ logger.info("audio out: track_subscribed listener registered")
571
+
572
+ if has_audio():
573
+ self._tasks.append(asyncio.create_task(self._audio_loop()))
574
+
575
+ # Camera open can block for a long time (flaky USB/driver reopen) —
576
+ # run it in a thread so it can't stall the event loop (and therefore
577
+ # incoming agent audio / heartbeats / the playback subscribed above).
578
+ self._capture = await asyncio.to_thread(
579
+ create_video_capture, self.agent.video_device, self.agent.width, self.agent.height, self.agent.fps
580
+ )
581
+ if self._capture:
582
+ self._tasks.append(asyncio.create_task(self._video_loop()))
583
+
584
+ async def _play_remote_audio(self, track, sid: str) -> None:
585
+ try:
586
+ import pyaudio
587
+ except Exception as e:
588
+ logger.warning(f"pyaudio unavailable for playback: {e}")
589
+ return
590
+ OUT_RATE = 48000
591
+ pa = pyaudio.PyAudio()
592
+ out = pa.open(format=pyaudio.paInt16, channels=1, rate=OUT_RATE, output=True)
593
+ logger.info(f"audio out: opened playback stream for {sid}")
594
+ frame_count = 0
595
+ try:
596
+ stream = self.rtc.AudioStream(track=track)
597
+ async for frame in stream:
598
+ af = frame.frame if hasattr(frame, "frame") else frame
599
+ data = bytes(af.data)
600
+ frame_count += 1
601
+ if frame_count == 1:
602
+ logger.info(f"audio out: first frame received for {sid} (rate={af.sample_rate})")
603
+ if af.sample_rate != OUT_RATE:
604
+ # Frames from Kokoro/TTS are already 48kHz in this stack;
605
+ # a mismatch here would need resampling like the Pi
606
+ # client does, but isn't expected on the local test loop.
607
+ logger.debug(
608
+ f"audio out: unexpected sample rate {af.sample_rate}, expected {OUT_RATE}"
609
+ )
610
+ out.write(data)
611
+ except Exception as e:
612
+ logger.warning(f"audio out stream error: {e}")
613
+ finally:
614
+ out.stop_stream()
615
+ out.close()
616
+ pa.terminate()
617
+
618
+ async def stop(self) -> None:
619
+ self._stop_event.set()
620
+ for t in self._tasks:
621
+ t.cancel()
622
+ try:
623
+ await t
624
+ except asyncio.CancelledError:
625
+ pass
626
+ self._tasks.clear()
627
+ if self._capture:
628
+ self._capture.stop()
629
+ self._capture = None
630
+ if self.room:
631
+ await self.room.disconnect()
632
+ self.room = None
633
+
634
+ async def _video_loop(self) -> None:
635
+ assert self._capture is not None and self.video_source is not None
636
+ frame_interval = 1.0 / self.agent.fps
637
+ while not self._stop_event.is_set():
638
+ start = time.monotonic()
639
+ # self._capture.read() is a synchronous, occasionally slow cv2
640
+ # call (same USB/driver flakiness as the initial camera open).
641
+ # Calling it inline here blocks the whole single-threaded event
642
+ # loop every ~66ms, starving the mic audio loop running on the
643
+ # same loop — audio throughput was observed collapsing from
644
+ # ~25 frames/sec to ~1 frame/sec whenever this stalled.
645
+ frame = await asyncio.to_thread(self._capture.read)
646
+ if frame is not None:
647
+ self.video_source.capture_frame(frame)
648
+ elapsed = time.monotonic() - start
649
+ sleep_for = frame_interval - elapsed
650
+ if sleep_for > 0:
651
+ try:
652
+ await asyncio.wait_for(self._stop_event.wait(), timeout=sleep_for)
653
+ except asyncio.TimeoutError:
654
+ pass
655
+
656
+ async def _audio_loop(self) -> None:
657
+ assert self.audio_source is not None
658
+ mic = create_audio_capture(self.agent.audio_device)
659
+ if mic is None:
660
+ logger.warning("audio_loop: create_audio_capture returned None, mic will not publish")
661
+ return
662
+ logger.info(f"audio_loop: mic capture started ({type(mic).__name__})")
663
+ # Read in bigger batches (100ms) instead of one 20ms frame per
664
+ # asyncio.to_thread() dispatch. Each dispatch/poll cycle has a fixed
665
+ # overhead (~30ms observed on this machine) that dominates when the
666
+ # payload itself is only 20ms — real capture fell to ~11 real
667
+ # frames/sec against a 50/sec target, and the resulting audio was so
668
+ # gappy (mostly *missing* frames, not silence) that the agent's VAD
669
+ # never saw enough continuous signal to trigger, even with loud,
670
+ # close-mic speech landing clean peaks in the frames that did arrive.
671
+ # Batching amortizes that overhead over 5 frames per dispatch.
672
+ BATCH_MS = 100
673
+ frame_ms = 20
674
+ samples_per_frame = int(48000 * frame_ms / 1000)
675
+ samples_per_batch = int(48000 * BATCH_MS / 1000)
676
+ bytes_per_frame = samples_per_frame * 2 # int16 mono
677
+ import audioop
678
+ _diag_peak = 0
679
+ _diag_count = 0
680
+ _diag_last_log = time.monotonic()
681
+ _diag_read_ms = 0.0
682
+ _diag_publish_ms = 0.0
683
+ batch_interval = BATCH_MS / 1000
684
+ while not self._stop_event.is_set():
685
+ _iter_start = time.monotonic()
686
+ _t0 = time.monotonic()
687
+ batch = await asyncio.to_thread(mic.read, samples_per_batch)
688
+ _t1 = time.monotonic()
689
+ if batch is not None:
690
+ data = bytes(batch.data)
691
+ _pub_start = time.monotonic()
692
+ for off in range(0, len(data) - bytes_per_frame + 1, bytes_per_frame):
693
+ chunk = data[off:off + bytes_per_frame]
694
+ frame = self.rtc.AudioFrame(
695
+ data=chunk, sample_rate=48000, num_channels=1, samples_per_channel=samples_per_frame,
696
+ )
697
+ await self.audio_source.capture_frame(frame)
698
+ p = audioop.max(chunk, 2)
699
+ _diag_peak = max(_diag_peak, p)
700
+ _diag_count += 1
701
+ _diag_read_ms += (_t1 - _t0) * 1000
702
+ _diag_publish_ms += (time.monotonic() - _pub_start) * 1000
703
+ if time.monotonic() - _diag_last_log > 1.0:
704
+ logger.info(f"audio_loop: {_diag_count} frames sent in last 1s, peak={_diag_peak}/32767, read={_diag_read_ms:.0f}ms, publish={_diag_publish_ms:.0f}ms")
705
+ _diag_peak = 0
706
+ _diag_count = 0
707
+ _diag_read_ms = 0.0
708
+ _diag_publish_ms = 0.0
709
+ _diag_last_log = time.monotonic()
710
+ # Pace to real wall-clock time per batch. mic.read()'s own
711
+ # buffer-polling wait is not a reliable substitute — a driver
712
+ # that briefly double-buffers can let it return a full batch
713
+ # almost instantly, and capture_frame() (which expects real-time
714
+ # delivery, blocking internally if fed faster) then accumulates
715
+ # backpressure that snowballs into multi-second stalls per call.
716
+ elapsed = time.monotonic() - _iter_start
717
+ sleep_for = batch_interval - elapsed
718
+ if sleep_for > 0:
719
+ try:
720
+ await asyncio.wait_for(self._stop_event.wait(), timeout=sleep_for)
721
+ except asyncio.TimeoutError:
722
+ pass
723
+
724
+
725
+ # -----------------------------------------------------------------------------
726
+ # Video capture abstraction: V4L2/USB via OpenCV, or picamera2 on Pi.
727
+ # -----------------------------------------------------------------------------
728
+
729
+ class VideoCapture:
730
+ def read(self) -> Optional["rtc.VideoFrame"]:
731
+ raise NotImplementedError
732
+
733
+ def stop(self) -> None:
734
+ raise NotImplementedError
735
+
736
+
737
+ class OpencvVideoCapture(VideoCapture):
738
+ def __init__(self, device: int | str, width: int, height: int, fps: int):
739
+ import cv2
740
+ self.cv2 = cv2
741
+ if isinstance(device, str) and device.startswith("/dev/video"):
742
+ device = int(device.replace("/dev/video", ""))
743
+ self.cap = cv2.VideoCapture(device)
744
+ self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, width)
745
+ self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height)
746
+ self.cap.set(cv2.CAP_PROP_FPS, fps)
747
+ self._ensure_frame()
748
+
749
+ def _ensure_frame(self):
750
+ ok, _ = self.cap.read()
751
+ if not ok:
752
+ raise RuntimeError("cannot read from camera")
753
+
754
+ def read(self):
755
+ from livekit import rtc
756
+ ok, bgr = self.cap.read()
757
+ if not ok or bgr is None:
758
+ return None
759
+ return rtc.VideoFrame(
760
+ width=bgr.shape[1],
761
+ height=bgr.shape[0],
762
+ type=rtc.VideoBufferType.BGR,
763
+ data=bgr.tobytes(),
764
+ )
765
+
766
+ def stop(self):
767
+ self.cap.release()
768
+
769
+
770
+ class Picamera2Capture(VideoCapture):
771
+ def __init__(self, width: int, height: int, fps: int):
772
+ from picamera2 import Picamera2
773
+ self.picam = Picamera2()
774
+ config = self.picam.create_video_configuration(
775
+ main={"format": "RGB888", "size": (width, height)},
776
+ controls={"FrameRate": fps},
777
+ )
778
+ self.picam.configure(config)
779
+ self.picam.start()
780
+ self.width = width
781
+ self.height = height
782
+
783
+ def read(self):
784
+ from livekit import rtc
785
+ arr = self.picam.capture_array()
786
+ if arr is None:
787
+ return None
788
+ return rtc.VideoFrame(
789
+ width=self.width,
790
+ height=self.height,
791
+ type=rtc.VideoBufferType.RGB,
792
+ data=arr.tobytes(),
793
+ )
794
+
795
+ def stop(self):
796
+ try:
797
+ self.picam.stop()
798
+ except Exception:
799
+ pass
800
+
801
+
802
+ def create_video_capture(device: str, width: int, height: int, fps: int) -> Optional[VideoCapture]:
803
+ if device.lower() in ("none", "", "false", "null"):
804
+ return None
805
+ try:
806
+ from livekit import rtc
807
+ _ = rtc.VideoSource
808
+ except Exception as e:
809
+ logger.error(f"livekit python sdk not installed: {e}")
810
+ return None
811
+
812
+ # Auto-detect: prefer first V4L2 device, fall back to picamera2 if available.
813
+ if device.lower() in ("auto", "default", "first"):
814
+ for i in range(4):
815
+ try:
816
+ cap = OpencvVideoCapture(i, width, height, fps)
817
+ logger.info(f"auto-selected USB camera /dev/video{i}")
818
+ return cap
819
+ except Exception:
820
+ continue
821
+ try:
822
+ cap = Picamera2Capture(width, height, fps)
823
+ logger.info("auto-selected Pi Camera")
824
+ return cap
825
+ except Exception:
826
+ pass
827
+ logger.error("no camera found (tried V4L2 and picamera2)")
828
+ return None
829
+
830
+ if device.lower() in ("picamera", "picamera2", "pi", "rpi"):
831
+ return Picamera2Capture(width, height, fps)
832
+
833
+ # Treat as V4L2 index or path
834
+ return OpencvVideoCapture(device, width, height, fps)
835
+
836
+
837
+ # -----------------------------------------------------------------------------
838
+ # Audio capture abstraction: PyAudio or sounddevice -> LiveKit AudioFrame.
839
+ # -----------------------------------------------------------------------------
840
+
841
+ class AudioCapture:
842
+ def read(self, samples_per_frame: int) -> Optional["rtc.AudioFrame"]:
843
+ raise NotImplementedError
844
+
845
+ def stop(self) -> None:
846
+ raise NotImplementedError
847
+
848
+
849
+ class PyAudioCapture(AudioCapture):
850
+ def __init__(self, device: str | int | None):
851
+ import pyaudio
852
+ self.pa = pyaudio.PyAudio()
853
+ self.device_index = self._resolve_device(device)
854
+ self.buffer = bytearray()
855
+ self.stream = self.pa.open(
856
+ format=pyaudio.paInt16,
857
+ channels=1,
858
+ rate=48000,
859
+ input=True,
860
+ input_device_index=self.device_index,
861
+ frames_per_buffer=960,
862
+ stream_callback=self._callback,
863
+ )
864
+ self.stream.start_stream()
865
+
866
+ def _resolve_device(self, device: str | int | None) -> Optional[int]:
867
+ if isinstance(device, int):
868
+ return device
869
+ if device is None or device == "default":
870
+ # PyAudio's global default resolves through the MME host API,
871
+ # which on Windows can silently capture pure silence (no error,
872
+ # no exception — the stream just never has any real signal in
873
+ # it) even though the same physical mic works fine everywhere
874
+ # else, including Windows' own input meter. WASAPI is the API
875
+ # actually backing that meter, so prefer its default input.
876
+ import pyaudio
877
+ try:
878
+ wasapi = self.pa.get_host_api_info_by_type(pyaudio.paWASAPI)
879
+ idx = wasapi.get("defaultInputDevice")
880
+ if idx is not None and idx >= 0:
881
+ return idx
882
+ except Exception:
883
+ pass
884
+ return None
885
+ # Try exact name match
886
+ for i in range(self.pa.get_device_count()):
887
+ info = self.pa.get_device_info_by_index(i)
888
+ if info.get("maxInputChannels", 0) > 0 and device.lower() in str(info.get("name", "")).lower():
889
+ return i
890
+ return None
891
+
892
+ def _callback(self, in_data, frame_count, time_info, status):
893
+ import pyaudio
894
+ self.buffer.extend(in_data)
895
+ return (None, pyaudio.paContinue)
896
+
897
+ def read(self, samples_per_frame: int):
898
+ from livekit import rtc
899
+ bytes_needed = samples_per_frame * 2 # int16 mono
900
+ while len(self.buffer) < bytes_needed:
901
+ time.sleep(0.005)
902
+ chunk = bytes(self.buffer[:bytes_needed])
903
+ self.buffer = self.buffer[bytes_needed:]
904
+ return rtc.AudioFrame(
905
+ data=chunk,
906
+ sample_rate=48000,
907
+ num_channels=1,
908
+ samples_per_channel=samples_per_frame,
909
+ )
910
+
911
+ def stop(self):
912
+ if self.stream:
913
+ self.stream.stop_stream()
914
+ self.stream.close()
915
+ self.pa.terminate()
916
+
917
+
918
+ def has_audio() -> bool:
919
+ try:
920
+ import pyaudio # noqa: F401
921
+ return True
922
+ except Exception:
923
+ return False
924
+
925
+
926
+ def create_audio_capture(device: str) -> Optional[AudioCapture]:
927
+ if device.lower() in ("none", "", "false", "null"):
928
+ return None
929
+ try:
930
+ import pyaudio # noqa: F401
931
+ return PyAudioCapture(device if device != "default" else None)
932
+ except Exception as e:
933
+ logger.warning(f"pyaudio not available: {e}")
934
+ return None
935
+
936
+
937
+ def _hostname() -> str:
938
+ import socket
939
+ return socket.gethostname().split(".")[0]
940
+
941
+
942
+ def main() -> None:
943
+ parser = argparse.ArgumentParser(description="RoboPark preview agent")
944
+ parser.add_argument("--scheduler-url", default=os.getenv("SCHEDULER_URL", "http://localhost:8080"))
945
+ parser.add_argument("--robot-id", default=os.getenv("ROBOT_ID", _hostname()))
946
+ parser.add_argument("--device-token", default=os.getenv("DEVICE_TOKEN"))
947
+ parser.add_argument("--enrollment-token", default=os.getenv("ENROLLMENT_TOKEN"))
948
+ parser.add_argument("--video-device", default=os.getenv("VIDEO_DEVICE", "auto"))
949
+ parser.add_argument("--audio-device", default=os.getenv("AUDIO_DEVICE", "default"))
950
+ parser.add_argument("--width", type=int, default=int(os.getenv("VIDEO_WIDTH", DEFAULT_VIDEO_WIDTH)))
951
+ parser.add_argument("--height", type=int, default=int(os.getenv("VIDEO_HEIGHT", DEFAULT_VIDEO_HEIGHT)))
952
+ parser.add_argument("--fps", type=int, default=int(os.getenv("VIDEO_FPS", DEFAULT_FPS)))
953
+ parser.add_argument("--poll-interval", type=float, default=float(os.getenv("POLL_INTERVAL", DEFAULT_POLL_INTERVAL)))
954
+ parser.add_argument("--heartbeat-interval", type=float, default=float(os.getenv("HEARTBEAT_INTERVAL", DEFAULT_HEARTBEAT_INTERVAL)))
955
+ parser.add_argument("--vision-webhook-port", type=int, default=int(os.getenv("VISION_WEBHOOK_PORT", DEFAULT_VISION_WEBHOOK_PORT)), help="local port for RoboVisionAI_PI's motion webhook (0 disables)")
956
+ parser.add_argument("--vision-trigger-cooldown", type=float, default=float(os.getenv("VISION_TRIGGER_COOLDOWN", DEFAULT_VISION_TRIGGER_COOLDOWN)))
957
+ parser.add_argument("--vision-session-seconds", type=float, default=float(os.getenv("VISION_SESSION_SECONDS", DEFAULT_VISION_SESSION_SECONDS)))
958
+ parser.add_argument("--save-config", action="store_true", help="write CLI args to ~/.robopark/preview_agent.json")
959
+ parser.add_argument("-v", "--verbose", action="store_true")
960
+ args = parser.parse_args()
961
+
962
+ logging.basicConfig(
963
+ level=logging.DEBUG if args.verbose else logging.INFO,
964
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
965
+ )
966
+
967
+ cfg = _load_config()
968
+ cfg.update({
969
+ "scheduler_url": args.scheduler_url,
970
+ "robot_id": args.robot_id,
971
+ "video_device": args.video_device,
972
+ "audio_device": args.audio_device,
973
+ "video_width": args.width,
974
+ "video_height": args.height,
975
+ "video_fps": args.fps,
976
+ "poll_interval": args.poll_interval,
977
+ "heartbeat_interval": args.heartbeat_interval,
978
+ "vision_webhook_port": args.vision_webhook_port,
979
+ "vision_trigger_cooldown": args.vision_trigger_cooldown,
980
+ "vision_session_seconds": args.vision_session_seconds,
981
+ })
982
+ if args.device_token:
983
+ cfg["device_token"] = args.device_token
984
+ _save_token(args.device_token)
985
+ if args.enrollment_token:
986
+ cfg["enrollment_token"] = args.enrollment_token
987
+
988
+ if args.save_config:
989
+ _save_config(cfg)
990
+ logger.info(f"saved config to {CONFIG_FILE}")
991
+
992
+ agent = PreviewAgent(cfg)
993
+
994
+ loop = asyncio.new_event_loop()
995
+ asyncio.set_event_loop(loop)
996
+ for sig in (signal.SIGINT, signal.SIGTERM):
997
+ try:
998
+ loop.add_signal_handler(sig, agent.shutdown)
999
+ except NotImplementedError:
1000
+ # add_signal_handler is POSIX-only (raises on Windows) — fall back to
1001
+ # signal.signal, dispatched back onto the loop thread-safely.
1002
+ signal.signal(sig, lambda *_: loop.call_soon_threadsafe(agent.shutdown))
1003
+
1004
+ try:
1005
+ loop.run_until_complete(agent.run())
1006
+ finally:
1007
+ loop.close()
1008
+
1009
+
1010
+ if __name__ == "__main__":
1011
+ main()