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,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RoboPark — `robopark setup-livekit`.
|
|
3
|
+
*
|
|
4
|
+
* Dispatches an opencode agent over the mesh to install and start a LiveKit
|
|
5
|
+
* server on the fleet hub via Docker. After the agent reports success, the
|
|
6
|
+
* scheduler is told about the new LiveKit server.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* robopark setup-livekit --hub-url http://HUB_IP:47913 --token <mesh_token>
|
|
10
|
+
*
|
|
11
|
+
* If --hub-url is omitted, the hub is discovered over Tailscale.
|
|
12
|
+
*/
|
|
13
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
14
|
+
import { homedir, hostname } from 'node:os';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import chalk from 'chalk';
|
|
17
|
+
import { discoverContext } from './discovery.js';
|
|
18
|
+
function loadMeshToken() {
|
|
19
|
+
const paths = [
|
|
20
|
+
join(homedir(), '.robopark', 'mesh.token'),
|
|
21
|
+
join(homedir(), '.config', 'infinicode-nodejs', 'config.json'),
|
|
22
|
+
join(homedir(), '.infinicode-nodejs', 'config.json'),
|
|
23
|
+
join(homedir(), 'AppData', 'Roaming', 'infinicode-nodejs', 'Config', 'config.json'),
|
|
24
|
+
];
|
|
25
|
+
for (const p of paths) {
|
|
26
|
+
if (!existsSync(p))
|
|
27
|
+
continue;
|
|
28
|
+
try {
|
|
29
|
+
if (p.endsWith('mesh.token'))
|
|
30
|
+
return readFileSync(p, 'utf8').trim();
|
|
31
|
+
const cfg = JSON.parse(readFileSync(p, 'utf8'));
|
|
32
|
+
if (cfg.federation?.token)
|
|
33
|
+
return cfg.federation.token;
|
|
34
|
+
}
|
|
35
|
+
catch { /* ignore */ }
|
|
36
|
+
}
|
|
37
|
+
return process.env.ROBOPARK_MESH_TOKEN;
|
|
38
|
+
}
|
|
39
|
+
function randId() {
|
|
40
|
+
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
41
|
+
}
|
|
42
|
+
function envelope(kind, from, data, opts = {}) {
|
|
43
|
+
return { v: 1, id: randId(), kind, from, to: opts.to, ts: Date.now(), auth: opts.auth, data };
|
|
44
|
+
}
|
|
45
|
+
async function meshRpc(targetUrl, token, env) {
|
|
46
|
+
const url = `${targetUrl.replace(/\/$/, '')}/fed/rpc?token=${encodeURIComponent(token)}`;
|
|
47
|
+
const res = await fetch(url, {
|
|
48
|
+
method: 'POST',
|
|
49
|
+
headers: { 'content-type': 'application/json' },
|
|
50
|
+
body: JSON.stringify(env),
|
|
51
|
+
});
|
|
52
|
+
if (!res.ok) {
|
|
53
|
+
const text = await res.text().catch(() => '');
|
|
54
|
+
throw new Error(`mesh RPC ${res.status}: ${text}`);
|
|
55
|
+
}
|
|
56
|
+
return (await res.json());
|
|
57
|
+
}
|
|
58
|
+
async function getHubManifest(hubUrl, token) {
|
|
59
|
+
try {
|
|
60
|
+
const url = `${hubUrl.replace(/\/$/, '')}/fed/manifest?token=${encodeURIComponent(token)}`;
|
|
61
|
+
const res = await fetch(url, { headers: { accept: 'application/json' } });
|
|
62
|
+
if (!res.ok)
|
|
63
|
+
return { nodeId: null, platform: null };
|
|
64
|
+
const manifest = (await res.json());
|
|
65
|
+
return { nodeId: manifest.nodeId || manifest.id || null, platform: manifest.platform ?? null };
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return { nodeId: null, platform: null };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Fallback: GET /fed/status returns the hub's own NodeManifest under `self`,
|
|
72
|
+
* including `platform`. Used when /fed/manifest doesn't carry it. */
|
|
73
|
+
async function getHubPlatformFromStatus(hubUrl, token) {
|
|
74
|
+
try {
|
|
75
|
+
const url = `${hubUrl.replace(/\/$/, '')}/fed/status?token=${encodeURIComponent(token)}`;
|
|
76
|
+
const res = await fetch(url, { headers: { accept: 'application/json' } });
|
|
77
|
+
if (!res.ok)
|
|
78
|
+
return null;
|
|
79
|
+
const status = (await res.json());
|
|
80
|
+
return status.self?.platform ?? null;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const LIVEKIT_SETUP_PROMPT_HEADER = `You are the RoboPark infrastructure agent running on the fleet hub.
|
|
87
|
+
|
|
88
|
+
GOAL: Install and start a LiveKit server in Docker on this machine so the RoboPark scheduler can mint tokens and the robot preview agent can publish camera/audio.
|
|
89
|
+
|
|
90
|
+
DO NOT ask the user for confirmation. Execute the steps directly. If a step is already done, skip it and report it.
|
|
91
|
+
`;
|
|
92
|
+
const LIVEKIT_SETUP_PROMPT_FOOTER = `
|
|
93
|
+
IMPORTANT: this is a headless fleet node. Do not open a browser. Only run shell commands and report the final LiveKit URL + credentials in a JSON code block like:
|
|
94
|
+
\`\`\`json
|
|
95
|
+
{
|
|
96
|
+
"url": "ws://127.0.0.1:7880",
|
|
97
|
+
"api_key": "...",
|
|
98
|
+
"api_secret": "...",
|
|
99
|
+
"running": true
|
|
100
|
+
}
|
|
101
|
+
\`\`\`
|
|
102
|
+
`;
|
|
103
|
+
const LIVEKIT_SETUP_STEPS_LINUX = `Steps:
|
|
104
|
+
1. Check if Docker is installed and running. If not, install Docker (apt-get install docker.io on Debian/Ubuntu, or dnf install docker on Fedora, or use the official convenience script if needed) and start it with systemctl.
|
|
105
|
+
2. Pull the official LiveKit server image: docker pull livekit/livekit-server:latest
|
|
106
|
+
3. Create a directory /opt/livekit if it does not exist.
|
|
107
|
+
4. Generate API credentials: run the LiveKit CLI container to create a key/secret and save them to /opt/livekit/keys.yaml:
|
|
108
|
+
docker run --rm livekit/livekit-server:latest generate-keys
|
|
109
|
+
Parse the output and write it as a YAML file with fields api_key and api_secret.
|
|
110
|
+
5. Start the LiveKit server container with host networking on port 7880:
|
|
111
|
+
docker run -d --name livekit-server --restart unless-stopped --network host \\
|
|
112
|
+
-v /opt/livekit/keys.yaml:/etc/livekit/keys.yaml:ro \\
|
|
113
|
+
livekit/livekit-server:latest \\
|
|
114
|
+
--config /etc/livekit/keys.yaml --bind 0.0.0.0 --port 7880
|
|
115
|
+
6. Wait up to 30 seconds and verify it is healthy: curl -sf http://127.0.0.1:7880
|
|
116
|
+
7. Report back the WebSocket URL (ws://THIS_MACHINE_TAILSCALE_IP:7880 or ws://127.0.0.1:7880), the api_key, the api_secret, and whether the container is running.
|
|
117
|
+
`;
|
|
118
|
+
// Windows hosts: Docker Desktop does not support --network host, so ports are
|
|
119
|
+
// mapped explicitly. --dev mode ships with well-known devkey/secret, which
|
|
120
|
+
// sidesteps the `generate-keys` dance (that container command is awkward to
|
|
121
|
+
// run/parse consistently from PowerShell).
|
|
122
|
+
const LIVEKIT_SETUP_STEPS_WINDOWS = `Steps (Windows host — use PowerShell, NOT bash):
|
|
123
|
+
1. Check if Docker Desktop is installed and running: docker info
|
|
124
|
+
If it is not installed, stop and report that Docker Desktop must be installed manually (winget install Docker.DockerDesktop), since it requires a GUI/WSL2 backend and cannot be silently installed headlessly.
|
|
125
|
+
2. Pull the official LiveKit server image: docker pull livekit/livekit-server:latest
|
|
126
|
+
3. Start the LiveKit server container in --dev mode (uses the well-known devkey/secret credentials, no key generation needed). Docker Desktop on Windows does not support --network host, so map ports explicitly:
|
|
127
|
+
docker run -d --name livekit-server --restart unless-stopped \`
|
|
128
|
+
-p 7880:7880 -p 7881:7881 -p 7881:7881/udp -p 50000-50100:50000-50100/udp \`
|
|
129
|
+
livekit/livekit-server:latest --dev --bind 0.0.0.0
|
|
130
|
+
(If a container named livekit-server already exists, remove it first with: docker rm -f livekit-server)
|
|
131
|
+
4. Wait up to 30 seconds and verify it is healthy: curl.exe -sf http://127.0.0.1:7880 (or Invoke-WebRequest)
|
|
132
|
+
5. The credentials in --dev mode are fixed: api_key = devkey, api_secret = secret.
|
|
133
|
+
6. Report back the WebSocket URL (ws://THIS_MACHINE_TAILSCALE_IP:7880 or ws://127.0.0.1:7880), api_key "devkey", api_secret "secret", and whether the container is running.
|
|
134
|
+
`;
|
|
135
|
+
// Used when the hub's platform could not be determined before dispatch: ask
|
|
136
|
+
// the agent to detect its own OS first, then follow the matching branch.
|
|
137
|
+
const LIVEKIT_SETUP_STEPS_AUTODETECT = `Steps:
|
|
138
|
+
0. FIRST, detect your own OS before running anything else:
|
|
139
|
+
- On Windows, $env:OS will be "Windows_NT" (PowerShell) — or the presence of \`Get-Command\` with no \`uname\` binary.
|
|
140
|
+
- On Linux/macOS, \`uname\` will succeed and print Linux/Darwin.
|
|
141
|
+
Branch into ONE of the two command sets below based on that detection. Do not run both.
|
|
142
|
+
|
|
143
|
+
--- IF WINDOWS (use PowerShell): ---
|
|
144
|
+
${LIVEKIT_SETUP_STEPS_WINDOWS}
|
|
145
|
+
|
|
146
|
+
--- IF LINUX/macOS (use bash/sh): ---
|
|
147
|
+
${LIVEKIT_SETUP_STEPS_LINUX}
|
|
148
|
+
`;
|
|
149
|
+
function buildLivekitSetupPrompt(platform) {
|
|
150
|
+
let steps;
|
|
151
|
+
if (platform === 'win32') {
|
|
152
|
+
steps = LIVEKIT_SETUP_STEPS_WINDOWS;
|
|
153
|
+
}
|
|
154
|
+
else if (platform === 'linux' || platform === 'darwin') {
|
|
155
|
+
steps = LIVEKIT_SETUP_STEPS_LINUX;
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
// Platform unknown ahead of dispatch — let the agent detect itself.
|
|
159
|
+
steps = LIVEKIT_SETUP_STEPS_AUTODETECT;
|
|
160
|
+
}
|
|
161
|
+
return LIVEKIT_SETUP_PROMPT_HEADER + '\n' + steps + LIVEKIT_SETUP_PROMPT_FOOTER;
|
|
162
|
+
}
|
|
163
|
+
function extractJson(text) {
|
|
164
|
+
const m = text.match(/\`\`\`json\s*([\s\S]*?)\s*\`\`\`/);
|
|
165
|
+
if (!m)
|
|
166
|
+
return null;
|
|
167
|
+
try {
|
|
168
|
+
return JSON.parse(m[1]);
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
async function registerLiveKitServer(schedulerUrl, info) {
|
|
175
|
+
const url = `${schedulerUrl.replace(/\/$/, '')}/api/servers`;
|
|
176
|
+
const body = {
|
|
177
|
+
id: 'livekit-hub-docker',
|
|
178
|
+
name: 'LiveKit Hub Docker',
|
|
179
|
+
url: info.url,
|
|
180
|
+
webhook_url: info.url.replace(/^wss?/, 'http'),
|
|
181
|
+
api_key: info.api_key,
|
|
182
|
+
api_secret: info.api_secret,
|
|
183
|
+
gpu_name: 'none',
|
|
184
|
+
gpu_vram_mb: 0,
|
|
185
|
+
max_sessions: 8,
|
|
186
|
+
status: 'online',
|
|
187
|
+
};
|
|
188
|
+
const res = await fetch(url, {
|
|
189
|
+
method: 'POST',
|
|
190
|
+
headers: { 'content-type': 'application/json' },
|
|
191
|
+
body: JSON.stringify(body),
|
|
192
|
+
});
|
|
193
|
+
if (!res.ok) {
|
|
194
|
+
const text = await res.text().catch(() => '');
|
|
195
|
+
throw new Error(`register server failed ${res.status}: ${text}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
export async function roboparkSetupLivekit(opts) {
|
|
199
|
+
const ctx = await discoverContext();
|
|
200
|
+
const hubUrl = opts.hubUrl ?? (ctx.hub ? `http://${ctx.hub.ip}:${ctx.meshPort ?? 47913}` : undefined);
|
|
201
|
+
const token = opts.token ?? ctx.meshToken ?? loadMeshToken();
|
|
202
|
+
const schedulerUrl = opts.schedulerUrl ?? (hubUrl ? hubUrl.replace(/:\d+$/, ':8080') : undefined);
|
|
203
|
+
const timeoutMs = (opts.timeout ? parseInt(opts.timeout, 10) : 180) * 1000;
|
|
204
|
+
if (!hubUrl) {
|
|
205
|
+
console.log(chalk.red(' ✗ no hub discovered. Pass --hub-url http://HUB_IP:47913'));
|
|
206
|
+
process.exit(1);
|
|
207
|
+
}
|
|
208
|
+
if (!token) {
|
|
209
|
+
console.log(chalk.red(' ✗ no mesh token. Pass --token or run robopark setup first.'));
|
|
210
|
+
process.exit(1);
|
|
211
|
+
}
|
|
212
|
+
console.log(chalk.bold('\n robopark setup-livekit'));
|
|
213
|
+
console.log(chalk.dim(' ' + '─'.repeat(52)));
|
|
214
|
+
console.log(` hub: ${chalk.cyan(hubUrl)}`);
|
|
215
|
+
console.log(` scheduler: ${chalk.cyan(schedulerUrl ?? 'not provided')}`);
|
|
216
|
+
const from = hostname().split('.')[0];
|
|
217
|
+
const manifest = await getHubManifest(hubUrl, token);
|
|
218
|
+
let platform = manifest.platform;
|
|
219
|
+
if (!platform)
|
|
220
|
+
platform = await getHubPlatformFromStatus(hubUrl, token);
|
|
221
|
+
console.log(` hub platform: ${chalk.cyan(platform ?? 'unknown (agent will self-detect)')}`);
|
|
222
|
+
console.log();
|
|
223
|
+
const prompt = buildLivekitSetupPrompt(platform);
|
|
224
|
+
if (opts.dryRun) {
|
|
225
|
+
console.log(chalk.yellow(' dry run — prompt that would be sent:'));
|
|
226
|
+
console.log(chalk.dim(prompt.split('\n').map(l => ' ' + l).join('\n')));
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const nodeId = manifest.nodeId;
|
|
230
|
+
if (!nodeId) {
|
|
231
|
+
console.log(chalk.red(' ✗ hub manifest not reachable. Is infinicode serve running on the hub?'));
|
|
232
|
+
process.exit(1);
|
|
233
|
+
}
|
|
234
|
+
console.log(chalk.dim(` hub node id: ${nodeId}`));
|
|
235
|
+
const dispatchEnv = envelope('task.dispatch', from, {
|
|
236
|
+
description: 'Install LiveKit server on hub via Docker',
|
|
237
|
+
capabilities: ['shell', 'docker', 'reasoning'],
|
|
238
|
+
prompt,
|
|
239
|
+
agent: 'opencode',
|
|
240
|
+
context: { role: 'hub', site: 'fleet-hub' },
|
|
241
|
+
}, { to: nodeId, auth: token });
|
|
242
|
+
console.log(chalk.dim(' dispatching opencode agent to hub…'));
|
|
243
|
+
const accepted = await meshRpc(hubUrl, token, dispatchEnv);
|
|
244
|
+
if (!accepted || accepted.kind !== 'task.accepted') {
|
|
245
|
+
const err = accepted?.data && typeof accepted.data === 'object' ? JSON.stringify(accepted.data) : 'unknown';
|
|
246
|
+
console.log(chalk.red(` ✗ dispatch failed: ${err}`));
|
|
247
|
+
process.exit(1);
|
|
248
|
+
}
|
|
249
|
+
const { runId } = accepted.data;
|
|
250
|
+
console.log(chalk.dim(` run id: ${runId}`));
|
|
251
|
+
const start = Date.now();
|
|
252
|
+
let lastStatus = null;
|
|
253
|
+
let result = null;
|
|
254
|
+
while (Date.now() - start < timeoutMs) {
|
|
255
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
256
|
+
const statusEnv = envelope('task.status', from, { runId }, { to: nodeId, auth: token });
|
|
257
|
+
result = await meshRpc(hubUrl, token, statusEnv);
|
|
258
|
+
const rec = (result?.data ?? {});
|
|
259
|
+
if (rec.status !== lastStatus) {
|
|
260
|
+
lastStatus = rec.status ?? null;
|
|
261
|
+
console.log(` status: ${chalk.cyan(rec.status ?? 'unknown')}${rec.error ? chalk.red(` — ${rec.error}`) : ''}`);
|
|
262
|
+
}
|
|
263
|
+
if (rec.status === 'completed' || rec.status === 'failed')
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
if (!result) {
|
|
267
|
+
console.log(chalk.red(' ✗ no response from hub'));
|
|
268
|
+
process.exit(1);
|
|
269
|
+
}
|
|
270
|
+
const rec = result.data;
|
|
271
|
+
if (rec.status !== 'completed') {
|
|
272
|
+
console.log(chalk.red(` ✗ LiveKit setup failed: ${rec.error || 'unknown'}`));
|
|
273
|
+
if (rec.outputs)
|
|
274
|
+
console.log(chalk.dim(JSON.stringify(rec.outputs, null, 2)));
|
|
275
|
+
process.exit(1);
|
|
276
|
+
}
|
|
277
|
+
const content = rec.outputs?.map(o => o.content).filter(Boolean).join('\n') || '';
|
|
278
|
+
const parsed = extractJson(content);
|
|
279
|
+
if (!parsed || !parsed.url || !parsed.api_key || !parsed.api_secret) {
|
|
280
|
+
console.log(chalk.yellow(' ⚠ agent finished but did not return LiveKit credentials in a JSON block'));
|
|
281
|
+
console.log(chalk.dim(content));
|
|
282
|
+
process.exit(1);
|
|
283
|
+
}
|
|
284
|
+
console.log(chalk.green(' ✓ LiveKit server reported running'));
|
|
285
|
+
console.log(` url: ${chalk.cyan(String(parsed.url))}`);
|
|
286
|
+
console.log(` key: ${chalk.cyan(String(parsed.api_key))}`);
|
|
287
|
+
if (schedulerUrl) {
|
|
288
|
+
try {
|
|
289
|
+
await registerLiveKitServer(schedulerUrl, {
|
|
290
|
+
url: String(parsed.url),
|
|
291
|
+
api_key: String(parsed.api_key),
|
|
292
|
+
api_secret: String(parsed.api_secret),
|
|
293
|
+
});
|
|
294
|
+
console.log(chalk.green(` ✓ registered LiveKit server with scheduler`));
|
|
295
|
+
}
|
|
296
|
+
catch (e) {
|
|
297
|
+
console.log(chalk.yellow(` ⚠ could not register with scheduler: ${e.message}`));
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RoboPark — `robopark setup`.
|
|
3
|
+
*
|
|
4
|
+
* One-liner setup per production machine. Each role turns on the right defaults
|
|
5
|
+
* and hides the mesh vocabulary.
|
|
6
|
+
*
|
|
7
|
+
* robopark setup --hub --name livekit-1 --site "Tel Aviv" --start --auto-start
|
|
8
|
+
* robopark setup --robot --name robobmw --start --auto-start
|
|
9
|
+
* robopark setup --control --start
|
|
10
|
+
*/
|
|
11
|
+
import chalk from 'chalk';
|
|
12
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { spawn } from 'node:child_process';
|
|
16
|
+
import { discoverContext, generateToken } from './discovery.js';
|
|
17
|
+
import { registerAutoStart, unregisterAutoStart } from './auto-start.js';
|
|
18
|
+
import { ensureMotorService } from './motor-control.js';
|
|
19
|
+
import { resolveInfinicodeBin } from './serve.js';
|
|
20
|
+
const DEFAULT_MESH_PORT = 47913;
|
|
21
|
+
const DEFAULT_SCHEDULER_PORT = 8080;
|
|
22
|
+
function saveToken(token) {
|
|
23
|
+
const dir = join(homedir(), '.robopark');
|
|
24
|
+
mkdirSync(dir, { recursive: true });
|
|
25
|
+
writeFileSync(join(dir, 'mesh.token'), token, 'utf8');
|
|
26
|
+
}
|
|
27
|
+
/** Run an external command and detach unless foreground is requested. */
|
|
28
|
+
function runDetached(cmd, args, env) {
|
|
29
|
+
const proc = spawn(cmd, args, {
|
|
30
|
+
stdio: 'ignore',
|
|
31
|
+
detached: true,
|
|
32
|
+
env: { ...process.env, ...env },
|
|
33
|
+
});
|
|
34
|
+
proc.on('error', (err) => {
|
|
35
|
+
console.log(chalk.red(` ✗ failed to start ${cmd}: ${err.message}`));
|
|
36
|
+
});
|
|
37
|
+
proc.unref();
|
|
38
|
+
}
|
|
39
|
+
/** Resolve the infinicode CLI and return the argv to spawn it. */
|
|
40
|
+
async function infinicodeArgv(args) {
|
|
41
|
+
const bin = await resolveInfinicodeBin();
|
|
42
|
+
if (bin)
|
|
43
|
+
return [bin.node, bin.script, ...args];
|
|
44
|
+
// Fallback: assume `infinicode` is on PATH. This will fail on Windows if the
|
|
45
|
+
// .cmd shim is not resolvable, but the resolved-bin path above is the
|
|
46
|
+
// intended path for all npm installs.
|
|
47
|
+
return ['infinicode', ...args];
|
|
48
|
+
}
|
|
49
|
+
/** Build a Windows-task-friendly command string from an argv array. */
|
|
50
|
+
function commandString(argv) {
|
|
51
|
+
return argv.map(a => /[^a-zA-Z0-9_./:=,-]/.test(a) ? `"${a.replace(/"/g, '""')}"` : a).join(' ');
|
|
52
|
+
}
|
|
53
|
+
export async function roboparkSetup(config, opts) {
|
|
54
|
+
const role = opts.role ?? 'control';
|
|
55
|
+
const ctx = await discoverContext({ lan: ['robot', 'voice-vision', 'motor'].includes(role) && !opts.tailscale });
|
|
56
|
+
const token = opts.token ?? ctx.meshToken ?? generateToken();
|
|
57
|
+
const port = opts.port ? parseInt(opts.port, 10) : DEFAULT_MESH_PORT;
|
|
58
|
+
const name = opts.name ?? ctx.localName;
|
|
59
|
+
const gatewayHost = opts.gateway ?? (ctx.gateway ? `ws://${ctx.gateway.host}:${ctx.gateway.port ?? 18789}` : undefined);
|
|
60
|
+
const gatewayToken = opts.gatewayToken ?? ctx.gateway?.token;
|
|
61
|
+
saveToken(token);
|
|
62
|
+
console.log(chalk.bold('\n robopark setup'));
|
|
63
|
+
console.log(chalk.dim(' ' + '─'.repeat(52)));
|
|
64
|
+
console.log(` role: ${chalk.yellow(role)}`);
|
|
65
|
+
console.log(` name: ${chalk.cyan(name)}${opts.site ? chalk.dim(' @ ' + opts.site) : ''}`);
|
|
66
|
+
console.log(` mesh: ${chalk.cyan('0.0.0.0:' + port)}`);
|
|
67
|
+
console.log(` token: ${chalk.green(token)}`);
|
|
68
|
+
if (gatewayHost)
|
|
69
|
+
console.log(` gateway:${chalk.cyan(gatewayHost)}`);
|
|
70
|
+
console.log();
|
|
71
|
+
if (role === 'hub') {
|
|
72
|
+
await setupHub(config, opts, { name, token, port, gatewayHost, gatewayToken });
|
|
73
|
+
}
|
|
74
|
+
else if (role === 'robot' || role === 'voice-vision' || role === 'motor') {
|
|
75
|
+
const deviceRole = role === 'robot' ? 'combined' : role === 'voice-vision' ? 'voice_vision' : 'motor';
|
|
76
|
+
await setupRobot(config, opts, ctx, { name, token, port }, deviceRole);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
await setupControl(config, opts, ctx, { name, token, port });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
async function setupHub(config, opts, internal) {
|
|
83
|
+
const fed = {
|
|
84
|
+
...(config.get('federation') ?? {}),
|
|
85
|
+
enabled: true,
|
|
86
|
+
role: 'hub',
|
|
87
|
+
port: internal.port,
|
|
88
|
+
displayName: internal.name,
|
|
89
|
+
token: internal.token,
|
|
90
|
+
lan: true,
|
|
91
|
+
};
|
|
92
|
+
config.set('federation', fed);
|
|
93
|
+
const schedulerPort = opts.schedulerPort ? parseInt(opts.schedulerPort, 10) : DEFAULT_SCHEDULER_PORT;
|
|
94
|
+
const infinicodeArgs = [
|
|
95
|
+
'serve', '--hub',
|
|
96
|
+
'--name', internal.name,
|
|
97
|
+
'--port', String(internal.port),
|
|
98
|
+
'--token', internal.token,
|
|
99
|
+
'--lan',
|
|
100
|
+
'--dashboard',
|
|
101
|
+
`--scheduler-url`, `http://127.0.0.1:${schedulerPort}`,
|
|
102
|
+
'--auto-update',
|
|
103
|
+
'--supervised',
|
|
104
|
+
];
|
|
105
|
+
if (internal.gatewayHost)
|
|
106
|
+
infinicodeArgs.push('--gateway', internal.gatewayHost);
|
|
107
|
+
if (internal.gatewayToken)
|
|
108
|
+
infinicodeArgs.push('--gateway-token', internal.gatewayToken);
|
|
109
|
+
console.log(chalk.dim(' hub commands:'));
|
|
110
|
+
console.log(' ' + chalk.cyan(`infinicode ${infinicodeArgs.join(' ')}`));
|
|
111
|
+
console.log(' ' + chalk.cyan(`robopark serve --port ${schedulerPort}`));
|
|
112
|
+
console.log();
|
|
113
|
+
if (opts.start) {
|
|
114
|
+
const hubArgv = await infinicodeArgv(infinicodeArgs);
|
|
115
|
+
console.log(chalk.dim(' starting infinicode hub…'));
|
|
116
|
+
runDetached(hubArgv[0], hubArgv.slice(1));
|
|
117
|
+
console.log(chalk.dim(' starting robopark scheduler…'));
|
|
118
|
+
runDetached(process.execPath, [process.argv[1], 'serve', '--port', String(schedulerPort)]);
|
|
119
|
+
}
|
|
120
|
+
if (opts.autoStart) {
|
|
121
|
+
const hubArgv = await infinicodeArgv(infinicodeArgs);
|
|
122
|
+
const reg = await registerAutoStart({
|
|
123
|
+
role: 'hub',
|
|
124
|
+
name: internal.name,
|
|
125
|
+
command: hubArgv[0],
|
|
126
|
+
args: hubArgv.slice(1),
|
|
127
|
+
});
|
|
128
|
+
console.log(reg.ok ? chalk.green(` ✓ ${reg.message}`) : chalk.yellow(` ⚠ ${reg.message}`));
|
|
129
|
+
const reg2 = await registerAutoStart({
|
|
130
|
+
role: 'hub-scheduler',
|
|
131
|
+
name: internal.name,
|
|
132
|
+
command: process.execPath,
|
|
133
|
+
args: [process.argv[1], 'serve', '--port', String(schedulerPort), '--foreground'],
|
|
134
|
+
});
|
|
135
|
+
console.log(reg2.ok ? chalk.green(` ✓ ${reg2.message}`) : chalk.yellow(` ⚠ ${reg2.message}`));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
async function setupRobot(config, opts, ctx, internal, deviceRole = 'combined') {
|
|
139
|
+
if (opts.lan && opts.tailscale) {
|
|
140
|
+
console.log(chalk.red(' ✗ choose exactly one robot network: --lan or --tailscale'));
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const network = opts.tailscale ? 'tailscale' : 'lan';
|
|
144
|
+
const networkFlag = network === 'tailscale' ? '--tailscale' : '--lan';
|
|
145
|
+
let hubUrl = opts.hub ?? (ctx.hub ? `http://${ctx.hub.ip}:${internal.port}` : undefined);
|
|
146
|
+
if (!hubUrl) {
|
|
147
|
+
console.log(chalk.red(' ✗ no hub discovered. Pass --hub http://HUB_IP:47913'));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (network === 'lan') {
|
|
151
|
+
const reachable = async (url) => {
|
|
152
|
+
try {
|
|
153
|
+
const response = await fetch(`${url.replace(/\/+$/, '')}/fed/status`, {
|
|
154
|
+
headers: { Authorization: `Bearer ${internal.token}` },
|
|
155
|
+
signal: AbortSignal.timeout(2500),
|
|
156
|
+
});
|
|
157
|
+
return response.ok;
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
if (!await reachable(hubUrl)) {
|
|
164
|
+
const discovered = ctx.hub?.source === 'lan'
|
|
165
|
+
? `http://${ctx.hub.ip}:${internal.port}`
|
|
166
|
+
: undefined;
|
|
167
|
+
if (!discovered || !await reachable(discovered)) {
|
|
168
|
+
console.log(chalk.red(` ✗ hub is unreachable from this robot: ${hubUrl}`));
|
|
169
|
+
console.log(chalk.dim(' verify the hub is running with --lan, then rerun setup'));
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
console.log(chalk.yellow(` âš supplied hub unreachable; using LAN-discovered hub ${discovered}`));
|
|
173
|
+
hubUrl = discovered;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const fed = {
|
|
177
|
+
...(config.get('federation') ?? {}),
|
|
178
|
+
enabled: true,
|
|
179
|
+
role: 'satellite',
|
|
180
|
+
port: internal.port,
|
|
181
|
+
displayName: internal.name,
|
|
182
|
+
token: internal.token,
|
|
183
|
+
seeds: [hubUrl],
|
|
184
|
+
lan: network === 'lan',
|
|
185
|
+
};
|
|
186
|
+
config.set('federation', fed);
|
|
187
|
+
// Derive the scheduler host from the SAME hubUrl used for the mesh seed
|
|
188
|
+
// (respects an explicit --hub-url), not from ctx.hub — using auto-discovery
|
|
189
|
+
// here independently of the mesh seed meant an explicit --hub-url only
|
|
190
|
+
// repointed the mesh connection while the scheduler (enroll/heartbeat/
|
|
191
|
+
// preview-agent) silently kept talking to whatever hub auto-discovery
|
|
192
|
+
// found (e.g. a real hub reachable over Tailscale), not the one requested.
|
|
193
|
+
// Keep the scheduler private on the hub. Both LAN and Tailscale robots use
|
|
194
|
+
// the authenticated mesh proxy instead of depending on port 8080 binding,
|
|
195
|
+
// firewall rules, or a separately routable scheduler address.
|
|
196
|
+
const schedulerUrl = `${hubUrl.replace(/\/+$/, '')}/robopark`;
|
|
197
|
+
// Vision (camera/motion detection -> presence-triggered session) is the
|
|
198
|
+
// production trigger — on by default. --no-vision opts out (e.g. no camera
|
|
199
|
+
// on this box, or RoboVisionAI_PI isn't installed).
|
|
200
|
+
const visionEnabled = deviceRole !== 'motor' && opts.vision !== false;
|
|
201
|
+
const defaultVideoDevice = process.platform === 'linux' && opts.autoStart
|
|
202
|
+
? '/dev/robopark-camera'
|
|
203
|
+
: '/dev/video0';
|
|
204
|
+
const runtimeArgs = [
|
|
205
|
+
'robot', 'up',
|
|
206
|
+
'--name', internal.name,
|
|
207
|
+
'--hub-url', hubUrl,
|
|
208
|
+
'--scheduler-url', schedulerUrl,
|
|
209
|
+
'--token', internal.token,
|
|
210
|
+
'--port', String(internal.port),
|
|
211
|
+
networkFlag,
|
|
212
|
+
'--video-device', opts.videoDevice ?? defaultVideoDevice,
|
|
213
|
+
'--audio-device', opts.audioDevice ?? 'Usb Audio Device: USB Audio',
|
|
214
|
+
'--device-role', deviceRole,
|
|
215
|
+
];
|
|
216
|
+
if (opts.enrollmentToken)
|
|
217
|
+
runtimeArgs.push('--enrollment-token', opts.enrollmentToken);
|
|
218
|
+
if (opts.character)
|
|
219
|
+
runtimeArgs.push('--character', opts.character);
|
|
220
|
+
if (!visionEnabled)
|
|
221
|
+
runtimeArgs.push('--no-vision');
|
|
222
|
+
console.log(chalk.dim(' single robot command:'));
|
|
223
|
+
console.log(' ' + chalk.cyan(`robopark ${runtimeArgs.join(' ')}`));
|
|
224
|
+
console.log(chalk.dim(` network: ${network} (scheduler: ${schedulerUrl})`));
|
|
225
|
+
console.log();
|
|
226
|
+
if (opts.start && !opts.autoStart && deviceRole !== 'voice_vision') {
|
|
227
|
+
const motorService = await ensureMotorService(internal.name, {
|
|
228
|
+
token: internal.token,
|
|
229
|
+
autoStart: false,
|
|
230
|
+
});
|
|
231
|
+
console.log(chalk.green(` ✓ ${motorService}`));
|
|
232
|
+
}
|
|
233
|
+
// On Linux, registerAutoStart enables and starts the systemd service. Do not
|
|
234
|
+
// also launch a detached copy that can leave mesh healthy while preview is
|
|
235
|
+
// duplicated, blocked, or running under a different HOME.
|
|
236
|
+
if (opts.start && !(opts.autoStart && process.platform === 'linux')) {
|
|
237
|
+
const robotArgv = [process.execPath, process.argv[1], ...runtimeArgs];
|
|
238
|
+
console.log(chalk.dim(' starting unified robot runtime…'));
|
|
239
|
+
runDetached(robotArgv[0], robotArgv.slice(1));
|
|
240
|
+
}
|
|
241
|
+
if (opts.autoStart) {
|
|
242
|
+
// Enrollment is a one-time bootstrap action. Persisting it in the
|
|
243
|
+
// supervisor would erase the device identity and attempt a new enrollment
|
|
244
|
+
// on every reboot, creating duplicate robots (or failing after the token
|
|
245
|
+
// is consumed).
|
|
246
|
+
const bootRuntimeArgs = runtimeArgs.filter((arg, index) => arg !== '--enrollment-token' && runtimeArgs[index - 1] !== '--enrollment-token');
|
|
247
|
+
const robotArgv = [process.execPath, process.argv[1], ...bootRuntimeArgs, '--foreground'];
|
|
248
|
+
// Motors have an independent owner so camera/audio restarts cannot take
|
|
249
|
+
// GPIO control down or leave a second process racing for port 8001.
|
|
250
|
+
if (deviceRole !== 'voice_vision') {
|
|
251
|
+
const motorService = await ensureMotorService(internal.name, { token: internal.token });
|
|
252
|
+
console.log(chalk.green(` ✓ ${motorService}`));
|
|
253
|
+
}
|
|
254
|
+
for (const role of ['robot', 'robot-preview-agent', 'robot-vision-agent']) {
|
|
255
|
+
unregisterAutoStart(role, internal.name);
|
|
256
|
+
}
|
|
257
|
+
const reg = await registerAutoStart({
|
|
258
|
+
role: 'robot-runtime',
|
|
259
|
+
name: internal.name,
|
|
260
|
+
command: robotArgv[0],
|
|
261
|
+
args: robotArgv.slice(1),
|
|
262
|
+
});
|
|
263
|
+
console.log(reg.ok ? chalk.green(` ✓ ${reg.message}`) : chalk.yellow(` ⚠ ${reg.message}`));
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
async function setupControl(config, opts, ctx, internal) {
|
|
267
|
+
const hubUrl = opts.hub ?? (ctx.hub ? `http://${ctx.hub.ip}:${internal.port}` : undefined);
|
|
268
|
+
const args = [
|
|
269
|
+
'mesh', 'install',
|
|
270
|
+
'--token', internal.token,
|
|
271
|
+
'--name', internal.name,
|
|
272
|
+
hubUrl ? '--seed' : '', hubUrl ?? '',
|
|
273
|
+
].filter(Boolean);
|
|
274
|
+
console.log(chalk.dim(' control command:'));
|
|
275
|
+
console.log(' ' + chalk.cyan(`infinicode ${args.join(' ')}`));
|
|
276
|
+
console.log();
|
|
277
|
+
if (opts.start) {
|
|
278
|
+
const controlArgv = await infinicodeArgv(args);
|
|
279
|
+
console.log(chalk.dim(' registering infinicode MCP host…'));
|
|
280
|
+
const proc = spawn(controlArgv[0], controlArgv.slice(1), { stdio: 'inherit' });
|
|
281
|
+
proc.on('error', (err) => {
|
|
282
|
+
console.log(chalk.red(` ✗ failed to start infinicode: ${err.message}`));
|
|
283
|
+
});
|
|
284
|
+
await new Promise(resolve => proc.on('close', () => resolve()));
|
|
285
|
+
}
|
|
286
|
+
}
|