robopark 2.8.35 → 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,195 @@
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import chalk from 'chalk';
6
+ import { registerAutoStart, unregisterAutoStart } from './auto-start.js';
7
+ import { findPython, findVisionMotorPath, prepareRobotPython } from './python-env.js';
8
+ import { resolveContext } from './profile.js';
9
+ const stateDir = join(homedir(), '.robopark');
10
+ const registryPath = join(stateDir, 'motors.json');
11
+ function slug(name) {
12
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'robot';
13
+ }
14
+ function serviceUnit(name) {
15
+ return `robopark-robot-motor-${slug(name)}.service`;
16
+ }
17
+ function serviceConfigPath(name) {
18
+ return join(stateDir, `motor-${slug(name)}.json`);
19
+ }
20
+ function atomicJson(path, value) {
21
+ mkdirSync(stateDir, { recursive: true });
22
+ const temporary = `${path}.${process.pid}.tmp`;
23
+ writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
24
+ renameSync(temporary, path);
25
+ }
26
+ export function parseMotorDefinitions(specs = []) {
27
+ const definitions = [];
28
+ for (const raw of specs) {
29
+ const match = String(raw).trim().match(/^(\d{1,2})[-:=]([a-zA-Z][a-zA-Z0-9_-]*)(?::(low|high))?$/i);
30
+ if (!match)
31
+ throw new Error(`invalid motor '${raw}'; use GPIO-NAME, for example 17-head or 27-eyes:low`);
32
+ const gpio = Number(match[1]);
33
+ if (gpio < 2 || gpio > 27)
34
+ throw new Error(`BCM GPIO ${gpio} is outside the safe range 2..27`);
35
+ definitions.push({ name: match[2].toLowerCase(), gpio, active_high: match[3]?.toLowerCase() !== 'low' });
36
+ }
37
+ const names = new Set();
38
+ const pins = new Set();
39
+ for (const motor of definitions) {
40
+ if (names.has(motor.name))
41
+ throw new Error(`motor name '${motor.name}' is duplicated`);
42
+ if (pins.has(motor.gpio))
43
+ throw new Error(`BCM GPIO ${motor.gpio} is assigned more than once`);
44
+ names.add(motor.name);
45
+ pins.add(motor.gpio);
46
+ }
47
+ return definitions;
48
+ }
49
+ function mergeRegistry(definitions) {
50
+ if (!definitions.length)
51
+ return;
52
+ let current = {};
53
+ if (existsSync(registryPath)) {
54
+ try {
55
+ current = JSON.parse(readFileSync(registryPath, 'utf8'));
56
+ }
57
+ catch { /* replace malformed registry */ }
58
+ }
59
+ const merged = {};
60
+ for (const [name, value] of Object.entries(current)) {
61
+ merged[name] = typeof value === 'number'
62
+ ? { name, gpio: value, active_high: true }
63
+ : { name: value.name || name, gpio: Number(value.gpio), active_high: value.active_high !== false };
64
+ }
65
+ for (const definition of definitions) {
66
+ for (const [name, existing] of Object.entries(merged)) {
67
+ if (existing.gpio === definition.gpio && name !== definition.name)
68
+ delete merged[name];
69
+ }
70
+ merged[definition.name] = definition;
71
+ }
72
+ atomicJson(registryPath, merged);
73
+ }
74
+ function loadServiceConfig(name) {
75
+ const path = serviceConfigPath(name);
76
+ if (!existsSync(path))
77
+ return { name, host: '127.0.0.1', port: 8001 };
78
+ return JSON.parse(readFileSync(path, 'utf8'));
79
+ }
80
+ async function request(name, path, init = {}) {
81
+ const config = loadServiceConfig(name);
82
+ const headers = new Headers(init.headers);
83
+ if (config.token)
84
+ headers.set('x-robopark-motor-token', config.token);
85
+ if (init.body)
86
+ headers.set('content-type', 'application/json');
87
+ const response = await fetch(`http://127.0.0.1:${config.port}${path}`, {
88
+ ...init,
89
+ headers,
90
+ signal: AbortSignal.timeout(5_000),
91
+ });
92
+ const body = await response.json().catch(() => ({}));
93
+ if (!response.ok)
94
+ throw new Error(body.detail || body.message || `motor API HTTP ${response.status}`);
95
+ return body;
96
+ }
97
+ export async function ensureMotorService(name, opts = {}) {
98
+ if (process.platform !== 'linux')
99
+ throw new Error('the production GPIO motor server requires Linux/Raspberry Pi');
100
+ const script = await findVisionMotorPath();
101
+ if (!script)
102
+ throw new Error('could not find motor_server.py');
103
+ const python = prepareRobotPython(findPython(), script, ['fastapi', 'uvicorn', 'pydantic', 'lgpio'], 'requirements_motor.txt');
104
+ if (!python)
105
+ throw new Error('could not prepare the motor server Python environment');
106
+ const context = await resolveContext({ token: opts.token });
107
+ const config = {
108
+ name,
109
+ host: opts.host ?? '127.0.0.1',
110
+ port: Number(opts.port ?? 8001),
111
+ token: context.token,
112
+ };
113
+ if (config.host !== '127.0.0.1' && config.host !== 'localhost') {
114
+ throw new Error('motor server must bind to loopback; remote actuation is routed through the authenticated robot supervisor');
115
+ }
116
+ if (!Number.isInteger(config.port) || config.port < 1024 || config.port > 65535)
117
+ throw new Error('motor port must be 1024..65535');
118
+ mergeRegistry(parseMotorDefinitions(opts.motor));
119
+ atomicJson(serviceConfigPath(name), config);
120
+ const env = {
121
+ ROBOPARK_ROBOT_NAME: name,
122
+ ROBOPARK_MOTOR_HOST: '127.0.0.1',
123
+ ROBOPARK_MOTOR_PORT: String(config.port),
124
+ ROBOPARK_MOTORS_FILE: registryPath,
125
+ };
126
+ if (config.token)
127
+ env.ROBOPARK_MOTOR_TOKEN = config.token;
128
+ if (opts.autoStart === false) {
129
+ const child = spawn(python, [script], { env: { ...process.env, ...env }, detached: true, stdio: 'ignore' });
130
+ child.unref();
131
+ return `started motor server for ${name} without boot registration`;
132
+ }
133
+ const registered = await registerAutoStart({
134
+ role: 'robot-motor', name, command: python, args: [script], env,
135
+ });
136
+ if (!registered.ok)
137
+ throw new Error(registered.message);
138
+ return registered.message;
139
+ }
140
+ export async function roboparkMotorUp(name, opts) {
141
+ console.log(chalk.bold('\n robopark motor up'));
142
+ console.log(chalk.dim(' ' + '-'.repeat(52)));
143
+ const message = await ensureMotorService(name, opts);
144
+ console.log(` robot: ${chalk.cyan(name)}`);
145
+ console.log(` endpoint: ${chalk.cyan(`http://127.0.0.1:${opts.port ?? '8001'}`)}`);
146
+ console.log(` registry: ${chalk.cyan(registryPath)}`);
147
+ console.log(chalk.green(` ${message}`));
148
+ }
149
+ export async function roboparkMotorStatus(name) {
150
+ const service = process.platform === 'linux'
151
+ ? spawnSync('systemctl', ['is-active', serviceUnit(name)], { encoding: 'utf8' }).stdout.trim()
152
+ : 'unknown';
153
+ try {
154
+ const status = await request(name, '/status');
155
+ const discovered = await request(name, '/discover');
156
+ console.log(chalk.bold(`\n ${name} motors`));
157
+ console.log(` service: ${service === 'active' ? chalk.green(service) : chalk.yellow(service)}`);
158
+ console.log(` API: ${chalk.green(status.status || 'ready')}`);
159
+ console.log(` GPIO: ${discovered.gpio_available ? chalk.green('available') : chalk.red('unavailable')}`);
160
+ console.log(` registry: ${discovered.count} motor(s)`);
161
+ for (const motor of discovered.motors || [])
162
+ console.log(` ${motor.name}: BCM GPIO ${motor.gpio}${motor.active_high === false ? ' (active low)' : ''}`);
163
+ }
164
+ catch (error) {
165
+ throw new Error(`motor endpoint is unavailable: ${error instanceof Error ? error.message : String(error)}`);
166
+ }
167
+ }
168
+ export function roboparkMotorRestart(name) {
169
+ if (process.platform !== 'linux')
170
+ throw new Error('motor restart currently requires systemd');
171
+ const result = spawnSync('systemctl', ['restart', serviceUnit(name)], { encoding: 'utf8' });
172
+ if (result.status !== 0)
173
+ throw new Error(String(result.stderr || result.stdout || 'motor restart failed').trim());
174
+ console.log(chalk.green(` restarted ${serviceUnit(name)}`));
175
+ }
176
+ export function roboparkMotorDown(name) {
177
+ const result = unregisterAutoStart('robot-motor', name);
178
+ if (!result.ok)
179
+ throw new Error(result.message);
180
+ console.log(chalk.green(` ${result.message}`));
181
+ }
182
+ export async function roboparkMotorDiscover(name) {
183
+ const data = await request(name, '/discover');
184
+ console.log(JSON.stringify(data, null, 2));
185
+ }
186
+ export async function roboparkMotorTest(name, motorName, seconds = 0.3) {
187
+ const data = motorName
188
+ ? await request(name, '/trigger-motor', { method: 'POST', body: JSON.stringify({ motor_name: motorName, seconds }) })
189
+ : await request(name, '/test', { method: 'POST', body: JSON.stringify({ seconds_on: seconds, seconds_pause: 0.2 }) });
190
+ console.log(chalk.green(` ${data.message || 'motor test started'}`));
191
+ }
192
+ export async function roboparkMotorStop(name) {
193
+ const data = await request(name, '/stop-motors', { method: 'POST', body: '{}' });
194
+ console.log(chalk.green(` ${data.message || 'motors stopped'}`));
195
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * RoboPark — `robopark preview-agent` launcher.
3
+ *
4
+ * Starts preview_agent.py with the right Python interpreter, auto-discovers
5
+ * the scheduler URL from mesh/hub, and detaches unless --foreground.
6
+ */
7
+ import { spawn } from 'node:child_process';
8
+ import { hostname } from 'node:os';
9
+ import chalk from 'chalk';
10
+ import { discoverContext } from './discovery.js';
11
+ import { findPython, findSchedulerPath, prepareRobotPython } from './python-env.js';
12
+ const DEFAULT_SCHEDULER_PORT = 8080;
13
+ export async function roboparkPreviewAgent(opts) {
14
+ const ctx = await discoverContext();
15
+ const port = DEFAULT_SCHEDULER_PORT;
16
+ const schedulerUrl = opts.schedulerUrl !== 'http://localhost:8080'
17
+ ? opts.schedulerUrl
18
+ : ctx.hub
19
+ ? `http://${ctx.hub.ip}:${port}`
20
+ : 'http://localhost:8080';
21
+ const robotId = opts.robotId ?? hostname().split('.')[0];
22
+ const script = await findSchedulerPath();
23
+ if (!script) {
24
+ console.log(chalk.red(' ✗ could not find preview_agent.py'));
25
+ process.exit(1);
26
+ }
27
+ const python = prepareRobotPython(findPython(), script);
28
+ if (!python)
29
+ process.exit(1);
30
+ const args = [
31
+ script,
32
+ '--scheduler-url', schedulerUrl,
33
+ '--robot-id', robotId,
34
+ '--video-device', opts.videoDevice,
35
+ '--audio-device', opts.audioDevice,
36
+ '--width', opts.width,
37
+ '--height', opts.height,
38
+ '--fps', opts.fps,
39
+ ];
40
+ if (opts.deviceToken)
41
+ args.push('--device-token', opts.deviceToken);
42
+ if (opts.enrollmentToken)
43
+ args.push('--enrollment-token', opts.enrollmentToken);
44
+ if (opts.visionWebhookPort)
45
+ args.push('--vision-webhook-port', opts.visionWebhookPort);
46
+ if (opts.visionTriggerCooldown)
47
+ args.push('--vision-trigger-cooldown', opts.visionTriggerCooldown);
48
+ if (opts.visionSessionSeconds)
49
+ args.push('--vision-session-seconds', opts.visionSessionSeconds);
50
+ if (opts.robovisionUrl)
51
+ args.push('--robovision-url', opts.robovisionUrl);
52
+ if (opts.saveConfig)
53
+ args.push('--save-config');
54
+ console.log(chalk.bold('\n robopark preview-agent'));
55
+ console.log(chalk.dim(' ' + '─'.repeat(52)));
56
+ console.log(` robot: ${chalk.cyan(robotId)}`);
57
+ console.log(` scheduler: ${chalk.cyan(schedulerUrl)}`);
58
+ console.log(` video: ${chalk.cyan(opts.videoDevice)}`);
59
+ console.log(` audio: ${chalk.cyan(opts.audioDevice)}`);
60
+ if (opts.visionWebhookPort)
61
+ console.log(` vision webhook: ${chalk.cyan(':' + opts.visionWebhookPort)} (point RoboVisionAI_PI's motion webhook here)`);
62
+ console.log();
63
+ if (opts.foreground) {
64
+ const proc = spawn(python, args, { stdio: 'inherit' });
65
+ await new Promise((resolve) => {
66
+ proc.on('close', () => resolve());
67
+ });
68
+ return;
69
+ }
70
+ const proc = spawn(python, args, {
71
+ stdio: 'ignore',
72
+ detached: true,
73
+ env: { ...process.env, ROBOPARK_PREVIEW_AGENT: '1' },
74
+ });
75
+ proc.unref();
76
+ console.log(chalk.green(' ✓ preview agent started in background'));
77
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * RoboPark — `robopark probe`.
3
+ *
4
+ * Spawn opencode agents (locally or over mesh) to build an accurate
5
+ * infrastructure map. Discovers peers via Tailscale, then runs a structured
6
+ * prompt through the locally installed `opencode` CLI for each node.
7
+ *
8
+ * The agent is asked only to report facts — it does not mutate anything.
9
+ */
10
+ import { spawn } from 'node:child_process';
11
+ import { hostname, networkInterfaces } from 'node:os';
12
+ import chalk from 'chalk';
13
+ import { discoverContext } from './discovery.js';
14
+ function getLanIps() {
15
+ const out = [];
16
+ for (const [name, addrs] of Object.entries(networkInterfaces())) {
17
+ if (!addrs)
18
+ continue;
19
+ for (const a of addrs) {
20
+ if (a.family === 'IPv4' && !a.internal)
21
+ out.push(a.address);
22
+ }
23
+ }
24
+ return out;
25
+ }
26
+ function findOpencode() {
27
+ const names = process.platform === 'win32' ? ['opencode.exe', 'opencode'] : ['opencode'];
28
+ for (const name of names) {
29
+ try {
30
+ const { execSync } = require('node:child_process');
31
+ execSync(`${name} --version`, { stdio: 'ignore' });
32
+ return name;
33
+ }
34
+ catch {
35
+ continue;
36
+ }
37
+ }
38
+ return null;
39
+ }
40
+ function runOpencodePrompt(prompt, timeoutMs) {
41
+ const bin = findOpencode();
42
+ if (!bin)
43
+ return Promise.resolve('opencode CLI not found; install opencode to run probes');
44
+ return new Promise((resolve) => {
45
+ const proc = spawn(bin, ['--no-project', '--non-interactive'], {
46
+ stdio: ['pipe', 'pipe', 'pipe'],
47
+ env: { ...process.env, OPENCODE_QUIET: '1' },
48
+ });
49
+ let stdout = '';
50
+ let stderr = '';
51
+ let killed = false;
52
+ const timer = setTimeout(() => {
53
+ killed = true;
54
+ proc.kill('SIGTERM');
55
+ }, timeoutMs);
56
+ proc.stdin?.write(prompt);
57
+ proc.stdin?.end();
58
+ proc.stdout?.on('data', (d) => { stdout += d.toString(); });
59
+ proc.stderr?.on('data', (d) => { stderr += d.toString(); });
60
+ proc.on('close', () => {
61
+ clearTimeout(timer);
62
+ if (killed)
63
+ resolve(stdout + '\n[probe timed out]');
64
+ else
65
+ resolve(stdout || stderr || '(no output)');
66
+ });
67
+ proc.on('error', (err) => {
68
+ clearTimeout(timer);
69
+ resolve(`opencode error: ${err.message}`);
70
+ });
71
+ });
72
+ }
73
+ function probePrompt(target, token) {
74
+ const tokenHint = token ? `mesh token is "${token}"` : 'no mesh token provided';
75
+ return `You are an infrastructure probe for the RoboPark fleet.
76
+ Target node: ${target.name} (${target.ip}) via ${target.source}.
77
+ ${tokenHint}.
78
+
79
+ Do NOT modify anything. Only report facts. Keep your answer under 600 words.
80
+
81
+ Please report:
82
+ 1. Hostname, OS, architecture.
83
+ 2. LAN IPs and Tailscale IP if available.
84
+ 3. Whether these processes are running: infinicode node, robopark scheduler, robopark preview_agent.
85
+ 4. If this node is the hub, whether the scheduler at http://localhost:8080/api/settings responds and what production_mode is.
86
+ 5. If this node is a robot/satellite, whether it has enrolled to the scheduler and whether ~/.robopark/device_token exists.
87
+ 6. Any recent errors in journalctl/systemctl for infinicode or robopark services (last 10 lines).
88
+ 7. Whether a camera (/dev/video0) or microphone is detected.
89
+
90
+ Answer as concise structured Markdown bullet list.`;
91
+ }
92
+ async function probeTarget(target, opts) {
93
+ const timeoutMs = opts.timeout ? parseInt(opts.timeout, 10) * 1000 : 60000;
94
+ try {
95
+ const summary = await runOpencodePrompt(probePrompt(target, opts.token), timeoutMs);
96
+ return { target, ok: true, summary, timestamp: new Date().toISOString() };
97
+ }
98
+ catch (e) {
99
+ return { target, ok: false, error: e.message, summary: '', timestamp: new Date().toISOString() };
100
+ }
101
+ }
102
+ export async function roboparkProbe(opts) {
103
+ const ctx = await discoverContext();
104
+ const self = { name: hostname().split('.')[0], ip: getLanIps()[0] || '127.0.0.1', source: 'self' };
105
+ const rawTargets = opts.local ? [self] : [self, ...(ctx.robots || []), ...(ctx.hub ? [ctx.hub] : [])];
106
+ const targets = rawTargets.map((t) => ({
107
+ name: t.name,
108
+ ip: t.ip,
109
+ source: t === self ? 'self' : 'tailscale',
110
+ }));
111
+ console.log(chalk.bold('\n robopark probe'));
112
+ console.log(chalk.dim(' ' + '─'.repeat(52)));
113
+ console.log(` targets: ${chalk.cyan(targets.length)}`);
114
+ console.log();
115
+ const results = [];
116
+ for (const t of targets) {
117
+ process.stdout.write(chalk.dim(` probing ${t.name}… `));
118
+ const r = await probeTarget(t, opts);
119
+ results.push(r);
120
+ console.log(r.ok ? chalk.green('ok') : chalk.red('failed'));
121
+ }
122
+ console.log();
123
+ console.log(chalk.bold(' Probe results'));
124
+ console.log(chalk.dim(' ' + '─'.repeat(52)));
125
+ for (const r of results) {
126
+ console.log(`\n ${chalk.cyan(r.target.name)} (${r.target.ip})`);
127
+ if (!r.ok) {
128
+ console.log(chalk.red(` error: ${r.error}`));
129
+ continue;
130
+ }
131
+ console.log(r.summary.split('\n').map(l => ' ' + l).join('\n'));
132
+ }
133
+ if (opts.output) {
134
+ const { writeFileSync } = await import('node:fs');
135
+ writeFileSync(opts.output, JSON.stringify(results, null, 2), 'utf8');
136
+ console.log(`\n ${chalk.green('✓')} wrote ${opts.output}`);
137
+ }
138
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * RoboPark — persisted hub profile.
3
+ *
4
+ * Operators kept hand-typing the same --hub-url/--token/--scheduler-url/--site
5
+ * flags on every command. `robopark hub-use` (see robopark-cli.ts) saves those
6
+ * once to ~/.robopark/hub-profile.json; `resolveContext()` here is the single
7
+ * place that layers them back in as defaults so every command benefits without
8
+ * having to re-implement the precedence logic itself.
9
+ *
10
+ * Precedence (highest wins): explicit CLI flags > saved hub profile > existing
11
+ * discoverContext() auto-discovery (Tailscale/LAN/local config files).
12
+ */
13
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
14
+ import { homedir } from 'node:os';
15
+ import { join } from 'node:path';
16
+ import { discoverContext } from './discovery.js';
17
+ const PROFILE_DIR = join(homedir(), '.robopark');
18
+ const PROFILE_PATH = join(PROFILE_DIR, 'hub-profile.json');
19
+ /** Persist a hub profile to ~/.robopark/hub-profile.json. */
20
+ export function saveHubProfile(profile) {
21
+ mkdirSync(PROFILE_DIR, { recursive: true });
22
+ writeFileSync(PROFILE_PATH, JSON.stringify(profile, null, 2), 'utf8');
23
+ }
24
+ /** Load the saved hub profile, or null if none has been saved. */
25
+ export function loadHubProfile() {
26
+ if (!existsSync(PROFILE_PATH))
27
+ return null;
28
+ try {
29
+ return JSON.parse(readFileSync(PROFILE_PATH, 'utf8'));
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ /** Delete the saved hub profile, if any. */
36
+ export function clearHubProfile() {
37
+ if (existsSync(PROFILE_PATH)) {
38
+ try {
39
+ rmSync(PROFILE_PATH);
40
+ }
41
+ catch {
42
+ // ignore
43
+ }
44
+ }
45
+ }
46
+ const DEFAULT_MESH_PORT = 47913;
47
+ const DEFAULT_SCHEDULER_PORT = 8080;
48
+ /**
49
+ * Resolve hub/scheduler/token/site context with the standard precedence:
50
+ * explicit CLI flags win, then the saved hub profile, then discoverContext()
51
+ * auto-discovery as the final fallback.
52
+ *
53
+ * Kept as a separate wrapper (rather than changing discoverContext()'s
54
+ * signature/behavior) so every existing discoverContext() caller keeps
55
+ * working unchanged when no profile has been saved.
56
+ */
57
+ export async function resolveContext(opts = {}) {
58
+ const discovered = await discoverContext();
59
+ const profile = loadHubProfile();
60
+ const hubUrl = opts.hubUrl
61
+ ?? profile?.hubUrl
62
+ ?? (discovered.hub ? `http://${discovered.hub.ip}:${discovered.meshPort ?? DEFAULT_MESH_PORT}` : undefined);
63
+ const token = opts.token ?? profile?.token ?? discovered.meshToken;
64
+ const schedulerUrl = opts.schedulerUrl
65
+ ?? profile?.schedulerUrl
66
+ ?? (hubUrl ? hubUrl.replace(/:\d+$/, `:${DEFAULT_SCHEDULER_PORT}`) : undefined);
67
+ const site = opts.site ?? profile?.site;
68
+ return { hubUrl, token, schedulerUrl, site, discovered };
69
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * RoboPark — shared Python environment helpers for `enroll`, `preview-agent`,
3
+ * and `vision-agent`, which spawn preview_agent.py / app_pi_clean.py.
4
+ */
5
+ import { execSync, spawnSync } from 'node:child_process';
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
7
+ import { homedir } from 'node:os';
8
+ import { join } from 'node:path';
9
+ import { dirname } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { createHash } from 'node:crypto';
12
+ import chalk from 'chalk';
13
+ /** Modules preview_agent.py imports — kept in sync with requirements-robot.txt. */
14
+ const REQUIRED_MODULES = ['httpx', 'cv2', 'pyaudio', 'livekit'];
15
+ /** Modules app_pi_clean.py imports — kept in sync with requirements_pi_unified.txt. */
16
+ export const VISION_REQUIRED_MODULES = ['flask', 'flask_cors', 'cv2', 'numpy', 'requests'];
17
+ async function findPackageScript(relativePath) {
18
+ const parts = relativePath.split('/');
19
+ const here = dirname(fileURLToPath(import.meta.url));
20
+ const candidates = [
21
+ // Standalone npm package: dist/robopark/python-env.js -> package root.
22
+ join(here, '..', '..', ...parts),
23
+ // InfiniCode monorepo/local build compatibility.
24
+ join(here, '..', '..', '..', 'packages', 'robopark', ...parts),
25
+ join(process.cwd(), 'packages', 'robopark', ...parts),
26
+ join(process.cwd(), ...parts),
27
+ ];
28
+ for (const p of candidates) {
29
+ if (existsSync(p))
30
+ return p;
31
+ }
32
+ try {
33
+ const { createRequire } = await import('node:module');
34
+ const require = createRequire(import.meta.url);
35
+ const pkgMain = require.resolve('infinicode/package.json');
36
+ const p = join(pkgMain, '..', 'packages', 'robopark', ...relativePath.split('/'));
37
+ if (existsSync(p))
38
+ return p;
39
+ }
40
+ catch { /* ignore */ }
41
+ return null;
42
+ }
43
+ export async function findSchedulerPath() {
44
+ return findPackageScript('scheduler/preview_agent.py');
45
+ }
46
+ export async function findRobotSupervisorPath() {
47
+ return findPackageScript('scheduler/robot_supervisor.py');
48
+ }
49
+ export async function findVisionPath() {
50
+ return findPackageScript('vision/app_pi_clean.py');
51
+ }
52
+ export async function findVisionAudioPath() {
53
+ return findPackageScript('vision/audio_server_pi.py');
54
+ }
55
+ export async function findVisionMotorPath() {
56
+ return findPackageScript('vision/motor_server.py');
57
+ }
58
+ export async function findConversationPath() {
59
+ return findPackageScript('conversation/elevenlabs_agent.py');
60
+ }
61
+ export async function findScreenRuntimePath() {
62
+ return findPackageScript('screen/screen_runtime.py');
63
+ }
64
+ export function findPython() {
65
+ const names = process.platform === 'win32'
66
+ ? ['python', 'python3', 'py']
67
+ : ['python3', 'python'];
68
+ for (const name of names) {
69
+ try {
70
+ execSync(`${name} --version`, { stdio: 'ignore' });
71
+ return name;
72
+ }
73
+ catch {
74
+ continue;
75
+ }
76
+ }
77
+ return process.platform === 'win32' ? 'python' : 'python3';
78
+ }
79
+ const ROBOPARK_VENV = join(homedir(), '.robopark', 'venv');
80
+ function venvPythonPath() {
81
+ return process.platform === 'win32'
82
+ ? join(ROBOPARK_VENV, 'Scripts', 'python.exe')
83
+ : join(ROBOPARK_VENV, 'bin', 'python');
84
+ }
85
+ function missingModules(python, modules) {
86
+ return modules.filter(mod => {
87
+ const res = spawnSync(python, ['-c', `import ${mod}`], { stdio: 'ignore' });
88
+ return res.status !== 0;
89
+ });
90
+ }
91
+ /**
92
+ * Ensure a robot-side script's Python deps are importable, installing them
93
+ * from a requirements file (next to the script) if not. Returns false — with
94
+ * a message already printed — if deps are still missing after the install
95
+ * attempt, so callers can bail out before hitting a raw traceback.
96
+ *
97
+ * Defaults match preview_agent.py; pass `modules`/`reqFile` for other scripts
98
+ * (e.g. app_pi_clean.py + requirements_pi_unified.txt).
99
+ */
100
+ export function ensurePythonDeps(python, scriptPath, modules = REQUIRED_MODULES, reqFile = 'requirements-robot.txt') {
101
+ const missing = missingModules(python, modules);
102
+ if (missing.length === 0)
103
+ return true;
104
+ const reqPath = join(scriptPath, '..', reqFile);
105
+ if (!existsSync(reqPath)) {
106
+ console.log(chalk.red(` ✗ missing Python modules: ${missing.join(', ')} (and ${reqFile} not found next to ${scriptPath})`));
107
+ return false;
108
+ }
109
+ console.log(chalk.dim(` installing Python deps (${missing.join(', ')})…`));
110
+ const install = spawnSync(python, ['-m', 'pip', 'install', '-r', reqPath], { stdio: 'inherit' });
111
+ if (install.status !== 0) {
112
+ console.log(chalk.red(` ✗ pip install failed — install manually: ${python} -m pip install -r "${reqPath}"`));
113
+ return false;
114
+ }
115
+ const stillMissing = missingModules(python, modules);
116
+ if (stillMissing.length > 0) {
117
+ console.log(chalk.red(` ✗ still missing after install: ${stillMissing.join(', ')} — install manually: ${python} -m pip install -r "${reqPath}"`));
118
+ return false;
119
+ }
120
+ return true;
121
+ }
122
+ /**
123
+ * Create and maintain the isolated robot runtime at ~/.robopark/venv.
124
+ * System site packages stay visible so Pi apt packages such as OpenCV,
125
+ * PyAudio and Picamera2 do not need to be rebuilt by pip on ARM.
126
+ */
127
+ export function prepareRobotPython(basePython, scriptPath, modules = REQUIRED_MODULES, reqFile = 'requirements-robot.txt') {
128
+ const reqPath = join(scriptPath, '..', reqFile);
129
+ if (!existsSync(reqPath)) {
130
+ console.log(chalk.red(` ✗ ${reqFile} not found next to ${scriptPath}`));
131
+ return null;
132
+ }
133
+ const python = venvPythonPath();
134
+ if (!existsSync(python)) {
135
+ console.log(chalk.dim(` creating RoboPark Python environment: ${ROBOPARK_VENV}`));
136
+ mkdirSync(join(homedir(), '.robopark'), { recursive: true });
137
+ const created = spawnSync(basePython, ['-m', 'venv', '--system-site-packages', ROBOPARK_VENV], { stdio: 'inherit' });
138
+ if (created.status !== 0 || !existsSync(python)) {
139
+ console.log(chalk.red(' ✗ could not create ~/.robopark/venv. On Debian/Raspberry Pi install python3-venv, then retry.'));
140
+ return null;
141
+ }
142
+ }
143
+ const requirements = readFileSync(reqPath, 'utf8');
144
+ const fingerprint = createHash('sha256').update(requirements).digest('hex');
145
+ const marker = join(ROBOPARK_VENV, `.robopark-${reqFile}.sha256`);
146
+ const installedFingerprint = existsSync(marker) ? readFileSync(marker, 'utf8').trim() : '';
147
+ if (installedFingerprint !== fingerprint || missingModules(python, modules).length > 0) {
148
+ console.log(chalk.dim(` syncing RoboPark Python dependencies from ${reqFile}…`));
149
+ const install = spawnSync(python, ['-m', 'pip', 'install', '--upgrade', '-r', reqPath], { stdio: 'inherit' });
150
+ if (install.status !== 0) {
151
+ console.log(chalk.red(` ✗ dependency install failed in ${ROBOPARK_VENV}`));
152
+ return null;
153
+ }
154
+ writeFileSync(marker, `${fingerprint}\n`);
155
+ }
156
+ const stillMissing = missingModules(python, modules);
157
+ if (stillMissing.length) {
158
+ console.log(chalk.red(` ✗ venv is still missing: ${stillMissing.join(', ')}`));
159
+ return null;
160
+ }
161
+ return python;
162
+ }