infinicode 2.8.46 β 2.8.51
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/kernel/federation/dashboard-html.d.ts +1 -1
- package/dist/kernel/federation/dashboard-html.js +7 -2
- package/dist/robopark/enroll.js +3 -3
- package/dist/robopark/preview-agent-launcher.d.ts +1 -0
- package/dist/robopark/preview-agent-launcher.js +5 -3
- package/dist/robopark/python-env.d.ts +6 -0
- package/dist/robopark/python-env.js +50 -1
- package/dist/robopark/setup.js +2 -0
- package/dist/robopark/vision-agent-launcher.js +4 -3
- 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
|
@@ -5867,7 +5867,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
5867
5867
|
var effPresetOptions='', effVoiceOptions='';
|
|
5868
5868
|
for(var ci=0;ci<characterPresets.length;ci++){ var cp=characterPresets[ci]; effPresetOptions += '<option value="'+esc(cp.id||'')+'"'+((cp.id===n.id)?' selected':'')+'>'+esc(cp.name||cp.id||'')+'</option>'; }
|
|
5869
5869
|
for(var vi=0;vi<voiceStacks.length;vi++){ var vs=voiceStacks[vi]; effVoiceOptions += '<option value="'+esc(vs.id||'')+'">'+esc(vs.name||vs.id||'')+'</option>'; }
|
|
5870
|
-
var devForPm=schedulerDevice(n)||{};
|
|
5870
|
+
var devForPm=schedulerDevice(n)||{};
|
|
5871
5871
|
b.innerHTML =
|
|
5872
5872
|
sect('π camera feed',
|
|
5873
5873
|
'<div class="pk-cam" id="pk-cam"><canvas id="pk-camcv" width="384" height="216"></canvas>'
|
|
@@ -5895,7 +5895,12 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
5895
5895
|
+ buildShellSection(schedId(n))
|
|
5896
5896
|
+ sect('π character / voice','<div class="pk-note" id="pk-eff-note" style="margin-bottom:0.45rem;">loading effective configβ¦</div><div class="rp-field"><label>Character preset</label><select class="rp-char" id="pk-eff-char" data-robot="'+esc(schedId(n))+'">'+effPresetOptions+'</select></div><div class="rp-field"><label>Voice stack</label><select class="rp-voice" id="pk-eff-voice" data-robot="'+esc(schedId(n))+'">'+effVoiceOptions+'</select></div><button class="rp-btn" id="pk-eff-save" data-robot="'+esc(schedId(n))+'" style="margin-top:0.35rem;">Set up on this robot</button>');
|
|
5897
5897
|
b.innerHTML += sect('Camera + audio devices','<div class="rp-field"><label>Search devices</label><input id="pk-device-search" placeholder="camera, USB, microphone, HDMI..." autocomplete="off"></div><div class="rp-field"><label>Camera input</label><select id="pk-video-device"></select></div><div class="rp-field"><label>Microphone input</label><select id="pk-audio-input"></select></div><div class="rp-field"><label>Speaker / output inventory</label><select id="pk-audio-output"></select></div><div class="rp-field"><label>Preset greeting phrases <span class="pk-note">one per line, cached per voice</span></label><textarea id="pk-greeting-phrases" rows="3" placeholder="Hi, I am Jaguar! / Welcome to RoboPark."></textarea></div><button class="rp-btn" id="pk-device-save" style="margin-top:0.35rem">Save device selection + greetings</button><div class="pk-note" id="pk-device-note">waiting for robot heartbeat inventory</div>');
|
|
5898
|
-
|
|
5898
|
+
// Put live hardware selection beside the camera and audio tests,
|
|
5899
|
+
// ahead of persona and service administration in the Park drawer.
|
|
5900
|
+
var deviceInput=$('pk-device-search'), deviceSection=deviceInput&&deviceInput.closest('.pk-sect');
|
|
5901
|
+
var speakerTest=$('pk-spk-test'), speakerSection=speakerTest&&speakerTest.closest('.pk-sect');
|
|
5902
|
+
if(deviceSection && speakerSection) speakerSection.after(deviceSection);
|
|
5903
|
+
refreshDeviceSelectors(n);
|
|
5899
5904
|
b.innerHTML += '<div class="pk-ctl" style="margin-top:0.5rem"><button class="rp-btn danger" id="pk-recover" data-robot="'+esc(n.name)+'">Recover robot</button><span class="pk-note" style="margin:0">clears stale sessions and queued triggers for this robot</span></div>';
|
|
5900
5905
|
var deviceSearch=$('pk-device-search'); if(deviceSearch) deviceSearch.addEventListener('input', function(){ refreshDeviceSelectors(n); });
|
|
5901
5906
|
var deviceSave=$('pk-device-save'); if(deviceSave) deviceSave.addEventListener('click', function(){ saveDeviceSelectors(n); });
|
package/dist/robopark/enroll.js
CHANGED
|
@@ -9,7 +9,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
|
9
9
|
import { homedir, hostname } from 'node:os';
|
|
10
10
|
import { join } from 'node:path';
|
|
11
11
|
import chalk from 'chalk';
|
|
12
|
-
import { findPython, findSchedulerPath,
|
|
12
|
+
import { findPython, findSchedulerPath, prepareRobotPython } from './python-env.js';
|
|
13
13
|
const ROBOPARK_DIR = join(homedir(), '.robopark');
|
|
14
14
|
const ENROLLMENT_FILE = join(ROBOPARK_DIR, 'enrollment_token');
|
|
15
15
|
function ensureDir() {
|
|
@@ -39,8 +39,8 @@ export async function roboparkEnroll(opts) {
|
|
|
39
39
|
console.log(chalk.red(' β could not find preview_agent.py'));
|
|
40
40
|
process.exit(1);
|
|
41
41
|
}
|
|
42
|
-
const python = findPython();
|
|
43
|
-
if (!
|
|
42
|
+
const python = prepareRobotPython(findPython(), script);
|
|
43
|
+
if (!python)
|
|
44
44
|
process.exit(1);
|
|
45
45
|
const args = [
|
|
46
46
|
script,
|
|
@@ -8,7 +8,7 @@ import { spawn } from 'node:child_process';
|
|
|
8
8
|
import { hostname } from 'node:os';
|
|
9
9
|
import chalk from 'chalk';
|
|
10
10
|
import { discoverContext } from './discovery.js';
|
|
11
|
-
import { findPython, findSchedulerPath,
|
|
11
|
+
import { findPython, findSchedulerPath, prepareRobotPython } from './python-env.js';
|
|
12
12
|
const DEFAULT_SCHEDULER_PORT = 8080;
|
|
13
13
|
export async function roboparkPreviewAgent(opts) {
|
|
14
14
|
const ctx = await discoverContext();
|
|
@@ -24,8 +24,8 @@ export async function roboparkPreviewAgent(opts) {
|
|
|
24
24
|
console.log(chalk.red(' β could not find preview_agent.py'));
|
|
25
25
|
process.exit(1);
|
|
26
26
|
}
|
|
27
|
-
const python = findPython();
|
|
28
|
-
if (!
|
|
27
|
+
const python = prepareRobotPython(findPython(), script);
|
|
28
|
+
if (!python)
|
|
29
29
|
process.exit(1);
|
|
30
30
|
const args = [
|
|
31
31
|
script,
|
|
@@ -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'));
|
|
@@ -13,3 +13,9 @@ export declare function findPython(): string;
|
|
|
13
13
|
* (e.g. app_pi_clean.py + requirements_pi_unified.txt).
|
|
14
14
|
*/
|
|
15
15
|
export declare function ensurePythonDeps(python: string, scriptPath: string, modules?: string[], reqFile?: string): boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Create and maintain the isolated robot runtime at ~/.robopark/venv.
|
|
18
|
+
* System site packages stay visible so Pi apt packages such as OpenCV,
|
|
19
|
+
* PyAudio and Picamera2 do not need to be rebuilt by pip on ARM.
|
|
20
|
+
*/
|
|
21
|
+
export declare function prepareRobotPython(basePython: string, scriptPath: string, modules?: string[], reqFile?: string): string | null;
|
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
* and `vision-agent`, which spawn preview_agent.py / app_pi_clean.py.
|
|
4
4
|
*/
|
|
5
5
|
import { execSync, spawnSync } from 'node:child_process';
|
|
6
|
-
import { existsSync } from 'node:fs';
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
7
8
|
import { join } from 'node:path';
|
|
9
|
+
import { createHash } from 'node:crypto';
|
|
8
10
|
import chalk from 'chalk';
|
|
9
11
|
/** Modules preview_agent.py imports β kept in sync with requirements-robot.txt. */
|
|
10
12
|
const REQUIRED_MODULES = ['httpx', 'cv2', 'pyaudio', 'livekit'];
|
|
@@ -52,6 +54,12 @@ export function findPython() {
|
|
|
52
54
|
}
|
|
53
55
|
return process.platform === 'win32' ? 'python' : 'python3';
|
|
54
56
|
}
|
|
57
|
+
const ROBOPARK_VENV = join(homedir(), '.robopark', 'venv');
|
|
58
|
+
function venvPythonPath() {
|
|
59
|
+
return process.platform === 'win32'
|
|
60
|
+
? join(ROBOPARK_VENV, 'Scripts', 'python.exe')
|
|
61
|
+
: join(ROBOPARK_VENV, 'bin', 'python');
|
|
62
|
+
}
|
|
55
63
|
function missingModules(python, modules) {
|
|
56
64
|
return modules.filter(mod => {
|
|
57
65
|
const res = spawnSync(python, ['-c', `import ${mod}`], { stdio: 'ignore' });
|
|
@@ -89,3 +97,44 @@ export function ensurePythonDeps(python, scriptPath, modules = REQUIRED_MODULES,
|
|
|
89
97
|
}
|
|
90
98
|
return true;
|
|
91
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* Create and maintain the isolated robot runtime at ~/.robopark/venv.
|
|
102
|
+
* System site packages stay visible so Pi apt packages such as OpenCV,
|
|
103
|
+
* PyAudio and Picamera2 do not need to be rebuilt by pip on ARM.
|
|
104
|
+
*/
|
|
105
|
+
export function prepareRobotPython(basePython, scriptPath, modules = REQUIRED_MODULES, reqFile = 'requirements-robot.txt') {
|
|
106
|
+
const reqPath = join(scriptPath, '..', reqFile);
|
|
107
|
+
if (!existsSync(reqPath)) {
|
|
108
|
+
console.log(chalk.red(` β ${reqFile} not found next to ${scriptPath}`));
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
const python = venvPythonPath();
|
|
112
|
+
if (!existsSync(python)) {
|
|
113
|
+
console.log(chalk.dim(` creating RoboPark Python environment: ${ROBOPARK_VENV}`));
|
|
114
|
+
mkdirSync(join(homedir(), '.robopark'), { recursive: true });
|
|
115
|
+
const created = spawnSync(basePython, ['-m', 'venv', '--system-site-packages', ROBOPARK_VENV], { stdio: 'inherit' });
|
|
116
|
+
if (created.status !== 0 || !existsSync(python)) {
|
|
117
|
+
console.log(chalk.red(' β could not create ~/.robopark/venv. On Debian/Raspberry Pi install python3-venv, then retry.'));
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const requirements = readFileSync(reqPath, 'utf8');
|
|
122
|
+
const fingerprint = createHash('sha256').update(requirements).digest('hex');
|
|
123
|
+
const marker = join(ROBOPARK_VENV, `.robopark-${reqFile}.sha256`);
|
|
124
|
+
const installedFingerprint = existsSync(marker) ? readFileSync(marker, 'utf8').trim() : '';
|
|
125
|
+
if (installedFingerprint !== fingerprint || missingModules(python, modules).length > 0) {
|
|
126
|
+
console.log(chalk.dim(` syncing RoboPark Python dependencies from ${reqFile}β¦`));
|
|
127
|
+
const install = spawnSync(python, ['-m', 'pip', 'install', '--upgrade', '-r', reqPath], { stdio: 'inherit' });
|
|
128
|
+
if (install.status !== 0) {
|
|
129
|
+
console.log(chalk.red(` β dependency install failed in ${ROBOPARK_VENV}`));
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
writeFileSync(marker, `${fingerprint}\n`);
|
|
133
|
+
}
|
|
134
|
+
const stillMissing = missingModules(python, modules);
|
|
135
|
+
if (stillMissing.length) {
|
|
136
|
+
console.log(chalk.red(` β venv is still missing: ${stillMissing.join(', ')}`));
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
return python;
|
|
140
|
+
}
|
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(' ')}`));
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { spawn } from 'node:child_process';
|
|
9
9
|
import chalk from 'chalk';
|
|
10
|
-
import { findPython, findVisionPath,
|
|
10
|
+
import { findPython, findVisionPath, prepareRobotPython, VISION_REQUIRED_MODULES } from './python-env.js';
|
|
11
11
|
const DEFAULT_VISION_PORT = 5000;
|
|
12
12
|
const DEFAULT_MOTION_WEBHOOK_PORT = 5057; // must match preview-agent's --vision-webhook-port default
|
|
13
13
|
function isPiArm() {
|
|
@@ -19,12 +19,13 @@ export async function roboparkVisionAgent(opts) {
|
|
|
19
19
|
console.log(chalk.red(' β could not find app_pi_clean.py'));
|
|
20
20
|
process.exit(1);
|
|
21
21
|
}
|
|
22
|
-
const
|
|
22
|
+
const basePython = findPython();
|
|
23
23
|
// Real Pi: opencv comes from `apt install python3-opencv`, not pip (see
|
|
24
24
|
// requirements_pi_unified.txt) β pip-installing it here would fight the
|
|
25
25
|
// ARM-optimized system build. Everywhere else: plain pip install works.
|
|
26
26
|
const reqFile = isPiArm() ? 'requirements_pi_unified.txt' : 'requirements-demo.txt';
|
|
27
|
-
|
|
27
|
+
const python = prepareRobotPython(basePython, script, VISION_REQUIRED_MODULES, reqFile);
|
|
28
|
+
if (!python)
|
|
28
29
|
process.exit(1);
|
|
29
30
|
const port = opts.port ?? String(DEFAULT_VISION_PORT);
|
|
30
31
|
const webhookUrl = opts.motionWebhookUrl ?? `http://127.0.0.1:${DEFAULT_MOTION_WEBHOOK_PORT}/`;
|
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'"
|