infinicode 2.8.59 → 2.8.61
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.
|
@@ -73,17 +73,27 @@ Wants=network-online.target
|
|
|
73
73
|
[Service]
|
|
74
74
|
Type=simple
|
|
75
75
|
ExecStart=${service.command} ${service.args.map(shellQuote).join(' ')}
|
|
76
|
-
WorkingDirectory=${service.workingDir ??
|
|
77
|
-
Restart=always
|
|
78
|
-
RestartSec=5
|
|
79
|
-
|
|
76
|
+
WorkingDirectory=${service.workingDir ?? homedir()}
|
|
77
|
+
Restart=always
|
|
78
|
+
RestartSec=5
|
|
79
|
+
KillMode=control-group
|
|
80
|
+
TimeoutStopSec=10
|
|
81
|
+
${envLines}
|
|
80
82
|
|
|
81
83
|
[Install]
|
|
82
84
|
WantedBy=multi-user.target
|
|
83
85
|
`;
|
|
84
86
|
try {
|
|
85
87
|
writeFileSync(unitPath, unit, 'utf8');
|
|
86
|
-
|
|
88
|
+
const reload = spawnSync('systemctl', ['daemon-reload'], { encoding: 'utf8' });
|
|
89
|
+
if (reload.status !== 0) {
|
|
90
|
+
return { ok: false, message: `wrote ${unitPath}, but systemctl daemon-reload failed: ${reload.stderr || reload.stdout}` };
|
|
91
|
+
}
|
|
92
|
+
const enabled = spawnSync('systemctl', ['enable', '--now', unitName], { encoding: 'utf8' });
|
|
93
|
+
if (enabled.status !== 0) {
|
|
94
|
+
return { ok: false, message: `wrote ${unitPath}, but systemctl enable --now failed: ${enabled.stderr || enabled.stdout}` };
|
|
95
|
+
}
|
|
96
|
+
return { ok: true, message: `enabled and started systemd unit ${unitName}` };
|
|
87
97
|
}
|
|
88
98
|
catch (e) {
|
|
89
99
|
return { ok: false, message: `could not write ${unitPath}: ${e instanceof Error ? e.message : String(e)}` };
|
|
@@ -101,8 +101,6 @@ export async function roboparkRobotRuntime(opts) {
|
|
|
101
101
|
'--scheduler-url', opts.schedulerUrl, '--robot-id', opts.name,
|
|
102
102
|
'--video-device', opts.videoDevice ?? 'auto', '--audio-device', opts.audioDevice ?? 'default',
|
|
103
103
|
'--save-config'];
|
|
104
|
-
if (opts.vision !== false)
|
|
105
|
-
previewArgs.push('--robovision-url', 'http://127.0.0.1:5000');
|
|
106
104
|
if (opts.enrollmentToken)
|
|
107
105
|
previewArgs.push('--enrollment-token', opts.enrollmentToken);
|
|
108
106
|
const preview = {
|
|
@@ -113,6 +111,13 @@ export async function roboparkRobotRuntime(opts) {
|
|
|
113
111
|
// Keep this in the child environment, never in the persisted robot config.
|
|
114
112
|
env: {
|
|
115
113
|
ROBOPARK_MESH_TOKEN: opts.token,
|
|
114
|
+
// Keep internal service wiring out of the Commander argument boundary.
|
|
115
|
+
// This also lets preview start while RoboVision is still warming up.
|
|
116
|
+
ROBOVISION_URL: opts.vision !== false ? 'http://127.0.0.1:5000' : '',
|
|
117
|
+
ROBOVISION_MEDIA_URL: opts.vision !== false
|
|
118
|
+
? 'http://127.0.0.1:5000/api/media/inventory'
|
|
119
|
+
: '',
|
|
120
|
+
ROBOVISION_CAMERA: opts.vision !== false ? '1' : '0',
|
|
116
121
|
// Inventory changes should reach the Control Center promptly.
|
|
117
122
|
HEARTBEAT_INTERVAL: '5',
|
|
118
123
|
},
|
|
@@ -121,6 +126,8 @@ export async function roboparkRobotRuntime(opts) {
|
|
|
121
126
|
const child = spawn(entry.command, entry.args, {
|
|
122
127
|
env: { ...process.env, ...entry.env, ROBOPARK_ROBOT_RUNTIME: '1' },
|
|
123
128
|
stdio: runtimeLog(`robot-${opts.name}-${entry.label}`, foreground),
|
|
129
|
+
// Give each launcher and its Python descendants one process group.
|
|
130
|
+
detached: process.platform !== 'win32',
|
|
124
131
|
});
|
|
125
132
|
entry.child = child;
|
|
126
133
|
child.on('error', error => console.error(chalk.red(` ${entry.label} failed to start: ${error.message}`)));
|
|
@@ -141,23 +148,34 @@ export async function roboparkRobotRuntime(opts) {
|
|
|
141
148
|
start(entry); }, RESTART_DELAY_MS);
|
|
142
149
|
});
|
|
143
150
|
};
|
|
151
|
+
const signalChild = (entry, signal) => {
|
|
152
|
+
const pid = entry.child?.pid;
|
|
153
|
+
if (!pid)
|
|
154
|
+
return;
|
|
155
|
+
try {
|
|
156
|
+
if (process.platform === 'win32')
|
|
157
|
+
entry.child?.kill(signal);
|
|
158
|
+
else
|
|
159
|
+
process.kill(-pid, signal);
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
// It may have exited between the liveness check and signal.
|
|
163
|
+
}
|
|
164
|
+
};
|
|
144
165
|
const stopChild = async (entry) => {
|
|
145
166
|
const child = entry.child;
|
|
146
167
|
if (!child)
|
|
147
168
|
return;
|
|
148
169
|
await new Promise(resolve => {
|
|
149
|
-
const timer = setTimeout(
|
|
170
|
+
const timer = setTimeout(() => {
|
|
171
|
+
signalChild(entry, 'SIGKILL');
|
|
172
|
+
resolve();
|
|
173
|
+
}, 5_000);
|
|
150
174
|
child.once('exit', () => {
|
|
151
175
|
clearTimeout(timer);
|
|
152
176
|
resolve();
|
|
153
177
|
});
|
|
154
|
-
|
|
155
|
-
child.kill('SIGTERM');
|
|
156
|
-
}
|
|
157
|
-
catch {
|
|
158
|
-
clearTimeout(timer);
|
|
159
|
-
resolve();
|
|
160
|
-
}
|
|
178
|
+
signalChild(entry, 'SIGTERM');
|
|
161
179
|
});
|
|
162
180
|
};
|
|
163
181
|
const recycleAfterMeshUpdate = async () => {
|
|
@@ -212,7 +230,7 @@ export async function roboparkRobotRuntime(opts) {
|
|
|
212
230
|
const shutdown = () => {
|
|
213
231
|
stopping = true;
|
|
214
232
|
for (const entry of [...children, preview])
|
|
215
|
-
entry
|
|
233
|
+
signalChild(entry, 'SIGTERM');
|
|
216
234
|
};
|
|
217
235
|
process.once('SIGINT', shutdown);
|
|
218
236
|
process.once('SIGTERM', shutdown);
|
package/dist/robopark/setup.js
CHANGED
|
@@ -189,7 +189,10 @@ async function setupRobot(config, opts, ctx, internal) {
|
|
|
189
189
|
console.log(' ' + chalk.cyan(`robopark ${runtimeArgs.join(' ')}`));
|
|
190
190
|
console.log(chalk.dim(` network: ${network} (scheduler: ${schedulerUrl})`));
|
|
191
191
|
console.log();
|
|
192
|
-
|
|
192
|
+
// On Linux, registerAutoStart enables and starts the systemd service. Do not
|
|
193
|
+
// also launch a detached copy that can leave mesh healthy while preview is
|
|
194
|
+
// duplicated, blocked, or running under a different HOME.
|
|
195
|
+
if (opts.start && !(opts.autoStart && process.platform === 'linux')) {
|
|
193
196
|
const robotArgv = [process.execPath, process.argv[1], ...runtimeArgs];
|
|
194
197
|
console.log(chalk.dim(' starting unified robot runtime…'));
|
|
195
198
|
runDetached(robotArgv[0], robotArgv.slice(1));
|
|
@@ -77,6 +77,24 @@ function stopAllUnix() {
|
|
|
77
77
|
const removedUnits = [];
|
|
78
78
|
const errors = [];
|
|
79
79
|
const self = process.pid;
|
|
80
|
+
if (process.platform === 'linux') {
|
|
81
|
+
// Disable restart policies before killing processes so systemd cannot
|
|
82
|
+
// repopulate the ports between process discovery and cleanup.
|
|
83
|
+
const list = spawnSync('systemctl', ['list-units', '--all', '--plain', '--no-legend'], { encoding: 'utf8' });
|
|
84
|
+
const units = (list.stdout ?? '')
|
|
85
|
+
.split('\n')
|
|
86
|
+
.map(l => l.trim().split(/\s+/)[0])
|
|
87
|
+
.filter((u) => !!u && u.endsWith('.service') && /infinicode|robopark/i.test(u));
|
|
88
|
+
for (const unit of units) {
|
|
89
|
+
const disable = spawnSync('systemctl', ['disable', '--now', unit], { encoding: 'utf8' });
|
|
90
|
+
if (disable.status === 0)
|
|
91
|
+
removedUnits.push(unit);
|
|
92
|
+
else
|
|
93
|
+
errors.push(`systemctl disable --now ${unit}: ${(disable.stderr || '').trim()}`);
|
|
94
|
+
}
|
|
95
|
+
if (units.length)
|
|
96
|
+
spawnSync('systemctl', ['daemon-reload'], { encoding: 'utf8' });
|
|
97
|
+
}
|
|
80
98
|
const ps = spawnSync('ps', ['ax', '-o', 'pid=,command='], { encoding: 'utf8' });
|
|
81
99
|
const lines = (ps.stdout ?? '').split('\n');
|
|
82
100
|
const re = new RegExp(PATTERNS.map(p => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'));
|
|
@@ -96,7 +114,7 @@ function stopAllUnix() {
|
|
|
96
114
|
}
|
|
97
115
|
// Also free the well-known RoboPark ports directly — belt and suspenders
|
|
98
116
|
// against a supervised process whose command line didn't match a pattern.
|
|
99
|
-
for (const port of [47913, 47921, 47922, 8080, 5000, 5057]) {
|
|
117
|
+
for (const port of [47913, 47921, 47922, 8080, 5000, 5057, 8000]) {
|
|
100
118
|
const lsof = spawnSync('lsof', ['-t', `-i:${port}`], { encoding: 'utf8' });
|
|
101
119
|
const pids = (lsof.stdout ?? '').split('\n').map(s => s.trim()).filter(Boolean).map(Number);
|
|
102
120
|
for (const pid of pids) {
|
|
@@ -112,21 +130,6 @@ function stopAllUnix() {
|
|
|
112
130
|
// include a bare "infinicode.service" (see pi-client install scripts),
|
|
113
131
|
// which a narrower glob would silently leave running (and respawning
|
|
114
132
|
// whatever we just killed, if it has Restart=always).
|
|
115
|
-
const list = spawnSync('systemctl', ['list-units', '--all', '--plain', '--no-legend'], { encoding: 'utf8' });
|
|
116
|
-
const units = (list.stdout ?? '')
|
|
117
|
-
.split('\n')
|
|
118
|
-
.map(l => l.trim().split(/\s+/)[0])
|
|
119
|
-
.filter((u) => !!u && u.endsWith('.service') && /infinicode|robopark/i.test(u));
|
|
120
|
-
for (const unit of units) {
|
|
121
|
-
spawnSync('systemctl', ['stop', unit], { encoding: 'utf8' });
|
|
122
|
-
const disable = spawnSync('systemctl', ['disable', unit], { encoding: 'utf8' });
|
|
123
|
-
if (disable.status === 0)
|
|
124
|
-
removedUnits.push(unit);
|
|
125
|
-
else
|
|
126
|
-
errors.push(`systemctl disable ${unit}: ${(disable.stderr || '').trim()}`);
|
|
127
|
-
}
|
|
128
|
-
if (units.length)
|
|
129
|
-
spawnSync('systemctl', ['daemon-reload'], { encoding: 'utf8' });
|
|
130
133
|
}
|
|
131
134
|
return { killedPids, removedUnits, errors };
|
|
132
135
|
}
|
package/dist/robopark-cli.js
CHANGED
|
@@ -135,6 +135,7 @@ program
|
|
|
135
135
|
.option('--enrollment-token <token>', 'one-time enrollment token')
|
|
136
136
|
.option('--video-device <dev>', 'camera: auto, /dev/video0, 0, picamera', 'auto')
|
|
137
137
|
.option('--audio-device <dev>', 'microphone: default, none, or name/index', 'default')
|
|
138
|
+
.option('--robovision-url <url>', 'RoboVision base URL used for camera streaming and hardware inventory')
|
|
138
139
|
.option('--width <px>', 'video width', '640')
|
|
139
140
|
.option('--height <px>', 'video height', '480')
|
|
140
141
|
.option('--fps <n>', 'video fps', '15')
|
package/package.json
CHANGED
|
@@ -337,7 +337,9 @@ def _get_lan_ip() -> Optional[str]:
|
|
|
337
337
|
return None
|
|
338
338
|
|
|
339
339
|
|
|
340
|
-
async def _send_heartbeat(
|
|
340
|
+
async def _send_heartbeat(
|
|
341
|
+
scheduler_url: str, device_id: str, token: str
|
|
342
|
+
) -> tuple[Optional[bool], bool]:
|
|
341
343
|
try:
|
|
342
344
|
inventory = _get_device_inventory()
|
|
343
345
|
headers = {"Authorization": f"Bearer {token}", **_mesh_proxy_headers()}
|
|
@@ -348,6 +350,13 @@ async def _send_heartbeat(scheduler_url: str, device_id: str, token: str) -> Opt
|
|
|
348
350
|
headers=headers,
|
|
349
351
|
timeout=10.0,
|
|
350
352
|
)
|
|
353
|
+
if response.status_code in (401, 404):
|
|
354
|
+
logger.warning(
|
|
355
|
+
"heartbeat credentials rejected for %s (%s)",
|
|
356
|
+
device_id,
|
|
357
|
+
response.status_code,
|
|
358
|
+
)
|
|
359
|
+
return None, False
|
|
351
360
|
response.raise_for_status()
|
|
352
361
|
logger.info(
|
|
353
362
|
"heartbeat accepted: inventory source=%s camera=%d mic=%d speaker=%d",
|
|
@@ -356,10 +365,10 @@ async def _send_heartbeat(scheduler_url: str, device_id: str, token: str) -> Opt
|
|
|
356
365
|
len(inventory.get("audio_input", [])),
|
|
357
366
|
len(inventory.get("audio_output", [])),
|
|
358
367
|
)
|
|
359
|
-
return bool(response.json().get("production_mode", False))
|
|
368
|
+
return bool(response.json().get("production_mode", False)), True
|
|
360
369
|
except Exception as e:
|
|
361
370
|
logger.warning(f"heartbeat failed: {e}")
|
|
362
|
-
return None
|
|
371
|
+
return None, True
|
|
363
372
|
|
|
364
373
|
|
|
365
374
|
@dataclass
|
|
@@ -420,18 +429,34 @@ class PreviewAgent:
|
|
|
420
429
|
self._last_device_config_poll = 0.0
|
|
421
430
|
self._motion_reference = None
|
|
422
431
|
self._last_motion_sample = 0.0
|
|
423
|
-
self._motion_capture: Optional[VideoCapture] = None
|
|
424
|
-
self._motion_capture_lock = asyncio.Lock()
|
|
425
|
-
|
|
432
|
+
self._motion_capture: Optional[VideoCapture] = None
|
|
433
|
+
self._motion_capture_lock = asyncio.Lock()
|
|
434
|
+
self._mesh_bootstrap_ready = False
|
|
435
|
+
self._next_mesh_bootstrap = 0.0
|
|
436
|
+
|
|
437
|
+
async def _ensure_mesh_identity(self) -> bool:
|
|
438
|
+
if not _mesh_proxy_headers():
|
|
439
|
+
return bool(self.device_id and self.device_token)
|
|
440
|
+
now = time.monotonic()
|
|
441
|
+
if self._mesh_bootstrap_ready:
|
|
442
|
+
return True
|
|
443
|
+
if now < self._next_mesh_bootstrap:
|
|
444
|
+
return False
|
|
445
|
+
self._next_mesh_bootstrap = now + 5.0
|
|
446
|
+
try:
|
|
447
|
+
self.device_id, self.device_token = await _bootstrap_mesh_device(
|
|
448
|
+
self.scheduler_url, self.robot_id
|
|
449
|
+
)
|
|
450
|
+
self._mesh_bootstrap_ready = True
|
|
451
|
+
return True
|
|
452
|
+
except Exception as exc:
|
|
453
|
+
logger.warning("mesh device bootstrap failed: %s", exc)
|
|
454
|
+
return False
|
|
455
|
+
|
|
426
456
|
async def run(self) -> None:
|
|
427
457
|
enrollment_token = os.getenv("ENROLLMENT_TOKEN") or self.enrollment_token
|
|
428
458
|
if _mesh_proxy_headers():
|
|
429
|
-
|
|
430
|
-
self.device_id, self.device_token = await _bootstrap_mesh_device(
|
|
431
|
-
self.scheduler_url, self.robot_id
|
|
432
|
-
)
|
|
433
|
-
except Exception as exc:
|
|
434
|
-
logger.warning("mesh device bootstrap failed: %s", exc)
|
|
459
|
+
await self._ensure_mesh_identity()
|
|
435
460
|
|
|
436
461
|
if not self.device_token and enrollment_token:
|
|
437
462
|
self.device_token = await _enroll(self.scheduler_url, enrollment_token, self.robot_id)
|
|
@@ -747,13 +772,18 @@ class PreviewAgent:
|
|
|
747
772
|
await asyncio.to_thread(_play_audio_effect, self.audio_output_device, "disconnect")
|
|
748
773
|
self._current_state = PreviewState()
|
|
749
774
|
|
|
750
|
-
async def _heartbeat_loop(self) -> None:
|
|
751
|
-
while not self._shutdown.is_set():
|
|
752
|
-
if
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
775
|
+
async def _heartbeat_loop(self) -> None:
|
|
776
|
+
while not self._shutdown.is_set():
|
|
777
|
+
if _mesh_proxy_headers() and not self._mesh_bootstrap_ready:
|
|
778
|
+
await self._ensure_mesh_identity()
|
|
779
|
+
if self.device_id and self.device_token:
|
|
780
|
+
production_mode, credentials_ok = await _send_heartbeat(
|
|
781
|
+
self.scheduler_url, self.device_id, self.device_token
|
|
782
|
+
)
|
|
783
|
+
if not credentials_ok:
|
|
784
|
+
self._mesh_bootstrap_ready = False
|
|
785
|
+
self._next_mesh_bootstrap = 0.0
|
|
786
|
+
if production_mode is not None:
|
|
757
787
|
self.production_mode = production_mode
|
|
758
788
|
if not production_mode and self._vision_session_id:
|
|
759
789
|
await self._end_vision_session()
|
|
@@ -1674,7 +1704,9 @@ def main() -> None:
|
|
|
1674
1704
|
"video_device": args.video_device,
|
|
1675
1705
|
"audio_device": args.audio_device,
|
|
1676
1706
|
"robovision_url": args.robovision_url or os.getenv("ROBOVISION_URL", "http://127.0.0.1:5000"),
|
|
1677
|
-
"use_robovision_camera": bool(args.robovision_url)
|
|
1707
|
+
"use_robovision_camera": bool(args.robovision_url) or os.getenv(
|
|
1708
|
+
"ROBOVISION_CAMERA", ""
|
|
1709
|
+
).lower() in ("1", "true", "yes", "on"),
|
|
1678
1710
|
"video_width": args.width,
|
|
1679
1711
|
"video_height": args.height,
|
|
1680
1712
|
"video_fps": args.fps,
|