robopark 3.1.2 → 3.3.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 (101) hide show
  1. package/README.md +66 -95
  2. package/bin/robopark.js +9 -2
  3. package/conversation/elevenlabs_agent.py +1617 -1617
  4. package/conversation/supervisor_store.py +189 -189
  5. package/dist/{kernel/config-schema.js → config-schema.js} +1 -0
  6. package/dist/config-schema.js.map +1 -0
  7. package/dist/robopark/add-robot.js +1 -0
  8. package/dist/robopark/add-robot.js.map +1 -0
  9. package/dist/robopark/agent-ctl.js +1 -0
  10. package/dist/robopark/agent-ctl.js.map +1 -0
  11. package/dist/robopark/auto-start.js +51 -73
  12. package/dist/robopark/auto-start.js.map +1 -0
  13. package/dist/robopark/conversation.js +1 -0
  14. package/dist/robopark/conversation.js.map +1 -0
  15. package/dist/robopark/discovery.js +1 -0
  16. package/dist/robopark/discovery.js.map +1 -0
  17. package/dist/robopark/doctor.js +1 -0
  18. package/dist/robopark/doctor.js.map +1 -0
  19. package/dist/robopark/enroll.js +1 -0
  20. package/dist/robopark/enroll.js.map +1 -0
  21. package/dist/robopark/llm-set.js +1 -0
  22. package/dist/robopark/llm-set.js.map +1 -0
  23. package/dist/robopark/motor-control.js +1 -0
  24. package/dist/robopark/motor-control.js.map +1 -0
  25. package/dist/robopark/preview-agent-launcher.js +1 -0
  26. package/dist/robopark/preview-agent-launcher.js.map +1 -0
  27. package/dist/robopark/probe.js +16 -15
  28. package/dist/robopark/probe.js.map +1 -0
  29. package/dist/robopark/profile.js +1 -0
  30. package/dist/robopark/profile.js.map +1 -0
  31. package/dist/robopark/python-env.js +1 -7
  32. package/dist/robopark/python-env.js.map +1 -0
  33. package/dist/robopark/robot-runtime.js +1 -0
  34. package/dist/robopark/robot-runtime.js.map +1 -0
  35. package/dist/robopark/scan.js +1 -0
  36. package/dist/robopark/scan.js.map +1 -0
  37. package/dist/robopark/screen-control.js +1 -0
  38. package/dist/robopark/screen-control.js.map +1 -0
  39. package/dist/robopark/secrets.js +1 -0
  40. package/dist/robopark/secrets.js.map +1 -0
  41. package/dist/robopark/serve.js +4 -6
  42. package/dist/robopark/serve.js.map +1 -0
  43. package/dist/robopark/server-add.js +1 -0
  44. package/dist/robopark/server-add.js.map +1 -0
  45. package/dist/robopark/setup-livekit.js +53 -52
  46. package/dist/robopark/setup-livekit.js.map +1 -0
  47. package/dist/robopark/setup.js +1 -0
  48. package/dist/robopark/setup.js.map +1 -0
  49. package/dist/robopark/stop-all.js +1 -0
  50. package/dist/robopark/stop-all.js.map +1 -0
  51. package/dist/robopark/verify.js +1 -0
  52. package/dist/robopark/verify.js.map +1 -0
  53. package/dist/robopark/vision-agent-launcher.js +1 -0
  54. package/dist/robopark/vision-agent-launcher.js.map +1 -0
  55. package/dist/robopark/vision-control.js +1 -0
  56. package/dist/robopark/vision-control.js.map +1 -0
  57. package/dist/robopark-cli.js +7 -125
  58. package/dist/robopark-cli.js.map +1 -0
  59. package/package.json +16 -25
  60. package/pi-client/_install_steps.sh +29 -29
  61. package/pi-client/client.py +76 -76
  62. package/pi-client/install.sh +40 -40
  63. package/pi-client/join_convo.sh +54 -54
  64. package/pi-client/livekit_bridge.py +24 -24
  65. package/pi-client/motor_bridge.py +10 -10
  66. package/scheduler/__pycache__/main.cpython-312.pyc +0 -0
  67. package/scheduler/main.py +196 -17
  68. package/scheduler/media_lock.py +57 -57
  69. package/scheduler/preview_agent.py +1122 -1122
  70. package/scheduler/production_config.json +73 -113
  71. package/scheduler/scripts/install-robot-supervisor-linux.sh +33 -33
  72. package/{static → scheduler/static}/vendor/elevenlabs-client.iife.js +24199 -24199
  73. package/scheduler/static/voice-join.html +177 -0
  74. package/screen/screen_runtime.py +75 -75
  75. package/vision/app_pi_clean.py +297 -297
  76. package/vision/audio_server_pi.py +24 -24
  77. package/vision/install.sh +34 -34
  78. package/vision/motor_server.py +275 -275
  79. package/vision/requirements_camera.txt +6 -6
  80. package/vision/requirements_motor.txt +4 -4
  81. package/vision/requirements_pi_unified.txt +2 -2
  82. package/vision/requirements_vision_agent.txt +19 -19
  83. package/vision/run.sh +244 -244
  84. package/vision/services/services.sh +12 -12
  85. package/dist/kernel/types.js +0 -7
  86. package/dist/robopark/access.js +0 -99
  87. package/dist/robopark/control-center-html.js +0 -9191
  88. package/dist/robopark/control-server.js +0 -148
  89. package/dist/robopark/deployment-commands.js +0 -47
  90. package/dist/robopark/standalone.js +0 -562
  91. package/dist/robopark/voice-join-html.js +0 -57
  92. package/scripts/windows-shim-fix.cjs +0 -58
  93. package/static/robopark/barracuda.png +0 -0
  94. package/static/robopark/bmw.png +0 -0
  95. package/static/robopark/jaguar.png +0 -0
  96. package/static/robopark/magnus.jpg +0 -0
  97. package/static/robopark/panda.png +0 -0
  98. package/static/robopark/tesla.png +0 -0
  99. package/static/robopark/titan.jpg +0 -0
  100. package/static/robopark/volt.png +0 -0
  101. package/static/vendor/elevenlabs-client.LICENSE +0 -21
@@ -1,562 +0,0 @@
1
- import chalk from 'chalk';
2
- import { spawn } from 'node:child_process';
3
- import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync, } from 'node:fs';
4
- import { homedir, hostname, networkInterfaces } from 'node:os';
5
- import { dirname, join, resolve } from 'node:path';
6
- import { fileURLToPath } from 'node:url';
7
- import { registerAutoStart } from './auto-start.js';
8
- import { findPython, findSchedulerPath, prepareRobotPython } from './python-env.js';
9
- import { roboparkServe, schedulerHealthy } from './serve.js';
10
- import { startControlServer } from './control-server.js';
11
- import { activateFromPairing } from './access.js';
12
- const STATE_DIR = process.env.ROBOPARK_HOME
13
- ? resolve(process.env.ROBOPARK_HOME)
14
- : join(homedir(), '.robopark');
15
- const CONNECTION_PATH = join(STATE_DIR, 'connection.json');
16
- const DESIRED_PATH = join(STATE_DIR, 'desired-runtime.json');
17
- const STATUS_PATH = join(STATE_DIR, 'runtime-status.json');
18
- const GATEWAY_PATH = join(STATE_DIR, 'gateway.json');
19
- const LOG_DIR = join(STATE_DIR, 'logs');
20
- function ensureStateDir() {
21
- mkdirSync(STATE_DIR, { recursive: true });
22
- mkdirSync(LOG_DIR, { recursive: true });
23
- }
24
- function atomicJson(path, value, mode = 0o600) {
25
- ensureStateDir();
26
- const temp = `${path}.${process.pid}.tmp`;
27
- writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode });
28
- renameSync(temp, path);
29
- }
30
- function normalizedUrl(value) {
31
- const candidate = value.trim().replace(/\/+$/, '');
32
- const url = new URL(candidate);
33
- if (url.protocol !== 'http:' && url.protocol !== 'https:') {
34
- throw new Error('gateway URL must use http:// or https://');
35
- }
36
- return url.toString().replace(/\/+$/, '');
37
- }
38
- export function encodePairingToken(payload) {
39
- return `rp1_${Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url')}`;
40
- }
41
- export function decodePairingToken(value) {
42
- const trimmed = value.trim();
43
- let payload;
44
- if (trimmed.startsWith('rp1_')) {
45
- try {
46
- payload = JSON.parse(Buffer.from(trimmed.slice(4), 'base64url').toString('utf8'));
47
- }
48
- catch {
49
- throw new Error('invalid RoboPark connection token');
50
- }
51
- }
52
- else if (/^https?:\/\//i.test(trimmed) && trimmed.includes('#')) {
53
- const split = trimmed.lastIndexOf('#');
54
- payload = {
55
- v: 1,
56
- schedulerUrl: trimmed.slice(0, split),
57
- enrollmentToken: trimmed.slice(split + 1),
58
- };
59
- }
60
- else {
61
- throw new Error('invalid connection token; copy the complete token shown by `robopark gateway start`');
62
- }
63
- if (payload.v !== 1 || !payload.schedulerUrl || !payload.enrollmentToken) {
64
- throw new Error('unsupported or incomplete RoboPark connection token');
65
- }
66
- payload.schedulerUrl = normalizedUrl(payload.schedulerUrl);
67
- if (payload.expiresAt && Date.parse(payload.expiresAt) <= Date.now()) {
68
- throw new Error('this connection token has expired; create a new token on the gateway');
69
- }
70
- return payload;
71
- }
72
- function readConnection() {
73
- if (existsSync(CONNECTION_PATH)) {
74
- const parsed = JSON.parse(readFileSync(CONNECTION_PATH, 'utf8'));
75
- if (!parsed.schedulerUrl || !parsed.deviceId || !parsed.deviceToken) {
76
- throw new Error(`invalid saved connection at ${CONNECTION_PATH}; reconnect this device`);
77
- }
78
- return parsed;
79
- }
80
- // Adopt the exact deployed preview-agent identity in place. Older releases
81
- // predate connection.json but already persist everything required here.
82
- const legacyPath = join(STATE_DIR, 'preview_agent.json');
83
- if (existsSync(legacyPath)) {
84
- try {
85
- const legacy = JSON.parse(readFileSync(legacyPath, 'utf8'));
86
- const tokenPath = join(STATE_DIR, 'device_token');
87
- const deviceToken = existsSync(tokenPath)
88
- ? readFileSync(tokenPath, 'utf8').trim()
89
- : String(legacy.device_token ?? '').trim();
90
- const schedulerUrl = String(legacy.scheduler_url ?? '').trim();
91
- const deviceId = String(legacy.device_id ?? '').trim();
92
- const name = String(legacy.robot_id ?? legacy.name ?? hostname().split('.')[0]).trim();
93
- if (schedulerUrl && deviceId && deviceToken) {
94
- const adopted = {
95
- version: 1,
96
- schedulerUrl: normalizedUrl(schedulerUrl),
97
- deviceId,
98
- deviceToken,
99
- name,
100
- connectedAt: new Date().toISOString(),
101
- };
102
- atomicJson(CONNECTION_PATH, adopted);
103
- return adopted;
104
- }
105
- }
106
- catch {
107
- // Fall through to the actionable connect error.
108
- }
109
- }
110
- throw new Error('this device is not connected; run `robopark connect <token>` first');
111
- }
112
- async function jsonRequest(url, init, timeoutMs = 10_000) {
113
- const response = await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) });
114
- const body = await response.text();
115
- let parsed = {};
116
- try {
117
- parsed = body ? JSON.parse(body) : {};
118
- }
119
- catch {
120
- parsed = { detail: body };
121
- }
122
- if (!response.ok) {
123
- const detail = typeof parsed === 'object' && parsed && 'detail' in parsed
124
- ? String(parsed.detail)
125
- : `HTTP ${response.status}`;
126
- throw new Error(detail);
127
- }
128
- return parsed;
129
- }
130
- export async function connectDevice(token, opts = {}) {
131
- const pairing = decodePairingToken(token);
132
- const name = (opts.name ?? hostname().split('.')[0]).trim();
133
- if (!name)
134
- throw new Error('device name cannot be empty');
135
- const enrolled = await jsonRequest(`${pairing.schedulerUrl}/api/devices/enroll`, {
136
- method: 'POST',
137
- headers: { 'content-type': 'application/json' },
138
- body: JSON.stringify({
139
- enrollment_token: pairing.enrollmentToken,
140
- name,
141
- device_role: 'voice_vision',
142
- }),
143
- });
144
- const connection = {
145
- version: 1,
146
- // The gateway may bind on 0.0.0.0 and report its own loopback default.
147
- // The pairing payload is the operator-selected device route.
148
- schedulerUrl: pairing.schedulerUrl,
149
- deviceId: enrolled.device_id,
150
- deviceToken: enrolled.device_token,
151
- name,
152
- connectedAt: new Date().toISOString(),
153
- controlPort: pairing.controlPort,
154
- };
155
- atomicJson(CONNECTION_PATH, connection);
156
- writeFileSync(join(STATE_DIR, 'device_token'), connection.deviceToken, { encoding: 'utf8', mode: 0o600 });
157
- activateFromPairing();
158
- console.log(chalk.green(`\n ✓ connected ${name}`));
159
- console.log(` gateway: ${chalk.cyan(connection.schedulerUrl)}`);
160
- console.log(` device: ${chalk.cyan(connection.deviceId)}`);
161
- console.log(chalk.dim(' credentials were saved locally; the pairing token was not retained.\n'));
162
- return connection;
163
- }
164
- function cliEntry() {
165
- const here = dirname(fileURLToPath(import.meta.url));
166
- const candidates = [
167
- join(here, '..', '..', 'bin', 'robopark.js'),
168
- join(here, '..', '..', '..', 'bin', 'robopark.js'),
169
- join(process.cwd(), 'bin', 'robopark.js'),
170
- ];
171
- const found = candidates.find(existsSync);
172
- if (!found)
173
- throw new Error('could not resolve the packaged RoboPark CLI entry point');
174
- return found;
175
- }
176
- async function updateDeviceConfiguration(desired) {
177
- const c = desired.connection;
178
- await jsonRequest(`${c.schedulerUrl}/api/devices/${encodeURIComponent(c.deviceId)}`, {
179
- method: 'PATCH',
180
- headers: {
181
- 'content-type': 'application/json',
182
- authorization: `Bearer ${c.deviceToken}`,
183
- },
184
- body: JSON.stringify({
185
- name: c.name,
186
- character_id: desired.character || null,
187
- video_device: desired.video ? desired.videoDevice : 'none',
188
- audio_device: desired.voice ? desired.audioDevice : 'none',
189
- device_role: 'voice_vision',
190
- production_mode: true,
191
- }),
192
- });
193
- }
194
- function positiveInteger(value, fallback, label) {
195
- const parsed = Number.parseInt(value ?? String(fallback), 10);
196
- if (!Number.isFinite(parsed) || parsed <= 0)
197
- throw new Error(`${label} must be a positive integer`);
198
- return parsed;
199
- }
200
- function openBrowser(url) {
201
- // Use detached platform-native launchers so this CLI remains usable from
202
- // terminals and does not inherit browser stdio or lifecycle.
203
- const command = process.platform === 'win32'
204
- ? 'cmd.exe'
205
- : process.platform === 'darwin'
206
- ? 'open'
207
- : 'xdg-open';
208
- const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
209
- try {
210
- spawn(command, args, { detached: true, stdio: 'ignore', windowsHide: true }).unref();
211
- }
212
- catch (err) {
213
- console.log(chalk.yellow(` ⚠ could not open browser: ${err instanceof Error ? err.message : String(err)}`));
214
- }
215
- }
216
- /**
217
- * Opens the character-specific ElevenLabs voice-join page (served by the Park
218
- * Control Center, not the scheduler) for the device just configured. Best
219
- * effort only: a browser-less robot host or an unreachable control server
220
- * must never fail `robopark start`/`robopark connect`.
221
- */
222
- async function openCharacterCallPage(connection, character) {
223
- try {
224
- const preset = await jsonRequest(`${connection.schedulerUrl}/api/character-presets/${encodeURIComponent(character)}`, {});
225
- if (!preset.elevenlabs_agent_id) {
226
- console.log(chalk.yellow(` ⚠ character "${character}" has no ElevenLabs agent configured; not opening a call page.`));
227
- return;
228
- }
229
- const controlPort = connection.controlPort ?? 47913;
230
- const controlHost = new URL(connection.schedulerUrl).hostname;
231
- const params = new URLSearchParams({
232
- character,
233
- agent_id: preset.elevenlabs_agent_id,
234
- mode: 'production',
235
- robot: connection.deviceId,
236
- });
237
- if (preset.elevenlabs_branch_id)
238
- params.set('branch_id', preset.elevenlabs_branch_id);
239
- const url = `http://${controlHost}:${controlPort}/voice-join.html?${params.toString()}`;
240
- console.log(` call page: ${chalk.cyan(url)}`);
241
- openBrowser(url);
242
- }
243
- catch (err) {
244
- console.log(chalk.yellow(` ⚠ could not open the character call page: ${err instanceof Error ? err.message : String(err)}`));
245
- }
246
- }
247
- export async function startDevice(opts) {
248
- const connection = readConnection();
249
- const desired = {
250
- version: 1,
251
- connection,
252
- voice: Boolean(opts.voice),
253
- video: Boolean(opts.video),
254
- character: opts.character?.trim() || undefined,
255
- videoDevice: opts.videoDevice ?? 'auto',
256
- audioDevice: opts.audioDevice ?? 'default',
257
- width: positiveInteger(opts.width, 640, 'width'),
258
- height: positiveInteger(opts.height, 480, 'height'),
259
- fps: positiveInteger(opts.fps, 15, 'fps'),
260
- updatedAt: new Date().toISOString(),
261
- };
262
- if (!desired.voice && !desired.video) {
263
- throw new Error('select at least one media path: `--voice`, `--video`, or both');
264
- }
265
- await updateDeviceConfiguration(desired);
266
- atomicJson(DESIRED_PATH, desired);
267
- if (desired.character) {
268
- await openCharacterCallPage(connection, desired.character);
269
- }
270
- if (opts.foreground) {
271
- await superviseDevice(DESIRED_PATH);
272
- return;
273
- }
274
- const registration = await registerAutoStart({
275
- role: 'robot-runtime',
276
- name: connection.name,
277
- command: process.execPath,
278
- args: [cliEntry(), '_supervise', '--config', DESIRED_PATH],
279
- workingDir: homedir(),
280
- exclusiveMedia: true,
281
- });
282
- if (!registration.ok) {
283
- throw new Error(`${registration.message}. Retry from an elevated terminal or use --foreground.`);
284
- }
285
- console.log(chalk.green(`\n ✓ RoboPark is supervising ${connection.name}`));
286
- console.log(` voice: ${desired.voice ? chalk.green('on') : chalk.dim('off')}`);
287
- console.log(` video: ${desired.video ? chalk.green('on') : chalk.dim('off')}`);
288
- console.log(` character: ${chalk.cyan(desired.character || 'gateway default')}`);
289
- console.log(` service: ${chalk.dim(registration.message)}`);
290
- console.log(` status: ${chalk.dim(STATUS_PATH)}\n`);
291
- }
292
- function status(state) {
293
- atomicJson(STATUS_PATH, { ...state, updatedAt: new Date().toISOString(), pid: process.pid });
294
- }
295
- async function reportSupervisor(desired, state, detail) {
296
- const c = desired.connection;
297
- try {
298
- await jsonRequest(`${c.schedulerUrl}/api/devices/${encodeURIComponent(c.deviceId)}/supervisor-status`, {
299
- method: 'POST',
300
- headers: {
301
- 'content-type': 'application/json',
302
- authorization: `Bearer ${c.deviceToken}`,
303
- },
304
- body: JSON.stringify({
305
- services: [{
306
- name: `robopark-device:${state}:${detail.slice(0, 120)}`,
307
- enabled: true,
308
- running: state === 'running' || state === 'starting',
309
- pid: process.pid,
310
- failure_count: 0,
311
- }],
312
- }),
313
- }, 4_000);
314
- }
315
- catch {
316
- // The local status file remains authoritative while the gateway is offline.
317
- }
318
- }
319
- async function heartbeatFresh(desired) {
320
- const c = desired.connection;
321
- try {
322
- const device = await jsonRequest(`${c.schedulerUrl}/api/devices/${encodeURIComponent(c.deviceId)}`, { headers: { authorization: `Bearer ${c.deviceToken}` } }, 5_000);
323
- const age = device.last_heartbeat ? Date.now() - Date.parse(device.last_heartbeat) : Number.POSITIVE_INFINITY;
324
- return age < 45_000 && device.status !== 'offline';
325
- }
326
- catch {
327
- // A gateway outage must not cause a restart storm. The worker's own retry
328
- // loop will reconnect, and the watchdog resumes once the gateway answers.
329
- return true;
330
- }
331
- }
332
- function wait(ms) {
333
- return new Promise(resolve => setTimeout(resolve, ms));
334
- }
335
- function terminate(child) {
336
- if (!child || child.killed)
337
- return;
338
- try {
339
- child.kill('SIGTERM');
340
- }
341
- catch { /* already gone */ }
342
- setTimeout(() => {
343
- if (child.exitCode === null) {
344
- try {
345
- child.kill('SIGKILL');
346
- }
347
- catch { /* already gone */ }
348
- }
349
- }, 10_000).unref();
350
- }
351
- export async function superviseDevice(configPath = DESIRED_PATH) {
352
- let desired = JSON.parse(readFileSync(configPath, 'utf8'));
353
- const script = await findSchedulerPath();
354
- if (!script)
355
- throw new Error('packaged preview_agent.py was not found');
356
- const python = prepareRobotPython(findPython(), script);
357
- if (!python)
358
- throw new Error('could not prepare the packaged RoboPark media runtime');
359
- let child;
360
- let stopping = false;
361
- let failures = 0;
362
- let startedAt = 0;
363
- const shutdown = (signal) => {
364
- stopping = true;
365
- status({ state: 'stopping', signal });
366
- terminate(child);
367
- };
368
- process.once('SIGINT', shutdown);
369
- process.once('SIGTERM', shutdown);
370
- while (!stopping) {
371
- desired = JSON.parse(readFileSync(configPath, 'utf8'));
372
- const c = desired.connection;
373
- const args = [
374
- script,
375
- '--scheduler-url', c.schedulerUrl,
376
- '--robot-id', c.name,
377
- '--device-token', c.deviceToken,
378
- '--video-device', desired.video ? desired.videoDevice : 'none',
379
- '--audio-device', desired.voice ? desired.audioDevice : 'none',
380
- '--width', String(desired.width),
381
- '--height', String(desired.height),
382
- '--fps', String(desired.fps),
383
- '--save-config',
384
- ];
385
- startedAt = Date.now();
386
- status({ state: 'starting', childFailures: failures, voice: desired.voice, video: desired.video });
387
- await reportSupervisor(desired, 'starting', 'starting packaged camera/voice worker');
388
- const outFd = openSync(join(LOG_DIR, 'device.log'), 'a');
389
- const errFd = openSync(join(LOG_DIR, 'device.err.log'), 'a');
390
- child = spawn(python, args, {
391
- env: { ...process.env, ROBOPARK_DEVICE_ID: c.deviceId },
392
- stdio: ['ignore', outFd, errFd],
393
- windowsHide: true,
394
- });
395
- closeSync(outFd);
396
- closeSync(errFd);
397
- status({ state: 'running', childPid: child.pid, childFailures: failures });
398
- await reportSupervisor(desired, 'running', `media worker pid ${child.pid ?? 'unknown'}`);
399
- let watchdogFailures = 0;
400
- const watchdog = setInterval(async () => {
401
- if (!child || child.exitCode !== null || Date.now() - startedAt < 60_000)
402
- return;
403
- watchdogFailures = await heartbeatFresh(desired) ? 0 : watchdogFailures + 1;
404
- if (watchdogFailures >= 3) {
405
- status({ state: 'restarting', reason: 'heartbeat_stalled', childPid: child.pid });
406
- await reportSupervisor(desired, 'degraded', 'heartbeat stalled; recycling media worker');
407
- terminate(child);
408
- }
409
- }, 15_000);
410
- watchdog.unref();
411
- const exit = await new Promise((resolve, reject) => {
412
- child?.once('error', reject);
413
- child?.once('exit', (code, signal) => resolve({ code, signal }));
414
- }).catch(error => ({ code: 1, signal: null, error }));
415
- clearInterval(watchdog);
416
- if (stopping)
417
- break;
418
- const ranFor = Date.now() - startedAt;
419
- failures = ranFor > 5 * 60_000 ? 0 : failures + 1;
420
- const delay = Math.min(30_000, 1_000 * 2 ** Math.min(failures, 5));
421
- const detail = `worker stopped (${String('error' in exit ? exit.error : exit.signal ?? exit.code)}); restart in ${delay}ms`;
422
- status({ state: 'backoff', detail, childFailures: failures, restartInMs: delay });
423
- await reportSupervisor(desired, 'restarting', detail);
424
- await wait(delay);
425
- }
426
- status({ state: 'stopped' });
427
- await reportSupervisor(desired, 'stopped', 'device supervisor stopped');
428
- }
429
- function localPublicUrl(host, port, explicit) {
430
- if (explicit)
431
- return normalizedUrl(explicit);
432
- let publicHost = host;
433
- if (host === '0.0.0.0' || host === '::') {
434
- const addresses = Object.entries(networkInterfaces())
435
- .flatMap(([name, entries]) => (entries ?? [])
436
- .filter(entry => entry.family === 'IPv4' && !entry.internal)
437
- .map(entry => ({ name: name.toLowerCase(), address: entry.address })));
438
- publicHost = addresses.find(entry => /tailscale|wireguard|wg/.test(entry.name))?.address
439
- ?? addresses.find(entry => entry.address.startsWith('100.'))?.address
440
- ?? addresses.find(entry => /ethernet|wi-?fi|wlan|eth/.test(entry.name))?.address
441
- ?? addresses[0]?.address
442
- ?? 'localhost';
443
- }
444
- return normalizedUrl(`http://${publicHost}:${port}`);
445
- }
446
- export async function gatewayStart(opts) {
447
- const port = positiveInteger(opts.port, 8080, 'port');
448
- const controlPort = positiveInteger(opts.controlPort, 47913, 'control port');
449
- const host = opts.host ?? '0.0.0.0';
450
- if (opts.foreground) {
451
- await runGatewayService({
452
- port: String(port),
453
- controlPort: String(controlPort),
454
- host,
455
- dataDir: opts.dataDir,
456
- open: opts.open,
457
- });
458
- }
459
- else {
460
- const args = [cliEntry(), '_gateway', '--port', String(port), '--host', host];
461
- args.push('--control-port', String(controlPort));
462
- if (opts.dataDir)
463
- args.push('--data-dir', opts.dataDir);
464
- const registration = await registerAutoStart({
465
- role: 'hub-scheduler',
466
- name: 'gateway',
467
- command: process.execPath,
468
- args,
469
- workingDir: homedir(),
470
- });
471
- if (!registration.ok) {
472
- throw new Error(`${registration.message}. Retry from an elevated terminal or add --foreground.`);
473
- }
474
- console.log(chalk.green(` ✓ ${registration.message}`));
475
- }
476
- const schedulerUrl = localPublicUrl(host, port, opts.publicUrl);
477
- const healthUrl = `http://127.0.0.1:${port}`;
478
- const controlHealthUrl = `http://127.0.0.1:${controlPort}/fed/ui-build`;
479
- const deadline = Date.now() + 30_000;
480
- let schedulerReady = false;
481
- let controlReady = false;
482
- while (!schedulerReady || !controlReady) {
483
- [schedulerReady, controlReady] = await Promise.all([
484
- schedulerHealthy(healthUrl, 1_000),
485
- fetch(controlHealthUrl, { signal: AbortSignal.timeout(1_000) })
486
- .then(async (response) => {
487
- if (!response.ok)
488
- return false;
489
- const body = await response.json();
490
- return body.owner === 'robopark';
491
- })
492
- .catch(() => false),
493
- ]);
494
- if (Date.now() >= deadline) {
495
- throw new Error(`gateway did not become healthy (scheduler ${port}: ${schedulerReady ? 'ready' : 'down'}, `
496
- + `Control Center ${controlPort}: ${controlReady ? 'ready' : 'down'})`);
497
- }
498
- await wait(500);
499
- }
500
- const token = await createGatewayTokenAt(healthUrl, schedulerUrl, controlPort);
501
- const controlHost = new URL(schedulerUrl).hostname;
502
- console.log(` Park Control Center: ${chalk.cyan(`http://${controlHost}:${controlPort}/`)}`);
503
- console.log(chalk.bold('\n Connect a device'));
504
- console.log(` ${chalk.cyan(`robopark connect ${token}`)}`);
505
- console.log(chalk.dim(' This pairing token expires in 24 hours. Connected devices keep their own durable credentials.\n'));
506
- }
507
- async function createGatewayTokenAt(localUrl, publicUrl, controlPort) {
508
- await jsonRequest(`${localUrl}/api/settings`, {
509
- method: 'PUT',
510
- headers: { 'content-type': 'application/json' },
511
- body: JSON.stringify({ production_mode: true }),
512
- });
513
- const result = await jsonRequest(`${localUrl}/api/settings/enrollment-token/rotate`, { method: 'POST' });
514
- const pairing = encodePairingToken({
515
- v: 1,
516
- schedulerUrl: publicUrl,
517
- enrollmentToken: result.enrollment_token,
518
- expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
519
- controlPort,
520
- });
521
- atomicJson(GATEWAY_PATH, { schedulerUrl: publicUrl, pairingToken: pairing, createdAt: new Date().toISOString() });
522
- return pairing;
523
- }
524
- export async function runGatewayService(opts) {
525
- const schedulerPort = positiveInteger(opts.port, 8080, 'port');
526
- const controlPort = positiveInteger(opts.controlPort, 47913, 'control port');
527
- const host = opts.host ?? '0.0.0.0';
528
- const control = await startControlServer({
529
- host,
530
- port: controlPort,
531
- schedulerUrl: `http://127.0.0.1:${schedulerPort}`,
532
- });
533
- console.log(` Park Control Center listening on ${host}:${controlPort}`);
534
- try {
535
- await roboparkServe({
536
- port: String(schedulerPort),
537
- host,
538
- dataDir: opts.dataDir,
539
- open: false,
540
- foreground: true,
541
- });
542
- // roboparkServe wires the scheduler child's lifecycle handlers and then
543
- // returns once it has launched. Keep this outer gateway process alive so
544
- // the sibling Control Center server is not closed by the finally block.
545
- await new Promise((resolve) => {
546
- const stop = () => resolve();
547
- process.once('SIGINT', stop);
548
- process.once('SIGTERM', stop);
549
- });
550
- }
551
- finally {
552
- control.close();
553
- }
554
- }
555
- export function printRuntimeStatus() {
556
- if (!existsSync(STATUS_PATH)) {
557
- console.log(chalk.yellow(' no local runtime status; run `robopark start --voice --video`'));
558
- return;
559
- }
560
- const current = JSON.parse(readFileSync(STATUS_PATH, 'utf8'));
561
- console.log(JSON.stringify(current, null, 2));
562
- }