robopark 3.1.1 → 3.1.2

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.
@@ -150,6 +150,7 @@ export async function connectDevice(token, opts = {}) {
150
150
  deviceToken: enrolled.device_token,
151
151
  name,
152
152
  connectedAt: new Date().toISOString(),
153
+ controlPort: pairing.controlPort,
153
154
  };
154
155
  atomicJson(CONNECTION_PATH, connection);
155
156
  writeFileSync(join(STATE_DIR, 'device_token'), connection.deviceToken, { encoding: 'utf8', mode: 0o600 });
@@ -196,6 +197,53 @@ function positiveInteger(value, fallback, label) {
196
197
  throw new Error(`${label} must be a positive integer`);
197
198
  return parsed;
198
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
+ }
199
247
  export async function startDevice(opts) {
200
248
  const connection = readConnection();
201
249
  const desired = {
@@ -216,6 +264,9 @@ export async function startDevice(opts) {
216
264
  }
217
265
  await updateDeviceConfiguration(desired);
218
266
  atomicJson(DESIRED_PATH, desired);
267
+ if (desired.character) {
268
+ await openCharacterCallPage(connection, desired.character);
269
+ }
219
270
  if (opts.foreground) {
220
271
  await superviseDevice(DESIRED_PATH);
221
272
  return;
@@ -424,20 +475,36 @@ export async function gatewayStart(opts) {
424
475
  }
425
476
  const schedulerUrl = localPublicUrl(host, port, opts.publicUrl);
426
477
  const healthUrl = `http://127.0.0.1:${port}`;
478
+ const controlHealthUrl = `http://127.0.0.1:${controlPort}/fed/ui-build`;
427
479
  const deadline = Date.now() + 30_000;
428
- while (!(await schedulerHealthy(healthUrl, 1_000))) {
429
- if (Date.now() >= deadline)
430
- throw new Error(`gateway did not become healthy at ${schedulerUrl}`);
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
+ }
431
498
  await wait(500);
432
499
  }
433
- const token = await createGatewayTokenAt(healthUrl, schedulerUrl);
500
+ const token = await createGatewayTokenAt(healthUrl, schedulerUrl, controlPort);
434
501
  const controlHost = new URL(schedulerUrl).hostname;
435
502
  console.log(` Park Control Center: ${chalk.cyan(`http://${controlHost}:${controlPort}/`)}`);
436
503
  console.log(chalk.bold('\n Connect a device'));
437
504
  console.log(` ${chalk.cyan(`robopark connect ${token}`)}`);
438
505
  console.log(chalk.dim(' This pairing token expires in 24 hours. Connected devices keep their own durable credentials.\n'));
439
506
  }
440
- async function createGatewayTokenAt(localUrl, publicUrl) {
507
+ async function createGatewayTokenAt(localUrl, publicUrl, controlPort) {
441
508
  await jsonRequest(`${localUrl}/api/settings`, {
442
509
  method: 'PUT',
443
510
  headers: { 'content-type': 'application/json' },
@@ -449,6 +516,7 @@ async function createGatewayTokenAt(localUrl, publicUrl) {
449
516
  schedulerUrl: publicUrl,
450
517
  enrollmentToken: result.enrollment_token,
451
518
  expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
519
+ controlPort,
452
520
  });
453
521
  atomicJson(GATEWAY_PATH, { schedulerUrl: publicUrl, pairingToken: pairing, createdAt: new Date().toISOString() });
454
522
  return pairing;
@@ -471,6 +539,14 @@ export async function runGatewayService(opts) {
471
539
  open: false,
472
540
  foreground: true,
473
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
+ });
474
550
  }
475
551
  finally {
476
552
  control.close();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "robopark",
3
- "version": "3.1.1",
3
+ "version": "3.1.2",
4
4
  "description": "Standalone packaged RoboPark control center and supervised robot runtime.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -33,7 +33,8 @@
33
33
  "commander": "^12.1.0",
34
34
  "conf": "^12.0.0",
35
35
  "execa": "^9.3.0",
36
- "livekit-client": "^2.20.1"
36
+ "livekit-client": "^2.20.1",
37
+ "robopark": "^3.1.1"
37
38
  },
38
39
  "devDependencies": {
39
40
  "@types/node": "^20.14.0",