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.
@@ -11,6 +11,7 @@ export interface PreviewAgentOptions {
11
11
  visionWebhookPort?: string;
12
12
  visionTriggerCooldown?: string;
13
13
  visionSessionSeconds?: string;
14
+ robovisionUrl?: string;
14
15
  saveConfig?: boolean;
15
16
  foreground?: boolean;
16
17
  }
@@ -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'));
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinicode",
3
- "version": "2.8.46",
3
+ "version": "2.8.49",
4
4
  "description": "OpenKernel — provider-agnostic AI execution kernel. Native coding agent + mission-driven execution runtime.",
5
5
  "type": "module",
6
6
  "main": "./dist/kernel/index.js",
@@ -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 json
26
- import logging
27
- import os
28
- import signal
29
- import sys
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 = {"status": "online", "ip": self.tailscale_ip or self.lan_ip}
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
- out = sd.OutputStream(samplerate=OUT_RATE, channels=1, dtype="int16", blocksize=0)
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
- # The USB mic is ALSA device index 1 (1 in, 0 out). Default is output-only.
207
- MIC_DEVICE = 1
208
- try:
209
- log.info(f"mic: opening device {MIC_DEVICE} ({sd.query_devices()[MIC_DEVICE]['name']})")
210
- except Exception:
211
- log.warning(f"mic: device {MIC_DEVICE} not found, trying default")
212
- MIC_DEVICE = None
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
- cap = cv2.VideoCapture(0, cv2.CAP_V4L2)
245
- if not cap.isOpened():
246
- log.warning("camera /dev/video0 not available")
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="grid grid-cols-3 gap-2">
5153
- <div>
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
- try:
149
- import cv2
150
- candidates = sorted(glob.glob("/dev/video*")) if os.name != "nt" else [str(i) for i in range(10)]
151
- for candidate in candidates:
152
- value = int(candidate) if os.name == "nt" else candidate
153
- backend = cv2.CAP_DSHOW if os.name == "nt" else cv2.CAP_ANY
154
- cap = cv2.VideoCapture(value, backend)
155
- if cap.isOpened():
156
- device_id = str(value)
157
- inventory["video"].append({"id": device_id, "name": f"Camera {candidate}", "backend": "dshow" if os.name == "nt" else "v4l2"})
158
- cap.release()
159
- except Exception as e:
160
- logger.debug(f"camera inventory unavailable: {e}")
161
-
162
- try:
163
- import pyaudio
164
- pa = pyaudio.PyAudio()
165
- default_in = None
166
- default_out = None
167
- try:
168
- wasapi = pa.get_host_api_info_by_type(pyaudio.paWASAPI)
169
- default_in = wasapi.get("defaultInputDevice")
170
- default_out = wasapi.get("defaultOutputDevice")
171
- except Exception:
172
- pass
173
- inventory["audio_input"].append({"id": "default", "name": "System default input"})
174
- inventory["audio_output"].append({"id": "default", "name": "System default output"})
175
- for i in range(pa.get_device_count()):
176
- info = pa.get_device_info_by_index(i)
177
- name = str(info.get("name", f"Audio device {i}"))
178
- item = {"id": str(i), "name": name, "host_api": str(info.get("hostApi", ""))}
179
- if info.get("maxInputChannels", 0) > 0:
180
- item["default"] = i == default_in
181
- inventory["audio_input"].append(item.copy())
182
- if info.get("maxOutputChannels", 0) > 0:
183
- item["default"] = i == default_out
184
- inventory["audio_output"].append(item.copy())
185
- pa.terminate()
186
- except Exception as e:
187
- logger.debug(f"audio inventory unavailable: {e}")
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
- if data.get("video_device") is not None:
544
- self.video_device = data["video_device"]
545
- if data.get("audio_device") is not None:
546
- self.audio_device = data["audio_device"]
547
- if data.get("audio_output_device") is not None:
548
- self.audio_output_device = data["audio_output_device"]
549
- except Exception as e:
550
- logger.debug(f"device config poll failed: {e}")
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
- create_video_capture, self.agent.video_device, self.agent.width, self.agent.height, self.agent.fps
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) -> Optional[VideoCapture]:
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 cv2
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
- for i in range(4):
319
- cap = _open_camera(i)
320
- if cap.isOpened():
321
- available.append({"index": i, "name": f"Camera {i}"})
322
- cap.release()
323
- return jsonify({"cameras": available, "current": current_camera_index})
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 = int(data.get('index', 0))
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():