infinicode 2.8.7 → 2.8.9

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.
@@ -0,0 +1,639 @@
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
+ Configuration (env / ~/.robopark/preview_agent.json):
11
+ SCHEDULER_URL base URL of the RoboPark scheduler
12
+ ROBOT_ID robot identity in the scheduler (defaults to hostname)
13
+ DEVICE_TOKEN long-lived device token (after enrollment)
14
+ ENROLLMENT_TOKEN one-time enrollment token (device token will be saved)
15
+ VIDEO_DEVICE camera path/index/name (e.g. /dev/video0, 0, "PiCamera")
16
+ AUDIO_DEVICE microphone name/index or "default"
17
+ VIDEO_WIDTH / HEIGHT capture resolution (default 640x480)
18
+ VIDEO_FPS capture fps (default 15)
19
+ POLL_INTERVAL seconds between scheduler polls (default 3)
20
+ HEARTBEAT_INTERVAL seconds between device heartbeats (default 30)
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import asyncio
26
+ import json
27
+ import logging
28
+ import os
29
+ import signal
30
+ import sys
31
+ import time
32
+ from dataclasses import dataclass
33
+ from pathlib import Path
34
+ from typing import Optional
35
+
36
+ import httpx
37
+
38
+ logger = logging.getLogger("robopark.preview_agent")
39
+
40
+ CONFIG_DIR = Path.home() / ".robopark"
41
+ CONFIG_FILE = CONFIG_DIR / "preview_agent.json"
42
+ TOKEN_FILE = CONFIG_DIR / "device_token"
43
+
44
+ DEFAULT_VIDEO_WIDTH = 640
45
+ DEFAULT_VIDEO_HEIGHT = 480
46
+ DEFAULT_FPS = 15
47
+ DEFAULT_POLL_INTERVAL = 3.0
48
+ DEFAULT_HEARTBEAT_INTERVAL = 30.0
49
+
50
+
51
+ def _load_config() -> dict:
52
+ cfg: dict = {}
53
+ if CONFIG_FILE.exists():
54
+ try:
55
+ cfg = json.loads(CONFIG_FILE.read_text(encoding="utf8"))
56
+ except Exception as e:
57
+ logger.warning(f"failed to read {CONFIG_FILE}: {e}")
58
+ return cfg
59
+
60
+
61
+ def _save_config(cfg: dict) -> None:
62
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
63
+ CONFIG_FILE.write_text(json.dumps(cfg, indent=2), encoding="utf8")
64
+
65
+
66
+ def _load_token() -> Optional[str]:
67
+ if TOKEN_FILE.exists():
68
+ return TOKEN_FILE.read_text(encoding="utf8").strip() or None
69
+ return None
70
+
71
+
72
+ def _save_token(token: str) -> None:
73
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
74
+ TOKEN_FILE.write_text(token, encoding="utf8")
75
+ os.chmod(TOKEN_FILE, 0o600)
76
+
77
+
78
+ async def _enroll(scheduler_url: str, enrollment_token: str, robot_id: str) -> str:
79
+ """Enroll this Pi with the scheduler and return the device token."""
80
+ import socket
81
+ payload = {
82
+ "enrollment_token": enrollment_token,
83
+ "name": robot_id,
84
+ "lan_ip": _get_lan_ip(),
85
+ }
86
+ async with httpx.AsyncClient() as client:
87
+ r = await client.post(
88
+ f"{scheduler_url.rstrip('/')}/api/devices/enroll",
89
+ json=payload,
90
+ timeout=30.0,
91
+ )
92
+ r.raise_for_status()
93
+ data = r.json()
94
+ device_token = data.get("device_token")
95
+ if not device_token:
96
+ raise RuntimeError("enrollment did not return a device_token")
97
+ _save_token(device_token)
98
+ cfg = _load_config()
99
+ cfg["device_id"] = data.get("device_id")
100
+ cfg["scheduler_url"] = data.get("scheduler_url", scheduler_url)
101
+ _save_config(cfg)
102
+ logger.info(f"enrolled as device {data.get('device_id')}")
103
+ return device_token
104
+
105
+
106
+ def _get_lan_ip() -> Optional[str]:
107
+ try:
108
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
109
+ s.settimeout(0.5)
110
+ s.connect(("10.255.255.255", 1))
111
+ ip = s.getsockname()[0]
112
+ s.close()
113
+ return ip
114
+ except Exception:
115
+ return None
116
+
117
+
118
+ async def _send_heartbeat(scheduler_url: str, device_id: str, token: str) -> None:
119
+ try:
120
+ async with httpx.AsyncClient() as client:
121
+ await client.post(
122
+ f"{scheduler_url.rstrip('/')}/api/devices/{device_id}/heartbeat",
123
+ json={"status": "online"},
124
+ headers={"Authorization": f"Bearer {token}"},
125
+ timeout=10.0,
126
+ )
127
+ except Exception as e:
128
+ logger.debug(f"heartbeat failed: {e}")
129
+
130
+
131
+ @dataclass
132
+ class PreviewState:
133
+ active: bool = False
134
+ url: Optional[str] = None
135
+ token: Optional[str] = None
136
+ room: Optional[str] = None
137
+
138
+
139
+ class PreviewAgent:
140
+ def __init__(self, cfg: dict):
141
+ self.scheduler_url = cfg.get("scheduler_url", os.getenv("SCHEDULER_URL", "http://localhost:8080"))
142
+ self.robot_id = cfg.get("robot_id", os.getenv("ROBOT_ID", _hostname()))
143
+ self.device_token: Optional[str] = cfg.get("device_token") or os.getenv("DEVICE_TOKEN") or _load_token()
144
+ self.device_id: Optional[str] = cfg.get("device_id")
145
+ self.video_device = cfg.get("video_device", os.getenv("VIDEO_DEVICE", "auto"))
146
+ self.audio_device = cfg.get("audio_device", os.getenv("AUDIO_DEVICE", "default"))
147
+ self.width = int(cfg.get("video_width", os.getenv("VIDEO_WIDTH", DEFAULT_VIDEO_WIDTH)))
148
+ self.height = int(cfg.get("video_height", os.getenv("VIDEO_HEIGHT", DEFAULT_VIDEO_HEIGHT)))
149
+ self.fps = int(cfg.get("video_fps", os.getenv("VIDEO_FPS", DEFAULT_FPS)))
150
+ self.poll_interval = float(cfg.get("poll_interval", os.getenv("POLL_INTERVAL", DEFAULT_POLL_INTERVAL)))
151
+ self.heartbeat_interval = float(cfg.get("heartbeat_interval", os.getenv("HEARTBEAT_INTERVAL", DEFAULT_HEARTBEAT_INTERVAL)))
152
+
153
+ self._shutdown = asyncio.Event()
154
+ self._task: Optional[asyncio.Task] = None
155
+ self._session: Optional[httpx.AsyncClient] = None
156
+ self._current_state: PreviewState = PreviewState()
157
+ self._publisher: Optional["LiveKitPublisher"] = None
158
+
159
+ async def run(self) -> None:
160
+ enrollment_token = os.getenv("ENROLLMENT_TOKEN") or cfg.get("enrollment_token")
161
+ if not self.device_token and enrollment_token:
162
+ self.device_token = await _enroll(self.scheduler_url, enrollment_token, self.robot_id)
163
+
164
+ if not self.device_token:
165
+ logger.error("no DEVICE_TOKEN and no ENROLLMENT_TOKEN; cannot poll scheduler")
166
+ sys.exit(1)
167
+
168
+ if not self.device_id:
169
+ await self._resolve_device_id()
170
+
171
+ self._session = httpx.AsyncClient()
172
+
173
+ tasks = [
174
+ asyncio.create_task(self._poll_loop()),
175
+ asyncio.create_task(self._heartbeat_loop()),
176
+ ]
177
+ await self._shutdown.wait()
178
+ for t in tasks:
179
+ t.cancel()
180
+ try:
181
+ await t
182
+ except asyncio.CancelledError:
183
+ pass
184
+ await self._stop_publisher()
185
+ await self._session.aclose()
186
+
187
+ async def _resolve_device_id(self) -> None:
188
+ """Look up our device_id from the scheduler using the token."""
189
+ if not self._session or not self.device_token:
190
+ return
191
+ try:
192
+ r = await self._session.get(
193
+ f"{self.scheduler_url.rstrip('/')}/api/devices",
194
+ headers={"Authorization": f"Bearer {self.device_token}"},
195
+ timeout=10.0,
196
+ )
197
+ r.raise_for_status()
198
+ for d in r.json():
199
+ if d.get("name") == self.robot_id:
200
+ self.device_id = d.get("id")
201
+ cfg = _load_config()
202
+ cfg["device_id"] = self.device_id
203
+ _save_config(cfg)
204
+ return
205
+ except Exception as e:
206
+ logger.warning(f"could not resolve device_id: {e}")
207
+
208
+ async def _poll_loop(self) -> None:
209
+ while not self._shutdown.is_set():
210
+ try:
211
+ state = await self._fetch_preview_state()
212
+ await self._apply_state(state)
213
+ except Exception as e:
214
+ logger.warning(f"poll error: {e}")
215
+ try:
216
+ await asyncio.wait_for(self._shutdown.wait(), timeout=self.poll_interval)
217
+ except asyncio.TimeoutError:
218
+ pass
219
+
220
+ async def _heartbeat_loop(self) -> None:
221
+ while not self._shutdown.is_set():
222
+ if self.device_id and self.device_token:
223
+ await _send_heartbeat(self.scheduler_url, self.device_id, self.device_token)
224
+ try:
225
+ await asyncio.wait_for(self._shutdown.wait(), timeout=self.heartbeat_interval)
226
+ except asyncio.TimeoutError:
227
+ pass
228
+
229
+ async def _fetch_preview_state(self) -> PreviewState:
230
+ if not self._session or not self.device_token:
231
+ return PreviewState()
232
+ r = await self._session.get(
233
+ f"{self.scheduler_url.rstrip('/')}/api/robots/{self.robot_id}/preview/agent",
234
+ headers={"Authorization": f"Bearer {self.device_token}"},
235
+ timeout=10.0,
236
+ )
237
+ r.raise_for_status()
238
+ data = r.json()
239
+ if not data.get("active"):
240
+ return PreviewState()
241
+ return PreviewState(
242
+ active=True,
243
+ url=data.get("url"),
244
+ token=data.get("token"),
245
+ room=data.get("room"),
246
+ )
247
+
248
+ async def _apply_state(self, state: PreviewState) -> None:
249
+ same = (
250
+ state.active == self._current_state.active
251
+ and state.room == self._current_state.room
252
+ and state.token == self._current_state.token
253
+ and state.url == self._current_state.url
254
+ )
255
+ if same:
256
+ return
257
+ self._current_state = state
258
+ if not state.active:
259
+ await self._stop_publisher()
260
+ return
261
+ await self._start_publisher(state)
262
+
263
+ async def _start_publisher(self, state: PreviewState) -> None:
264
+ await self._stop_publisher()
265
+ if not state.url or not state.token or not state.room:
266
+ return
267
+ try:
268
+ pub = LiveKitPublisher(state.url, state.token, state.room, self)
269
+ await pub.start()
270
+ self._publisher = pub
271
+ logger.info(f"joined preview room {state.room}")
272
+ except Exception as e:
273
+ logger.error(f"failed to start publisher: {e}")
274
+
275
+ async def _stop_publisher(self) -> None:
276
+ if self._publisher:
277
+ try:
278
+ await self._publisher.stop()
279
+ except Exception as e:
280
+ logger.warning(f"publisher stop error: {e}")
281
+ self._publisher = None
282
+
283
+ def shutdown(self) -> None:
284
+ self._shutdown.set()
285
+
286
+
287
+ class LiveKitPublisher:
288
+ """Encapsulates LiveKit room connection, camera capture and mic capture."""
289
+
290
+ def __init__(self, url: str, token: str, room_name: str, agent: PreviewAgent):
291
+ from livekit import rtc
292
+ self.url = url
293
+ self.token = token
294
+ self.room_name = room_name
295
+ self.agent = agent
296
+ self.rtc = rtc
297
+ self.room: Optional[rtc.Room] = None
298
+ self.video_source: Optional[rtc.VideoSource] = None
299
+ self.video_track: Optional[rtc.LocalVideoTrack] = None
300
+ self.audio_source: Optional[rtc.AudioSource] = None
301
+ self.audio_track: Optional[rtc.LocalAudioTrack] = None
302
+ self._stop_event = asyncio.Event()
303
+ self._tasks: list[asyncio.Task] = []
304
+ self._capture: Optional["VideoCapture"] = None
305
+
306
+ async def start(self) -> None:
307
+ self.room = self.rtc.Room()
308
+ await self.room.connect(self.url, self.token)
309
+
310
+ # Video
311
+ self.video_source = self.rtc.VideoSource(self.agent.width, self.agent.height)
312
+ self.video_track = self.rtc.LocalVideoTrack.create_video_track("camera", self.video_source)
313
+ vopts = self.rtc.TrackPublishOptions()
314
+ vopts.source = self.rtc.TrackSource.SOURCE_CAMERA
315
+ await self.room.local_participant.publish_track(self.video_track, vopts)
316
+
317
+ # Audio
318
+ self.audio_source = self.rtc.AudioSource(48000, 1)
319
+ self.audio_track = self.rtc.LocalAudioTrack.create_audio_track("microphone", self.audio_source)
320
+ aopts = self.rtc.TrackPublishOptions()
321
+ aopts.source = self.rtc.TrackSource.SOURCE_MICROPHONE
322
+ await self.room.local_participant.publish_track(self.audio_track, aopts)
323
+
324
+ self._capture = create_video_capture(self.agent.video_device, self.agent.width, self.agent.height, self.agent.fps)
325
+ if self._capture:
326
+ self._tasks.append(asyncio.create_task(self._video_loop()))
327
+ if has_audio():
328
+ self._tasks.append(asyncio.create_task(self._audio_loop()))
329
+
330
+ async def stop(self) -> None:
331
+ self._stop_event.set()
332
+ for t in self._tasks:
333
+ t.cancel()
334
+ try:
335
+ await t
336
+ except asyncio.CancelledError:
337
+ pass
338
+ self._tasks.clear()
339
+ if self._capture:
340
+ self._capture.stop()
341
+ self._capture = None
342
+ if self.room:
343
+ await self.room.disconnect()
344
+ self.room = None
345
+
346
+ async def _video_loop(self) -> None:
347
+ assert self._capture is not None and self.video_source is not None
348
+ frame_interval = 1.0 / self.agent.fps
349
+ while not self._stop_event.is_set():
350
+ start = time.monotonic()
351
+ frame = self._capture.read()
352
+ if frame is not None:
353
+ self.video_source.capture_frame(frame)
354
+ elapsed = time.monotonic() - start
355
+ sleep_for = frame_interval - elapsed
356
+ if sleep_for > 0:
357
+ try:
358
+ await asyncio.wait_for(self._stop_event.wait(), timeout=sleep_for)
359
+ except asyncio.TimeoutError:
360
+ pass
361
+
362
+ async def _audio_loop(self) -> None:
363
+ assert self.audio_source is not None
364
+ mic = create_audio_capture(self.agent.audio_device)
365
+ if mic is None:
366
+ return
367
+ frame_duration = 0.02 # 20ms
368
+ samples_per_frame = int(48000 * frame_duration)
369
+ while not self._stop_event.is_set():
370
+ frame = mic.read(samples_per_frame)
371
+ if frame is not None:
372
+ self.audio_source.capture_frame(frame)
373
+ try:
374
+ await asyncio.wait_for(self._stop_event.wait(), timeout=frame_duration)
375
+ except asyncio.TimeoutError:
376
+ pass
377
+
378
+
379
+ # -----------------------------------------------------------------------------
380
+ # Video capture abstraction: V4L2/USB via OpenCV, or picamera2 on Pi.
381
+ # -----------------------------------------------------------------------------
382
+
383
+ class VideoCapture:
384
+ def read(self) -> Optional["rtc.VideoFrame"]:
385
+ raise NotImplementedError
386
+
387
+ def stop(self) -> None:
388
+ raise NotImplementedError
389
+
390
+
391
+ class OpencvVideoCapture(VideoCapture):
392
+ def __init__(self, device: int | str, width: int, height: int, fps: int):
393
+ import cv2
394
+ self.cv2 = cv2
395
+ if isinstance(device, str) and device.startswith("/dev/video"):
396
+ device = int(device.replace("/dev/video", ""))
397
+ self.cap = cv2.VideoCapture(device)
398
+ self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, width)
399
+ self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height)
400
+ self.cap.set(cv2.CAP_PROP_FPS, fps)
401
+ self._ensure_frame()
402
+
403
+ def _ensure_frame(self):
404
+ ok, _ = self.cap.read()
405
+ if not ok:
406
+ raise RuntimeError("cannot read from camera")
407
+
408
+ def read(self):
409
+ from livekit import rtc
410
+ ok, bgr = self.cap.read()
411
+ if not ok or bgr is None:
412
+ return None
413
+ return rtc.VideoFrame(
414
+ width=bgr.shape[1],
415
+ height=bgr.shape[0],
416
+ type=rtc.VideoBufferType.BGR,
417
+ data=bgr.tobytes(),
418
+ )
419
+
420
+ def stop(self):
421
+ self.cap.release()
422
+
423
+
424
+ class Picamera2Capture(VideoCapture):
425
+ def __init__(self, width: int, height: int, fps: int):
426
+ from picamera2 import Picamera2
427
+ self.picam = Picamera2()
428
+ config = self.picam.create_video_configuration(
429
+ main={"format": "RGB888", "size": (width, height)},
430
+ controls={"FrameRate": fps},
431
+ )
432
+ self.picam.configure(config)
433
+ self.picam.start()
434
+ self.width = width
435
+ self.height = height
436
+
437
+ def read(self):
438
+ from livekit import rtc
439
+ arr = self.picam.capture_array()
440
+ if arr is None:
441
+ return None
442
+ return rtc.VideoFrame(
443
+ width=self.width,
444
+ height=self.height,
445
+ type=rtc.VideoBufferType.RGB,
446
+ data=arr.tobytes(),
447
+ )
448
+
449
+ def stop(self):
450
+ try:
451
+ self.picam.stop()
452
+ except Exception:
453
+ pass
454
+
455
+
456
+ def create_video_capture(device: str, width: int, height: int, fps: int) -> Optional[VideoCapture]:
457
+ if device.lower() in ("none", "", "false", "null"):
458
+ return None
459
+ try:
460
+ from livekit import rtc
461
+ _ = rtc.VideoSource
462
+ except Exception as e:
463
+ logger.error(f"livekit python sdk not installed: {e}")
464
+ return None
465
+
466
+ # Auto-detect: prefer first V4L2 device, fall back to picamera2 if available.
467
+ if device.lower() in ("auto", "default", "first"):
468
+ for i in range(4):
469
+ try:
470
+ cap = OpencvVideoCapture(i, width, height, fps)
471
+ logger.info(f"auto-selected USB camera /dev/video{i}")
472
+ return cap
473
+ except Exception:
474
+ continue
475
+ try:
476
+ cap = Picamera2Capture(width, height, fps)
477
+ logger.info("auto-selected Pi Camera")
478
+ return cap
479
+ except Exception:
480
+ pass
481
+ logger.error("no camera found (tried V4L2 and picamera2)")
482
+ return None
483
+
484
+ if device.lower() in ("picamera", "picamera2", "pi", "rpi"):
485
+ return Picamera2Capture(width, height, fps)
486
+
487
+ # Treat as V4L2 index or path
488
+ return OpencvVideoCapture(device, width, height, fps)
489
+
490
+
491
+ # -----------------------------------------------------------------------------
492
+ # Audio capture abstraction: PyAudio or sounddevice -> LiveKit AudioFrame.
493
+ # -----------------------------------------------------------------------------
494
+
495
+ class AudioCapture:
496
+ def read(self, samples_per_frame: int) -> Optional["rtc.AudioFrame"]:
497
+ raise NotImplementedError
498
+
499
+ def stop(self) -> None:
500
+ raise NotImplementedError
501
+
502
+
503
+ class PyAudioCapture(AudioCapture):
504
+ def __init__(self, device: str | int | None):
505
+ import pyaudio
506
+ self.pa = pyaudio.PyAudio()
507
+ self.device_index = self._resolve_device(device)
508
+ self.buffer = bytearray()
509
+ self.stream = self.pa.open(
510
+ format=pyaudio.paInt16,
511
+ channels=1,
512
+ rate=48000,
513
+ input=True,
514
+ input_device_index=self.device_index,
515
+ frames_per_buffer=960,
516
+ stream_callback=self._callback,
517
+ )
518
+ self.stream.start_stream()
519
+
520
+ def _resolve_device(self, device: str | int | None) -> Optional[int]:
521
+ if device is None or device == "default":
522
+ return None
523
+ if isinstance(device, int):
524
+ return device
525
+ # Try exact name match
526
+ for i in range(self.pa.get_device_count()):
527
+ info = self.pa.get_device_info_by_index(i)
528
+ if info.get("maxInputChannels", 0) > 0 and device.lower() in str(info.get("name", "")).lower():
529
+ return i
530
+ return None
531
+
532
+ def _callback(self, in_data, frame_count, time_info, status):
533
+ self.buffer.extend(in_data)
534
+ return (None, pyaudio.paContinue)
535
+
536
+ def read(self, samples_per_frame: int):
537
+ from livekit import rtc
538
+ bytes_needed = samples_per_frame * 2 # int16 mono
539
+ while len(self.buffer) < bytes_needed:
540
+ time.sleep(0.005)
541
+ chunk = bytes(self.buffer[:bytes_needed])
542
+ self.buffer = self.buffer[bytes_needed:]
543
+ return rtc.AudioFrame(
544
+ data=chunk,
545
+ sample_rate=48000,
546
+ num_channels=1,
547
+ samples_per_channel=samples_per_frame,
548
+ )
549
+
550
+ def stop(self):
551
+ if self.stream:
552
+ self.stream.stop_stream()
553
+ self.stream.close()
554
+ self.pa.terminate()
555
+
556
+
557
+ def has_audio() -> bool:
558
+ try:
559
+ import pyaudio # noqa: F401
560
+ return True
561
+ except Exception:
562
+ return False
563
+
564
+
565
+ def create_audio_capture(device: str) -> Optional[AudioCapture]:
566
+ if device.lower() in ("none", "", "false", "null"):
567
+ return None
568
+ try:
569
+ import pyaudio # noqa: F401
570
+ return PyAudioCapture(device if device != "default" else None)
571
+ except Exception as e:
572
+ logger.warning(f"pyaudio not available: {e}")
573
+ return None
574
+
575
+
576
+ def _hostname() -> str:
577
+ import socket
578
+ return socket.gethostname().split(".")[0]
579
+
580
+
581
+ def main() -> None:
582
+ parser = argparse.ArgumentParser(description="RoboPark preview agent")
583
+ parser.add_argument("--scheduler-url", default=os.getenv("SCHEDULER_URL", "http://localhost:8080"))
584
+ parser.add_argument("--robot-id", default=os.getenv("ROBOT_ID", _hostname()))
585
+ parser.add_argument("--device-token", default=os.getenv("DEVICE_TOKEN"))
586
+ parser.add_argument("--enrollment-token", default=os.getenv("ENROLLMENT_TOKEN"))
587
+ parser.add_argument("--video-device", default=os.getenv("VIDEO_DEVICE", "auto"))
588
+ parser.add_argument("--audio-device", default=os.getenv("AUDIO_DEVICE", "default"))
589
+ parser.add_argument("--width", type=int, default=int(os.getenv("VIDEO_WIDTH", DEFAULT_VIDEO_WIDTH)))
590
+ parser.add_argument("--height", type=int, default=int(os.getenv("VIDEO_HEIGHT", DEFAULT_VIDEO_HEIGHT)))
591
+ parser.add_argument("--fps", type=int, default=int(os.getenv("VIDEO_FPS", DEFAULT_FPS)))
592
+ parser.add_argument("--poll-interval", type=float, default=float(os.getenv("POLL_INTERVAL", DEFAULT_POLL_INTERVAL)))
593
+ parser.add_argument("--heartbeat-interval", type=float, default=float(os.getenv("HEARTBEAT_INTERVAL", DEFAULT_HEARTBEAT_INTERVAL)))
594
+ parser.add_argument("--save-config", action="store_true", help="write CLI args to ~/.robopark/preview_agent.json")
595
+ parser.add_argument("-v", "--verbose", action="store_true")
596
+ args = parser.parse_args()
597
+
598
+ logging.basicConfig(
599
+ level=logging.DEBUG if args.verbose else logging.INFO,
600
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
601
+ )
602
+
603
+ cfg = _load_config()
604
+ cfg.update({
605
+ "scheduler_url": args.scheduler_url,
606
+ "robot_id": args.robot_id,
607
+ "video_device": args.video_device,
608
+ "audio_device": args.audio_device,
609
+ "video_width": args.width,
610
+ "video_height": args.height,
611
+ "video_fps": args.fps,
612
+ "poll_interval": args.poll_interval,
613
+ "heartbeat_interval": args.heartbeat_interval,
614
+ })
615
+ if args.device_token:
616
+ cfg["device_token"] = args.device_token
617
+ _save_token(args.device_token)
618
+ if args.enrollment_token:
619
+ cfg["enrollment_token"] = args.enrollment_token
620
+
621
+ if args.save_config:
622
+ _save_config(cfg)
623
+ logger.info(f"saved config to {CONFIG_FILE}")
624
+
625
+ agent = PreviewAgent(cfg)
626
+
627
+ loop = asyncio.new_event_loop()
628
+ asyncio.set_event_loop(loop)
629
+ for sig in (signal.SIGINT, signal.SIGTERM):
630
+ loop.add_signal_handler(sig, agent.shutdown)
631
+
632
+ try:
633
+ loop.run_until_complete(agent.run())
634
+ finally:
635
+ loop.close()
636
+
637
+
638
+ if __name__ == "__main__":
639
+ main()
@@ -5,4 +5,8 @@ httpx==0.27.2
5
5
  pydantic==2.9.2
6
6
  python-multipart==0.0.12
7
7
  livekit-api>=0.10
8
+ livekit>=0.18
9
+ opencv-python>=4.8
10
+ pyaudio>=0.2
11
+ picamera2>=0.3; platform_machine == 'aarch64' or platform_machine == 'arm64'
8
12
  PyJWT==2.9.0