robopark 2.8.35 → 3.0.0
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/README.md +88 -63
- package/bin/robopark.js +7 -17
- package/conversation/elevenlabs_agent.py +1985 -0
- package/conversation/requirements.txt +3 -0
- package/conversation/supervisor_store.py +189 -0
- package/dist/kernel/config-schema.js +37 -0
- package/dist/kernel/types.js +7 -0
- package/dist/robopark/access.js +99 -0
- package/dist/robopark/add-robot.js +188 -0
- package/dist/robopark/agent-ctl.js +305 -0
- package/dist/robopark/auto-start.js +289 -0
- package/dist/robopark/conversation.js +505 -0
- package/dist/robopark/deployment-commands.js +47 -0
- package/dist/robopark/discovery.js +180 -0
- package/dist/robopark/doctor.js +175 -0
- package/dist/robopark/enroll.js +68 -0
- package/dist/robopark/llm-set.js +87 -0
- package/dist/robopark/motor-control.js +195 -0
- package/dist/robopark/preview-agent-launcher.js +77 -0
- package/dist/robopark/probe.js +138 -0
- package/dist/robopark/profile.js +69 -0
- package/dist/robopark/python-env.js +162 -0
- package/dist/robopark/robot-runtime.js +489 -0
- package/dist/robopark/scan.js +97 -0
- package/dist/robopark/screen-control.js +55 -0
- package/dist/robopark/secrets.js +41 -0
- package/dist/robopark/serve.js +285 -0
- package/dist/robopark/server-add.js +114 -0
- package/dist/robopark/setup-livekit.js +300 -0
- package/dist/robopark/setup.js +286 -0
- package/dist/robopark/standalone.js +466 -0
- package/dist/robopark/stop-all.js +141 -0
- package/dist/robopark/verify.js +192 -0
- package/dist/robopark/vision-agent-launcher.js +98 -0
- package/dist/robopark/vision-control.js +81 -0
- package/dist/robopark-cli.js +799 -0
- package/package.json +21 -5
- package/pi-client/_install_steps.sh +29 -29
- package/pi-client/client.py +61 -2
- package/pi-client/install.sh +40 -40
- package/pi-client/join_convo.sh +54 -54
- package/pi-client/livekit_bridge.py +16 -7
- package/pi-client/motor_bridge.py +6 -3
- package/scheduler/fleet_config.json +75 -0
- package/scheduler/main.py +4505 -135
- package/scheduler/media_lock.py +57 -0
- package/scheduler/preview_agent.py +1465 -87
- package/scheduler/production_config.json +139 -0
- package/scheduler/robot_supervisor.py +1705 -0
- package/scheduler/scripts/install-robot-supervisor-linux.sh +33 -0
- package/scheduler/scripts/install-robot-supervisor-windows.ps1 +49 -0
- package/scheduler/scripts/robopark-supervisor.service +20 -0
- package/scheduler/scripts/start-scheduler-local.ps1 +50 -0
- package/scheduler/supervisor.example.json +26 -0
- package/scheduler/vision_motion_trigger.py +101 -0
- package/screen/screen_runtime.py +75 -0
- package/vision/app_pi_clean.py +253 -16
- package/vision/audio_server_pi.py +19 -0
- package/vision/install.sh +34 -34
- package/vision/motor_server.py +224 -61
- package/vision/requirements_camera.txt +6 -0
- package/vision/requirements_motor.txt +4 -0
- package/vision/requirements_pi_unified.txt +1 -0
- package/vision/requirements_vision_agent.txt +19 -0
- package/vision/run.sh +244 -244
- package/vision/services/services.sh +12 -12
- package/scheduler/__pycache__/main.cpython-312.pyc +0 -0
- package/scheduler/__pycache__/preview_agent.cpython-312.pyc +0 -0
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
3
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { homedir, hostname } from 'node:os';
|
|
5
|
+
import { join, resolve } from 'node:path';
|
|
6
|
+
import { randomBytes } from 'node:crypto';
|
|
7
|
+
import { registerAutoStart, unregisterAutoStart } from './auto-start.js';
|
|
8
|
+
import { findConversationPath, findPython, findVisionPath, prepareRobotPython, VISION_REQUIRED_MODULES, } from './python-env.js';
|
|
9
|
+
function schedulerTelemetryIdentity() {
|
|
10
|
+
const dir = join(homedir(), '.robopark');
|
|
11
|
+
try {
|
|
12
|
+
const preview = JSON.parse(readFileSync(join(dir, 'preview_agent.json'), 'utf8'));
|
|
13
|
+
const schedulerUrl = String(preview.scheduler_url ?? '').replace(/\/+$/, '');
|
|
14
|
+
const deviceId = String(preview.device_id ?? '').trim();
|
|
15
|
+
const tokenPath = join(dir, 'device_token');
|
|
16
|
+
const deviceToken = existsSync(tokenPath)
|
|
17
|
+
? readFileSync(tokenPath, 'utf8').trim()
|
|
18
|
+
: String(preview.device_token ?? '').trim();
|
|
19
|
+
return schedulerUrl && deviceId && deviceToken ? { schedulerUrl, deviceId, deviceToken } : null;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const PI_AUDIO_PACKAGES = [
|
|
26
|
+
'python3', 'python3-venv', 'python3-pip', 'python3-dev', 'build-essential',
|
|
27
|
+
'libportaudio2', 'portaudio19-dev', 'libasound2-dev', 'alsa-utils',
|
|
28
|
+
'libsndfile1', 'python3-pyaudio', 'psmisc', 'procps', 'ca-certificates', 'curl',
|
|
29
|
+
];
|
|
30
|
+
/** Install the native and Python layers separately, then prove ALSA/PyAudio
|
|
31
|
+
* can see at least one real capture and playback device. Idempotent on Pi OS. */
|
|
32
|
+
export async function roboparkConversationInstall(opts = {}) {
|
|
33
|
+
if (process.platform !== 'linux') {
|
|
34
|
+
throw new Error('conversation install is intended for Debian/Raspberry Pi Linux');
|
|
35
|
+
}
|
|
36
|
+
if (!opts.skipApt) {
|
|
37
|
+
const elevated = typeof process.getuid === 'function' && process.getuid() === 0;
|
|
38
|
+
const command = elevated ? 'apt-get' : 'sudo';
|
|
39
|
+
const prefix = elevated ? [] : ['apt-get'];
|
|
40
|
+
const env = { ...process.env, DEBIAN_FRONTEND: 'noninteractive' };
|
|
41
|
+
console.log(chalk.dim(' refreshing Raspberry Pi OS package metadata…'));
|
|
42
|
+
const update = spawnSync(command, [...prefix, 'update'], { stdio: 'inherit', env });
|
|
43
|
+
if (update.status !== 0)
|
|
44
|
+
throw new Error('apt-get update failed');
|
|
45
|
+
const packages = opts.withVision
|
|
46
|
+
? [...PI_AUDIO_PACKAGES, 'python3-opencv', 'python3-numpy']
|
|
47
|
+
: PI_AUDIO_PACKAGES;
|
|
48
|
+
console.log(chalk.dim(' installing native audio/Python prerequisites…'));
|
|
49
|
+
const apt = spawnSync(command, [...prefix, 'install', '-y', '--no-install-recommends', ...packages], {
|
|
50
|
+
stdio: 'inherit', env,
|
|
51
|
+
});
|
|
52
|
+
if (apt.status !== 0)
|
|
53
|
+
throw new Error('native dependency installation failed');
|
|
54
|
+
}
|
|
55
|
+
const script = await findConversationPath();
|
|
56
|
+
if (!script)
|
|
57
|
+
throw new Error('packaged ElevenLabs conversation runtime was not found');
|
|
58
|
+
const python = prepareRobotPython(findPython(), script, ['elevenlabs', 'pyaudio', 'gpiozero'], 'requirements.txt');
|
|
59
|
+
if (!python)
|
|
60
|
+
throw new Error('could not prepare the robot conversation Python environment');
|
|
61
|
+
if (opts.withVision) {
|
|
62
|
+
const visionScript = await findVisionPath();
|
|
63
|
+
if (!visionScript)
|
|
64
|
+
throw new Error('packaged RoboVision camera runtime was not found');
|
|
65
|
+
if (!prepareRobotPython(python, visionScript, VISION_REQUIRED_MODULES, 'requirements_camera.txt')) {
|
|
66
|
+
throw new Error('could not prepare the RoboVision Python environment');
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const probeCode = [
|
|
70
|
+
'import json, pyaudio',
|
|
71
|
+
'pa=pyaudio.PyAudio()',
|
|
72
|
+
'rows=[pa.get_device_info_by_index(i) for i in range(pa.get_device_count())]',
|
|
73
|
+
'ins=[{"index":int(d["index"]),"name":d["name"],"channels":int(d["maxInputChannels"]),"rate":int(d["defaultSampleRate"])} for d in rows if d["maxInputChannels"]>0]',
|
|
74
|
+
'outs=[{"index":int(d["index"]),"name":d["name"],"channels":int(d["maxOutputChannels"]),"rate":int(d["defaultSampleRate"])} for d in rows if d["maxOutputChannels"]>0]',
|
|
75
|
+
'pa.terminate()',
|
|
76
|
+
'print(json.dumps({"inputs":ins,"outputs":outs}))',
|
|
77
|
+
].join(';');
|
|
78
|
+
const probe = spawnSync(python, ['-c', probeCode], { encoding: 'utf8' });
|
|
79
|
+
if (probe.status !== 0)
|
|
80
|
+
throw new Error(`PyAudio device probe failed: ${probe.stderr || probe.stdout}`);
|
|
81
|
+
const inventory = JSON.parse(probe.stdout.trim());
|
|
82
|
+
// PortAudio on Debian Trixie can omit a valid USB capture endpoint even
|
|
83
|
+
// though ALSA exposes it. The runtime intentionally captures that device
|
|
84
|
+
// through arecord, so accept the same proven fallback during installation.
|
|
85
|
+
const alsaCapture = spawnSync('arecord', ['-l'], { encoding: 'utf8' });
|
|
86
|
+
const alsaInputs = String(alsaCapture.stdout ?? '').split(/\r?\n/)
|
|
87
|
+
.map(line => line.trim())
|
|
88
|
+
.filter(line => /^card\s+\d+:.*device\s+\d+:/i.test(line));
|
|
89
|
+
if (!inventory.inputs.length && !alsaInputs.length) {
|
|
90
|
+
throw new Error('no real microphone input device registered with ALSA or PyAudio');
|
|
91
|
+
}
|
|
92
|
+
if (!inventory.outputs.length)
|
|
93
|
+
throw new Error('no real speaker output device registered with ALSA/PyAudio');
|
|
94
|
+
console.log(chalk.green(` ✓ conversation dependencies installed and verified`));
|
|
95
|
+
console.log(` microphones: ${inventory.inputs.length
|
|
96
|
+
? inventory.inputs.map(d => `${d.name} (#${d.index}, ${d.rate}Hz)`).join(', ')
|
|
97
|
+
: `${alsaInputs.join(', ')} (direct ALSA capture)`}`);
|
|
98
|
+
console.log(` speakers: ${inventory.outputs.map(d => `${d.name} (#${d.index}, ${d.rate}Hz)`).join(', ')}`);
|
|
99
|
+
}
|
|
100
|
+
function positiveNumber(value, label) {
|
|
101
|
+
const parsed = Number(value);
|
|
102
|
+
if (!Number.isFinite(parsed) || parsed <= 0)
|
|
103
|
+
throw new Error(`${label} must be greater than zero`);
|
|
104
|
+
return parsed;
|
|
105
|
+
}
|
|
106
|
+
function nonNegativeNumber(value, label) {
|
|
107
|
+
const parsed = Number(value);
|
|
108
|
+
if (!Number.isFinite(parsed) || parsed < 0)
|
|
109
|
+
throw new Error(`${label} must be zero or greater`);
|
|
110
|
+
return parsed;
|
|
111
|
+
}
|
|
112
|
+
function configPath(name) {
|
|
113
|
+
const safe = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'robot';
|
|
114
|
+
return join(homedir(), '.robopark', `conversation-${safe}.json`);
|
|
115
|
+
}
|
|
116
|
+
function activeConversationPath() {
|
|
117
|
+
return join(homedir(), '.robopark', 'active-conversation.json');
|
|
118
|
+
}
|
|
119
|
+
function conversationUnit(name) {
|
|
120
|
+
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'robot';
|
|
121
|
+
return `robopark-robot-conversation-${slug}.service`;
|
|
122
|
+
}
|
|
123
|
+
function listConversationUnits() {
|
|
124
|
+
if (process.platform !== 'linux')
|
|
125
|
+
return [];
|
|
126
|
+
const units = new Set();
|
|
127
|
+
for (const args of [
|
|
128
|
+
['list-unit-files', 'robopark-robot-conversation-*.service', '--no-legend', '--no-pager'],
|
|
129
|
+
['list-units', '--all', 'robopark-robot-conversation-*.service', '--no-legend', '--no-pager'],
|
|
130
|
+
]) {
|
|
131
|
+
const listed = spawnSync('systemctl', args, { encoding: 'utf8' });
|
|
132
|
+
for (const line of String(listed.stdout ?? '').split(/\r?\n/)) {
|
|
133
|
+
const unit = line.trim().split(/\s+/, 1)[0];
|
|
134
|
+
if (/^robopark-robot-conversation-[a-z0-9-]+\.service$/.test(unit))
|
|
135
|
+
units.add(unit);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return [...units].sort();
|
|
139
|
+
}
|
|
140
|
+
function resolveConversationTarget(name) {
|
|
141
|
+
if (name?.trim()) {
|
|
142
|
+
const selected = name.trim();
|
|
143
|
+
return { name: selected, unit: conversationUnit(selected), configPath: configPath(selected) };
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
const marker = JSON.parse(readFileSync(activeConversationPath(), 'utf8'));
|
|
147
|
+
if (marker.name && marker.unit && marker.configPath
|
|
148
|
+
&& spawnSync('systemctl', ['cat', marker.unit], { stdio: 'ignore' }).status === 0)
|
|
149
|
+
return marker;
|
|
150
|
+
}
|
|
151
|
+
catch { /* existing installations may not have a marker yet */ }
|
|
152
|
+
const units = listConversationUnits();
|
|
153
|
+
if (!units.length)
|
|
154
|
+
throw new Error('no local conversation service is installed; run conversation up once');
|
|
155
|
+
let candidates = units.filter(unit => spawnSync('systemctl', ['is-enabled', unit], { stdio: 'ignore' }).status === 0);
|
|
156
|
+
if (candidates.length !== 1)
|
|
157
|
+
candidates = units;
|
|
158
|
+
if (candidates.length !== 1) {
|
|
159
|
+
throw new Error(`multiple conversation services exist (${candidates.join(', ')}); use --name once to select one`);
|
|
160
|
+
}
|
|
161
|
+
const unit = candidates[0];
|
|
162
|
+
const slug = unit.replace(/^robopark-robot-conversation-/, '').replace(/\.service$/, '');
|
|
163
|
+
const fragment = spawnSync('systemctl', ['show', unit, '--property=FragmentPath', '--value'], { encoding: 'utf8' });
|
|
164
|
+
let selectedConfig = configPath(slug);
|
|
165
|
+
try {
|
|
166
|
+
const unitText = readFileSync(String(fragment.stdout ?? '').trim(), 'utf8');
|
|
167
|
+
const match = unitText.match(/--config\s+(?:'([^']+)'|"([^"]+)"|(\S+))/);
|
|
168
|
+
selectedConfig = match?.[1] || match?.[2] || match?.[3] || selectedConfig;
|
|
169
|
+
}
|
|
170
|
+
catch { /* conventional config path remains a safe fallback */ }
|
|
171
|
+
return { name: slug, unit, configPath: selectedConfig };
|
|
172
|
+
}
|
|
173
|
+
function detectTailscaleIpv4() {
|
|
174
|
+
if (process.platform !== 'linux')
|
|
175
|
+
return null;
|
|
176
|
+
const result = spawnSync('tailscale', ['ip', '-4'], { encoding: 'utf8' });
|
|
177
|
+
if (result.status !== 0)
|
|
178
|
+
return null;
|
|
179
|
+
return result.stdout.split(/\s+/).find(value => /^100\.\d+\.\d+\.\d+$/.test(value)) ?? null;
|
|
180
|
+
}
|
|
181
|
+
function parseMotors(values = []) {
|
|
182
|
+
const motors = {};
|
|
183
|
+
for (const value of values) {
|
|
184
|
+
const match = value.trim().match(/^(\d+)\s*[-:=]\s*([a-zA-Z][a-zA-Z0-9_-]*)$/);
|
|
185
|
+
if (!match)
|
|
186
|
+
throw new Error(`invalid motor '${value}'; use PIN-NAME, for example 17-head`);
|
|
187
|
+
const pin = Number(match[1]);
|
|
188
|
+
if (pin < 0 || pin > 27)
|
|
189
|
+
throw new Error(`motor pin must be a BCM GPIO from 0 to 27: ${value}`);
|
|
190
|
+
const motorName = match[2].toLowerCase().replace(/_/g, '-');
|
|
191
|
+
if (motors[motorName] !== undefined)
|
|
192
|
+
throw new Error(`duplicate motor name: ${motorName}`);
|
|
193
|
+
if (Object.values(motors).includes(pin))
|
|
194
|
+
throw new Error(`GPIO${pin} is registered more than once`);
|
|
195
|
+
motors[motorName] = pin;
|
|
196
|
+
}
|
|
197
|
+
return motors;
|
|
198
|
+
}
|
|
199
|
+
async function elevenLabsRequest(apiKey, path, init = {}) {
|
|
200
|
+
const response = await fetch(`https://api.elevenlabs.io${path}`, {
|
|
201
|
+
...init,
|
|
202
|
+
headers: {
|
|
203
|
+
'Content-Type': 'application/json',
|
|
204
|
+
'xi-api-key': apiKey,
|
|
205
|
+
...(init.headers ?? {}),
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
const text = await response.text();
|
|
209
|
+
const payload = text ? JSON.parse(text) : {};
|
|
210
|
+
if (!response.ok) {
|
|
211
|
+
const detail = payload?.detail?.message ?? payload?.detail ?? payload?.message ?? text;
|
|
212
|
+
throw new Error(`ElevenLabs ${init.method ?? 'GET'} ${path} failed (${response.status}): ${typeof detail === 'string' ? detail : JSON.stringify(detail)}`);
|
|
213
|
+
}
|
|
214
|
+
return payload;
|
|
215
|
+
}
|
|
216
|
+
async function provisionMotorTool(apiKey, agentId) {
|
|
217
|
+
const listed = await elevenLabsRequest(apiKey, '/v1/convai/tools?page_size=100');
|
|
218
|
+
let tool = (listed.tools ?? []).find((candidate) => candidate?.tool_config?.type === 'client' && candidate?.tool_config?.name === 'robotMotor');
|
|
219
|
+
if (!tool) {
|
|
220
|
+
tool = await elevenLabsRequest(apiKey, '/v1/convai/tools', {
|
|
221
|
+
method: 'POST',
|
|
222
|
+
body: JSON.stringify({
|
|
223
|
+
tool_config: {
|
|
224
|
+
type: 'client',
|
|
225
|
+
name: 'robotMotor',
|
|
226
|
+
description: 'Safely activate a named motor or relay registered by the current RoboPark robot. Use pulse for movement and off to deactivate.',
|
|
227
|
+
expects_response: true,
|
|
228
|
+
response_timeout_secs: 5,
|
|
229
|
+
parameters: {
|
|
230
|
+
type: 'object',
|
|
231
|
+
required: ['name'],
|
|
232
|
+
description: 'A bounded command for one registered robot output.',
|
|
233
|
+
properties: {
|
|
234
|
+
name: { type: 'string', description: 'Registered motor name supplied in the current robot context.' },
|
|
235
|
+
action: { type: 'string', enum: ['pulse', 'on', 'activate', 'move', 'off'], description: 'Requested bounded action.' },
|
|
236
|
+
duration_ms: { type: 'integer', minimum: 50, maximum: 3000, description: 'Activation duration in milliseconds.' },
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
}),
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
const toolId = tool.id;
|
|
244
|
+
if (!toolId)
|
|
245
|
+
throw new Error('ElevenLabs returned a motor tool without an id');
|
|
246
|
+
const agent = await elevenLabsRequest(apiKey, `/v1/convai/agents/${encodeURIComponent(agentId)}`);
|
|
247
|
+
const conversationConfig = structuredClone(agent?.conversation_config ?? {});
|
|
248
|
+
const agentConfig = conversationConfig.agent ??= {};
|
|
249
|
+
if (!String(agentConfig.first_message ?? '').trim()) {
|
|
250
|
+
const agentName = String(agent?.name ?? 'your RoboPark guide').trim() || 'your RoboPark guide';
|
|
251
|
+
agentConfig.first_message = `Hello, I am ${agentName}. How can I help you today?`;
|
|
252
|
+
}
|
|
253
|
+
const promptConfig = agentConfig.prompt ??= {};
|
|
254
|
+
const existingIds = promptConfig.tool_ids ?? [];
|
|
255
|
+
promptConfig.tool_ids = existingIds.includes(toolId) ? existingIds : [...existingIds, toolId];
|
|
256
|
+
// ElevenLabs may still return the deprecated inline field for older agents,
|
|
257
|
+
// but rejects PATCH payloads containing both it and the migrated tool IDs.
|
|
258
|
+
delete promptConfig.tools;
|
|
259
|
+
const motorPolicyMarker = '[RoboPark motor policy]';
|
|
260
|
+
const existingPrompt = String(promptConfig.prompt ?? '');
|
|
261
|
+
if (!existingPrompt.includes(motorPolicyMarker)) {
|
|
262
|
+
promptConfig.prompt = `${existingPrompt}\n\n${motorPolicyMarker}\nWhen a user asks the robot to move, turn, activate, test, or switch a physical part, you MUST call the robotMotor client tool before confirming the action. Map the requested part to one of the registered motor names supplied in the current session context. Use action \"pulse\" unless the user explicitly asks to switch it off. Never claim physical movement occurred unless the tool returned success.`.trim();
|
|
263
|
+
}
|
|
264
|
+
// Send the complete configuration back. A nested partial PATCH can replace
|
|
265
|
+
// sibling conversation/TTS fields and silently leave an agent in text-only
|
|
266
|
+
// mode, which produces transcripts but no speaker audio on the robot.
|
|
267
|
+
const sessionConfig = conversationConfig.conversation ??= {};
|
|
268
|
+
sessionConfig.text_only = false;
|
|
269
|
+
const clientEvents = sessionConfig.client_events ?? [];
|
|
270
|
+
sessionConfig.client_events = [...new Set([...clientEvents, 'audio', 'interruption'])];
|
|
271
|
+
const ttsConfig = conversationConfig.tts ??= {};
|
|
272
|
+
ttsConfig.agent_output_audio_format = 'pcm_16000';
|
|
273
|
+
await elevenLabsRequest(apiKey, `/v1/convai/agents/${encodeURIComponent(agentId)}`, {
|
|
274
|
+
method: 'PATCH',
|
|
275
|
+
body: JSON.stringify({
|
|
276
|
+
conversation_config: conversationConfig,
|
|
277
|
+
version_description: 'RoboPark robotMotor registration with audio settings preserved',
|
|
278
|
+
}),
|
|
279
|
+
});
|
|
280
|
+
return toolId;
|
|
281
|
+
}
|
|
282
|
+
export async function roboparkConversationUp(opts) {
|
|
283
|
+
const name = opts.name?.trim() || hostname();
|
|
284
|
+
const tailscaleIp = detectTailscaleIpv4();
|
|
285
|
+
const apiKey = opts.apiKey?.trim() || process.env.ELEVENLABS_API_KEY?.trim();
|
|
286
|
+
if (!opts.publicAgent && !apiKey) {
|
|
287
|
+
throw new Error('ELEVENLABS_API_KEY is required for a private agent (or pass --public-agent)');
|
|
288
|
+
}
|
|
289
|
+
const script = await findConversationPath();
|
|
290
|
+
if (!script)
|
|
291
|
+
throw new Error('packaged ElevenLabs conversation runtime was not found');
|
|
292
|
+
const python = prepareRobotPython(findPython(), script, ['elevenlabs', 'pyaudio'], 'requirements.txt');
|
|
293
|
+
if (!python)
|
|
294
|
+
throw new Error('could not prepare the robot conversation Python environment');
|
|
295
|
+
let visionScript = null;
|
|
296
|
+
let manageVision = opts.manageVision !== false && opts.motion !== false;
|
|
297
|
+
const visionUrl = new URL(opts.visionUrl);
|
|
298
|
+
if (manageVision && !['127.0.0.1', 'localhost', '::1'].includes(visionUrl.hostname)) {
|
|
299
|
+
manageVision = false;
|
|
300
|
+
console.log(chalk.yellow(' ! remote --vision-url detected; RoboVision process supervision is disabled'));
|
|
301
|
+
}
|
|
302
|
+
if (manageVision) {
|
|
303
|
+
visionScript = await findVisionPath();
|
|
304
|
+
if (!visionScript)
|
|
305
|
+
throw new Error('packaged RoboVision camera runtime was not found');
|
|
306
|
+
const visionPython = prepareRobotPython(findPython(), visionScript, VISION_REQUIRED_MODULES, 'requirements_camera.txt');
|
|
307
|
+
if (!visionPython)
|
|
308
|
+
throw new Error('could not prepare the RoboVision camera environment');
|
|
309
|
+
if (resolve(visionPython) !== resolve(python)) {
|
|
310
|
+
throw new Error('conversation and RoboVision must use the same managed Python environment');
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const motors = parseMotors(opts.motor);
|
|
314
|
+
if (Object.keys(motors).length && opts.provisionAgentTools !== false) {
|
|
315
|
+
if (!apiKey)
|
|
316
|
+
throw new Error('an API key is required to provision the ElevenLabs motor tool');
|
|
317
|
+
const toolId = await provisionMotorTool(apiKey, opts.agentId.trim());
|
|
318
|
+
console.log(chalk.green(` ✓ ElevenLabs robotMotor tool ready and attached (${toolId})`));
|
|
319
|
+
}
|
|
320
|
+
const path = configPath(name);
|
|
321
|
+
const statusToken = tailscaleIp ? randomBytes(24).toString('hex') : null;
|
|
322
|
+
const telemetry = schedulerTelemetryIdentity();
|
|
323
|
+
const config = {
|
|
324
|
+
agent_id: opts.agentId.trim(),
|
|
325
|
+
branch_id: opts.branchId?.trim() || null,
|
|
326
|
+
api_key: apiKey || null,
|
|
327
|
+
requires_auth: !opts.publicAgent,
|
|
328
|
+
always_on: Boolean(opts.alwaysOn),
|
|
329
|
+
robot_name: name,
|
|
330
|
+
audio_input: opts.audioInput,
|
|
331
|
+
audio_output: opts.audioOutput,
|
|
332
|
+
motion_enabled: opts.motion !== false,
|
|
333
|
+
voice_trigger_enabled: opts.voiceTrigger !== false,
|
|
334
|
+
motion_host: opts.motionHost,
|
|
335
|
+
status_bind_host: tailscaleIp ? '0.0.0.0' : opts.motionHost,
|
|
336
|
+
tailscale_status_ip: tailscaleIp,
|
|
337
|
+
tailscale_status_token: statusToken,
|
|
338
|
+
config_path: resolve(path),
|
|
339
|
+
motion_port: positiveNumber(opts.motionPort, 'motion port'),
|
|
340
|
+
vision_url: opts.visionUrl.replace(/\/$/, ''),
|
|
341
|
+
manage_vision: manageVision,
|
|
342
|
+
vision_script: visionScript ? resolve(visionScript) : null,
|
|
343
|
+
vision_python: manageVision ? resolve(python) : null,
|
|
344
|
+
vision_port: Number(visionUrl.port || (visionUrl.protocol === 'https:' ? 443 : 80)),
|
|
345
|
+
camera_device: opts.cameraDevice,
|
|
346
|
+
voice_threshold: positiveNumber(opts.voiceThreshold, 'voice threshold'),
|
|
347
|
+
idle_timeout: nonNegativeNumber(opts.idleTimeout, 'idle timeout'),
|
|
348
|
+
max_session: nonNegativeNumber(opts.maxSession, 'max session'),
|
|
349
|
+
cooldown: positiveNumber(opts.cooldown, 'cooldown'),
|
|
350
|
+
// The local supervisor owns repeated session disposal. Per-session process
|
|
351
|
+
// recycling creates avoidable ALSA and port races on long-running robots.
|
|
352
|
+
recycle_after_session: false,
|
|
353
|
+
voice_engine_default: 'elevenlabs',
|
|
354
|
+
robovoice_enabled: true,
|
|
355
|
+
allow_session_override: true,
|
|
356
|
+
apply_changes_when_idle: true,
|
|
357
|
+
desired_revision: 1,
|
|
358
|
+
applied_revision: 1,
|
|
359
|
+
character_id: name,
|
|
360
|
+
motors,
|
|
361
|
+
motor_active_high: Boolean(opts.motorActiveHigh),
|
|
362
|
+
motor_pulse_ms: positiveNumber(opts.motorPulseMs, 'motor pulse'),
|
|
363
|
+
scheduler_url: telemetry?.schedulerUrl ?? null,
|
|
364
|
+
scheduler_device_id: telemetry?.deviceId ?? null,
|
|
365
|
+
scheduler_device_token: telemetry?.deviceToken ?? null,
|
|
366
|
+
};
|
|
367
|
+
mkdirSync(join(homedir(), '.robopark'), { recursive: true });
|
|
368
|
+
writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
369
|
+
try {
|
|
370
|
+
chmodSync(path, 0o600);
|
|
371
|
+
}
|
|
372
|
+
catch { /* Windows has no POSIX mode enforcement. */ }
|
|
373
|
+
console.log(chalk.bold('\n robopark conversation'));
|
|
374
|
+
console.log(chalk.dim(' ' + '─'.repeat(48)));
|
|
375
|
+
console.log(` robot: ${name}`);
|
|
376
|
+
console.log(` agent: ${opts.agentId}`);
|
|
377
|
+
if (config.branch_id)
|
|
378
|
+
console.log(` branch: ${config.branch_id}`);
|
|
379
|
+
console.log(` mode: ${config.always_on ? 'always-on with automatic reconnect' : 'triggered'}`);
|
|
380
|
+
console.log(` input: ${opts.audioInput}`);
|
|
381
|
+
console.log(` output: ${opts.audioOutput}`);
|
|
382
|
+
console.log(` motion: ${config.motion_enabled ? `http://${opts.motionHost}:${opts.motionPort}/` : 'off'}`);
|
|
383
|
+
console.log(` vision: ${config.motion_enabled ? (manageVision ? `managed camera ${opts.cameraDevice}` : opts.visionUrl) : 'off'}`);
|
|
384
|
+
console.log(` voice: ${config.voice_trigger_enabled ? `armed (threshold ${opts.voiceThreshold})` : 'off'}`);
|
|
385
|
+
console.log(` timeout: ${opts.idleTimeout}s idle / ${opts.maxSession}s maximum\n`);
|
|
386
|
+
if (tailscaleIp)
|
|
387
|
+
console.log(` devices: http://${tailscaleIp}:${opts.motionPort}/?token=${statusToken}\n`);
|
|
388
|
+
if (Object.keys(config.motors).length) {
|
|
389
|
+
console.log(` motors: ${Object.entries(config.motors).map(([motor, pin]) => `${motor}=GPIO${pin}`).join(', ')}`);
|
|
390
|
+
console.log(' tool: robotMotor(name, action, duration_ms)\n');
|
|
391
|
+
}
|
|
392
|
+
console.log(` oversight:${telemetry ? chalk.green(' centralized sessions + transcripts') : chalk.yellow(' local only (run robot setup once to register)')}\n`);
|
|
393
|
+
if (opts.autoStart) {
|
|
394
|
+
// The legacy unified runtime owns the same camera and ALSA devices. Retire
|
|
395
|
+
// it so one command cannot leave two services fighting over hardware.
|
|
396
|
+
const legacy = unregisterAutoStart('robot-runtime', name);
|
|
397
|
+
if (!legacy.ok)
|
|
398
|
+
throw new Error(`could not retire conflicting legacy runtime: ${legacy.message}`);
|
|
399
|
+
const result = await registerAutoStart({
|
|
400
|
+
role: 'robot-conversation',
|
|
401
|
+
name,
|
|
402
|
+
command: resolve(python),
|
|
403
|
+
args: [resolve(script), '--config', resolve(path)],
|
|
404
|
+
workingDir: homedir(),
|
|
405
|
+
env: { PYTHONUNBUFFERED: '1' },
|
|
406
|
+
// Every conversation mode ultimately opens the same physical devices.
|
|
407
|
+
// Ownership must therefore be exclusive even before its first trigger.
|
|
408
|
+
exclusiveMedia: true,
|
|
409
|
+
});
|
|
410
|
+
if (!result.ok)
|
|
411
|
+
throw new Error(result.message);
|
|
412
|
+
const selected = { name, unit: conversationUnit(name), configPath: resolve(path) };
|
|
413
|
+
writeFileSync(activeConversationPath(), `${JSON.stringify(selected, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
414
|
+
try {
|
|
415
|
+
chmodSync(activeConversationPath(), 0o600);
|
|
416
|
+
}
|
|
417
|
+
catch { /* Windows has no POSIX mode enforcement. */ }
|
|
418
|
+
await waitForConversationReady(Number(config.motion_port), Boolean(config.always_on));
|
|
419
|
+
console.log(chalk.dim(` legacy: ${legacy.message}`));
|
|
420
|
+
console.log(chalk.green(` ✓ ${result.message}`));
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
const child = spawn(python, [script, '--config', path], {
|
|
424
|
+
stdio: 'inherit',
|
|
425
|
+
env: { ...process.env, PYTHONUNBUFFERED: '1' },
|
|
426
|
+
});
|
|
427
|
+
await new Promise((resolvePromise, reject) => {
|
|
428
|
+
child.once('error', reject);
|
|
429
|
+
child.once('exit', code => code === 0
|
|
430
|
+
? resolvePromise()
|
|
431
|
+
: reject(new Error(`conversation runtime exited with code ${code}`)));
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
export function roboparkConversationDown(name) {
|
|
435
|
+
const target = resolveConversationTarget(name);
|
|
436
|
+
const result = unregisterAutoStart('robot-conversation', target.name);
|
|
437
|
+
if (!result.ok)
|
|
438
|
+
throw new Error(result.message);
|
|
439
|
+
console.log(chalk.green(` ✓ ${result.message}`));
|
|
440
|
+
}
|
|
441
|
+
export function roboparkConversationStop(name) {
|
|
442
|
+
if (process.platform !== 'linux')
|
|
443
|
+
throw new Error('conversation stop currently requires systemd on Linux');
|
|
444
|
+
const units = name?.trim() ? [conversationUnit(name.trim())] : listConversationUnits();
|
|
445
|
+
if (!units.length)
|
|
446
|
+
throw new Error('no local conversation services were found');
|
|
447
|
+
const failures = [];
|
|
448
|
+
for (const unit of units) {
|
|
449
|
+
const stopped = spawnSync('systemctl', ['stop', unit], { encoding: 'utf8' });
|
|
450
|
+
if (stopped.status !== 0)
|
|
451
|
+
failures.push(`${unit}: ${stopped.stderr || stopped.stdout}`);
|
|
452
|
+
}
|
|
453
|
+
if (failures.length)
|
|
454
|
+
throw new Error(`could not stop conversation service: ${failures.join('; ')}`);
|
|
455
|
+
console.log(chalk.green(` ✓ stopped ${units.join(', ')}`));
|
|
456
|
+
}
|
|
457
|
+
async function waitForConversationReady(port, requireAudio) {
|
|
458
|
+
const endpoint = `http://127.0.0.1:${port}/`;
|
|
459
|
+
let lastError = '';
|
|
460
|
+
for (let attempt = 0; attempt < 120; attempt += 1) {
|
|
461
|
+
try {
|
|
462
|
+
const response = await fetch(endpoint, { signal: AbortSignal.timeout(1000) });
|
|
463
|
+
if (response.ok) {
|
|
464
|
+
const status = await response.json();
|
|
465
|
+
lastError = status.last_error ?? '';
|
|
466
|
+
if (!requireAudio || (status.active && status.active_audio?.active))
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
catch (error) {
|
|
471
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
472
|
+
}
|
|
473
|
+
await new Promise(resolvePromise => setTimeout(resolvePromise, 500));
|
|
474
|
+
}
|
|
475
|
+
throw new Error(`conversation endpoint on port ${port} did not become ${requireAudio ? 'audio-ready' : 'ready'} within 60 seconds`
|
|
476
|
+
+ (lastError ? `; last error: ${lastError}` : ''));
|
|
477
|
+
}
|
|
478
|
+
export async function roboparkConversationStart(name, trigger = false) {
|
|
479
|
+
if (process.platform !== 'linux')
|
|
480
|
+
throw new Error('conversation start currently requires systemd on Linux');
|
|
481
|
+
const target = resolveConversationTarget(name);
|
|
482
|
+
const unit = target.unit;
|
|
483
|
+
const slug = unit.replace(/^robopark-robot-conversation-/, '').replace(/\.service$/, '');
|
|
484
|
+
spawnSync('systemctl', ['stop', `robopark-robot-runtime-${slug}.service`], { stdio: 'ignore' });
|
|
485
|
+
spawnSync('systemctl', ['reset-failed', unit], { stdio: 'ignore' });
|
|
486
|
+
const restarted = spawnSync('systemctl', ['restart', unit], { encoding: 'utf8' });
|
|
487
|
+
if (restarted.status !== 0) {
|
|
488
|
+
throw new Error(`systemctl restart failed: ${restarted.stderr || restarted.stdout}`);
|
|
489
|
+
}
|
|
490
|
+
const config = JSON.parse(readFileSync(target.configPath, 'utf8'));
|
|
491
|
+
const port = config.motion_port || 5057;
|
|
492
|
+
const endpoint = `http://127.0.0.1:${port}/`;
|
|
493
|
+
await waitForConversationReady(port, Boolean(config.always_on));
|
|
494
|
+
console.log(chalk.green(` ✓ ${unit} restarted and ready`));
|
|
495
|
+
if (trigger) {
|
|
496
|
+
const response = await fetch(endpoint, { method: 'POST', signal: AbortSignal.timeout(3000) });
|
|
497
|
+
const body = await response.text();
|
|
498
|
+
if (!response.ok)
|
|
499
|
+
throw new Error(`trigger failed (${response.status}): ${body}`);
|
|
500
|
+
console.log(chalk.green(` ✓ conversation trigger accepted: ${body}`));
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
export async function roboparkConversationRestart(name, trigger = false) {
|
|
504
|
+
await roboparkConversationStart(name, trigger);
|
|
505
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
function loadProductionConfig() {
|
|
5
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const candidates = [
|
|
7
|
+
join(here, '..', '..', 'scheduler', 'production_config.json'),
|
|
8
|
+
join(here, '..', '..', '..', 'packages', 'robopark', 'scheduler', 'production_config.json'),
|
|
9
|
+
];
|
|
10
|
+
for (const path of candidates) {
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
// Try the source-tree or installed-package layout next.
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
throw new Error('packaged production character configuration was not found');
|
|
19
|
+
}
|
|
20
|
+
export function productionCharacters() {
|
|
21
|
+
return loadProductionConfig().characters
|
|
22
|
+
.filter(character => character.default_engine === 'elevenlabs' && character.elevenlabs_agent_id)
|
|
23
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
24
|
+
}
|
|
25
|
+
export function deploymentCommandSheet(options) {
|
|
26
|
+
const install = `npm install -g robopark@${options.version}`;
|
|
27
|
+
const token = options.token?.trim() || 'rp1_<token-from-gateway>';
|
|
28
|
+
const publicUrl = options.publicUrl?.trim() || 'https://<gateway-tailnet-or-domain>';
|
|
29
|
+
const characters = productionCharacters();
|
|
30
|
+
const lines = [
|
|
31
|
+
'ROBOPARK DEPLOYMENT COMMANDS',
|
|
32
|
+
'',
|
|
33
|
+
'GATEWAY DEVICE',
|
|
34
|
+
install,
|
|
35
|
+
`robopark --password '<activation-password>' gateway start --public-url ${publicUrl}`,
|
|
36
|
+
'',
|
|
37
|
+
'EACH ROBOT DEVICE (run install/connect once, then its start command)',
|
|
38
|
+
install,
|
|
39
|
+
`robopark connect ${token}`,
|
|
40
|
+
'',
|
|
41
|
+
];
|
|
42
|
+
for (const character of characters) {
|
|
43
|
+
lines.push(`${character.name}: robopark start --voice --video --character ${character.id}`);
|
|
44
|
+
}
|
|
45
|
+
lines.push('', 'OPERATIONS', 'robopark status', 'robopark gateway start', '', `${characters.length} verified character commands - production config revision ${loadProductionConfig().revision}`);
|
|
46
|
+
return lines.join('\n');
|
|
47
|
+
}
|