infinicode 2.8.46 → 2.8.49
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/robopark/preview-agent-launcher.d.ts +1 -0
- package/dist/robopark/preview-agent-launcher.js +2 -0
- package/dist/robopark/setup.js +2 -0
- package/package.json +1 -1
- package/packages/robopark/pi-client/client.py +76 -17
- package/packages/robopark/pi-client/livekit_bridge.py +24 -15
- package/packages/robopark/scheduler/main.py +20 -36
- package/packages/robopark/scheduler/preview_agent.py +124 -63
- package/packages/robopark/vision/app_pi_clean.py +79 -16
|
@@ -47,6 +47,8 @@ export async function roboparkPreviewAgent(opts) {
|
|
|
47
47
|
args.push('--vision-trigger-cooldown', opts.visionTriggerCooldown);
|
|
48
48
|
if (opts.visionSessionSeconds)
|
|
49
49
|
args.push('--vision-session-seconds', opts.visionSessionSeconds);
|
|
50
|
+
if (opts.robovisionUrl)
|
|
51
|
+
args.push('--robovision-url', opts.robovisionUrl);
|
|
50
52
|
if (opts.saveConfig)
|
|
51
53
|
args.push('--save-config');
|
|
52
54
|
console.log(chalk.bold('\n robopark preview-agent'));
|
package/dist/robopark/setup.js
CHANGED
|
@@ -225,6 +225,8 @@ async function setupRobot(config, opts, ctx, internal) {
|
|
|
225
225
|
// production trigger — on by default. --no-vision opts out (e.g. no camera
|
|
226
226
|
// on this box, or RoboVisionAI_PI isn't installed).
|
|
227
227
|
const visionEnabled = opts.vision !== false;
|
|
228
|
+
if (visionEnabled)
|
|
229
|
+
previewAgentArgs.push('--robovision-url', 'http://127.0.0.1:5000');
|
|
228
230
|
const visionAgentArgs = ['vision-agent'];
|
|
229
231
|
console.log(chalk.dim(' robot command:'));
|
|
230
232
|
console.log(' ' + chalk.cyan(`infinicode ${args.join(' ')}`));
|
package/package.json
CHANGED
|
@@ -20,13 +20,15 @@
|
|
|
20
20
|
# Subsequent runs (token is in device.json):
|
|
21
21
|
# python client.py --scheduler-url http://100.64.1.5:8080
|
|
22
22
|
|
|
23
|
-
import argparse
|
|
24
|
-
import asyncio
|
|
25
|
-
import
|
|
26
|
-
import
|
|
27
|
-
import
|
|
28
|
-
import
|
|
29
|
-
import
|
|
23
|
+
import argparse
|
|
24
|
+
import asyncio
|
|
25
|
+
import glob
|
|
26
|
+
import json
|
|
27
|
+
import logging
|
|
28
|
+
import os
|
|
29
|
+
import signal
|
|
30
|
+
import subprocess
|
|
31
|
+
import sys
|
|
30
32
|
import time
|
|
31
33
|
from pathlib import Path
|
|
32
34
|
from typing import Optional
|
|
@@ -39,7 +41,45 @@ logging.basicConfig(
|
|
|
39
41
|
level=logging.INFO,
|
|
40
42
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
41
43
|
)
|
|
42
|
-
log = logging.getLogger("robopark-pi")
|
|
44
|
+
log = logging.getLogger("robopark-pi")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get_device_inventory() -> dict:
|
|
48
|
+
"""Report real Linux media devices so the remote dashboard can select them."""
|
|
49
|
+
inventory = {
|
|
50
|
+
"video": [{"id": "auto", "name": "Auto detect"}, {"id": "none", "name": "Disable camera"}],
|
|
51
|
+
"audio_input": [{"id": "default", "name": "System default input"}],
|
|
52
|
+
"audio_output": [{"id": "default", "name": "System default output"}],
|
|
53
|
+
"platform": sys.platform,
|
|
54
|
+
}
|
|
55
|
+
for path in sorted(glob.glob("/dev/video*")):
|
|
56
|
+
inventory["video"].append({"id": path, "name": path, "backend": "v4l2"})
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
import pyaudio
|
|
60
|
+
pa = pyaudio.PyAudio()
|
|
61
|
+
for index in range(pa.get_device_count()):
|
|
62
|
+
info = pa.get_device_info_by_index(index)
|
|
63
|
+
name = str(info.get("name", f"Audio device {index}"))
|
|
64
|
+
item = {"id": str(index), "name": name, "host_api": str(info.get("hostApi", ""))}
|
|
65
|
+
if info.get("maxInputChannels", 0) > 0:
|
|
66
|
+
inventory["audio_input"].append(item.copy())
|
|
67
|
+
if info.get("maxOutputChannels", 0) > 0:
|
|
68
|
+
inventory["audio_output"].append(item.copy())
|
|
69
|
+
pa.terminate()
|
|
70
|
+
except Exception as exc:
|
|
71
|
+
# Keep the device visible even when PortAudio is not installed; ALSA
|
|
72
|
+
# names are still useful for an operator diagnosing a Pi remotely.
|
|
73
|
+
log.debug("PyAudio inventory unavailable: %s", exc)
|
|
74
|
+
for command, key, kind in (("arecord", "audio_input", "input"), ("aplay", "audio_output", "output")):
|
|
75
|
+
try:
|
|
76
|
+
output = subprocess.run([command, "-L"], capture_output=True, text=True, timeout=3, check=False).stdout
|
|
77
|
+
for name in (line.strip() for line in output.splitlines()):
|
|
78
|
+
if name and not name.startswith("(") and name not in {x["id"] for x in inventory[key]}:
|
|
79
|
+
inventory[key].append({"id": name, "name": name, "backend": "alsa", "kind": kind})
|
|
80
|
+
except Exception:
|
|
81
|
+
pass
|
|
82
|
+
return inventory
|
|
43
83
|
|
|
44
84
|
|
|
45
85
|
class PiClient:
|
|
@@ -56,7 +96,10 @@ class PiClient:
|
|
|
56
96
|
self.heartbeat_interval = args.heartbeat_interval
|
|
57
97
|
self.config_poll_interval = args.config_poll_interval
|
|
58
98
|
self.join_room = args.join_room
|
|
59
|
-
self.room_name = args.room_name
|
|
99
|
+
self.room_name = args.room_name
|
|
100
|
+
self.video_device = args.video_device
|
|
101
|
+
self.audio_device = args.audio_device
|
|
102
|
+
self.audio_output_device = args.audio_output_device
|
|
60
103
|
|
|
61
104
|
self.device_id: Optional[str] = None
|
|
62
105
|
self.device_token: Optional[str] = None
|
|
@@ -129,8 +172,12 @@ class PiClient:
|
|
|
129
172
|
log.info(f"Enrolled as device_id={self.device_id}")
|
|
130
173
|
self._save_state()
|
|
131
174
|
|
|
132
|
-
async def heartbeat(self):
|
|
133
|
-
body = {
|
|
175
|
+
async def heartbeat(self):
|
|
176
|
+
body = {
|
|
177
|
+
"status": "online",
|
|
178
|
+
"ip": self.tailscale_ip or self.lan_ip,
|
|
179
|
+
"device_inventory": get_device_inventory(),
|
|
180
|
+
}
|
|
134
181
|
async with httpx.AsyncClient(timeout=10.0) as c:
|
|
135
182
|
r = await c.post(
|
|
136
183
|
f"{self.scheduler_url}/api/devices/{self.device_id}/heartbeat",
|
|
@@ -186,7 +233,10 @@ class PiClient:
|
|
|
186
233
|
production_mode_provider=lambda: self.production_mode,
|
|
187
234
|
scheduler_url=self.scheduler_url,
|
|
188
235
|
device_id=self.device_id,
|
|
189
|
-
device_token=self.device_token,
|
|
236
|
+
device_token=self.device_token,
|
|
237
|
+
video_device=self.video_device,
|
|
238
|
+
audio_device=self.audio_device,
|
|
239
|
+
audio_output_device=self.audio_output_device,
|
|
190
240
|
)
|
|
191
241
|
|
|
192
242
|
# ---- main loop ------------------------------------------------------
|
|
@@ -234,8 +284,14 @@ class PiClient:
|
|
|
234
284
|
if int(time.time()) % int(self.config_poll_interval) < int(self.heartbeat_interval):
|
|
235
285
|
cfg = await self.fetch_config()
|
|
236
286
|
self.production_mode = bool(cfg.get("production_mode", False))
|
|
237
|
-
if cfg.get("character_id"):
|
|
238
|
-
self.character_id = cfg["character_id"]
|
|
287
|
+
if cfg.get("character_id"):
|
|
288
|
+
self.character_id = cfg["character_id"]
|
|
289
|
+
if cfg.get("video_device") is not None:
|
|
290
|
+
self.video_device = cfg["video_device"]
|
|
291
|
+
if cfg.get("audio_device") is not None:
|
|
292
|
+
self.audio_device = cfg["audio_device"]
|
|
293
|
+
if cfg.get("audio_output_device") is not None:
|
|
294
|
+
self.audio_output_device = cfg["audio_output_device"]
|
|
239
295
|
except Exception as e:
|
|
240
296
|
log.debug(f"Config poll error: {e}")
|
|
241
297
|
|
|
@@ -267,8 +323,11 @@ def parse_args() -> argparse.Namespace:
|
|
|
267
323
|
help="Base URL of the local RoboVisionAI_PI motor server")
|
|
268
324
|
p.add_argument("--livekit-url", default=None, help="e.g. ws://100.64.1.5:7880")
|
|
269
325
|
p.add_argument("--character-id", default=None, help="Override character assignment")
|
|
270
|
-
p.add_argument("--room-name", default="robopark-pi-standby",
|
|
271
|
-
help="LiveKit room to join")
|
|
326
|
+
p.add_argument("--room-name", default="robopark-pi-standby",
|
|
327
|
+
help="LiveKit room to join")
|
|
328
|
+
p.add_argument("--video-device", default="auto", help="Camera path/index, picamera, or auto")
|
|
329
|
+
p.add_argument("--audio-device", default="default", help="Microphone name/index or default")
|
|
330
|
+
p.add_argument("--audio-output-device", default="default", help="Speaker name/index or default")
|
|
272
331
|
p.add_argument("--heartbeat-interval", type=float, default=10.0)
|
|
273
332
|
p.add_argument("--config-poll-interval", type=float, default=30.0)
|
|
274
333
|
p.add_argument("--join-room", action="store_true",
|
|
@@ -302,4 +361,4 @@ def main():
|
|
|
302
361
|
|
|
303
362
|
|
|
304
363
|
if __name__ == "__main__":
|
|
305
|
-
main()
|
|
364
|
+
main()
|
|
@@ -75,9 +75,12 @@ async def run_livekit(
|
|
|
75
75
|
on_motor_command: Callable[[str | bytes], Awaitable[str]],
|
|
76
76
|
stop_event: asyncio.Event,
|
|
77
77
|
production_mode_provider: Callable[[], bool],
|
|
78
|
-
scheduler_url: str = "",
|
|
79
|
-
device_id: str = "",
|
|
80
|
-
device_token: str = "",
|
|
78
|
+
scheduler_url: str = "",
|
|
79
|
+
device_id: str = "",
|
|
80
|
+
device_token: str = "",
|
|
81
|
+
video_device: str = "auto",
|
|
82
|
+
audio_device: str = "default",
|
|
83
|
+
audio_output_device: str = "default",
|
|
81
84
|
):
|
|
82
85
|
"""Join a LiveKit room, publish mic + cam, subscribe to data channel.
|
|
83
86
|
|
|
@@ -140,7 +143,8 @@ async def run_livekit(
|
|
|
140
143
|
try:
|
|
141
144
|
stream = rtc.AudioStream(track=track)
|
|
142
145
|
# Open output stream at 48kHz mono (Pi speaker)
|
|
143
|
-
|
|
146
|
+
output = None if str(audio_output_device).lower() in ("", "default", "auto") else audio_output_device
|
|
147
|
+
out = sd.OutputStream(samplerate=OUT_RATE, channels=1, dtype="int16", blocksize=0, device=output)
|
|
144
148
|
out.start()
|
|
145
149
|
_active_streams[sid] = out
|
|
146
150
|
|
|
@@ -203,13 +207,16 @@ async def run_livekit(
|
|
|
203
207
|
except Exception as e:
|
|
204
208
|
log.warning(f"sounddevice unavailable, skipping mic: {e}")
|
|
205
209
|
return
|
|
206
|
-
|
|
207
|
-
MIC_DEVICE =
|
|
208
|
-
try:
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
210
|
+
selected_mic = str(audio_device or "default")
|
|
211
|
+
MIC_DEVICE = None if selected_mic.lower() in ("", "default", "auto") else selected_mic
|
|
212
|
+
try:
|
|
213
|
+
if MIC_DEVICE is None:
|
|
214
|
+
log.info("mic: opening system default input")
|
|
215
|
+
else:
|
|
216
|
+
log.info(f"mic: opening device {MIC_DEVICE}")
|
|
217
|
+
except Exception:
|
|
218
|
+
log.warning(f"mic: device {MIC_DEVICE} not found, trying default")
|
|
219
|
+
MIC_DEVICE = None
|
|
213
220
|
try:
|
|
214
221
|
source = rtc.AudioSource(sample_rate=16000, num_channels=1)
|
|
215
222
|
track = rtc.LocalAudioTrack.create_audio_track("mic", source)
|
|
@@ -241,9 +248,11 @@ async def run_livekit(
|
|
|
241
248
|
except Exception as e:
|
|
242
249
|
log.warning(f"opencv unavailable, skipping camera: {e}")
|
|
243
250
|
return
|
|
244
|
-
|
|
245
|
-
if
|
|
246
|
-
|
|
251
|
+
selected_camera = str(video_device or "auto")
|
|
252
|
+
camera_index = 0 if selected_camera.lower() in ("", "auto", "default", "first") else selected_camera
|
|
253
|
+
cap = cv2.VideoCapture(camera_index, cv2.CAP_V4L2)
|
|
254
|
+
if not cap.isOpened():
|
|
255
|
+
log.warning(f"camera {selected_camera} not available")
|
|
247
256
|
return
|
|
248
257
|
cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
|
|
249
258
|
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
|
|
@@ -286,4 +295,4 @@ async def run_livekit(
|
|
|
286
295
|
except Exception: pass
|
|
287
296
|
try: await room.disconnect()
|
|
288
297
|
except Exception: pass
|
|
289
|
-
log.info("left room, will loop to reconnect")
|
|
298
|
+
log.info("left room, will loop to reconnect")
|
|
@@ -4235,6 +4235,7 @@ async def dashboard():
|
|
|
4235
4235
|
const savedVideo = device?.video_device || 'auto';
|
|
4236
4236
|
const savedAudio = device?.audio_device || 'default';
|
|
4237
4237
|
const hardware = device?.device_inventory || {};
|
|
4238
|
+
const inventoryReported = !!device?.device_inventory;
|
|
4238
4239
|
const videoOptions = (hardware.video || []).filter(Boolean);
|
|
4239
4240
|
const audioOptions = (hardware.audio_input || []).filter(Boolean);
|
|
4240
4241
|
const audioOutputOptions = (hardware.audio_output || []).filter(Boolean);
|
|
@@ -4251,6 +4252,7 @@ async def dashboard():
|
|
|
4251
4252
|
map[robot.id] = {
|
|
4252
4253
|
deviceId: device?.id || null,
|
|
4253
4254
|
robotName: robot.name,
|
|
4255
|
+
inventoryReported,
|
|
4254
4256
|
selectedVideo: savedVideo,
|
|
4255
4257
|
selectedAudio: savedAudio,
|
|
4256
4258
|
selectedAudioOutput: device?.audio_output_device || 'default',
|
|
@@ -5133,6 +5135,21 @@ async def dashboard():
|
|
|
5133
5135
|
<span>Robot production: <strong :class="devices.find(d => d.id === deviceInventory[robot.id].deviceId)?.production_mode ? 'text-green-400' : 'text-gray-400'">{{ devices.find(d => d.id === deviceInventory[robot.id].deviceId)?.production_mode ? 'ON' : 'OFF' }}</strong></span>
|
|
5134
5136
|
<button @click="toggleDeviceProduction(robot.id)" class="px-2 py-1 rounded bg-gray-600 hover:bg-gray-500">Toggle</button>
|
|
5135
5137
|
</div>
|
|
5138
|
+
<div v-if="deviceInventory[robot.id]" class="mb-3 rounded bg-gray-800/70 p-2">
|
|
5139
|
+
<div class="flex items-center justify-between mb-2 text-xs font-semibold text-gray-300">
|
|
5140
|
+
<span>Robot Hardware</span>
|
|
5141
|
+
<span :class="deviceInventory[robot.id].inventoryReported ? 'text-green-300' : 'text-amber-300'">{{ deviceInventory[robot.id].inventoryReported ? 'inventory reported' : 'waiting for heartbeat' }}</span>
|
|
5142
|
+
</div>
|
|
5143
|
+
<div class="grid grid-cols-3 gap-2 text-sm">
|
|
5144
|
+
<div><label class="text-xs text-gray-500">Camera</label><select v-model="deviceInventory[robot.id].selectedVideo" @change="updateDeviceMedia(robot.id)" class="w-full bg-gray-700 rounded px-2 py-1 text-xs"><option v-for="opt in deviceInventory[robot.id].videoOptions" :key="deviceInventory[robot.id].optionId(opt)" :value="deviceInventory[robot.id].optionId(opt)">{{ deviceInventory[robot.id].optionName(opt) }}</option></select></div>
|
|
5145
|
+
<div><label class="text-xs text-gray-500">Microphone</label><select v-model="deviceInventory[robot.id].selectedAudio" @change="updateDeviceMedia(robot.id)" class="w-full bg-gray-700 rounded px-2 py-1 text-xs"><option v-for="opt in deviceInventory[robot.id].audioOptions" :key="deviceInventory[robot.id].optionId(opt)" :value="deviceInventory[robot.id].optionId(opt)">{{ deviceInventory[robot.id].optionName(opt) }}</option></select></div>
|
|
5146
|
+
<div><label class="text-xs text-gray-500">Speaker</label><select v-model="deviceInventory[robot.id].selectedAudioOutput" @change="updateDeviceMedia(robot.id)" class="w-full bg-gray-700 rounded px-2 py-1 text-xs"><option v-for="opt in deviceInventory[robot.id].audioOutputOptions" :key="deviceInventory[robot.id].optionId(opt)" :value="deviceInventory[robot.id].optionId(opt)">{{ deviceInventory[robot.id].optionName(opt) }}</option></select></div>
|
|
5147
|
+
</div>
|
|
5148
|
+
<div class="grid grid-cols-2 gap-2 mt-2">
|
|
5149
|
+
<button @click="startPreview(robot)" :disabled="!settings.production_mode || !livekit.url || !livekit.has_secret || preview.active" class="px-2 py-1 rounded text-xs bg-cyan-600 hover:bg-cyan-500 disabled:bg-gray-700 disabled:cursor-not-allowed">Camera Feed</button>
|
|
5150
|
+
<button @click="testSpeaker(robot)" class="px-2 py-1 rounded text-xs bg-emerald-700 hover:bg-emerald-600">Audio Test</button>
|
|
5151
|
+
</div>
|
|
5152
|
+
</div>
|
|
5136
5153
|
<div class="grid grid-cols-2 gap-2 mb-2 text-sm">
|
|
5137
5154
|
<div>
|
|
5138
5155
|
<label class="text-xs text-gray-500">Character</label>
|
|
@@ -5148,48 +5165,15 @@ async def dashboard():
|
|
|
5148
5165
|
<div class="text-xs text-gray-400 px-2 py-1 bg-gray-800 rounded truncate">{{ stackName(robot.voice_stack_id) }}</div>
|
|
5149
5166
|
</div>
|
|
5150
5167
|
</div>
|
|
5151
|
-
<div v-if="deviceInventory[robot.id]" class="space-y-2 text-sm">
|
|
5152
|
-
<div class="
|
|
5153
|
-
|
|
5154
|
-
<label class="text-xs text-gray-500">Camera</label>
|
|
5155
|
-
<select v-model="deviceInventory[robot.id].selectedVideo"
|
|
5156
|
-
@change="updateDeviceMedia(robot.id)"
|
|
5157
|
-
class="w-full bg-gray-800 rounded px-2 py-1 text-xs">
|
|
5158
|
-
<option v-for="opt in deviceInventory[robot.id].videoOptions" :key="deviceInventory[robot.id].optionId(opt)" :value="deviceInventory[robot.id].optionId(opt)">{{ deviceInventory[robot.id].optionName(opt) }}</option>
|
|
5159
|
-
</select>
|
|
5160
|
-
</div>
|
|
5161
|
-
<div>
|
|
5162
|
-
<label class="text-xs text-gray-500">Microphone</label>
|
|
5163
|
-
<select v-model="deviceInventory[robot.id].selectedAudio"
|
|
5164
|
-
@change="updateDeviceMedia(robot.id)"
|
|
5165
|
-
class="w-full bg-gray-800 rounded px-2 py-1 text-xs">
|
|
5166
|
-
<option v-for="opt in deviceInventory[robot.id].audioOptions" :key="deviceInventory[robot.id].optionId(opt)" :value="deviceInventory[robot.id].optionId(opt)">{{ deviceInventory[robot.id].optionName(opt) }}</option>
|
|
5167
|
-
</select>
|
|
5168
|
-
</div>
|
|
5169
|
-
<div>
|
|
5170
|
-
<label class="text-xs text-gray-500">Speaker</label>
|
|
5171
|
-
<select v-model="deviceInventory[robot.id].selectedAudioOutput"
|
|
5172
|
-
@change="updateDeviceMedia(robot.id)"
|
|
5173
|
-
class="w-full bg-gray-800 rounded px-2 py-1 text-xs">
|
|
5174
|
-
<option v-for="opt in deviceInventory[robot.id].audioOutputOptions" :key="deviceInventory[robot.id].optionId(opt)" :value="deviceInventory[robot.id].optionId(opt)">{{ deviceInventory[robot.id].optionName(opt) }}</option>
|
|
5175
|
-
</select>
|
|
5176
|
-
</div>
|
|
5168
|
+
<div v-if="deviceInventory[robot.id]" class="space-y-2 text-sm">
|
|
5169
|
+
<div v-if="!deviceInventory[robot.id].inventoryReported" class="rounded bg-amber-900/40 border border-amber-700 px-2 py-1 text-xs text-amber-200">
|
|
5170
|
+
Hardware inventory not reported yet. Keep the Pi client or preview agent running for one heartbeat, then refresh.
|
|
5177
5171
|
</div>
|
|
5178
5172
|
<div>
|
|
5179
5173
|
<label class="text-xs text-gray-500">Cached greeting phrases (one per line)</label>
|
|
5180
5174
|
<textarea v-model="deviceInventory[robot.id].greetingText" rows="2" class="w-full bg-gray-800 rounded px-2 py-1 text-xs" placeholder="Hello! Want to go for a ride?"></textarea>
|
|
5181
5175
|
<button @click="saveDeviceGreetings(robot.id)" class="mt-1 px-2 py-1 rounded bg-gray-600 hover:bg-gray-500 text-xs">Save Greetings</button>
|
|
5182
5176
|
</div>
|
|
5183
|
-
<button @click="startPreview(robot)"
|
|
5184
|
-
:disabled="!settings.production_mode || !livekit.url || !livekit.has_secret || preview.active"
|
|
5185
|
-
:class="(settings.production_mode && livekit.url && livekit.has_secret && !preview.active) ? 'bg-cyan-600 hover:bg-cyan-700' : 'bg-gray-700 cursor-not-allowed'"
|
|
5186
|
-
class="w-full px-2 py-1 rounded text-xs">
|
|
5187
|
-
{{ preview.active && preview.robot?.id === robot.id ? 'Preview Active' : 'Start Preview' }}
|
|
5188
|
-
</button>
|
|
5189
|
-
<button @click="testSpeaker(robot)"
|
|
5190
|
-
class="w-full px-2 py-1 rounded text-xs bg-emerald-700 hover:bg-emerald-600">
|
|
5191
|
-
Test Speaker + Mic
|
|
5192
|
-
</button>
|
|
5193
5177
|
<div class="grid grid-cols-2 gap-2">
|
|
5194
5178
|
<button @click="simulateTrigger(robot)"
|
|
5195
5179
|
:disabled="!settings.production_mode || simulation.busy || robot.status === 'connecting' || robot.status === 'running'"
|
|
@@ -69,6 +69,7 @@ logger = logging.getLogger("robopark.preview_agent")
|
|
|
69
69
|
_DEVICE_INVENTORY_CACHE: Optional[dict] = None
|
|
70
70
|
_DEVICE_INVENTORY_CACHE_AT = 0.0
|
|
71
71
|
DEVICE_INVENTORY_CACHE_SECONDS = 30.0
|
|
72
|
+
ROBOVISION_MEDIA_URL = os.getenv("ROBOVISION_MEDIA_URL", "http://127.0.0.1:5000/api/media/inventory")
|
|
72
73
|
|
|
73
74
|
|
|
74
75
|
def _normalize_livekit_url(url: Optional[str]) -> Optional[str]:
|
|
@@ -141,50 +142,67 @@ def _get_device_inventory() -> dict:
|
|
|
141
142
|
and now - _DEVICE_INVENTORY_CACHE_AT < DEVICE_INVENTORY_CACHE_SECONDS):
|
|
142
143
|
return _DEVICE_INVENTORY_CACHE
|
|
143
144
|
|
|
144
|
-
inventory = {"video": [], "audio_input": [], "audio_output": [], "platform": sys.platform}
|
|
145
|
-
inventory["video"].append({"id": "auto", "name": "Auto detect"})
|
|
146
|
-
inventory["video"].append({"id": "none", "name": "Disable camera"})
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
145
|
+
inventory = {"video": [], "audio_input": [], "audio_output": [], "platform": sys.platform}
|
|
146
|
+
inventory["video"].append({"id": "auto", "name": "Auto detect"})
|
|
147
|
+
inventory["video"].append({"id": "none", "name": "Disable camera"})
|
|
148
|
+
|
|
149
|
+
# RoboVisionAI_PI owns the production camera. Prefer its native inventory
|
|
150
|
+
# so this process never probes an already-open V4L2 device just to fill a
|
|
151
|
+
# dashboard dropdown.
|
|
152
|
+
robovision_inventory = None
|
|
153
|
+
try:
|
|
154
|
+
response = httpx.get(ROBOVISION_MEDIA_URL, timeout=0.8)
|
|
155
|
+
if response.is_success:
|
|
156
|
+
robovision_inventory = response.json()
|
|
157
|
+
for key in ("video", "audio_input", "audio_output"):
|
|
158
|
+
if isinstance(robovision_inventory.get(key), list):
|
|
159
|
+
inventory[key] = robovision_inventory[key]
|
|
160
|
+
inventory["source"] = robovision_inventory.get("source", "robovision_pi")
|
|
161
|
+
except Exception:
|
|
162
|
+
pass
|
|
163
|
+
|
|
164
|
+
if not robovision_inventory:
|
|
165
|
+
try:
|
|
166
|
+
import cv2
|
|
167
|
+
candidates = sorted(glob.glob("/dev/video*")) if os.name != "nt" else [str(i) for i in range(10)]
|
|
168
|
+
for candidate in candidates:
|
|
169
|
+
value = int(candidate) if os.name == "nt" else candidate
|
|
170
|
+
backend = cv2.CAP_DSHOW if os.name == "nt" else cv2.CAP_ANY
|
|
171
|
+
cap = cv2.VideoCapture(value, backend)
|
|
172
|
+
if cap.isOpened():
|
|
173
|
+
device_id = str(value)
|
|
174
|
+
inventory["video"].append({"id": device_id, "name": f"Camera {candidate}", "backend": "dshow" if os.name == "nt" else "v4l2"})
|
|
175
|
+
cap.release()
|
|
176
|
+
except Exception as e:
|
|
177
|
+
logger.debug(f"camera inventory unavailable: {e}")
|
|
178
|
+
|
|
179
|
+
if not robovision_inventory:
|
|
180
|
+
try:
|
|
181
|
+
import pyaudio
|
|
182
|
+
pa = pyaudio.PyAudio()
|
|
183
|
+
default_in = None
|
|
184
|
+
default_out = None
|
|
185
|
+
try:
|
|
186
|
+
wasapi = pa.get_host_api_info_by_type(pyaudio.paWASAPI)
|
|
187
|
+
default_in = wasapi.get("defaultInputDevice")
|
|
188
|
+
default_out = wasapi.get("defaultOutputDevice")
|
|
189
|
+
except Exception:
|
|
190
|
+
pass
|
|
191
|
+
inventory["audio_input"].append({"id": "default", "name": "System default input"})
|
|
192
|
+
inventory["audio_output"].append({"id": "default", "name": "System default output"})
|
|
193
|
+
for i in range(pa.get_device_count()):
|
|
194
|
+
info = pa.get_device_info_by_index(i)
|
|
195
|
+
name = str(info.get("name", f"Audio device {i}"))
|
|
196
|
+
item = {"id": str(i), "name": name, "host_api": str(info.get("hostApi", ""))}
|
|
197
|
+
if info.get("maxInputChannels", 0) > 0:
|
|
198
|
+
item["default"] = i == default_in
|
|
199
|
+
inventory["audio_input"].append(item.copy())
|
|
200
|
+
if info.get("maxOutputChannels", 0) > 0:
|
|
201
|
+
item["default"] = i == default_out
|
|
202
|
+
inventory["audio_output"].append(item.copy())
|
|
203
|
+
pa.terminate()
|
|
204
|
+
except Exception as e:
|
|
205
|
+
logger.debug(f"audio inventory unavailable: {e}")
|
|
188
206
|
|
|
189
207
|
_DEVICE_INVENTORY_CACHE = inventory
|
|
190
208
|
_DEVICE_INVENTORY_CACHE_AT = now
|
|
@@ -318,8 +336,10 @@ class PreviewAgent:
|
|
|
318
336
|
self.device_id: Optional[str] = cfg.get("device_id")
|
|
319
337
|
self.enrollment_token: Optional[str] = cfg.get("enrollment_token")
|
|
320
338
|
self.video_device = cfg.get("video_device", os.getenv("VIDEO_DEVICE", "auto"))
|
|
321
|
-
self.audio_device = cfg.get("audio_device", os.getenv("AUDIO_DEVICE", "default"))
|
|
322
|
-
self.audio_output_device = cfg.get("audio_output_device", os.getenv("AUDIO_OUTPUT_DEVICE", "default"))
|
|
339
|
+
self.audio_device = cfg.get("audio_device", os.getenv("AUDIO_DEVICE", "default"))
|
|
340
|
+
self.audio_output_device = cfg.get("audio_output_device", os.getenv("AUDIO_OUTPUT_DEVICE", "default"))
|
|
341
|
+
self.robovision_url = cfg.get("robovision_url", os.getenv("ROBOVISION_URL", "http://127.0.0.1:5000"))
|
|
342
|
+
self.use_robovision_camera = bool(cfg.get("use_robovision_camera", False))
|
|
323
343
|
self.width = int(cfg.get("video_width", os.getenv("VIDEO_WIDTH", DEFAULT_VIDEO_WIDTH)))
|
|
324
344
|
self.height = int(cfg.get("video_height", os.getenv("VIDEO_HEIGHT", DEFAULT_VIDEO_HEIGHT)))
|
|
325
345
|
self.fps = int(cfg.get("video_fps", os.getenv("VIDEO_FPS", DEFAULT_FPS)))
|
|
@@ -524,7 +544,7 @@ class PreviewAgent:
|
|
|
524
544
|
if data.get("trigger"):
|
|
525
545
|
await self._on_vision_motion({"source": data.get("source", "dashboard")})
|
|
526
546
|
|
|
527
|
-
async def _poll_device_config(self) -> None:
|
|
547
|
+
async def _poll_device_config(self) -> None:
|
|
528
548
|
"""Apply dashboard-selected camera and microphone IDs before preview."""
|
|
529
549
|
if not self._session or not self.device_id or not self.device_token:
|
|
530
550
|
return
|
|
@@ -538,16 +558,41 @@ class PreviewAgent:
|
|
|
538
558
|
headers={"Authorization": f"Bearer {self.device_token}"},
|
|
539
559
|
timeout=10.0,
|
|
540
560
|
)
|
|
541
|
-
r.raise_for_status()
|
|
542
|
-
data = r.json()
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
self.
|
|
547
|
-
if data.get("
|
|
548
|
-
self.
|
|
549
|
-
|
|
550
|
-
|
|
561
|
+
r.raise_for_status()
|
|
562
|
+
data = r.json()
|
|
563
|
+
changed = False
|
|
564
|
+
if data.get("video_device") is not None:
|
|
565
|
+
changed = changed or self.video_device != data["video_device"]
|
|
566
|
+
self.video_device = data["video_device"]
|
|
567
|
+
if data.get("audio_device") is not None:
|
|
568
|
+
changed = changed or self.audio_device != data["audio_device"]
|
|
569
|
+
self.audio_device = data["audio_device"]
|
|
570
|
+
if data.get("audio_output_device") is not None:
|
|
571
|
+
changed = changed or self.audio_output_device != data["audio_output_device"]
|
|
572
|
+
self.audio_output_device = data["audio_output_device"]
|
|
573
|
+
if changed:
|
|
574
|
+
await self._sync_robovision_media_config()
|
|
575
|
+
except Exception as e:
|
|
576
|
+
logger.debug(f"device config poll failed: {e}")
|
|
577
|
+
|
|
578
|
+
async def _sync_robovision_media_config(self) -> None:
|
|
579
|
+
"""Apply scheduler media choices to the local RoboVisionAI_PI owner."""
|
|
580
|
+
try:
|
|
581
|
+
response = await self._session.post(
|
|
582
|
+
f"{self.robovision_url.rstrip('/')}/api/media/config",
|
|
583
|
+
json={
|
|
584
|
+
"video_device": self.video_device,
|
|
585
|
+
"audio_device": self.audio_device,
|
|
586
|
+
"audio_output_device": self.audio_output_device,
|
|
587
|
+
},
|
|
588
|
+
timeout=1.0,
|
|
589
|
+
)
|
|
590
|
+
if response.is_success:
|
|
591
|
+
global _DEVICE_INVENTORY_CACHE
|
|
592
|
+
_DEVICE_INVENTORY_CACHE = None
|
|
593
|
+
except Exception:
|
|
594
|
+
# RoboVision is optional on laptops and simulation nodes.
|
|
595
|
+
pass
|
|
551
596
|
|
|
552
597
|
async def _check_vision_session(self) -> None:
|
|
553
598
|
"""Decide whether the active motion-triggered session should keep
|
|
@@ -908,7 +953,8 @@ class LiveKitPublisher:
|
|
|
908
953
|
if video_enabled:
|
|
909
954
|
try:
|
|
910
955
|
self._capture = await asyncio.to_thread(
|
|
911
|
-
|
|
956
|
+
create_video_capture, self.agent.video_device, self.agent.width, self.agent.height, self.agent.fps,
|
|
957
|
+
self.agent.robovision_url if self.agent.use_robovision_camera else None,
|
|
912
958
|
)
|
|
913
959
|
except Exception as e:
|
|
914
960
|
# Camera availability must not block the microphone/session.
|
|
@@ -1389,15 +1435,26 @@ class Picamera2Capture(VideoCapture):
|
|
|
1389
1435
|
pass
|
|
1390
1436
|
|
|
1391
1437
|
|
|
1392
|
-
def create_video_capture(device: str, width: int, height: int, fps: int
|
|
1438
|
+
def create_video_capture(device: str, width: int, height: int, fps: int,
|
|
1439
|
+
robovision_url: Optional[str] = None) -> Optional[VideoCapture]:
|
|
1393
1440
|
if device.lower() in ("none", "", "false", "null"):
|
|
1394
1441
|
return None
|
|
1395
|
-
try:
|
|
1442
|
+
try:
|
|
1396
1443
|
from livekit import rtc
|
|
1397
1444
|
_ = rtc.VideoSource
|
|
1398
1445
|
except Exception as e:
|
|
1399
1446
|
logger.error(f"livekit python sdk not installed: {e}")
|
|
1400
|
-
return None
|
|
1447
|
+
return None
|
|
1448
|
+
|
|
1449
|
+
if robovision_url:
|
|
1450
|
+
stream_url = f"{robovision_url.rstrip('/')}/video_feed"
|
|
1451
|
+
try:
|
|
1452
|
+
cap = OpencvVideoCapture(stream_url, width, height, fps)
|
|
1453
|
+
logger.info(f"using RoboVisionAI_PI camera stream {stream_url}")
|
|
1454
|
+
return cap
|
|
1455
|
+
except Exception as e:
|
|
1456
|
+
logger.error(f"RoboVision camera stream unavailable: {e}")
|
|
1457
|
+
return None
|
|
1401
1458
|
|
|
1402
1459
|
# Auto-detect: prefer first V4L2 device, fall back to picamera2 if available.
|
|
1403
1460
|
if device.lower() in ("auto", "default", "first"):
|
|
@@ -1538,7 +1595,9 @@ def main() -> None:
|
|
|
1538
1595
|
parser.add_argument("--device-token", default=os.getenv("DEVICE_TOKEN"))
|
|
1539
1596
|
parser.add_argument("--enrollment-token", default=os.getenv("ENROLLMENT_TOKEN"))
|
|
1540
1597
|
parser.add_argument("--video-device", default=os.getenv("VIDEO_DEVICE", "auto"))
|
|
1541
|
-
parser.add_argument("--audio-device", default=os.getenv("AUDIO_DEVICE", "default"))
|
|
1598
|
+
parser.add_argument("--audio-device", default=os.getenv("AUDIO_DEVICE", "default"))
|
|
1599
|
+
parser.add_argument("--robovision-url", default=None,
|
|
1600
|
+
help="use RoboVisionAI_PI's /video_feed as the LiveKit camera source")
|
|
1542
1601
|
parser.add_argument("--width", type=int, default=int(os.getenv("VIDEO_WIDTH", DEFAULT_VIDEO_WIDTH)))
|
|
1543
1602
|
parser.add_argument("--height", type=int, default=int(os.getenv("VIDEO_HEIGHT", DEFAULT_VIDEO_HEIGHT)))
|
|
1544
1603
|
parser.add_argument("--fps", type=int, default=int(os.getenv("VIDEO_FPS", DEFAULT_FPS)))
|
|
@@ -1561,7 +1620,9 @@ def main() -> None:
|
|
|
1561
1620
|
"scheduler_url": args.scheduler_url,
|
|
1562
1621
|
"robot_id": args.robot_id,
|
|
1563
1622
|
"video_device": args.video_device,
|
|
1564
|
-
"audio_device": args.audio_device,
|
|
1623
|
+
"audio_device": args.audio_device,
|
|
1624
|
+
"robovision_url": args.robovision_url or os.getenv("ROBOVISION_URL", "http://127.0.0.1:5000"),
|
|
1625
|
+
"use_robovision_camera": bool(args.robovision_url),
|
|
1565
1626
|
"video_width": args.width,
|
|
1566
1627
|
"video_height": args.height,
|
|
1567
1628
|
"video_fps": args.fps,
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
from flask import Flask, Response, jsonify, request
|
|
2
2
|
from flask_cors import CORS
|
|
3
|
-
import argparse
|
|
4
|
-
import
|
|
3
|
+
import argparse
|
|
4
|
+
import glob
|
|
5
|
+
import cv2
|
|
5
6
|
import os
|
|
6
7
|
import sys
|
|
7
8
|
import time
|
|
@@ -14,8 +15,10 @@ app = Flask(__name__)
|
|
|
14
15
|
CORS(app)
|
|
15
16
|
|
|
16
17
|
# Camera management
|
|
17
|
-
current_camera_index = 0
|
|
18
|
-
camera = None
|
|
18
|
+
current_camera_index = 0
|
|
19
|
+
camera = None
|
|
20
|
+
audio_input_device = "default"
|
|
21
|
+
audio_output_device = "default"
|
|
19
22
|
|
|
20
23
|
def _open_camera(index):
|
|
21
24
|
# On Windows, cv2.VideoCapture(index) with no explicit backend can
|
|
@@ -312,21 +315,24 @@ def caption_mode():
|
|
|
312
315
|
caption_mode_enabled = bool(data.get('enabled', False))
|
|
313
316
|
return jsonify({"enabled": caption_mode_enabled})
|
|
314
317
|
|
|
315
|
-
@app.route('/api/cameras', methods=['GET'])
|
|
316
|
-
def list_cameras():
|
|
317
|
-
available = []
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
318
|
+
@app.route('/api/cameras', methods=['GET'])
|
|
319
|
+
def list_cameras():
|
|
320
|
+
available = []
|
|
321
|
+
candidates = sorted(glob.glob('/dev/video*')) if sys.platform != 'win32' else list(range(4))
|
|
322
|
+
for candidate in candidates:
|
|
323
|
+
cap = _open_camera(candidate)
|
|
324
|
+
if cap.isOpened():
|
|
325
|
+
available.append({"index": candidate, "id": str(candidate), "name": f"Camera {candidate}", "backend": "v4l2" if sys.platform != 'win32' else "dshow"})
|
|
326
|
+
cap.release()
|
|
327
|
+
return jsonify({"cameras": available, "current": current_camera_index})
|
|
324
328
|
|
|
325
329
|
@app.route('/api/camera/switch', methods=['POST'])
|
|
326
|
-
def switch_camera():
|
|
330
|
+
def switch_camera():
|
|
327
331
|
global camera, current_camera_index
|
|
328
332
|
data = request.json or {}
|
|
329
|
-
new_index =
|
|
333
|
+
new_index = data.get('device', data.get('index', 0))
|
|
334
|
+
if isinstance(new_index, str) and new_index.isdigit():
|
|
335
|
+
new_index = int(new_index)
|
|
330
336
|
|
|
331
337
|
if camera:
|
|
332
338
|
camera.release()
|
|
@@ -334,7 +340,64 @@ def switch_camera():
|
|
|
334
340
|
current_camera_index = new_index
|
|
335
341
|
camera = None
|
|
336
342
|
|
|
337
|
-
return jsonify({"status": "ok", "camera_index": current_camera_index})
|
|
343
|
+
return jsonify({"status": "ok", "camera_index": current_camera_index})
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _audio_inventory():
|
|
347
|
+
inputs = [{"id": "default", "name": "System default input"}]
|
|
348
|
+
outputs = [{"id": "default", "name": "System default output"}]
|
|
349
|
+
try:
|
|
350
|
+
import pyaudio
|
|
351
|
+
pa = pyaudio.PyAudio()
|
|
352
|
+
for index in range(pa.get_device_count()):
|
|
353
|
+
info = pa.get_device_info_by_index(index)
|
|
354
|
+
item = {"id": str(index), "name": str(info.get("name", f"Audio device {index}")), "host_api": str(info.get("hostApi", ""))}
|
|
355
|
+
if info.get("maxInputChannels", 0) > 0:
|
|
356
|
+
inputs.append(item.copy())
|
|
357
|
+
if info.get("maxOutputChannels", 0) > 0:
|
|
358
|
+
outputs.append(item.copy())
|
|
359
|
+
pa.terminate()
|
|
360
|
+
except Exception:
|
|
361
|
+
pass
|
|
362
|
+
return inputs, outputs
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
@app.route('/api/media/inventory', methods=['GET'])
|
|
366
|
+
def media_inventory():
|
|
367
|
+
cameras = list_cameras().get_json().get("cameras", [])
|
|
368
|
+
if not cameras and sys.platform != 'win32':
|
|
369
|
+
# The active V4L2 handle may reject a second probe. The node path is
|
|
370
|
+
# still a valid selectable camera and is safer than hiding it.
|
|
371
|
+
cameras = [{"id": path, "index": path, "name": f"Camera {path}", "backend": "v4l2"}
|
|
372
|
+
for path in sorted(glob.glob('/dev/video*'))]
|
|
373
|
+
inputs, outputs = _audio_inventory()
|
|
374
|
+
return jsonify({
|
|
375
|
+
"video": [{"id": "auto", "name": "Auto detect"}, {"id": "none", "name": "Disable camera"}] + cameras,
|
|
376
|
+
"audio_input": inputs,
|
|
377
|
+
"audio_output": outputs,
|
|
378
|
+
"selected": {"video_device": str(current_camera_index), "audio_device": audio_input_device, "audio_output_device": audio_output_device},
|
|
379
|
+
"source": "robovision_pi",
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
@app.route('/api/media/config', methods=['GET', 'POST'])
|
|
384
|
+
def media_config():
|
|
385
|
+
global audio_input_device, audio_output_device, camera, current_camera_index
|
|
386
|
+
if request.method == 'POST':
|
|
387
|
+
data = request.json or {}
|
|
388
|
+
if "video_device" in data:
|
|
389
|
+
value = str(data["video_device"])
|
|
390
|
+
if value not in ("", "auto", "none"):
|
|
391
|
+
switch_camera_value = value
|
|
392
|
+
if camera:
|
|
393
|
+
camera.release()
|
|
394
|
+
current_camera_index = int(switch_camera_value) if switch_camera_value.isdigit() else switch_camera_value
|
|
395
|
+
camera = None
|
|
396
|
+
if "audio_device" in data:
|
|
397
|
+
audio_input_device = str(data["audio_device"])
|
|
398
|
+
if "audio_output_device" in data:
|
|
399
|
+
audio_output_device = str(data["audio_output_device"])
|
|
400
|
+
return jsonify({"video_device": str(current_camera_index), "audio_device": audio_input_device, "audio_output_device": audio_output_device, "source": "robovision_pi"})
|
|
338
401
|
|
|
339
402
|
@app.route('/api/motion/status', methods=['GET'])
|
|
340
403
|
def motion_status():
|