robopark 2.8.36 → 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.
Files changed (68) hide show
  1. package/README.md +88 -63
  2. package/bin/robopark.js +7 -17
  3. package/conversation/elevenlabs_agent.py +1985 -0
  4. package/conversation/requirements.txt +3 -0
  5. package/conversation/supervisor_store.py +189 -0
  6. package/dist/kernel/config-schema.js +37 -0
  7. package/dist/kernel/types.js +7 -0
  8. package/dist/robopark/access.js +99 -0
  9. package/dist/robopark/add-robot.js +188 -0
  10. package/dist/robopark/agent-ctl.js +305 -0
  11. package/dist/robopark/auto-start.js +289 -0
  12. package/dist/robopark/conversation.js +505 -0
  13. package/dist/robopark/deployment-commands.js +47 -0
  14. package/dist/robopark/discovery.js +180 -0
  15. package/dist/robopark/doctor.js +175 -0
  16. package/dist/robopark/enroll.js +68 -0
  17. package/dist/robopark/llm-set.js +87 -0
  18. package/dist/robopark/motor-control.js +195 -0
  19. package/dist/robopark/preview-agent-launcher.js +77 -0
  20. package/dist/robopark/probe.js +138 -0
  21. package/dist/robopark/profile.js +69 -0
  22. package/dist/robopark/python-env.js +162 -0
  23. package/dist/robopark/robot-runtime.js +489 -0
  24. package/dist/robopark/scan.js +97 -0
  25. package/dist/robopark/screen-control.js +55 -0
  26. package/dist/robopark/secrets.js +41 -0
  27. package/dist/robopark/serve.js +285 -0
  28. package/dist/robopark/server-add.js +114 -0
  29. package/dist/robopark/setup-livekit.js +300 -0
  30. package/dist/robopark/setup.js +286 -0
  31. package/dist/robopark/standalone.js +466 -0
  32. package/dist/robopark/stop-all.js +141 -0
  33. package/dist/robopark/verify.js +192 -0
  34. package/dist/robopark/vision-agent-launcher.js +98 -0
  35. package/dist/robopark/vision-control.js +81 -0
  36. package/dist/robopark-cli.js +799 -0
  37. package/package.json +21 -5
  38. package/pi-client/_install_steps.sh +29 -29
  39. package/pi-client/client.py +61 -2
  40. package/pi-client/install.sh +40 -40
  41. package/pi-client/join_convo.sh +54 -54
  42. package/pi-client/livekit_bridge.py +16 -7
  43. package/pi-client/motor_bridge.py +6 -3
  44. package/scheduler/fleet_config.json +75 -0
  45. package/scheduler/main.py +4505 -135
  46. package/scheduler/media_lock.py +57 -0
  47. package/scheduler/preview_agent.py +1465 -87
  48. package/scheduler/production_config.json +139 -0
  49. package/scheduler/robot_supervisor.py +1705 -0
  50. package/scheduler/scripts/install-robot-supervisor-linux.sh +33 -0
  51. package/scheduler/scripts/install-robot-supervisor-windows.ps1 +49 -0
  52. package/scheduler/scripts/robopark-supervisor.service +20 -0
  53. package/scheduler/scripts/start-scheduler-local.ps1 +50 -0
  54. package/scheduler/supervisor.example.json +26 -0
  55. package/scheduler/vision_motion_trigger.py +101 -0
  56. package/screen/screen_runtime.py +75 -0
  57. package/vision/app_pi_clean.py +253 -16
  58. package/vision/audio_server_pi.py +19 -0
  59. package/vision/install.sh +34 -34
  60. package/vision/motor_server.py +224 -61
  61. package/vision/requirements_camera.txt +6 -0
  62. package/vision/requirements_motor.txt +4 -0
  63. package/vision/requirements_pi_unified.txt +1 -0
  64. package/vision/requirements_vision_agent.txt +19 -0
  65. package/vision/run.sh +244 -244
  66. package/vision/services/services.sh +12 -12
  67. package/scheduler/__pycache__/main.cpython-312.pyc +0 -0
  68. package/scheduler/__pycache__/preview_agent.cpython-312.pyc +0 -0
@@ -0,0 +1,489 @@
1
+ /**
2
+ * RoboPark robot runtime.
3
+ *
4
+ * One long-lived parent for a robot's mesh satellite, RoboVision camera/audio
5
+ * service, and preview agent. Starting these as unrelated detached commands
6
+ * allowed a healthy-looking mesh node with no motion service or no device
7
+ * inventory. The runtime starts dependencies in order and restarts a child if
8
+ * it exits unexpectedly.
9
+ */
10
+ import { spawn } from 'node:child_process';
11
+ import { existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
12
+ import { homedir, networkInterfaces } from 'node:os';
13
+ import { dirname, join } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import chalk from 'chalk';
16
+ import { resolveInfinicodeBin } from './serve.js';
17
+ import { findPython, findRobotSupervisorPath, prepareRobotPython } from './python-env.js';
18
+ const VISION_URL = 'http://127.0.0.1:5000/api/media/inventory';
19
+ const VISION_STATUS_URL = 'http://127.0.0.1:5000/api/camera/status';
20
+ const MOTOR_SERVER_URL = 'http://127.0.0.1:8001';
21
+ const RESTART_DELAY_MS = 5_000;
22
+ const MEDIA_WATCHDOG_INTERVAL_MS = 10_000;
23
+ const SCHEDULER_HEARTBEAT_INTERVAL_MS = 5_000;
24
+ function currentLanIp() {
25
+ for (const addresses of Object.values(networkInterfaces())) {
26
+ for (const address of addresses ?? []) {
27
+ if (address.family === 'IPv4' && !address.internal)
28
+ return address.address;
29
+ }
30
+ }
31
+ return undefined;
32
+ }
33
+ function readSchedulerIdentity() {
34
+ const configDir = join(homedir(), '.robopark');
35
+ try {
36
+ const config = JSON.parse(readFileSync(join(configDir, 'preview_agent.json'), 'utf8'));
37
+ const deviceId = String(config.device_id ?? '').trim();
38
+ const fileToken = existsSync(join(configDir, 'device_token'))
39
+ ? readFileSync(join(configDir, 'device_token'), 'utf8').trim()
40
+ : '';
41
+ const deviceToken = fileToken || String(config.device_token ?? '').trim();
42
+ if (deviceId && deviceToken)
43
+ return { deviceId, deviceToken };
44
+ }
45
+ catch {
46
+ // First boot has no identity yet.
47
+ }
48
+ return undefined;
49
+ }
50
+ function persistSchedulerIdentity(identity, schedulerUrl) {
51
+ const configDir = join(homedir(), '.robopark');
52
+ const configPath = join(configDir, 'preview_agent.json');
53
+ mkdirSync(configDir, { recursive: true });
54
+ let config = {};
55
+ try {
56
+ config = JSON.parse(readFileSync(configPath, 'utf8'));
57
+ }
58
+ catch { /* first boot */ }
59
+ config.device_id = identity.deviceId;
60
+ config.device_token = identity.deviceToken;
61
+ config.scheduler_url = schedulerUrl;
62
+ writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
63
+ writeFileSync(join(configDir, 'device_token'), identity.deviceToken, { encoding: 'utf8', mode: 0o600 });
64
+ }
65
+ function startSchedulerHeartbeat(opts, livekitUrl) {
66
+ let identity = readSchedulerIdentity();
67
+ let stopped = false;
68
+ let inFlight = false;
69
+ let motorDiscoveryQueued = false;
70
+ let lastMotorDiscoveryAttempt = 0;
71
+ const bootstrap = async () => {
72
+ const response = await fetch(`${opts.schedulerUrl.replace(/\/+$/, '')}/api/devices/bootstrap`, {
73
+ method: 'POST',
74
+ headers: {
75
+ 'content-type': 'application/json',
76
+ 'x-robopark-mesh-token': opts.token,
77
+ },
78
+ body: JSON.stringify({
79
+ name: opts.name,
80
+ device_role: opts.deviceRole ?? 'combined',
81
+ character_id: opts.character,
82
+ lan_ip: currentLanIp(),
83
+ livekit_url: livekitUrl,
84
+ motor_server_url: opts.deviceRole === 'voice_vision' ? undefined : MOTOR_SERVER_URL,
85
+ }),
86
+ signal: AbortSignal.timeout(10_000),
87
+ });
88
+ if (!response.ok)
89
+ throw new Error(`bootstrap HTTP ${response.status}`);
90
+ const data = await response.json();
91
+ const next = {
92
+ deviceId: String(data.device_id ?? '').trim(),
93
+ deviceToken: String(data.device_token ?? '').trim(),
94
+ };
95
+ if (!next.deviceId || !next.deviceToken)
96
+ throw new Error('bootstrap returned no device credentials');
97
+ persistSchedulerIdentity(next, opts.schedulerUrl);
98
+ return next;
99
+ };
100
+ const beat = async () => {
101
+ if (stopped || inFlight)
102
+ return;
103
+ inFlight = true;
104
+ try {
105
+ identity = readSchedulerIdentity() ?? identity;
106
+ if (!identity)
107
+ identity = await bootstrap();
108
+ let response = await fetch(`${opts.schedulerUrl.replace(/\/+$/, '')}/api/devices/${encodeURIComponent(identity.deviceId)}/heartbeat`, {
109
+ method: 'POST',
110
+ headers: {
111
+ authorization: `Bearer ${identity.deviceToken}`,
112
+ 'content-type': 'application/json',
113
+ 'x-robopark-mesh-token': opts.token,
114
+ },
115
+ body: JSON.stringify({
116
+ status: 'online', ip: currentLanIp(), livekit_url: livekitUrl,
117
+ device_role: opts.deviceRole ?? 'combined',
118
+ motor_server_url: opts.deviceRole === 'voice_vision' ? undefined : MOTOR_SERVER_URL,
119
+ }),
120
+ signal: AbortSignal.timeout(10_000),
121
+ });
122
+ if (response.status === 401 || response.status === 404) {
123
+ identity = await bootstrap();
124
+ response = await fetch(`${opts.schedulerUrl.replace(/\/+$/, '')}/api/devices/${encodeURIComponent(identity.deviceId)}/heartbeat`, {
125
+ method: 'POST',
126
+ headers: {
127
+ authorization: `Bearer ${identity.deviceToken}`,
128
+ 'content-type': 'application/json',
129
+ 'x-robopark-mesh-token': opts.token,
130
+ },
131
+ body: JSON.stringify({
132
+ status: 'online', ip: currentLanIp(), livekit_url: livekitUrl,
133
+ device_role: opts.deviceRole ?? 'combined',
134
+ motor_server_url: opts.deviceRole === 'voice_vision' ? undefined : MOTOR_SERVER_URL,
135
+ }),
136
+ signal: AbortSignal.timeout(10_000),
137
+ });
138
+ }
139
+ if (!response.ok)
140
+ throw new Error(`heartbeat HTTP ${response.status}`);
141
+ if (opts.deviceRole !== 'voice_vision' && !motorDiscoveryQueued && Date.now() - lastMotorDiscoveryAttempt > 30_000) {
142
+ lastMotorDiscoveryAttempt = Date.now();
143
+ const discovery = await fetch(`${opts.schedulerUrl.replace(/\/+$/, '')}/api/robots/${encodeURIComponent(identity.deviceId)}/motors/discover`, {
144
+ method: 'POST',
145
+ headers: {
146
+ authorization: `Bearer ${identity.deviceToken}`,
147
+ 'x-robopark-mesh-token': opts.token,
148
+ },
149
+ signal: AbortSignal.timeout(10_000),
150
+ });
151
+ motorDiscoveryQueued = discovery.ok;
152
+ if (discovery.ok)
153
+ console.log(chalk.green(' motor registry discovery queued'));
154
+ }
155
+ }
156
+ catch (error) {
157
+ console.error(chalk.yellow(` scheduler heartbeat watchdog: ${error instanceof Error ? error.message : String(error)}`));
158
+ }
159
+ finally {
160
+ inFlight = false;
161
+ }
162
+ };
163
+ void beat();
164
+ const timer = setInterval(() => { void beat(); }, SCHEDULER_HEARTBEAT_INTERVAL_MS);
165
+ return () => {
166
+ stopped = true;
167
+ clearInterval(timer);
168
+ };
169
+ }
170
+ function resetStaleEnrollment() {
171
+ const configDir = join(homedir(), '.robopark');
172
+ const tokenPath = join(configDir, 'device_token');
173
+ const configPath = join(configDir, 'preview_agent.json');
174
+ try {
175
+ if (existsSync(tokenPath))
176
+ rmSync(tokenPath);
177
+ }
178
+ catch { /* enrollment will report a real error */ }
179
+ if (!existsSync(configPath))
180
+ return;
181
+ try {
182
+ const config = JSON.parse(readFileSync(configPath, 'utf8'));
183
+ delete config.device_id;
184
+ delete config.device_token;
185
+ writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
186
+ }
187
+ catch {
188
+ // A malformed optional config must not block first boot.
189
+ }
190
+ }
191
+ function roboparkCliPath() {
192
+ // dist/robopark/robot-runtime.js -> package root -> dist/robopark-cli.js
193
+ const packageRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
194
+ const compiledCli = join(packageRoot, 'dist', 'robopark-cli.js');
195
+ return existsSync(compiledCli) ? compiledCli : process.argv[1];
196
+ }
197
+ function runtimeLog(label, foreground) {
198
+ if (foreground)
199
+ return 'inherit';
200
+ const logDir = join(homedir(), '.robopark', 'logs');
201
+ mkdirSync(logDir, { recursive: true });
202
+ return [
203
+ 'ignore',
204
+ // spawn() accepts file descriptors, but not WriteStreams before their
205
+ // asynchronous open event. openSync makes detached startup reliable.
206
+ openSync(join(logDir, `${label}.log`), 'a'),
207
+ openSync(join(logDir, `${label}.err.log`), 'a'),
208
+ ];
209
+ }
210
+ async function waitForVision() {
211
+ const deadline = Date.now() + 120_000;
212
+ while (Date.now() < deadline) {
213
+ try {
214
+ const response = await fetch(VISION_URL, { signal: AbortSignal.timeout(1_500) });
215
+ if (response.ok)
216
+ return true;
217
+ }
218
+ catch {
219
+ // Dependency installation on a fresh Pi can take a little while.
220
+ }
221
+ await new Promise(resolve => setTimeout(resolve, 1_000));
222
+ }
223
+ return false;
224
+ }
225
+ export async function roboparkRobotRuntime(opts) {
226
+ const port = opts.port ?? '47913';
227
+ const network = opts.network ?? 'lan';
228
+ const foreground = opts.foreground === true;
229
+ const deviceRole = opts.deviceRole ?? 'combined';
230
+ const ownsVision = deviceRole !== 'motor' && opts.vision !== false;
231
+ const ownsPreview = deviceRole === 'combined';
232
+ const cli = roboparkCliPath();
233
+ const hub = new URL(opts.hubUrl);
234
+ const livekitUrl = `${hub.protocol === 'https:' ? 'wss:' : 'ws:'}//${hub.hostname}:7880`;
235
+ const children = [];
236
+ let stopping = false;
237
+ let recycling = false;
238
+ const requestedVideo = String(opts.videoDevice ?? '').trim();
239
+ const hardwareVideo = !requestedVideo || ['auto', 'default', 'first'].includes(requestedVideo.toLowerCase())
240
+ ? '/dev/video0'
241
+ : requestedVideo;
242
+ const infinicode = await resolveInfinicodeBin();
243
+ if (!infinicode)
244
+ throw new Error('could not resolve infinicode CLI');
245
+ const supervisorScript = await findRobotSupervisorPath();
246
+ if (!supervisorScript)
247
+ throw new Error('could not resolve RoboPark robot supervisor');
248
+ const supervisorPython = prepareRobotPython(findPython(), supervisorScript, ['httpx'], 'requirements-robot.txt');
249
+ if (!supervisorPython)
250
+ throw new Error('could not prepare RoboPark robot supervisor Python environment');
251
+ children.push({
252
+ label: 'mesh',
253
+ command: infinicode.node,
254
+ args: [infinicode.script, 'serve', '--role', 'satellite', '--name', opts.name,
255
+ '--port', port, '--token', opts.token, '--seed', opts.hubUrl,
256
+ network === 'tailscale' ? '--tailscale' : '--lan', '--auto-update', '--supervised'],
257
+ });
258
+ if (ownsVision) {
259
+ children.push({
260
+ label: 'vision',
261
+ command: process.execPath,
262
+ args: [cli, 'vision-agent', '--foreground', '--port', '5000',
263
+ '--motion-webhook-url', 'http://127.0.0.1:5057/'],
264
+ env: {
265
+ ROBOPARK_CAMERA_DEVICE: hardwareVideo,
266
+ ROBOPARK_MESH_TOKEN: opts.token,
267
+ ROBOPARK_MOTOR_TOKEN: opts.token,
268
+ },
269
+ });
270
+ }
271
+ children.push({
272
+ label: 'supervisor',
273
+ command: supervisorPython,
274
+ args: [supervisorScript, '--commands-only'],
275
+ env: { ROBOPARK_MESH_TOKEN: opts.token },
276
+ });
277
+ const previewArgs = [cli, 'preview-agent', '--foreground',
278
+ '--scheduler-url', opts.schedulerUrl, '--robot-id', opts.name,
279
+ '--video-device', hardwareVideo,
280
+ '--audio-device', opts.audioDevice ?? 'Usb Audio Device: USB Audio',
281
+ '--robovision-url', 'http://127.0.0.1:5000',
282
+ '--save-config'];
283
+ if (opts.enrollmentToken)
284
+ previewArgs.push('--enrollment-token', opts.enrollmentToken);
285
+ const preview = {
286
+ label: 'preview',
287
+ command: process.execPath,
288
+ args: previewArgs,
289
+ // Tailscale scheduler access goes through the authenticated hub proxy.
290
+ // Keep this in the child environment, never in the persisted robot config.
291
+ env: {
292
+ ROBOPARK_MESH_TOKEN: opts.token,
293
+ ROBOPARK_LIVEKIT_URL: livekitUrl,
294
+ // Keep internal service wiring out of the Commander argument boundary.
295
+ // This also lets preview start while RoboVision is still warming up.
296
+ ROBOVISION_URL: ownsVision ? 'http://127.0.0.1:5000' : '',
297
+ ROBOVISION_MEDIA_URL: ownsVision
298
+ ? 'http://127.0.0.1:5000/api/media/inventory'
299
+ : '',
300
+ ROBOVISION_CAMERA: ownsVision ? '1' : '0',
301
+ // Motion remains enabled, but it reads RoboVision's shared stream. Only
302
+ // RoboVision may open the physical V4L2 device.
303
+ LOCAL_CAMERA_MOTION: 'true',
304
+ // The first production sound is the greeting. A beep would occupy the
305
+ // same exclusive ALSA device and delay or clip TTS.
306
+ ROBOPARK_MOTION_CUE: 'false',
307
+ // Fleet hardware profile: resolve volatile ALSA hw:X,Y coordinates
308
+ // from these USB product labels on every inventory refresh.
309
+ ROBOPARK_AUDIO_INPUT_MATCH: 'Usb Audio Device: USB Audio',
310
+ ROBOPARK_AUDIO_OUTPUT_MATCH: 'USB Audio Device: -',
311
+ // Launch hardware has no acoustic echo cancellation. Suppress mic PCM
312
+ // only while audible robot speech is playing, then hold briefly for
313
+ // room echo decay so VAD cannot interrupt the robot with its own voice.
314
+ ROBOPARK_HALF_DUPLEX: 'true',
315
+ ROBOPARK_ECHO_TAIL_MS: '350',
316
+ ROBOPARK_ECHO_GATE_PEAK: '96',
317
+ // Keep ordinary speaker echo muted but reopen the microphone when a
318
+ // nearby voice rises decisively above the learned acoustic echo floor.
319
+ ROBOPARK_ADAPTIVE_BARGE_IN: 'true',
320
+ ROBOPARK_BARGE_IN_MIN_PEAK: '2200',
321
+ ROBOPARK_BARGE_IN_ECHO_RATIO: '2.4',
322
+ ROBOPARK_BARGE_IN_HOLD_MS: '1400',
323
+ // Inventory changes should reach the Control Center promptly.
324
+ HEARTBEAT_INTERVAL: '5',
325
+ },
326
+ };
327
+ const start = (entry) => {
328
+ const child = spawn(entry.command, entry.args, {
329
+ env: { ...process.env, ...entry.env, ROBOPARK_ROBOT_RUNTIME: '1' },
330
+ stdio: runtimeLog(`robot-${opts.name}-${entry.label}`, foreground),
331
+ // Give each launcher and its Python descendants one process group.
332
+ detached: process.platform !== 'win32',
333
+ });
334
+ entry.child = child;
335
+ child.on('error', error => console.error(chalk.red(` ${entry.label} failed to start: ${error.message}`)));
336
+ child.on('exit', (code, signal) => {
337
+ entry.child = undefined;
338
+ if (stopping)
339
+ return;
340
+ // A clean mesh exit is how /update signals that npm replaced this
341
+ // package. Reload every child so the robot runs one coherent release.
342
+ if (entry.label === 'mesh' && code === 0) {
343
+ void recycleAfterMeshUpdate();
344
+ return;
345
+ }
346
+ if (recycling)
347
+ return;
348
+ console.error(chalk.yellow(` ${entry.label} exited (${signal ?? code ?? 'unknown'}); restarting in 5s`));
349
+ setTimeout(() => { if (!stopping)
350
+ start(entry); }, RESTART_DELAY_MS);
351
+ });
352
+ };
353
+ const signalChild = (entry, signal) => {
354
+ const pid = entry.child?.pid;
355
+ if (!pid)
356
+ return;
357
+ try {
358
+ if (process.platform === 'win32')
359
+ entry.child?.kill(signal);
360
+ else
361
+ process.kill(-pid, signal);
362
+ }
363
+ catch {
364
+ // It may have exited between the liveness check and signal.
365
+ }
366
+ };
367
+ const stopChild = async (entry) => {
368
+ const child = entry.child;
369
+ if (!child)
370
+ return;
371
+ await new Promise(resolve => {
372
+ const timer = setTimeout(() => {
373
+ signalChild(entry, 'SIGKILL');
374
+ resolve();
375
+ }, 5_000);
376
+ child.once('exit', () => {
377
+ clearTimeout(timer);
378
+ resolve();
379
+ });
380
+ signalChild(entry, 'SIGTERM');
381
+ });
382
+ };
383
+ let visionFailureCount = 0;
384
+ let visionRecoveryInFlight = false;
385
+ const monitorVision = async () => {
386
+ if (stopping || recycling || visionRecoveryInFlight || !ownsVision)
387
+ return;
388
+ const vision = children.find(entry => entry.label === 'vision');
389
+ if (!vision?.child)
390
+ return;
391
+ let healthy = false;
392
+ try {
393
+ const response = await fetch(VISION_STATUS_URL, { signal: AbortSignal.timeout(2_000) });
394
+ if (response.ok) {
395
+ const status = await response.json();
396
+ const frameTooOld = status.worker_started === true
397
+ && typeof status.last_frame_age_seconds === 'number'
398
+ && status.last_frame_age_seconds > 15;
399
+ healthy = !status.read_stalled && !frameTooOld;
400
+ }
401
+ }
402
+ catch {
403
+ healthy = false;
404
+ }
405
+ visionFailureCount = healthy ? 0 : visionFailureCount + 1;
406
+ if (visionFailureCount < 3)
407
+ return;
408
+ visionRecoveryInFlight = true;
409
+ console.error(chalk.yellow(' media watchdog: RoboVision unhealthy for 30s; recycling camera/audio pair'));
410
+ try {
411
+ await stopChild(vision);
412
+ if (!stopping)
413
+ start(vision);
414
+ visionFailureCount = 0;
415
+ }
416
+ finally {
417
+ visionRecoveryInFlight = false;
418
+ }
419
+ };
420
+ const recycleAfterMeshUpdate = async () => {
421
+ if (stopping || recycling)
422
+ return;
423
+ recycling = true;
424
+ console.log(chalk.dim(' mesh updated; restarting RoboVision and preview on the new package...'));
425
+ await Promise.all([...children.filter(entry => entry.label !== 'mesh'), ...(ownsPreview ? [preview] : [])].map(stopChild));
426
+ if (stopping)
427
+ return;
428
+ for (const entry of children)
429
+ start(entry);
430
+ if (ownsPreview)
431
+ start(preview);
432
+ recycling = false;
433
+ console.log(chalk.green(' robot runtime updated and healthy'));
434
+ if (ownsVision) {
435
+ void waitForVision().then(ready => {
436
+ if (!stopping)
437
+ console.log(ready
438
+ ? chalk.green(' RoboVision camera/audio inventory ready')
439
+ : chalk.yellow(' RoboVision is degraded; preview heartbeat remains online and will retry'));
440
+ });
441
+ }
442
+ };
443
+ console.log(chalk.bold('\n robopark robot up'));
444
+ console.log(chalk.dim(' ' + '─'.repeat(52)));
445
+ console.log(` robot: ${chalk.cyan(opts.name)}`);
446
+ console.log(` scheduler: ${chalk.cyan(opts.schedulerUrl)}`);
447
+ console.log(` network: ${chalk.cyan(network)}`);
448
+ console.log(` role: ${chalk.cyan(deviceRole)}`);
449
+ console.log(` services: ${deviceRole === 'motor' ? 'mesh + motor + command supervisor' : deviceRole === 'voice_vision' ? 'mesh + RoboVision + command supervisor' : ownsVision ? 'mesh + RoboVision + preview + command supervisor' : 'mesh + preview + command supervisor'}`);
450
+ console.log();
451
+ if (opts.enrollmentToken) {
452
+ console.log(chalk.dim(' clearing stale device identity for fresh enrollment…'));
453
+ resetStaleEnrollment();
454
+ }
455
+ // Identity and inventory reporting start immediately. RoboVision may still
456
+ // be installing or recovering without making the robot disappear.
457
+ const stopSchedulerHeartbeat = startSchedulerHeartbeat(opts, livekitUrl);
458
+ for (const entry of children)
459
+ start(entry);
460
+ if (ownsPreview)
461
+ start(preview);
462
+ if (ownsVision) {
463
+ console.log(chalk.dim(' RoboVision health check running in background…'));
464
+ void waitForVision().then(ready => {
465
+ if (!stopping)
466
+ console.log(ready
467
+ ? chalk.green(' RoboVision camera/audio inventory ready')
468
+ : chalk.yellow(' RoboVision is degraded; preview heartbeat remains online and will retry'));
469
+ });
470
+ }
471
+ const mediaWatchdog = setInterval(() => { void monitorVision(); }, MEDIA_WATCHDOG_INTERVAL_MS);
472
+ const shutdown = () => {
473
+ stopping = true;
474
+ stopSchedulerHeartbeat();
475
+ clearInterval(mediaWatchdog);
476
+ for (const entry of [...children, ...(ownsPreview ? [preview] : [])])
477
+ signalChild(entry, 'SIGTERM');
478
+ };
479
+ process.once('SIGINT', shutdown);
480
+ process.once('SIGTERM', shutdown);
481
+ await new Promise(resolve => {
482
+ const check = setInterval(() => {
483
+ if (stopping && ![...children, ...(ownsPreview ? [preview] : [])].some(entry => entry.child)) {
484
+ clearInterval(check);
485
+ resolve();
486
+ }
487
+ }, 250);
488
+ });
489
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * RoboPark — `robopark scan`.
3
+ *
4
+ * Scans Tailscale + LAN + local config and emits ready-to-run setup commands
5
+ * for every machine in the fleet. No manual IP/token typing.
6
+ */
7
+ import chalk from 'chalk';
8
+ import { discoverContext, generateToken } from './discovery.js';
9
+ function hubCommand(ctx, opts) {
10
+ const name = ctx.hub?.name ?? 'livekit-1';
11
+ const site = 'Tel Aviv'; // TODO: discover from role context or prompt
12
+ const token = opts.token ?? ctx.meshToken ?? generateToken();
13
+ const gw = opts.gateway !== false ? ctx.gateway : undefined;
14
+ const parts = [
15
+ 'robopark setup --hub',
16
+ `--name ${name}`,
17
+ `--site "${site}"`,
18
+ `--token ${token}`,
19
+ '--start',
20
+ '--auto-start',
21
+ ];
22
+ if (ctx.tailscaleAuthKey)
23
+ parts.push(`--tailscale-auth ${ctx.tailscaleAuthKey}`);
24
+ if (gw)
25
+ parts.push(`--gateway ws://${gw.host}:${gw.port}`, `--gateway-token ${gw.token ?? 'SET_TOKEN'}`);
26
+ return parts.join(' ');
27
+ }
28
+ function robotCommand(robot, ctx, opts) {
29
+ const token = opts.token ?? ctx.meshToken ?? generateToken();
30
+ const hubUrl = ctx.hub ? `http://${ctx.hub.ip}:47913` : 'http://HUB_IP:47913';
31
+ const parts = [
32
+ 'robopark setup --robot',
33
+ `--name ${robot.name}`,
34
+ `--hub-url ${hubUrl}`,
35
+ `--token ${token}`,
36
+ '--start',
37
+ '--auto-start',
38
+ ];
39
+ if (ctx.tailscaleAuthKey)
40
+ parts.push(`--tailscale-auth ${ctx.tailscaleAuthKey}`);
41
+ return parts.join(' ');
42
+ }
43
+ function controlCommand(ctx, opts) {
44
+ const token = opts.token ?? ctx.meshToken ?? generateToken();
45
+ const hubUrl = ctx.hub ? `http://${ctx.hub.ip}:47913` : 'http://HUB_IP:47913';
46
+ const parts = [
47
+ 'robopark setup --control',
48
+ `--hub-url ${hubUrl}`,
49
+ `--token ${token}`,
50
+ '--start',
51
+ '--auto-start',
52
+ ];
53
+ return parts.join(' ');
54
+ }
55
+ export async function roboparkScan(opts = {}) {
56
+ console.log(chalk.bold('\n robopark scan — auto-discovering your fleet\n'));
57
+ const ctx = await discoverContext();
58
+ if (!ctx.hub) {
59
+ console.log(chalk.yellow(' ⚠ no hub found on Tailscale. Look for a machine named like livekit-* / hub / scheduler.'));
60
+ console.log(chalk.dim(' make sure the hub box is online and tagged, or run `robopark setup --hub` on it first.'));
61
+ }
62
+ else {
63
+ console.log(` ${chalk.green('✓')} hub found: ${chalk.cyan(ctx.hub.name)} @ ${chalk.cyan(ctx.hub.ip)}`);
64
+ }
65
+ if (!ctx.gateway) {
66
+ console.log(chalk.dim(' · no InfiniBot gateway discovered (set one with --gateway or INFINIBOT_GATEWAY env)'));
67
+ }
68
+ else {
69
+ console.log(` ${chalk.green('✓')} gateway found: ${chalk.cyan(`${ctx.gateway.host}:${ctx.gateway.port}`)}`);
70
+ }
71
+ if (!ctx.robots.length) {
72
+ console.log(chalk.dim(' · no robots discovered yet. They will LAN-join after the hub is running.'));
73
+ }
74
+ else {
75
+ console.log(` ${chalk.green('✓')} ${ctx.robots.length} robot(s) found:`);
76
+ for (const r of ctx.robots)
77
+ console.log(` · ${chalk.cyan(r.name)} @ ${chalk.cyan(r.ip)}`);
78
+ }
79
+ const token = opts.token ?? ctx.meshToken ?? generateToken();
80
+ console.log(`\n ${chalk.green('✓')} using mesh token: ${chalk.cyan(token)}`);
81
+ console.log(chalk.dim(' save it with: echo "${TOKEN}" > ~/.robopark/mesh.token'));
82
+ console.log(chalk.bold('\n Generated setup commands:\n'));
83
+ console.log(chalk.dim(' # on the hub box'));
84
+ console.log(' ' + chalk.cyan(hubCommand(ctx, opts)));
85
+ console.log();
86
+ console.log(chalk.dim(' # on each robot'));
87
+ for (const r of ctx.robots) {
88
+ console.log(' ' + chalk.cyan(robotCommand(r, ctx, opts)));
89
+ }
90
+ if (!ctx.robots.length) {
91
+ console.log(' ' + chalk.cyan(robotCommand({ name: 'robobmw', ip: 'HUB_IP', online: true, source: 'tailscale' }, ctx, opts).replace('robobmw', '<ROBOT_NAME>')));
92
+ }
93
+ console.log();
94
+ console.log(chalk.dim(' # on this dev laptop'));
95
+ console.log(' ' + chalk.cyan(controlCommand(ctx, opts)));
96
+ console.log();
97
+ }
@@ -0,0 +1,55 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { mkdirSync, writeFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join, resolve } from 'node:path';
5
+ import chalk from 'chalk';
6
+ import { registerAutoStart, unregisterAutoStart } from './auto-start.js';
7
+ import { findPython, findScreenRuntimePath } from './python-env.js';
8
+ function safeName(name) { return name.toLowerCase().replace(/[^a-z0-9]+/g, '-'); }
9
+ function unit(name) { return `robopark-robot-screen-${safeName(name)}.service`; }
10
+ export function roboparkScreenInstall() {
11
+ if (process.platform !== 'linux')
12
+ throw new Error('screen sharing installation is supported on Linux robot hosts');
13
+ const update = spawnSync('apt-get', ['update'], { stdio: 'inherit' });
14
+ if (update.status !== 0)
15
+ throw new Error('apt-get update failed');
16
+ const install = spawnSync('apt-get', ['install', '-y', 'wayvnc', 'x11vnc', 'novnc', 'websockify'], { stdio: 'inherit' });
17
+ if (install.status !== 0)
18
+ throw new Error('could not install wayvnc/x11vnc/noVNC/websockify');
19
+ console.log(chalk.green(' desktop relay dependencies installed'));
20
+ }
21
+ export async function roboparkScreenUp(name, desktopUser) {
22
+ if (process.platform !== 'linux')
23
+ throw new Error('screen sharing requires a Linux robot host');
24
+ const script = await findScreenRuntimePath();
25
+ if (!script)
26
+ throw new Error('screen_runtime.py is missing from this Infinicode installation');
27
+ for (const command of ['websockify']) {
28
+ if (spawnSync('sh', ['-c', `command -v ${command}`], { stdio: 'ignore' }).status !== 0) {
29
+ throw new Error(`missing ${command}; run \`sudo robopark screen install\` first`);
30
+ }
31
+ }
32
+ const dir = join(homedir(), '.robopark');
33
+ mkdirSync(dir, { recursive: true });
34
+ const config = join(dir, `screen-${safeName(name)}.json`);
35
+ writeFileSync(config, `${JSON.stringify({ name, desktop_user: desktopUser ?? '' }, null, 2)}\n`, { mode: 0o600 });
36
+ const pythonName = findPython();
37
+ const python = spawnSync('sh', ['-c', `command -v ${pythonName}`], { encoding: 'utf8' }).stdout.trim() || pythonName;
38
+ const result = await registerAutoStart({ role: 'robot-screen', name, command: resolve(python), args: [resolve(script), '--config', resolve(config)], workingDir: homedir() });
39
+ if (!result.ok)
40
+ throw new Error(result.message);
41
+ console.log(chalk.green(` ${result.message}`));
42
+ console.log(` dashboard: /fed/media/robots/${encodeURIComponent(name)}/screen/vnc.html`);
43
+ }
44
+ export function roboparkScreenRestart(name) {
45
+ const result = spawnSync('systemctl', ['restart', unit(name)], { encoding: 'utf8' });
46
+ if (result.status !== 0)
47
+ throw new Error(String(result.stderr || result.stdout).trim());
48
+ console.log(chalk.green(` restarted ${unit(name)}`));
49
+ }
50
+ export function roboparkScreenDown(name) {
51
+ const result = unregisterAutoStart('robot-screen', name);
52
+ if (!result.ok)
53
+ throw new Error(result.message);
54
+ console.log(chalk.green(` ${result.message}`));
55
+ }
@@ -0,0 +1,41 @@
1
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
2
+ import { dirname } from 'node:path';
3
+ import chalk from 'chalk';
4
+ function updateEnv(contents, token) {
5
+ const line = `ROBOPARK_AGENT_TOKEN=${token}`;
6
+ const re = /^ROBOPARK_AGENT_TOKEN=.*$/m;
7
+ return re.test(contents)
8
+ ? contents.replace(re, line)
9
+ : `${contents.trimEnd()}\n${line}\n`;
10
+ }
11
+ /** Provision the scheduler's shared worker secret into a ROBOVOICE .env file. */
12
+ export async function roboparkSecrets(opts) {
13
+ const base = opts.schedulerUrl.replace(/\/$/, '');
14
+ const response = await fetch(`${base}/api/settings/agent-token/provision`, {
15
+ method: 'POST',
16
+ headers: { 'content-type': 'application/json' },
17
+ body: JSON.stringify({ rotate: Boolean(opts.rotate) }),
18
+ });
19
+ if (!response.ok) {
20
+ throw new Error(`scheduler rejected secret provisioning (${response.status})`);
21
+ }
22
+ const body = await response.json();
23
+ if (!body.agent_token)
24
+ throw new Error('scheduler returned no agent token');
25
+ let current = '';
26
+ try {
27
+ current = await readFile(opts.workerEnv, 'utf8');
28
+ }
29
+ catch (err) {
30
+ if (err?.code !== 'ENOENT')
31
+ throw err;
32
+ }
33
+ const next = updateEnv(current, body.agent_token);
34
+ await mkdir(dirname(opts.workerEnv), { recursive: true });
35
+ const temp = `${opts.workerEnv}.tmp-${process.pid}`;
36
+ await writeFile(temp, next, { encoding: 'utf8', mode: 0o600 });
37
+ await rename(temp, opts.workerEnv);
38
+ console.log(chalk.green(` ✓ ROBOVOICE secret synchronized to ${opts.workerEnv}`));
39
+ if (opts.rotate)
40
+ console.log(chalk.yellow(' Restart the ROBOVOICE worker to use the rotated secret.'));
41
+ }