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.
- package/dist/kernel/federation/peer-mesh.js +15 -1
- package/dist/kernel/providers/ollama-provider.d.ts +4 -0
- package/dist/kernel/providers/ollama-provider.js +16 -2
- package/dist/kernel/setup.js +13 -0
- package/dist/robopark/add-robot.d.ts +8 -0
- package/dist/robopark/add-robot.js +113 -0
- package/dist/robopark/agent-ctl.d.ts +20 -0
- package/dist/robopark/agent-ctl.js +237 -0
- package/dist/robopark/doctor.d.ts +6 -0
- package/dist/robopark/doctor.js +175 -0
- package/dist/robopark/llm-set.d.ts +8 -0
- package/dist/robopark/llm-set.js +87 -0
- package/dist/robopark/profile.d.ts +40 -0
- package/dist/robopark/profile.js +69 -0
- package/dist/robopark/server-add.d.ts +13 -0
- package/dist/robopark/server-add.js +114 -0
- package/dist/robopark/setup-livekit.js +89 -22
- package/dist/robopark/setup.js +52 -8
- package/dist/robopark/stop-all.js +6 -2
- package/dist/robopark-cli.js +94 -0
- package/package.json +1 -1
- package/packages/robopark/scheduler/main.py +107 -10
- package/packages/robopark/scheduler/preview_agent.py +1011 -782
- package/packages/robopark/vision/app_pi_clean.py +81 -1
|
@@ -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
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
import
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
import
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
"
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
self.
|
|
175
|
-
self.
|
|
176
|
-
self.
|
|
177
|
-
self.
|
|
178
|
-
self.
|
|
179
|
-
self.
|
|
180
|
-
self.
|
|
181
|
-
self.
|
|
182
|
-
self.
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
self._session
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
#
|
|
200
|
-
#
|
|
201
|
-
#
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
if self.
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
self.
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
self.
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
)
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
await self.
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
self.
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
class
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
self.
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
self.
|
|
530
|
-
self.
|
|
531
|
-
self.
|
|
532
|
-
self.
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
)
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
self.
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
if
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
except Exception:
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
)
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
"
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
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 ceiling — see 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()
|