infinicode 2.8.53 → 2.8.56
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/dist/kernel/federation/dashboard-html.d.ts +1 -1
- package/dist/kernel/federation/dashboard-html.js +40 -16
- package/dist/robopark/auto-start.d.ts +1 -1
- package/dist/robopark/auto-start.js +29 -3
- package/dist/robopark/robot-runtime.d.ts +14 -0
- package/dist/robopark/robot-runtime.js +163 -0
- package/dist/robopark/setup.js +25 -112
- package/dist/robopark/stop-all.js +3 -0
- package/dist/robopark/vision-agent-launcher.js +1 -1
- package/dist/robopark-cli.js +37 -0
- package/package.json +1 -1
- package/packages/robopark/scheduler/main.py +68 -29
- package/packages/robopark/scheduler/preview_agent.py +21 -13
- package/packages/robopark/vision/requirements_vision_agent.txt +18 -0
|
@@ -1324,9 +1324,21 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
1324
1324
|
var rows = data[0], devices = data[1];
|
|
1325
1325
|
var deviceById = {}, deviceByName = {};
|
|
1326
1326
|
function norm(v){ return String(v||'').trim().toLowerCase(); }
|
|
1327
|
+
// A stale re-enrollment can leave more than one row with the same
|
|
1328
|
+
// display name. Keep the live/fresh device, never whichever SQLite
|
|
1329
|
+
// happened to return last, because all operator routes are id-keyed.
|
|
1330
|
+
function deviceScore(device){
|
|
1331
|
+
var status=norm(device&&device.status), at=Date.parse(device&&device.last_heartbeat||''), score=0;
|
|
1332
|
+
if(status==='online'||status==='running'||status==='detecting'||status==='connecting') score+=10000;
|
|
1333
|
+
if(Number.isFinite(at)) score+=Math.max(0, Math.floor(at/1000));
|
|
1334
|
+
return score;
|
|
1335
|
+
}
|
|
1327
1336
|
(Array.isArray(devices)?devices:[]).forEach(function(device){
|
|
1328
1337
|
deviceById[device.id] = device;
|
|
1329
|
-
if(device.name)
|
|
1338
|
+
if(device.name){
|
|
1339
|
+
var key=norm(device.name), current=deviceByName[key];
|
|
1340
|
+
if(!current || deviceScore(device)>deviceScore(current)) deviceByName[key] = device;
|
|
1341
|
+
}
|
|
1330
1342
|
});
|
|
1331
1343
|
schedulerDevicesById = deviceById;
|
|
1332
1344
|
schedulerDevicesByName = deviceByName;
|
|
@@ -1336,7 +1348,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
1336
1348
|
// legacy robots table has not been populated for that device yet.
|
|
1337
1349
|
(Array.isArray(devices)?devices:[]).forEach(function(device){
|
|
1338
1350
|
var already = candidates.some(function(row){
|
|
1339
|
-
return norm(row.id||row.robot_id)===norm(device.id)
|
|
1351
|
+
return norm(row.id||row.robot_id)===norm(device.id);
|
|
1340
1352
|
});
|
|
1341
1353
|
if(!already) candidates.push({id:device.id, name:device.name});
|
|
1342
1354
|
});
|
|
@@ -5313,8 +5325,9 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
5313
5325
|
// name, which is not necessarily the scheduler's robots.id (e.g. an
|
|
5314
5326
|
// enrolled device's id is a generated dev_xxxx, distinct from its name).
|
|
5315
5327
|
// Falls back to n.name if telemetry hasn't matched a row yet.
|
|
5316
|
-
function schedRow(n){
|
|
5317
|
-
var
|
|
5328
|
+
function schedRow(n){
|
|
5329
|
+
var preferred=schedulerDevice(n), preferredId=preferred&&preferred.id;
|
|
5330
|
+
var row=(preferredId&&roboparkByName[String(preferredId).toLowerCase()]) || roboparkByName[String(n.name||'').toLowerCase()] || roboparkByName[n.name], exact=row, keys=(n.telemKeys||[]).concat([String(n.name||'').toLowerCase(),String(preferredId||'').toLowerCase()]);
|
|
5318
5331
|
var seen={}, candidates=[];
|
|
5319
5332
|
for(var rk in roboparkByName){
|
|
5320
5333
|
var candidate=roboparkByName[rk], cid=candidate&&(candidate.robot_id||candidate.id);
|
|
@@ -5324,8 +5337,9 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
5324
5337
|
for(var ki=0;ki<keys.length;ki++){ var key=String(keys[ki]||'').toLowerCase(); if(key&&(label===key||idlabel===key||label.indexOf(key)>-1||key.indexOf(label)>-1)){ matches=true; break; } }
|
|
5325
5338
|
if(matches) candidates.push(candidate);
|
|
5326
5339
|
}
|
|
5327
|
-
function readyScore(candidate){
|
|
5328
|
-
var device=schedulerDevicesById[candidate.robot_id||candidate.id]||{}, status=String(candidate.device_status||device.status||'').toLowerCase(), score=0;
|
|
5340
|
+
function readyScore(candidate){
|
|
5341
|
+
var device=schedulerDevicesById[candidate.robot_id||candidate.id]||{}, status=String(candidate.device_status||device.status||'').toLowerCase(), score=0;
|
|
5342
|
+
if(preferredId && String(candidate.robot_id||candidate.id||'')===String(preferredId)) score+=100000;
|
|
5329
5343
|
if(candidate.production_mode===true||device.production_mode===true) score+=1000;
|
|
5330
5344
|
if(status==='online'||status==='running'||status==='detecting') score+=500;
|
|
5331
5345
|
if(candidate.readiness_code==='ready') score+=250;
|
|
@@ -5335,12 +5349,14 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
5335
5349
|
candidates.sort(function(a,b){ return readyScore(b)-readyScore(a); });
|
|
5336
5350
|
return candidates[0]||row;
|
|
5337
5351
|
}
|
|
5338
|
-
function schedId(n){
|
|
5339
|
-
|
|
5340
|
-
|
|
5341
|
-
var
|
|
5342
|
-
|
|
5343
|
-
|
|
5352
|
+
function schedId(n){
|
|
5353
|
+
// Device ids are authoritative. A legacy robots row may share the
|
|
5354
|
+
// name but have no preview, heartbeat, or hardware inventory.
|
|
5355
|
+
var device=schedulerDevice(n); if(device&&device.id) return device.id;
|
|
5356
|
+
var row=schedRow(n); if(row&&(row.id||row.robot_id)) return row.id||row.robot_id;
|
|
5357
|
+
if(n.schedulerRobotId) return n.schedulerRobotId;
|
|
5358
|
+
return n.name;
|
|
5359
|
+
}
|
|
5344
5360
|
function closeDrawer(){
|
|
5345
5361
|
// tell the scheduler to end any live preview so the robot stops its cam
|
|
5346
5362
|
if(previewOn && drawerId){ var pn=byId[drawerId]; if(pn){ try{ fetch('/robopark/api/robots/'+encodeURIComponent(schedId(pn))+'/preview/stop'+QS,{method:'POST',keepalive:true}); }catch(e){} } }
|
|
@@ -5474,10 +5490,18 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
5474
5490
|
refreshDrawer();
|
|
5475
5491
|
}).catch(function(){});
|
|
5476
5492
|
}
|
|
5477
|
-
function schedulerDevice(n){
|
|
5478
|
-
|
|
5479
|
-
|
|
5480
|
-
|
|
5493
|
+
function schedulerDevice(n){
|
|
5494
|
+
// Deliberately does not call schedRow: telemetry can contain a legacy
|
|
5495
|
+
// robot name, while this lookup must choose the current enrolled device.
|
|
5496
|
+
var ids=[n.schedulerDeviceId,n.schedulerRobotId,n.id,n.nodeId];
|
|
5497
|
+
for(var i=0;i<ids.length;i++) if(ids[i]&&schedulerDevicesById[ids[i]]) return schedulerDevicesById[ids[i]];
|
|
5498
|
+
var names=[n.name,n.displayName,n.label];
|
|
5499
|
+
for(var j=0;j<names.length;j++){
|
|
5500
|
+
var key=String(names[j]||'').toLowerCase();
|
|
5501
|
+
if(key&&schedulerDevicesByName[key]) return schedulerDevicesByName[key];
|
|
5502
|
+
}
|
|
5503
|
+
return null;
|
|
5504
|
+
}
|
|
5481
5505
|
function deviceList(inv, key, fallback){
|
|
5482
5506
|
var list=inv&&Array.isArray(inv[key])?inv[key]:[];
|
|
5483
5507
|
return list.length?list:fallback.map(function(id){ return {id:id,name:id}; });
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export interface ServiceArgs {
|
|
2
|
-
role: 'hub' | 'robot' | 'control' | 'hub-scheduler' | 'robot-preview-agent' | 'robot-vision-agent';
|
|
2
|
+
role: 'hub' | 'robot' | 'control' | 'hub-scheduler' | 'robot-runtime' | 'robot-preview-agent' | 'robot-vision-agent';
|
|
3
3
|
name: string;
|
|
4
4
|
command: string;
|
|
5
5
|
args: string[];
|
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
*
|
|
9
9
|
* This is used by `robopark setup --auto-start`.
|
|
10
10
|
*/
|
|
11
|
-
import { writeFileSync, mkdirSync } from 'node:fs';
|
|
11
|
+
import { existsSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
|
|
12
12
|
import { homedir } from 'node:os';
|
|
13
13
|
import { dirname, join } from 'node:path';
|
|
14
|
-
import { spawn } from 'node:child_process';
|
|
14
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
15
15
|
function shellQuote(s) {
|
|
16
16
|
if (!/[^a-zA-Z0-9_./:=,-]/.test(s))
|
|
17
17
|
return s;
|
|
@@ -138,5 +138,31 @@ export function unregisterAutoStart(role, name) {
|
|
|
138
138
|
return { ok: false, message: `failed: ${e instanceof Error ? e.message : String(e)}` };
|
|
139
139
|
}
|
|
140
140
|
}
|
|
141
|
-
|
|
141
|
+
if (platform === 'linux') {
|
|
142
|
+
const unitName = `robopark-${role}-${name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}.service`;
|
|
143
|
+
const unitPath = `/etc/systemd/system/${unitName}`;
|
|
144
|
+
const stopped = spawnSync('systemctl', ['disable', '--now', unitName], { encoding: 'utf8' });
|
|
145
|
+
try {
|
|
146
|
+
if (existsSync(unitPath))
|
|
147
|
+
rmSync(unitPath);
|
|
148
|
+
}
|
|
149
|
+
catch { /* systemd output is more useful below */ }
|
|
150
|
+
spawnSync('systemctl', ['daemon-reload'], { stdio: 'ignore' });
|
|
151
|
+
return {
|
|
152
|
+
ok: stopped.status === 0 || !existsSync(unitPath),
|
|
153
|
+
message: `removed systemd unit ${unitName}`,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
if (platform === 'darwin') {
|
|
157
|
+
const label = `ai.robopark.${role}.${name}`;
|
|
158
|
+
const plistPath = join(homedir(), 'Library', 'LaunchAgents', `${label}.plist`);
|
|
159
|
+
spawnSync('launchctl', ['unload', plistPath], { stdio: 'ignore' });
|
|
160
|
+
try {
|
|
161
|
+
if (existsSync(plistPath))
|
|
162
|
+
rmSync(plistPath);
|
|
163
|
+
}
|
|
164
|
+
catch { /* ignore */ }
|
|
165
|
+
return { ok: true, message: `removed launchd plist ${plistPath}` };
|
|
166
|
+
}
|
|
167
|
+
return { ok: false, message: `unregister not supported on ${platform}` };
|
|
142
168
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface RobotRuntimeOptions {
|
|
2
|
+
name: string;
|
|
3
|
+
hubUrl: string;
|
|
4
|
+
schedulerUrl: string;
|
|
5
|
+
token: string;
|
|
6
|
+
port?: string;
|
|
7
|
+
network?: 'lan' | 'tailscale';
|
|
8
|
+
enrollmentToken?: string;
|
|
9
|
+
videoDevice?: string;
|
|
10
|
+
audioDevice?: string;
|
|
11
|
+
vision?: boolean;
|
|
12
|
+
foreground?: boolean;
|
|
13
|
+
}
|
|
14
|
+
export declare function roboparkRobotRuntime(opts: RobotRuntimeOptions): Promise<void>;
|
|
@@ -0,0 +1,163 @@
|
|
|
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, createWriteStream, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { homedir } 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
|
+
const VISION_URL = 'http://127.0.0.1:5000/api/media/inventory';
|
|
18
|
+
const RESTART_DELAY_MS = 5_000;
|
|
19
|
+
function resetStaleEnrollment() {
|
|
20
|
+
const configDir = join(homedir(), '.robopark');
|
|
21
|
+
const tokenPath = join(configDir, 'device_token');
|
|
22
|
+
const configPath = join(configDir, 'preview_agent.json');
|
|
23
|
+
try {
|
|
24
|
+
if (existsSync(tokenPath))
|
|
25
|
+
rmSync(tokenPath);
|
|
26
|
+
}
|
|
27
|
+
catch { /* enrollment will report a real error */ }
|
|
28
|
+
if (!existsSync(configPath))
|
|
29
|
+
return;
|
|
30
|
+
try {
|
|
31
|
+
const config = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
32
|
+
delete config.device_id;
|
|
33
|
+
delete config.device_token;
|
|
34
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// A malformed optional config must not block first boot.
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function roboparkCliPath() {
|
|
41
|
+
// dist/robopark/robot-runtime.js -> package root -> dist/robopark-cli.js
|
|
42
|
+
const packageRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
|
|
43
|
+
const compiledCli = join(packageRoot, 'dist', 'robopark-cli.js');
|
|
44
|
+
return existsSync(compiledCli) ? compiledCli : process.argv[1];
|
|
45
|
+
}
|
|
46
|
+
function runtimeLog(label, foreground) {
|
|
47
|
+
if (foreground)
|
|
48
|
+
return 'inherit';
|
|
49
|
+
const logDir = join(homedir(), '.robopark', 'logs');
|
|
50
|
+
mkdirSync(logDir, { recursive: true });
|
|
51
|
+
return [
|
|
52
|
+
'ignore',
|
|
53
|
+
createWriteStream(join(logDir, `${label}.log`), { flags: 'a' }),
|
|
54
|
+
createWriteStream(join(logDir, `${label}.err.log`), { flags: 'a' }),
|
|
55
|
+
];
|
|
56
|
+
}
|
|
57
|
+
async function waitForVision() {
|
|
58
|
+
const deadline = Date.now() + 120_000;
|
|
59
|
+
while (Date.now() < deadline) {
|
|
60
|
+
try {
|
|
61
|
+
const response = await fetch(VISION_URL, { signal: AbortSignal.timeout(1_500) });
|
|
62
|
+
if (response.ok)
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// Dependency installation on a fresh Pi can take a little while.
|
|
67
|
+
}
|
|
68
|
+
await new Promise(resolve => setTimeout(resolve, 1_000));
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
export async function roboparkRobotRuntime(opts) {
|
|
73
|
+
const port = opts.port ?? '47913';
|
|
74
|
+
const network = opts.network ?? 'lan';
|
|
75
|
+
const foreground = opts.foreground === true;
|
|
76
|
+
const cli = roboparkCliPath();
|
|
77
|
+
const children = [];
|
|
78
|
+
let stopping = false;
|
|
79
|
+
const infinicode = await resolveInfinicodeBin();
|
|
80
|
+
if (!infinicode)
|
|
81
|
+
throw new Error('could not resolve infinicode CLI');
|
|
82
|
+
children.push({
|
|
83
|
+
label: 'mesh',
|
|
84
|
+
command: infinicode.node,
|
|
85
|
+
args: [infinicode.script, 'serve', '--role', 'satellite', '--name', opts.name,
|
|
86
|
+
'--port', port, '--token', opts.token, '--seed', opts.hubUrl,
|
|
87
|
+
network === 'tailscale' ? '--tailscale' : '--lan', '--auto-update'],
|
|
88
|
+
});
|
|
89
|
+
if (opts.vision !== false) {
|
|
90
|
+
children.push({
|
|
91
|
+
label: 'vision',
|
|
92
|
+
command: process.execPath,
|
|
93
|
+
args: [cli, 'vision-agent', '--foreground', '--port', '5000',
|
|
94
|
+
'--motion-webhook-url', 'http://127.0.0.1:5057/'],
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
const previewArgs = [cli, 'preview-agent', '--foreground',
|
|
98
|
+
'--scheduler-url', opts.schedulerUrl, '--robot-id', opts.name,
|
|
99
|
+
'--video-device', opts.videoDevice ?? 'auto', '--audio-device', opts.audioDevice ?? 'default',
|
|
100
|
+
'--save-config'];
|
|
101
|
+
if (opts.vision !== false)
|
|
102
|
+
previewArgs.push('--robovision-url', 'http://127.0.0.1:5000');
|
|
103
|
+
if (opts.enrollmentToken)
|
|
104
|
+
previewArgs.push('--enrollment-token', opts.enrollmentToken);
|
|
105
|
+
const preview = { label: 'preview', command: process.execPath, args: previewArgs };
|
|
106
|
+
const start = (entry) => {
|
|
107
|
+
const child = spawn(entry.command, entry.args, {
|
|
108
|
+
env: { ...process.env, ...entry.env, ROBOPARK_ROBOT_RUNTIME: '1' },
|
|
109
|
+
stdio: runtimeLog(`robot-${opts.name}-${entry.label}`, foreground),
|
|
110
|
+
});
|
|
111
|
+
entry.child = child;
|
|
112
|
+
child.on('error', error => console.error(chalk.red(` ${entry.label} failed to start: ${error.message}`)));
|
|
113
|
+
child.on('exit', (code, signal) => {
|
|
114
|
+
entry.child = undefined;
|
|
115
|
+
if (stopping)
|
|
116
|
+
return;
|
|
117
|
+
console.error(chalk.yellow(` ${entry.label} exited (${signal ?? code ?? 'unknown'}); restarting in 5s`));
|
|
118
|
+
setTimeout(() => { if (!stopping)
|
|
119
|
+
start(entry); }, RESTART_DELAY_MS);
|
|
120
|
+
});
|
|
121
|
+
};
|
|
122
|
+
console.log(chalk.bold('\n robopark robot up'));
|
|
123
|
+
console.log(chalk.dim(' ' + '─'.repeat(52)));
|
|
124
|
+
console.log(` robot: ${chalk.cyan(opts.name)}`);
|
|
125
|
+
console.log(` scheduler: ${chalk.cyan(opts.schedulerUrl)}`);
|
|
126
|
+
console.log(` network: ${chalk.cyan(network)}`);
|
|
127
|
+
console.log(` services: ${opts.vision === false ? 'mesh + preview' : 'mesh + RoboVision + preview'}`);
|
|
128
|
+
console.log();
|
|
129
|
+
if (opts.enrollmentToken) {
|
|
130
|
+
console.log(chalk.dim(' clearing stale device identity for fresh enrollment…'));
|
|
131
|
+
resetStaleEnrollment();
|
|
132
|
+
}
|
|
133
|
+
// The mesh can reconnect independently; camera/audio must be healthy before
|
|
134
|
+
// preview sends its first inventory heartbeat or opens the motion webhook.
|
|
135
|
+
start(children[0]);
|
|
136
|
+
if (opts.vision !== false) {
|
|
137
|
+
start(children[1]);
|
|
138
|
+
console.log(chalk.dim(' waiting for RoboVision camera/audio inventory…'));
|
|
139
|
+
if (!await waitForVision()) {
|
|
140
|
+
stopping = true;
|
|
141
|
+
for (const entry of children)
|
|
142
|
+
entry.child?.kill('SIGTERM');
|
|
143
|
+
throw new Error('RoboVision did not become healthy at http://127.0.0.1:5000 within 120s');
|
|
144
|
+
}
|
|
145
|
+
console.log(chalk.green(' ✓ RoboVision ready; starting preview agent'));
|
|
146
|
+
}
|
|
147
|
+
start(preview);
|
|
148
|
+
const shutdown = () => {
|
|
149
|
+
stopping = true;
|
|
150
|
+
for (const entry of [...children, preview])
|
|
151
|
+
entry.child?.kill('SIGTERM');
|
|
152
|
+
};
|
|
153
|
+
process.once('SIGINT', shutdown);
|
|
154
|
+
process.once('SIGTERM', shutdown);
|
|
155
|
+
await new Promise(resolve => {
|
|
156
|
+
const check = setInterval(() => {
|
|
157
|
+
if (stopping && ![...children, preview].some(entry => entry.child)) {
|
|
158
|
+
clearInterval(check);
|
|
159
|
+
resolve();
|
|
160
|
+
}
|
|
161
|
+
}, 250);
|
|
162
|
+
});
|
|
163
|
+
}
|
package/dist/robopark/setup.js
CHANGED
|
@@ -9,12 +9,12 @@
|
|
|
9
9
|
* robopark setup --control --start
|
|
10
10
|
*/
|
|
11
11
|
import chalk from 'chalk';
|
|
12
|
-
import { mkdirSync, writeFileSync
|
|
12
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
13
13
|
import { homedir } from 'node:os';
|
|
14
14
|
import { join } from 'node:path';
|
|
15
15
|
import { spawn } from 'node:child_process';
|
|
16
16
|
import { discoverContext, generateToken } from './discovery.js';
|
|
17
|
-
import { registerAutoStart } from './auto-start.js';
|
|
17
|
+
import { registerAutoStart, unregisterAutoStart } from './auto-start.js';
|
|
18
18
|
import { resolveInfinicodeBin } from './serve.js';
|
|
19
19
|
const DEFAULT_MESH_PORT = 47913;
|
|
20
20
|
const DEFAULT_SCHEDULER_PORT = 8080;
|
|
@@ -23,43 +23,6 @@ function saveToken(token) {
|
|
|
23
23
|
mkdirSync(dir, { recursive: true });
|
|
24
24
|
writeFileSync(join(dir, 'mesh.token'), token, 'utf8');
|
|
25
25
|
}
|
|
26
|
-
/**
|
|
27
|
-
* Clear a stale device identity before a fresh enrollment.
|
|
28
|
-
*
|
|
29
|
-
* preview_agent.py only re-enrolls (POSTs /api/devices/enroll) when it has
|
|
30
|
-
* NO device_token. If ~/.robopark/device_token and ~/.robopark/preview_agent.json
|
|
31
|
-
* survive from a previous enrollment (e.g. against a different scheduler),
|
|
32
|
-
* PreviewAgent.__init__ loads that old token/device_id and skips enrollment
|
|
33
|
-
* entirely — silently reusing a stale identity, which then 401s against the
|
|
34
|
-
* new target scheduler. An explicit --enrollment-token means the caller
|
|
35
|
-
* wants a fresh enrollment, not to reuse a stale identity from a previous
|
|
36
|
-
* target scheduler, so wipe the persisted device_token/device_id (and the
|
|
37
|
-
* token file) whenever one is passed.
|
|
38
|
-
*/
|
|
39
|
-
function resetStaleEnrollment() {
|
|
40
|
-
const dir = join(homedir(), '.robopark');
|
|
41
|
-
const configPath = join(dir, 'preview_agent.json');
|
|
42
|
-
const tokenPath = join(dir, 'device_token');
|
|
43
|
-
if (existsSync(tokenPath)) {
|
|
44
|
-
try {
|
|
45
|
-
rmSync(tokenPath);
|
|
46
|
-
}
|
|
47
|
-
catch (err) {
|
|
48
|
-
console.log(chalk.yellow(` ⚠ could not clear stale device token: ${err.message}`));
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
if (existsSync(configPath)) {
|
|
52
|
-
try {
|
|
53
|
-
const cfg = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
54
|
-
delete cfg.device_id;
|
|
55
|
-
delete cfg.device_token;
|
|
56
|
-
writeFileSync(configPath, JSON.stringify(cfg, null, 2), 'utf8');
|
|
57
|
-
}
|
|
58
|
-
catch (err) {
|
|
59
|
-
console.log(chalk.yellow(` ⚠ could not clear stale enrollment config: ${err.message}`));
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
26
|
/** Run an external command and detach unless foreground is requested. */
|
|
64
27
|
function runDetached(cmd, args, env) {
|
|
65
28
|
const proc = spawn(cmd, args, {
|
|
@@ -193,16 +156,6 @@ async function setupRobot(config, opts, ctx, internal) {
|
|
|
193
156
|
lan: network === 'lan',
|
|
194
157
|
};
|
|
195
158
|
config.set('federation', fed);
|
|
196
|
-
const args = [
|
|
197
|
-
'serve', '--role', 'satellite',
|
|
198
|
-
'--name', internal.name,
|
|
199
|
-
'--port', String(internal.port),
|
|
200
|
-
'--token', internal.token,
|
|
201
|
-
'--seed', hubUrl,
|
|
202
|
-
networkFlag,
|
|
203
|
-
'--auto-update',
|
|
204
|
-
'--supervised',
|
|
205
|
-
];
|
|
206
159
|
// Derive the scheduler host from the SAME hubUrl used for the mesh seed
|
|
207
160
|
// (respects an explicit --hub-url), not from ctx.hub — using auto-discovery
|
|
208
161
|
// here independently of the mesh seed meant an explicit --hub-url only
|
|
@@ -211,86 +164,46 @@ async function setupRobot(config, opts, ctx, internal) {
|
|
|
211
164
|
// found (e.g. a real hub reachable over Tailscale), not the one requested.
|
|
212
165
|
const hubHost = new URL(hubUrl).hostname;
|
|
213
166
|
const schedulerUrl = `http://${hubHost}:${opts.schedulerPort ? parseInt(opts.schedulerPort, 10) : DEFAULT_SCHEDULER_PORT}`;
|
|
214
|
-
|
|
215
|
-
|
|
167
|
+
// Vision (camera/motion detection -> presence-triggered session) is the
|
|
168
|
+
// production trigger — on by default. --no-vision opts out (e.g. no camera
|
|
169
|
+
// on this box, or RoboVisionAI_PI isn't installed).
|
|
170
|
+
const visionEnabled = opts.vision !== false;
|
|
171
|
+
const runtimeArgs = [
|
|
172
|
+
'robot', 'up',
|
|
173
|
+
'--name', internal.name,
|
|
174
|
+
'--hub-url', hubUrl,
|
|
216
175
|
'--scheduler-url', schedulerUrl,
|
|
217
|
-
'--
|
|
176
|
+
'--token', internal.token,
|
|
177
|
+
'--port', String(internal.port),
|
|
178
|
+
networkFlag,
|
|
218
179
|
'--video-device', opts.videoDevice ?? 'auto',
|
|
219
180
|
'--audio-device', opts.audioDevice ?? 'default',
|
|
220
|
-
'--save-config',
|
|
221
181
|
];
|
|
222
182
|
if (opts.enrollmentToken)
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
if (visionEnabled)
|
|
229
|
-
previewAgentArgs.push('--robovision-url', 'http://127.0.0.1:5000');
|
|
230
|
-
const visionAgentArgs = ['vision-agent'];
|
|
231
|
-
console.log(chalk.dim(' robot command:'));
|
|
232
|
-
console.log(' ' + chalk.cyan(`infinicode ${args.join(' ')}`));
|
|
233
|
-
console.log(' ' + chalk.cyan(`robopark ${previewAgentArgs.join(' ')}`));
|
|
234
|
-
if (visionEnabled)
|
|
235
|
-
console.log(' ' + chalk.cyan(`robopark ${visionAgentArgs.join(' ')}`));
|
|
183
|
+
runtimeArgs.push('--enrollment-token', opts.enrollmentToken);
|
|
184
|
+
if (!visionEnabled)
|
|
185
|
+
runtimeArgs.push('--no-vision');
|
|
186
|
+
console.log(chalk.dim(' single robot command:'));
|
|
187
|
+
console.log(' ' + chalk.cyan(`robopark ${runtimeArgs.join(' ')}`));
|
|
236
188
|
console.log(chalk.dim(` network: ${network} (scheduler: ${schedulerUrl})`));
|
|
237
189
|
console.log();
|
|
238
190
|
if (opts.start) {
|
|
239
|
-
const robotArgv =
|
|
240
|
-
console.log(chalk.dim(' starting robot
|
|
191
|
+
const robotArgv = [process.execPath, process.argv[1], ...runtimeArgs];
|
|
192
|
+
console.log(chalk.dim(' starting unified robot runtime…'));
|
|
241
193
|
runDetached(robotArgv[0], robotArgv.slice(1));
|
|
242
|
-
// NOTE: there is deliberately no separate `robopark enroll` spawn here.
|
|
243
|
-
// preview_agent.py's own startup (PreviewAgent.start(), see
|
|
244
|
-
// packages/robopark/scheduler/preview_agent.py) already performs
|
|
245
|
-
// enrollment itself whenever it has an --enrollment-token and no
|
|
246
|
-
// device_token yet. Spawning `robopark enroll` as well used to launch a
|
|
247
|
-
// SECOND full preview_agent.py process (enroll.ts runs the whole script,
|
|
248
|
-
// not a one-shot enroll-and-exit) with a different arg set (no
|
|
249
|
-
// --video-device/--audio-device/vision flags), which raced the "real"
|
|
250
|
-
// preview-agent process below for the same camera/mic and the vision
|
|
251
|
-
// webhook port (5057). --enrollment-token is already forwarded via
|
|
252
|
-
// previewAgentArgs, so one process below covers both enrollment and the
|
|
253
|
-
// long-running agent.
|
|
254
|
-
if (opts.enrollmentToken) {
|
|
255
|
-
console.log(chalk.dim(' fresh --enrollment-token given — clearing any stale device identity…'));
|
|
256
|
-
resetStaleEnrollment();
|
|
257
|
-
}
|
|
258
|
-
console.log(chalk.dim(' starting preview agent…'));
|
|
259
|
-
const previewArgv = [process.execPath, process.argv[1], ...previewAgentArgs];
|
|
260
|
-
runDetached(previewArgv[0], previewArgv.slice(1));
|
|
261
|
-
if (visionEnabled) {
|
|
262
|
-
console.log(chalk.dim(' starting vision agent…'));
|
|
263
|
-
const visionArgv = [process.execPath, process.argv[1], ...visionAgentArgs];
|
|
264
|
-
runDetached(visionArgv[0], visionArgv.slice(1));
|
|
265
|
-
}
|
|
266
194
|
}
|
|
267
195
|
if (opts.autoStart) {
|
|
268
|
-
const robotArgv =
|
|
196
|
+
const robotArgv = [process.execPath, process.argv[1], ...runtimeArgs, '--foreground'];
|
|
197
|
+
for (const role of ['robot', 'robot-preview-agent', 'robot-vision-agent']) {
|
|
198
|
+
unregisterAutoStart(role, internal.name);
|
|
199
|
+
}
|
|
269
200
|
const reg = await registerAutoStart({
|
|
270
|
-
role: 'robot',
|
|
201
|
+
role: 'robot-runtime',
|
|
271
202
|
name: internal.name,
|
|
272
203
|
command: robotArgv[0],
|
|
273
204
|
args: robotArgv.slice(1),
|
|
274
205
|
});
|
|
275
206
|
console.log(reg.ok ? chalk.green(` ✓ ${reg.message}`) : chalk.yellow(` ⚠ ${reg.message}`));
|
|
276
|
-
const previewArgv = [process.execPath, process.argv[1], ...previewAgentArgs, '--foreground'];
|
|
277
|
-
const reg2 = await registerAutoStart({
|
|
278
|
-
role: 'robot-preview-agent',
|
|
279
|
-
name: internal.name,
|
|
280
|
-
command: previewArgv[0],
|
|
281
|
-
args: previewArgv.slice(1),
|
|
282
|
-
});
|
|
283
|
-
console.log(reg2.ok ? chalk.green(` ✓ ${reg2.message}`) : chalk.yellow(` ⚠ ${reg2.message}`));
|
|
284
|
-
if (visionEnabled) {
|
|
285
|
-
const visionArgv = [process.execPath, process.argv[1], ...visionAgentArgs, '--foreground'];
|
|
286
|
-
const reg3 = await registerAutoStart({
|
|
287
|
-
role: 'robot-vision-agent',
|
|
288
|
-
name: internal.name,
|
|
289
|
-
command: visionArgv[0],
|
|
290
|
-
args: visionArgv.slice(1),
|
|
291
|
-
});
|
|
292
|
-
console.log(reg3.ok ? chalk.green(` ✓ ${reg3.message}`) : chalk.yellow(` ⚠ ${reg3.message}`));
|
|
293
|
-
}
|
|
294
207
|
}
|
|
295
208
|
}
|
|
296
209
|
async function setupControl(config, opts, ctx, internal) {
|
|
@@ -18,6 +18,9 @@ const PATTERNS = [
|
|
|
18
18
|
'cli.js serve', // source/dev invocation: `node dist/cli.js serve ...`
|
|
19
19
|
'robopark serve',
|
|
20
20
|
'robopark-cli.js serve',
|
|
21
|
+
'robopark-cli.js robot-run',
|
|
22
|
+
'robopark-cli.js robot up',
|
|
23
|
+
'robopark.js robot up',
|
|
21
24
|
// `robopark serve` detaches this child, so the Node launcher is gone while
|
|
22
25
|
// the Python scheduler remains. Match both Windows and POSIX separators.
|
|
23
26
|
'scheduler.{0,3}main\\.py',
|
|
@@ -23,7 +23,7 @@ export async function roboparkVisionAgent(opts) {
|
|
|
23
23
|
// Real Pi: opencv comes from `apt install python3-opencv`, not pip (see
|
|
24
24
|
// requirements_pi_unified.txt) — pip-installing it here would fight the
|
|
25
25
|
// ARM-optimized system build. Everywhere else: plain pip install works.
|
|
26
|
-
const reqFile = isPiArm() ? '
|
|
26
|
+
const reqFile = isPiArm() ? 'requirements_vision_agent.txt' : 'requirements-demo.txt';
|
|
27
27
|
const requiredModules = isPiArm() ? [...VISION_REQUIRED_MODULES, 'fastapi', 'uvicorn', 'sounddevice', 'soundfile', 'groq'] : VISION_REQUIRED_MODULES;
|
|
28
28
|
const python = prepareRobotPython(basePython, script, requiredModules, reqFile);
|
|
29
29
|
if (!python)
|
package/dist/robopark-cli.js
CHANGED
|
@@ -21,6 +21,7 @@ import { roboparkAddRobot } from './robopark/add-robot.js';
|
|
|
21
21
|
import { saveHubProfile, clearHubProfile } from './robopark/profile.js';
|
|
22
22
|
import { roboparkAgentUp, roboparkAgentDown, roboparkAgentLogs } from './robopark/agent-ctl.js';
|
|
23
23
|
import { roboparkSecrets } from './robopark/secrets.js';
|
|
24
|
+
import { roboparkRobotRuntime } from './robopark/robot-runtime.js';
|
|
24
25
|
// Read the real version from package.json so `--version` never drifts from
|
|
25
26
|
// what is actually installed (this was a hardcoded constant that went stale
|
|
26
27
|
// across several releases — see the identical fix + comment in cli.ts).
|
|
@@ -157,6 +158,42 @@ program
|
|
|
157
158
|
const { roboparkVisionAgent } = await import('./robopark/vision-agent-launcher.js');
|
|
158
159
|
await roboparkVisionAgent(opts);
|
|
159
160
|
});
|
|
161
|
+
const robotProgram = program
|
|
162
|
+
.command('robot')
|
|
163
|
+
.description('Run the complete robot runtime as one supervised service');
|
|
164
|
+
robotProgram
|
|
165
|
+
.command('up')
|
|
166
|
+
.description('Start mesh, RoboVision, audio, motion webhook, and preview agent in dependency order')
|
|
167
|
+
.requiredOption('--name <name>', 'robot name')
|
|
168
|
+
.requiredOption('--hub-url <url>', 'hub mesh URL, e.g. http://192.168.1.12:47913')
|
|
169
|
+
.requiredOption('--token <token>', 'shared mesh token')
|
|
170
|
+
.option('--scheduler-url <url>', 'scheduler URL (derived from --hub-url when omitted)')
|
|
171
|
+
.option('--enrollment-token <token>', 'one-time scheduler enrollment token for a fresh device')
|
|
172
|
+
.option('--port <port>', 'robot mesh port', '47913')
|
|
173
|
+
.option('--lan', 'use LAN transport (default)')
|
|
174
|
+
.option('--tailscale', 'use Tailscale transport')
|
|
175
|
+
.option('--video-device <dev>', 'camera selection', 'auto')
|
|
176
|
+
.option('--audio-device <dev>', 'microphone selection', 'default')
|
|
177
|
+
.option('--no-vision', 'do not start RoboVision camera/audio/motion services')
|
|
178
|
+
.option('--foreground', 'keep logs in this terminal instead of ~/.robopark/logs')
|
|
179
|
+
.action(async (opts) => {
|
|
180
|
+
if (opts.lan && opts.tailscale)
|
|
181
|
+
throw new Error('choose exactly one of --lan or --tailscale');
|
|
182
|
+
const hub = new URL(opts.hubUrl);
|
|
183
|
+
await roboparkRobotRuntime({
|
|
184
|
+
name: opts.name,
|
|
185
|
+
hubUrl: opts.hubUrl,
|
|
186
|
+
schedulerUrl: opts.schedulerUrl ?? `http://${hub.hostname}:8080`,
|
|
187
|
+
token: opts.token,
|
|
188
|
+
port: opts.port,
|
|
189
|
+
network: opts.tailscale ? 'tailscale' : 'lan',
|
|
190
|
+
enrollmentToken: opts.enrollmentToken,
|
|
191
|
+
videoDevice: opts.videoDevice,
|
|
192
|
+
audioDevice: opts.audioDevice,
|
|
193
|
+
vision: opts.vision,
|
|
194
|
+
foreground: opts.foreground,
|
|
195
|
+
});
|
|
196
|
+
});
|
|
160
197
|
program
|
|
161
198
|
.command('stop')
|
|
162
199
|
.description('Stop every infinicode/robopark process on this device AND remove any supervisor (systemd/Task Scheduler/launchd) that would respawn it')
|
package/package.json
CHANGED