infinicode 2.8.45 → 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 +67 -42
- package/packages/robopark/scheduler/preview_agent.py +138 -72
- 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")
|
|
@@ -4230,23 +4230,37 @@ async def dashboard():
|
|
|
4230
4230
|
const buildDeviceInventory = () => {
|
|
4231
4231
|
const map = {};
|
|
4232
4232
|
for (const robot of robots.value) {
|
|
4233
|
-
const
|
|
4233
|
+
const robotName = String(robot.name || '').toLowerCase();
|
|
4234
|
+
const device = devices.value.find(d => String(d.name || '').toLowerCase() === robotName || d.id === robot.id);
|
|
4234
4235
|
const savedVideo = device?.video_device || 'auto';
|
|
4235
4236
|
const savedAudio = device?.audio_device || 'default';
|
|
4236
4237
|
const hardware = device?.device_inventory || {};
|
|
4237
|
-
const
|
|
4238
|
-
const
|
|
4239
|
-
const
|
|
4238
|
+
const inventoryReported = !!device?.device_inventory;
|
|
4239
|
+
const videoOptions = (hardware.video || []).filter(Boolean);
|
|
4240
|
+
const audioOptions = (hardware.audio_input || []).filter(Boolean);
|
|
4241
|
+
const audioOutputOptions = (hardware.audio_output || []).filter(Boolean);
|
|
4242
|
+
const optionId = x => typeof x === 'string' ? x : x.id;
|
|
4243
|
+
const optionName = x => typeof x === 'string' ? x : (x.name || x.id);
|
|
4244
|
+
const uniqueOptions = (items, defaults) => {
|
|
4245
|
+
const seen = new Set();
|
|
4246
|
+
return defaults.concat(items).filter(x => {
|
|
4247
|
+
const id = optionId(x);
|
|
4248
|
+
if (!id || seen.has(id)) return false;
|
|
4249
|
+
seen.add(id); return true;
|
|
4250
|
+
});
|
|
4251
|
+
};
|
|
4240
4252
|
map[robot.id] = {
|
|
4241
4253
|
deviceId: device?.id || null,
|
|
4242
4254
|
robotName: robot.name,
|
|
4255
|
+
inventoryReported,
|
|
4243
4256
|
selectedVideo: savedVideo,
|
|
4244
4257
|
selectedAudio: savedAudio,
|
|
4245
4258
|
selectedAudioOutput: device?.audio_output_device || 'default',
|
|
4246
4259
|
greetingText: (device?.greeting_phrases || []).join('\\n'),
|
|
4247
|
-
videoOptions:
|
|
4248
|
-
audioOptions:
|
|
4249
|
-
audioOutputOptions:
|
|
4260
|
+
videoOptions: uniqueOptions(videoOptions, ['auto', 'none', savedVideo]),
|
|
4261
|
+
audioOptions: uniqueOptions(audioOptions, ['default', 'none', savedAudio]),
|
|
4262
|
+
audioOutputOptions: uniqueOptions(audioOutputOptions, ['default', 'none', device?.audio_output_device || 'default']),
|
|
4263
|
+
optionId, optionName,
|
|
4250
4264
|
};
|
|
4251
4265
|
}
|
|
4252
4266
|
deviceInventory.value = map;
|
|
@@ -4366,7 +4380,7 @@ async def dashboard():
|
|
|
4366
4380
|
const inv = deviceInventory.value[robotId];
|
|
4367
4381
|
if (!inv || !inv.deviceId) return;
|
|
4368
4382
|
try {
|
|
4369
|
-
await fetch(`/api/devices/${inv.deviceId}`, {
|
|
4383
|
+
const response = await fetch(`/api/devices/${inv.deviceId}`, {
|
|
4370
4384
|
method: 'PATCH',
|
|
4371
4385
|
headers: {'Content-Type': 'application/json'},
|
|
4372
4386
|
body: JSON.stringify({
|
|
@@ -4416,6 +4430,31 @@ async def dashboard():
|
|
|
4416
4430
|
} catch (e) { console.warn('preview stop failed', e); }
|
|
4417
4431
|
}
|
|
4418
4432
|
};
|
|
4433
|
+
|
|
4434
|
+
const testSpeaker = async (robot) => {
|
|
4435
|
+
const inv = deviceInventory.value[robot.id];
|
|
4436
|
+
const deviceId = inv?.deviceId || robot.id;
|
|
4437
|
+
try {
|
|
4438
|
+
const response = await fetch(`/api/robots/${deviceId}/shell/speaker-test`, {
|
|
4439
|
+
method: 'POST', headers: {'Content-Type': 'application/json'},
|
|
4440
|
+
body: JSON.stringify({
|
|
4441
|
+
output: inv?.selectedAudioOutput || 'default',
|
|
4442
|
+
input: inv?.selectedAudio || 'default',
|
|
4443
|
+
duration: 0.6,
|
|
4444
|
+
}),
|
|
4445
|
+
});
|
|
4446
|
+
const queued = await response.json();
|
|
4447
|
+
if (!response.ok) throw new Error(queued.detail || `HTTP ${response.status}`);
|
|
4448
|
+
const result = await fetch(`/api/robots/${deviceId}/shell/wait/${queued.request_id}?timeout=12`).then(r => r.json());
|
|
4449
|
+
if (!result.completed) throw new Error('Robot did not answer. Check its heartbeat and supervisor.');
|
|
4450
|
+
const detail = result.result || {};
|
|
4451
|
+
alert(detail.ok
|
|
4452
|
+
? `Audio test passed\\nOutput: ${detail.output_device || 'default'}\\nInput: ${detail.input_device || 'default'}\\nPeak: ${detail.peak_db ?? 'n/a'} dB`
|
|
4453
|
+
: `Audio test failed: ${detail.error || 'no microphone signal'}`);
|
|
4454
|
+
} catch (e) {
|
|
4455
|
+
alert('Audio test failed: ' + e.message);
|
|
4456
|
+
}
|
|
4457
|
+
};
|
|
4419
4458
|
|
|
4420
4459
|
const simulateTrigger = async (robot) => {
|
|
4421
4460
|
if (!settings.value.production_mode || simulation.value.busy) return;
|
|
@@ -4766,14 +4805,14 @@ async def dashboard():
|
|
|
4766
4805
|
return {
|
|
4767
4806
|
robots, servers, sessions, stats, connected, serverMetrics,
|
|
4768
4807
|
devices, settings, livekit, showAddDevice, showDevices, showCommands, commandNetwork, commandHubLan, commandHubTailscale, commandRobotId, selectedCommandRobot, commandRows, copyCommand, newDevice, rotatedToken, enrollmentNetwork, enrollmentCommand, simulation,
|
|
4769
|
-
showLiveKitConfig, livekitForm, preview, deviceInventory,
|
|
4808
|
+
showLiveKitConfig, livekitForm, preview, deviceInventory,
|
|
4770
4809
|
voiceStacks, characterPresets, showVoiceStackForm, showCharacterForm,
|
|
4771
4810
|
editingVoiceStack, editingCharacter, voiceStackForm, characterForm,
|
|
4772
4811
|
loadModel, unloadModel, statusColor, formatDuration, formatTime,
|
|
4773
4812
|
toggleProductionMode, rotateEnrollmentToken, provisionAgentToken,
|
|
4774
4813
|
submitNewDevice, deleteDevice, rotateDeviceToken, dismissRotatedToken,
|
|
4775
4814
|
joinConversation, saveLiveKitConfig, openLiveKitConfig,
|
|
4776
|
-
startPreview, stopPreview, simulateTrigger, stopConversation, recoverRobot, toggleDeviceProduction, setDeviceProduction, saveDeviceGreetings, updateDeviceMedia,
|
|
4815
|
+
startPreview, stopPreview, simulateTrigger, stopConversation, recoverRobot, toggleDeviceProduction, setDeviceProduction, saveDeviceGreetings, updateDeviceMedia, testSpeaker,
|
|
4777
4816
|
openVoiceStackForm, saveVoiceStack, deleteVoiceStack,
|
|
4778
4817
|
openCharacterForm, saveCharacter, deleteCharacter,
|
|
4779
4818
|
assignRobotCharacter, stackName, characterName,
|
|
@@ -5096,6 +5135,21 @@ async def dashboard():
|
|
|
5096
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>
|
|
5097
5136
|
<button @click="toggleDeviceProduction(robot.id)" class="px-2 py-1 rounded bg-gray-600 hover:bg-gray-500">Toggle</button>
|
|
5098
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>
|
|
5099
5153
|
<div class="grid grid-cols-2 gap-2 mb-2 text-sm">
|
|
5100
5154
|
<div>
|
|
5101
5155
|
<label class="text-xs text-gray-500">Character</label>
|
|
@@ -5111,44 +5165,15 @@ async def dashboard():
|
|
|
5111
5165
|
<div class="text-xs text-gray-400 px-2 py-1 bg-gray-800 rounded truncate">{{ stackName(robot.voice_stack_id) }}</div>
|
|
5112
5166
|
</div>
|
|
5113
5167
|
</div>
|
|
5114
|
-
<div v-if="deviceInventory[robot.id]" class="space-y-2 text-sm">
|
|
5115
|
-
<div class="
|
|
5116
|
-
|
|
5117
|
-
<label class="text-xs text-gray-500">Camera</label>
|
|
5118
|
-
<select v-model="deviceInventory[robot.id].selectedVideo"
|
|
5119
|
-
@change="updateDeviceMedia(robot.id)"
|
|
5120
|
-
class="w-full bg-gray-800 rounded px-2 py-1 text-xs">
|
|
5121
|
-
<option v-for="opt in deviceInventory[robot.id].videoOptions" :key="opt" :value="opt">{{ opt }}</option>
|
|
5122
|
-
</select>
|
|
5123
|
-
</div>
|
|
5124
|
-
<div>
|
|
5125
|
-
<label class="text-xs text-gray-500">Microphone</label>
|
|
5126
|
-
<select v-model="deviceInventory[robot.id].selectedAudio"
|
|
5127
|
-
@change="updateDeviceMedia(robot.id)"
|
|
5128
|
-
class="w-full bg-gray-800 rounded px-2 py-1 text-xs">
|
|
5129
|
-
<option v-for="opt in deviceInventory[robot.id].audioOptions" :key="opt" :value="opt">{{ opt }}</option>
|
|
5130
|
-
</select>
|
|
5131
|
-
</div>
|
|
5132
|
-
<div>
|
|
5133
|
-
<label class="text-xs text-gray-500">Speaker</label>
|
|
5134
|
-
<select v-model="deviceInventory[robot.id].selectedAudioOutput"
|
|
5135
|
-
@change="updateDeviceMedia(robot.id)"
|
|
5136
|
-
class="w-full bg-gray-800 rounded px-2 py-1 text-xs">
|
|
5137
|
-
<option v-for="opt in deviceInventory[robot.id].audioOutputOptions" :key="opt" :value="opt">{{ opt }}</option>
|
|
5138
|
-
</select>
|
|
5139
|
-
</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.
|
|
5140
5171
|
</div>
|
|
5141
5172
|
<div>
|
|
5142
5173
|
<label class="text-xs text-gray-500">Cached greeting phrases (one per line)</label>
|
|
5143
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>
|
|
5144
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>
|
|
5145
5176
|
</div>
|
|
5146
|
-
<button @click="startPreview(robot)"
|
|
5147
|
-
:disabled="!settings.production_mode || !livekit.url || !livekit.has_secret || preview.active"
|
|
5148
|
-
:class="(settings.production_mode && livekit.url && livekit.has_secret && !preview.active) ? 'bg-cyan-600 hover:bg-cyan-700' : 'bg-gray-700 cursor-not-allowed'"
|
|
5149
|
-
class="w-full px-2 py-1 rounded text-xs">
|
|
5150
|
-
{{ preview.active && preview.robot?.id === robot.id ? 'Preview Active' : 'Start Preview' }}
|
|
5151
|
-
</button>
|
|
5152
5177
|
<div class="grid grid-cols-2 gap-2">
|
|
5153
5178
|
<button @click="simulateTrigger(robot)"
|
|
5154
5179
|
:disabled="!settings.production_mode || simulation.busy || robot.status === 'connecting' || robot.status === 'running'"
|
|
@@ -66,7 +66,10 @@ import httpx
|
|
|
66
66
|
|
|
67
67
|
logger = logging.getLogger("robopark.preview_agent")
|
|
68
68
|
|
|
69
|
-
_DEVICE_INVENTORY_CACHE: Optional[dict] = None
|
|
69
|
+
_DEVICE_INVENTORY_CACHE: Optional[dict] = None
|
|
70
|
+
_DEVICE_INVENTORY_CACHE_AT = 0.0
|
|
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")
|
|
70
73
|
|
|
71
74
|
|
|
72
75
|
def _normalize_livekit_url(url: Optional[str]) -> Optional[str]:
|
|
@@ -131,58 +134,78 @@ def _play_audio_effect(selected_output: str | None, effect: str) -> None:
|
|
|
131
134
|
pa.terminate()
|
|
132
135
|
|
|
133
136
|
|
|
134
|
-
def _get_device_inventory() -> dict:
|
|
135
|
-
"""Return discoverable camera and audio devices for dashboard selection."""
|
|
136
|
-
global _DEVICE_INVENTORY_CACHE
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
inventory
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
137
|
+
def _get_device_inventory() -> dict:
|
|
138
|
+
"""Return discoverable camera and audio devices for dashboard selection."""
|
|
139
|
+
global _DEVICE_INVENTORY_CACHE, _DEVICE_INVENTORY_CACHE_AT
|
|
140
|
+
now = time.monotonic()
|
|
141
|
+
if (_DEVICE_INVENTORY_CACHE is not None
|
|
142
|
+
and now - _DEVICE_INVENTORY_CACHE_AT < DEVICE_INVENTORY_CACHE_SECONDS):
|
|
143
|
+
return _DEVICE_INVENTORY_CACHE
|
|
144
|
+
|
|
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}")
|
|
206
|
+
|
|
207
|
+
_DEVICE_INVENTORY_CACHE = inventory
|
|
208
|
+
_DEVICE_INVENTORY_CACHE_AT = now
|
|
186
209
|
logger.info(
|
|
187
210
|
f"device inventory: {len(inventory['video']) - 2} cameras, "
|
|
188
211
|
f"{len(inventory['audio_input']) - 1} inputs, "
|
|
@@ -313,8 +336,10 @@ class PreviewAgent:
|
|
|
313
336
|
self.device_id: Optional[str] = cfg.get("device_id")
|
|
314
337
|
self.enrollment_token: Optional[str] = cfg.get("enrollment_token")
|
|
315
338
|
self.video_device = cfg.get("video_device", os.getenv("VIDEO_DEVICE", "auto"))
|
|
316
|
-
self.audio_device = cfg.get("audio_device", os.getenv("AUDIO_DEVICE", "default"))
|
|
317
|
-
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))
|
|
318
343
|
self.width = int(cfg.get("video_width", os.getenv("VIDEO_WIDTH", DEFAULT_VIDEO_WIDTH)))
|
|
319
344
|
self.height = int(cfg.get("video_height", os.getenv("VIDEO_HEIGHT", DEFAULT_VIDEO_HEIGHT)))
|
|
320
345
|
self.fps = int(cfg.get("video_fps", os.getenv("VIDEO_FPS", DEFAULT_FPS)))
|
|
@@ -519,7 +544,7 @@ class PreviewAgent:
|
|
|
519
544
|
if data.get("trigger"):
|
|
520
545
|
await self._on_vision_motion({"source": data.get("source", "dashboard")})
|
|
521
546
|
|
|
522
|
-
async def _poll_device_config(self) -> None:
|
|
547
|
+
async def _poll_device_config(self) -> None:
|
|
523
548
|
"""Apply dashboard-selected camera and microphone IDs before preview."""
|
|
524
549
|
if not self._session or not self.device_id or not self.device_token:
|
|
525
550
|
return
|
|
@@ -533,16 +558,41 @@ class PreviewAgent:
|
|
|
533
558
|
headers={"Authorization": f"Bearer {self.device_token}"},
|
|
534
559
|
timeout=10.0,
|
|
535
560
|
)
|
|
536
|
-
r.raise_for_status()
|
|
537
|
-
data = r.json()
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
self.
|
|
542
|
-
if data.get("
|
|
543
|
-
self.
|
|
544
|
-
|
|
545
|
-
|
|
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
|
|
546
596
|
|
|
547
597
|
async def _check_vision_session(self) -> None:
|
|
548
598
|
"""Decide whether the active motion-triggered session should keep
|
|
@@ -903,7 +953,8 @@ class LiveKitPublisher:
|
|
|
903
953
|
if video_enabled:
|
|
904
954
|
try:
|
|
905
955
|
self._capture = await asyncio.to_thread(
|
|
906
|
-
|
|
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,
|
|
907
958
|
)
|
|
908
959
|
except Exception as e:
|
|
909
960
|
# Camera availability must not block the microphone/session.
|
|
@@ -1384,15 +1435,26 @@ class Picamera2Capture(VideoCapture):
|
|
|
1384
1435
|
pass
|
|
1385
1436
|
|
|
1386
1437
|
|
|
1387
|
-
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]:
|
|
1388
1440
|
if device.lower() in ("none", "", "false", "null"):
|
|
1389
1441
|
return None
|
|
1390
|
-
try:
|
|
1442
|
+
try:
|
|
1391
1443
|
from livekit import rtc
|
|
1392
1444
|
_ = rtc.VideoSource
|
|
1393
1445
|
except Exception as e:
|
|
1394
1446
|
logger.error(f"livekit python sdk not installed: {e}")
|
|
1395
|
-
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
|
|
1396
1458
|
|
|
1397
1459
|
# Auto-detect: prefer first V4L2 device, fall back to picamera2 if available.
|
|
1398
1460
|
if device.lower() in ("auto", "default", "first"):
|
|
@@ -1533,7 +1595,9 @@ def main() -> None:
|
|
|
1533
1595
|
parser.add_argument("--device-token", default=os.getenv("DEVICE_TOKEN"))
|
|
1534
1596
|
parser.add_argument("--enrollment-token", default=os.getenv("ENROLLMENT_TOKEN"))
|
|
1535
1597
|
parser.add_argument("--video-device", default=os.getenv("VIDEO_DEVICE", "auto"))
|
|
1536
|
-
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")
|
|
1537
1601
|
parser.add_argument("--width", type=int, default=int(os.getenv("VIDEO_WIDTH", DEFAULT_VIDEO_WIDTH)))
|
|
1538
1602
|
parser.add_argument("--height", type=int, default=int(os.getenv("VIDEO_HEIGHT", DEFAULT_VIDEO_HEIGHT)))
|
|
1539
1603
|
parser.add_argument("--fps", type=int, default=int(os.getenv("VIDEO_FPS", DEFAULT_FPS)))
|
|
@@ -1556,7 +1620,9 @@ def main() -> None:
|
|
|
1556
1620
|
"scheduler_url": args.scheduler_url,
|
|
1557
1621
|
"robot_id": args.robot_id,
|
|
1558
1622
|
"video_device": args.video_device,
|
|
1559
|
-
"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),
|
|
1560
1626
|
"video_width": args.width,
|
|
1561
1627
|
"video_height": args.height,
|
|
1562
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():
|