infinicode 2.8.58 → 2.8.60
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 +4 -0
- package/dist/kernel/federation/transport-http.d.ts +4 -0
- package/dist/kernel/federation/transport-http.js +26 -1
- package/dist/robopark/auto-start.js +10 -2
- package/dist/robopark/robot-runtime.js +37 -24
- package/dist/robopark/setup.js +13 -3
- package/dist/robopark-cli.js +4 -1
- package/package.json +1 -1
- package/packages/robopark/scheduler/main.py +91 -9
- package/packages/robopark/scheduler/preview_agent.py +104 -28
|
@@ -5530,6 +5530,10 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
5530
5530
|
if(greetings && document.activeElement!==greetings) greetings.value=(device.greeting_phrases||[]).join(String.fromCharCode(10));
|
|
5531
5531
|
var note=$('pk-device-note');
|
|
5532
5532
|
if(note){
|
|
5533
|
+
if(!device.id && n && n.connected){
|
|
5534
|
+
note.textContent='mesh connected; scheduler identity is bootstrapping or failed';
|
|
5535
|
+
return;
|
|
5536
|
+
}
|
|
5533
5537
|
var source=inv.source||'robot heartbeat', counts=(inv.video||[]).length+'/'+(inv.audio_input||[]).length+'/'+(inv.audio_output||[]).length;
|
|
5534
5538
|
note.textContent=hasRealInventory(inv) ? ('inventory from '+source+' · camera/mic/speaker '+counts) : (device.id ? 'no real hardware inventory reported yet · restart vision-agent + preview-agent on this robot' : 'waiting for robot heartbeat inventory');
|
|
5535
5539
|
}
|
|
@@ -63,6 +63,10 @@ export declare class MeshServer {
|
|
|
63
63
|
* (browsers, which can't set headers on navigation or EventSource). */
|
|
64
64
|
private presentedToken;
|
|
65
65
|
private authOk;
|
|
66
|
+
/** A robot uses this header only when it talks to the hub's scheduler proxy.
|
|
67
|
+
* Its device token remains the Authorization header and is forwarded to the
|
|
68
|
+
* scheduler. Requiring both keeps arbitrary Tailnet callers out. */
|
|
69
|
+
private schedulerProxyAuthOk;
|
|
66
70
|
private handle;
|
|
67
71
|
private openStream;
|
|
68
72
|
private readBody;
|
|
@@ -106,8 +106,24 @@ export class MeshServer {
|
|
|
106
106
|
this.readBody(req)
|
|
107
107
|
.then(async (body) => {
|
|
108
108
|
const headers = { 'content-type': req.headers['content-type'] ?? 'application/json' };
|
|
109
|
+
const meshProxyToken = req.headers['x-robopark-mesh-token'];
|
|
110
|
+
if (typeof meshProxyToken === 'string') {
|
|
111
|
+
// The scheduler uses this only for device bootstrap. The mesh server
|
|
112
|
+
// has already validated it before this proxy method is reached.
|
|
113
|
+
headers['x-robopark-mesh-token'] = meshProxyToken;
|
|
114
|
+
}
|
|
109
115
|
if (this.opts.schedulerToken)
|
|
110
116
|
headers['authorization'] = `Bearer ${this.opts.schedulerToken}`;
|
|
117
|
+
else {
|
|
118
|
+
const inbound = req.headers.authorization;
|
|
119
|
+
const presented = this.presentedToken(req);
|
|
120
|
+
const isMeshToken = this.opts.token && presented && safeEqual(presented, this.opts.token);
|
|
121
|
+
// Robot device tokens must reach the scheduler unchanged. Mesh tokens
|
|
122
|
+
// authenticate the proxy itself and are not scheduler credentials.
|
|
123
|
+
if (!isMeshToken && typeof inbound === 'string' && inbound.startsWith('Bearer ')) {
|
|
124
|
+
headers.authorization = inbound;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
111
127
|
const upstream = await fetch(target, {
|
|
112
128
|
method: req.method,
|
|
113
129
|
headers,
|
|
@@ -250,6 +266,15 @@ export class MeshServer {
|
|
|
250
266
|
const presented = this.presentedToken(req);
|
|
251
267
|
return presented !== undefined && safeEqual(presented, this.opts.token);
|
|
252
268
|
}
|
|
269
|
+
/** A robot uses this header only when it talks to the hub's scheduler proxy.
|
|
270
|
+
* Its device token remains the Authorization header and is forwarded to the
|
|
271
|
+
* scheduler. Requiring both keeps arbitrary Tailnet callers out. */
|
|
272
|
+
schedulerProxyAuthOk(req, path) {
|
|
273
|
+
if (!path.startsWith('/robopark/api/') || !this.opts.token)
|
|
274
|
+
return false;
|
|
275
|
+
const presented = req.headers['x-robopark-mesh-token'];
|
|
276
|
+
return typeof presented === 'string' && safeEqual(presented, this.opts.token);
|
|
277
|
+
}
|
|
253
278
|
handle(req, res) {
|
|
254
279
|
const url = req.url ?? '/';
|
|
255
280
|
const path = url.split('?', 1)[0];
|
|
@@ -257,7 +282,7 @@ export class MeshServer {
|
|
|
257
282
|
res.writeHead(204).end();
|
|
258
283
|
return;
|
|
259
284
|
}
|
|
260
|
-
if (!this.authOk(req)) {
|
|
285
|
+
if (!this.authOk(req) && !this.schedulerProxyAuthOk(req, path)) {
|
|
261
286
|
res.writeHead(401).end('unauthorized');
|
|
262
287
|
return;
|
|
263
288
|
}
|
|
@@ -73,7 +73,7 @@ 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 ??
|
|
76
|
+
WorkingDirectory=${service.workingDir ?? homedir()}
|
|
77
77
|
Restart=always
|
|
78
78
|
RestartSec=5
|
|
79
79
|
${envLines}
|
|
@@ -83,7 +83,15 @@ WantedBy=multi-user.target
|
|
|
83
83
|
`;
|
|
84
84
|
try {
|
|
85
85
|
writeFileSync(unitPath, unit, 'utf8');
|
|
86
|
-
|
|
86
|
+
const reload = spawnSync('systemctl', ['daemon-reload'], { encoding: 'utf8' });
|
|
87
|
+
if (reload.status !== 0) {
|
|
88
|
+
return { ok: false, message: `wrote ${unitPath}, but systemctl daemon-reload failed: ${reload.stderr || reload.stdout}` };
|
|
89
|
+
}
|
|
90
|
+
const enabled = spawnSync('systemctl', ['enable', '--now', unitName], { encoding: 'utf8' });
|
|
91
|
+
if (enabled.status !== 0) {
|
|
92
|
+
return { ok: false, message: `wrote ${unitPath}, but systemctl enable --now failed: ${enabled.stderr || enabled.stdout}` };
|
|
93
|
+
}
|
|
94
|
+
return { ok: true, message: `enabled and started systemd unit ${unitName}` };
|
|
87
95
|
}
|
|
88
96
|
catch (e) {
|
|
89
97
|
return { ok: false, message: `could not write ${unitPath}: ${e instanceof Error ? e.message : String(e)}` };
|
|
@@ -101,11 +101,27 @@ 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
|
-
const preview = {
|
|
106
|
+
const preview = {
|
|
107
|
+
label: 'preview',
|
|
108
|
+
command: process.execPath,
|
|
109
|
+
args: previewArgs,
|
|
110
|
+
// Tailscale scheduler access goes through the authenticated hub proxy.
|
|
111
|
+
// Keep this in the child environment, never in the persisted robot config.
|
|
112
|
+
env: {
|
|
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',
|
|
121
|
+
// Inventory changes should reach the Control Center promptly.
|
|
122
|
+
HEARTBEAT_INTERVAL: '5',
|
|
123
|
+
},
|
|
124
|
+
};
|
|
109
125
|
const start = (entry) => {
|
|
110
126
|
const child = spawn(entry.command, entry.args, {
|
|
111
127
|
env: { ...process.env, ...entry.env, ROBOPARK_ROBOT_RUNTIME: '1' },
|
|
@@ -160,20 +176,18 @@ export async function roboparkRobotRuntime(opts) {
|
|
|
160
176
|
start(children[0]);
|
|
161
177
|
if (opts.vision !== false) {
|
|
162
178
|
start(children[1]);
|
|
163
|
-
if (!await waitForVision()) {
|
|
164
|
-
console.error(chalk.red(' RoboVision did not recover after the mesh update; retrying in 5s'));
|
|
165
|
-
// Stop the partially restarted stack before retrying. Otherwise a
|
|
166
|
-
// second mesh child would collide with the existing listener.
|
|
167
|
-
await Promise.all([...children, preview].map(stopChild));
|
|
168
|
-
recycling = false;
|
|
169
|
-
setTimeout(() => { if (!stopping)
|
|
170
|
-
void recycleAfterMeshUpdate(); }, RESTART_DELAY_MS);
|
|
171
|
-
return;
|
|
172
|
-
}
|
|
173
179
|
}
|
|
174
180
|
start(preview);
|
|
175
181
|
recycling = false;
|
|
176
182
|
console.log(chalk.green(' robot runtime updated and healthy'));
|
|
183
|
+
if (opts.vision !== false) {
|
|
184
|
+
void waitForVision().then(ready => {
|
|
185
|
+
if (!stopping)
|
|
186
|
+
console.log(ready
|
|
187
|
+
? chalk.green(' RoboVision camera/audio inventory ready')
|
|
188
|
+
: chalk.yellow(' RoboVision is degraded; preview heartbeat remains online and will retry'));
|
|
189
|
+
});
|
|
190
|
+
}
|
|
177
191
|
};
|
|
178
192
|
console.log(chalk.bold('\n robopark robot up'));
|
|
179
193
|
console.log(chalk.dim(' ' + '─'.repeat(52)));
|
|
@@ -186,21 +200,20 @@ export async function roboparkRobotRuntime(opts) {
|
|
|
186
200
|
console.log(chalk.dim(' clearing stale device identity for fresh enrollment…'));
|
|
187
201
|
resetStaleEnrollment();
|
|
188
202
|
}
|
|
189
|
-
//
|
|
190
|
-
//
|
|
203
|
+
// Identity and inventory reporting start immediately. RoboVision may still
|
|
204
|
+
// be installing or recovering without making the robot disappear.
|
|
191
205
|
start(children[0]);
|
|
206
|
+
start(preview);
|
|
192
207
|
if (opts.vision !== false) {
|
|
193
208
|
start(children[1]);
|
|
194
|
-
console.log(chalk.dim('
|
|
195
|
-
|
|
196
|
-
stopping
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
}
|
|
201
|
-
console.log(chalk.green(' ✓ RoboVision ready; starting preview agent'));
|
|
209
|
+
console.log(chalk.dim(' RoboVision health check running in background…'));
|
|
210
|
+
void waitForVision().then(ready => {
|
|
211
|
+
if (!stopping)
|
|
212
|
+
console.log(ready
|
|
213
|
+
? chalk.green(' RoboVision camera/audio inventory ready')
|
|
214
|
+
: chalk.yellow(' RoboVision is degraded; preview heartbeat remains online and will retry'));
|
|
215
|
+
});
|
|
202
216
|
}
|
|
203
|
-
start(preview);
|
|
204
217
|
const shutdown = () => {
|
|
205
218
|
stopping = true;
|
|
206
219
|
for (const entry of [...children, preview])
|
package/dist/robopark/setup.js
CHANGED
|
@@ -163,7 +163,9 @@ async function setupRobot(config, opts, ctx, internal) {
|
|
|
163
163
|
// preview-agent) silently kept talking to whatever hub auto-discovery
|
|
164
164
|
// found (e.g. a real hub reachable over Tailscale), not the one requested.
|
|
165
165
|
const hubHost = new URL(hubUrl).hostname;
|
|
166
|
-
const schedulerUrl =
|
|
166
|
+
const schedulerUrl = network === 'tailscale'
|
|
167
|
+
? `${hubUrl.replace(/\/+$/, '')}/robopark`
|
|
168
|
+
: `http://${hubHost}:${opts.schedulerPort ? parseInt(opts.schedulerPort, 10) : DEFAULT_SCHEDULER_PORT}`;
|
|
167
169
|
// Vision (camera/motion detection -> presence-triggered session) is the
|
|
168
170
|
// production trigger — on by default. --no-vision opts out (e.g. no camera
|
|
169
171
|
// on this box, or RoboVisionAI_PI isn't installed).
|
|
@@ -187,13 +189,21 @@ async function setupRobot(config, opts, ctx, internal) {
|
|
|
187
189
|
console.log(' ' + chalk.cyan(`robopark ${runtimeArgs.join(' ')}`));
|
|
188
190
|
console.log(chalk.dim(` network: ${network} (scheduler: ${schedulerUrl})`));
|
|
189
191
|
console.log();
|
|
190
|
-
|
|
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')) {
|
|
191
196
|
const robotArgv = [process.execPath, process.argv[1], ...runtimeArgs];
|
|
192
197
|
console.log(chalk.dim(' starting unified robot runtime…'));
|
|
193
198
|
runDetached(robotArgv[0], robotArgv.slice(1));
|
|
194
199
|
}
|
|
195
200
|
if (opts.autoStart) {
|
|
196
|
-
|
|
201
|
+
// Enrollment is a one-time bootstrap action. Persisting it in the
|
|
202
|
+
// supervisor would erase the device identity and attempt a new enrollment
|
|
203
|
+
// on every reboot, creating duplicate robots (or failing after the token
|
|
204
|
+
// is consumed).
|
|
205
|
+
const bootRuntimeArgs = runtimeArgs.filter((arg, index) => arg !== '--enrollment-token' && runtimeArgs[index - 1] !== '--enrollment-token');
|
|
206
|
+
const robotArgv = [process.execPath, process.argv[1], ...bootRuntimeArgs, '--foreground'];
|
|
197
207
|
for (const role of ['robot', 'robot-preview-agent', 'robot-vision-agent']) {
|
|
198
208
|
unregisterAutoStart(role, internal.name);
|
|
199
209
|
}
|
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')
|
|
@@ -183,7 +184,9 @@ robotProgram
|
|
|
183
184
|
await roboparkRobotRuntime({
|
|
184
185
|
name: opts.name,
|
|
185
186
|
hubUrl: opts.hubUrl,
|
|
186
|
-
schedulerUrl: opts.schedulerUrl ??
|
|
187
|
+
schedulerUrl: opts.schedulerUrl ?? (opts.tailscale
|
|
188
|
+
? `${opts.hubUrl.replace(/\/+$/, '')}/robopark`
|
|
189
|
+
: `http://${hub.hostname}:8080`),
|
|
187
190
|
token: opts.token,
|
|
188
191
|
port: opts.port,
|
|
189
192
|
network: opts.tailscale ? 'tailscale' : 'lan',
|
package/package.json
CHANGED
|
@@ -43,6 +43,21 @@ ROBOPARK_AGENT_TOKEN_FILE = os.getenv(
|
|
|
43
43
|
"ROBOPARK_AGENT_TOKEN_FILE",
|
|
44
44
|
os.path.join(os.getenv("SCHEDULER_DATA_DIR", "/app/data"), ".robopark-agent-token"),
|
|
45
45
|
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _load_mesh_token() -> str:
|
|
49
|
+
"""Load the hub credential shared with unified robot runtimes."""
|
|
50
|
+
configured = os.getenv("ROBOPARK_MESH_TOKEN", "").strip()
|
|
51
|
+
if configured:
|
|
52
|
+
return configured
|
|
53
|
+
try:
|
|
54
|
+
with open(os.path.expanduser("~/.robopark/mesh.token"), "r", encoding="utf8") as handle:
|
|
55
|
+
return handle.read().strip()
|
|
56
|
+
except OSError:
|
|
57
|
+
return ""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
ROBOPARK_MESH_TOKEN = _load_mesh_token()
|
|
46
61
|
|
|
47
62
|
# ElevenLabs credentials used by the /api/voices/elevenlabs endpoint. We accept
|
|
48
63
|
# both the long-form ELEVENLABS_API_KEY and a shorter ELEVENLABS_KEY alias so
|
|
@@ -180,7 +195,7 @@ class DeviceCreate(BaseModel):
|
|
|
180
195
|
notes: Optional[str] = None
|
|
181
196
|
enrollment_token: Optional[str] = None # if omitted, one is auto-generated
|
|
182
197
|
|
|
183
|
-
class DeviceEnrollRequest(BaseModel):
|
|
198
|
+
class DeviceEnrollRequest(BaseModel):
|
|
184
199
|
enrollment_token: str
|
|
185
200
|
name: Optional[str] = None
|
|
186
201
|
tailscale_ip: Optional[str] = None
|
|
@@ -188,8 +203,13 @@ class DeviceEnrollRequest(BaseModel):
|
|
|
188
203
|
motor_server_url: Optional[str] = None
|
|
189
204
|
livekit_url: Optional[str] = None
|
|
190
205
|
character_id: Optional[str] = None
|
|
191
|
-
video_device: Optional[str] = None
|
|
192
|
-
audio_device: Optional[str] = None
|
|
206
|
+
video_device: Optional[str] = None
|
|
207
|
+
audio_device: Optional[str] = None
|
|
208
|
+
|
|
209
|
+
class DeviceBootstrapRequest(BaseModel):
|
|
210
|
+
name: str
|
|
211
|
+
lan_ip: Optional[str] = None
|
|
212
|
+
tailscale_ip: Optional[str] = None
|
|
193
213
|
|
|
194
214
|
class DeviceEnrollResponse(BaseModel):
|
|
195
215
|
device_id: str
|
|
@@ -2343,7 +2363,7 @@ async def rotate_device_token(device_id: str):
|
|
|
2343
2363
|
await _log_history("device", "device", device_id, "token_rotated", "operator")
|
|
2344
2364
|
return {"device_id": device_id, "device_token": new_token}
|
|
2345
2365
|
|
|
2346
|
-
async def _authorize_device(device_id: str, authorization: Optional[str]) -> bool:
|
|
2366
|
+
async def _authorize_device(device_id: str, authorization: Optional[str]) -> bool:
|
|
2347
2367
|
"""Verify Bearer token matches stored hash for this device."""
|
|
2348
2368
|
if not authorization or not authorization.lower().startswith("bearer "):
|
|
2349
2369
|
return False
|
|
@@ -2351,9 +2371,18 @@ async def _authorize_device(device_id: str, authorization: Optional[str]) -> boo
|
|
|
2351
2371
|
async with aiosqlite.connect(DB_PATH) as db:
|
|
2352
2372
|
async with db.execute("SELECT token_hash FROM devices WHERE id = ?", (device_id,)) as c:
|
|
2353
2373
|
row = await c.fetchone()
|
|
2354
|
-
if not row or not row[0]:
|
|
2355
|
-
return False
|
|
2356
|
-
return hmac.compare_digest(row[0], _hash(token))
|
|
2374
|
+
if not row or not row[0]:
|
|
2375
|
+
return False
|
|
2376
|
+
return hmac.compare_digest(row[0], _hash(token))
|
|
2377
|
+
|
|
2378
|
+
|
|
2379
|
+
def _authorize_mesh_bootstrap(presented: Optional[str]) -> bool:
|
|
2380
|
+
"""Authenticate bootstrap before a device-scoped token exists."""
|
|
2381
|
+
return bool(
|
|
2382
|
+
ROBOPARK_MESH_TOKEN
|
|
2383
|
+
and presented
|
|
2384
|
+
and hmac.compare_digest(ROBOPARK_MESH_TOKEN, presented.strip())
|
|
2385
|
+
)
|
|
2357
2386
|
|
|
2358
2387
|
async def _log_history(category: str, entity_type: Optional[str] = None, entity_id: Optional[str] = None,
|
|
2359
2388
|
action: Optional[str] = None, actor: Optional[str] = None, details: Optional[str] = None):
|
|
@@ -2430,8 +2459,61 @@ async def _authorize_fleet(authorization: Optional[str]) -> bool:
|
|
|
2430
2459
|
rows = await c.fetchall()
|
|
2431
2460
|
return any(r[0] and hmac.compare_digest(r[0], token_hash) for r in rows)
|
|
2432
2461
|
|
|
2433
|
-
@app.post("/api/devices/
|
|
2434
|
-
async def
|
|
2462
|
+
@app.post("/api/devices/bootstrap", response_model=DeviceEnrollResponse)
|
|
2463
|
+
async def bootstrap_device(
|
|
2464
|
+
payload: DeviceBootstrapRequest,
|
|
2465
|
+
x_robopark_mesh_token: Optional[str] = Header(default=None),
|
|
2466
|
+
):
|
|
2467
|
+
"""Create or recover one scheduler identity for a mesh-authenticated robot."""
|
|
2468
|
+
if not _authorize_mesh_bootstrap(x_robopark_mesh_token):
|
|
2469
|
+
raise HTTPException(401, "Invalid or missing RoboPark mesh token")
|
|
2470
|
+
name = payload.name.strip()
|
|
2471
|
+
if not name:
|
|
2472
|
+
raise HTTPException(400, "name is required")
|
|
2473
|
+
|
|
2474
|
+
new_token = secrets.token_urlsafe(32)
|
|
2475
|
+
new_hash = _hash(new_token)
|
|
2476
|
+
now = datetime.utcnow().isoformat()
|
|
2477
|
+
async with aiosqlite.connect(DB_PATH) as db:
|
|
2478
|
+
db.row_factory = aiosqlite.Row
|
|
2479
|
+
async with db.execute(
|
|
2480
|
+
"""SELECT id FROM devices WHERE lower(name) = lower(?)
|
|
2481
|
+
ORDER BY CASE lower(status) WHEN 'online' THEN 0 WHEN 'running' THEN 1 ELSE 2 END,
|
|
2482
|
+
last_heartbeat DESC, enrolled_at DESC LIMIT 1""",
|
|
2483
|
+
(name,),
|
|
2484
|
+
) as cursor:
|
|
2485
|
+
existing = await cursor.fetchone()
|
|
2486
|
+
if existing:
|
|
2487
|
+
device_id = existing["id"]
|
|
2488
|
+
await db.execute(
|
|
2489
|
+
"""UPDATE devices SET token_hash = ?, status = 'enrolled',
|
|
2490
|
+
lan_ip = COALESCE(?, lan_ip), tailscale_ip = COALESCE(?, tailscale_ip),
|
|
2491
|
+
enrolled_at = COALESCE(enrolled_at, ?) WHERE id = ?""",
|
|
2492
|
+
(new_hash, payload.lan_ip, payload.tailscale_ip, now, device_id),
|
|
2493
|
+
)
|
|
2494
|
+
else:
|
|
2495
|
+
device_id = f"dev_{secrets.token_hex(4)}"
|
|
2496
|
+
await db.execute(
|
|
2497
|
+
"""INSERT INTO devices
|
|
2498
|
+
(id, name, lan_ip, tailscale_ip, token_hash, status, enrolled_at, created_at)
|
|
2499
|
+
VALUES (?, ?, ?, ?, ?, 'enrolled', ?, ?)""",
|
|
2500
|
+
(device_id, name, payload.lan_ip, payload.tailscale_ip, new_hash, now, now),
|
|
2501
|
+
)
|
|
2502
|
+
await db.execute(
|
|
2503
|
+
"INSERT OR IGNORE INTO robots (id, name, status) VALUES (?, ?, 'idle')",
|
|
2504
|
+
(device_id, name),
|
|
2505
|
+
)
|
|
2506
|
+
await db.execute("UPDATE robots SET name = ? WHERE id = ?", (name, device_id))
|
|
2507
|
+
await db.commit()
|
|
2508
|
+
|
|
2509
|
+
await broadcast("device_bootstrapped", {"device_id": device_id, "name": name})
|
|
2510
|
+
await _log_history("device", "device", device_id, "bootstrapped", "mesh", f"name={name}")
|
|
2511
|
+
scheduler_url = os.getenv("SCHEDULER_PUBLIC_URL", f"http://localhost:{os.getenv('SCHEDULER_PORT', '8080')}")
|
|
2512
|
+
return DeviceEnrollResponse(device_id=device_id, device_token=new_token, scheduler_url=scheduler_url)
|
|
2513
|
+
|
|
2514
|
+
|
|
2515
|
+
@app.post("/api/devices/enroll", response_model=DeviceEnrollResponse)
|
|
2516
|
+
async def enroll_device(payload: DeviceEnrollRequest):
|
|
2435
2517
|
"""First-boot enrollment for a Pi. Requires production_mode = true and a valid enrollment token.
|
|
2436
2518
|
Returns a long-lived device_token the Pi will use for heartbeats."""
|
|
2437
2519
|
if not await get_production_mode():
|
|
@@ -68,7 +68,7 @@ logger = logging.getLogger("robopark.preview_agent")
|
|
|
68
68
|
|
|
69
69
|
_DEVICE_INVENTORY_CACHE: Optional[dict] = None
|
|
70
70
|
_DEVICE_INVENTORY_CACHE_AT = 0.0
|
|
71
|
-
DEVICE_INVENTORY_CACHE_SECONDS =
|
|
71
|
+
DEVICE_INVENTORY_CACHE_SECONDS = 5.0
|
|
72
72
|
ROBOVISION_MEDIA_URL = os.getenv("ROBOVISION_MEDIA_URL", "http://127.0.0.1:5000/api/media/inventory")
|
|
73
73
|
|
|
74
74
|
|
|
@@ -256,13 +256,48 @@ def _load_token() -> Optional[str]:
|
|
|
256
256
|
return None
|
|
257
257
|
|
|
258
258
|
|
|
259
|
-
def _save_token(token: str) -> None:
|
|
260
|
-
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
261
|
-
TOKEN_FILE.write_text(token, encoding="utf8")
|
|
262
|
-
os.chmod(TOKEN_FILE, 0o600)
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
259
|
+
def _save_token(token: str) -> None:
|
|
260
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
261
|
+
TOKEN_FILE.write_text(token, encoding="utf8")
|
|
262
|
+
os.chmod(TOKEN_FILE, 0o600)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _mesh_proxy_headers() -> dict:
|
|
266
|
+
"""Add hub authentication only when the runtime supplied it.
|
|
267
|
+
|
|
268
|
+
Direct LAN scheduler calls work unchanged. Tailscale calls use the hub's
|
|
269
|
+
/robopark proxy, which needs this separate mesh credential while the normal
|
|
270
|
+
Authorization header remains the scheduler device token.
|
|
271
|
+
"""
|
|
272
|
+
token = os.getenv("ROBOPARK_MESH_TOKEN", "").strip()
|
|
273
|
+
return {"X-RoboPark-Mesh-Token": token} if token else {}
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
async def _bootstrap_mesh_device(scheduler_url: str, robot_id: str) -> tuple[str, str]:
|
|
277
|
+
"""Create or recover this robot's scheduler identity using mesh auth."""
|
|
278
|
+
async with httpx.AsyncClient(headers=_mesh_proxy_headers()) as client:
|
|
279
|
+
response = await client.post(
|
|
280
|
+
f"{scheduler_url.rstrip('/')}/api/devices/bootstrap",
|
|
281
|
+
json={"name": robot_id, "lan_ip": _get_lan_ip()},
|
|
282
|
+
timeout=30.0,
|
|
283
|
+
)
|
|
284
|
+
response.raise_for_status()
|
|
285
|
+
data = response.json()
|
|
286
|
+
device_id = str(data.get("device_id") or "").strip()
|
|
287
|
+
device_token = str(data.get("device_token") or "").strip()
|
|
288
|
+
if not device_id or not device_token:
|
|
289
|
+
raise RuntimeError("mesh bootstrap did not return device credentials")
|
|
290
|
+
_save_token(device_token)
|
|
291
|
+
cfg = _load_config()
|
|
292
|
+
cfg["device_id"] = device_id
|
|
293
|
+
cfg["device_token"] = device_token
|
|
294
|
+
cfg["scheduler_url"] = scheduler_url
|
|
295
|
+
_save_config(cfg)
|
|
296
|
+
logger.info("mesh bootstrap resolved scheduler device %s", device_id)
|
|
297
|
+
return device_id, device_token
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
async def _enroll(scheduler_url: str, enrollment_token: str, robot_id: str) -> str:
|
|
266
301
|
"""Enroll this Pi with the scheduler and return the device token."""
|
|
267
302
|
import socket
|
|
268
303
|
payload = {
|
|
@@ -270,7 +305,7 @@ async def _enroll(scheduler_url: str, enrollment_token: str, robot_id: str) -> s
|
|
|
270
305
|
"name": robot_id,
|
|
271
306
|
"lan_ip": _get_lan_ip(),
|
|
272
307
|
}
|
|
273
|
-
async with httpx.AsyncClient() as client:
|
|
308
|
+
async with httpx.AsyncClient(headers=_mesh_proxy_headers()) as client:
|
|
274
309
|
r = await client.post(
|
|
275
310
|
f"{scheduler_url.rstrip('/')}/api/devices/enroll",
|
|
276
311
|
json=payload,
|
|
@@ -302,16 +337,26 @@ def _get_lan_ip() -> Optional[str]:
|
|
|
302
337
|
return None
|
|
303
338
|
|
|
304
339
|
|
|
305
|
-
async def _send_heartbeat(
|
|
340
|
+
async def _send_heartbeat(
|
|
341
|
+
scheduler_url: str, device_id: str, token: str
|
|
342
|
+
) -> tuple[Optional[bool], bool]:
|
|
306
343
|
try:
|
|
307
344
|
inventory = _get_device_inventory()
|
|
345
|
+
headers = {"Authorization": f"Bearer {token}", **_mesh_proxy_headers()}
|
|
308
346
|
async with httpx.AsyncClient() as client:
|
|
309
347
|
response = await client.post(
|
|
310
348
|
f"{scheduler_url.rstrip('/')}/api/devices/{device_id}/heartbeat",
|
|
311
349
|
json={"status": "online", "ip": _get_lan_ip(), "device_inventory": inventory},
|
|
312
|
-
headers=
|
|
350
|
+
headers=headers,
|
|
313
351
|
timeout=10.0,
|
|
314
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
|
|
315
360
|
response.raise_for_status()
|
|
316
361
|
logger.info(
|
|
317
362
|
"heartbeat accepted: inventory source=%s camera=%d mic=%d speaker=%d",
|
|
@@ -320,10 +365,10 @@ async def _send_heartbeat(scheduler_url: str, device_id: str, token: str) -> Opt
|
|
|
320
365
|
len(inventory.get("audio_input", [])),
|
|
321
366
|
len(inventory.get("audio_output", [])),
|
|
322
367
|
)
|
|
323
|
-
return bool(response.json().get("production_mode", False))
|
|
368
|
+
return bool(response.json().get("production_mode", False)), True
|
|
324
369
|
except Exception as e:
|
|
325
370
|
logger.warning(f"heartbeat failed: {e}")
|
|
326
|
-
return None
|
|
371
|
+
return None, True
|
|
327
372
|
|
|
328
373
|
|
|
329
374
|
@dataclass
|
|
@@ -384,19 +429,43 @@ class PreviewAgent:
|
|
|
384
429
|
self._last_device_config_poll = 0.0
|
|
385
430
|
self._motion_reference = None
|
|
386
431
|
self._last_motion_sample = 0.0
|
|
387
|
-
self._motion_capture: Optional[VideoCapture] = None
|
|
388
|
-
self._motion_capture_lock = asyncio.Lock()
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
+
|
|
456
|
+
async def run(self) -> None:
|
|
457
|
+
enrollment_token = os.getenv("ENROLLMENT_TOKEN") or self.enrollment_token
|
|
458
|
+
if _mesh_proxy_headers():
|
|
459
|
+
await self._ensure_mesh_identity()
|
|
460
|
+
|
|
461
|
+
if not self.device_token and enrollment_token:
|
|
393
462
|
self.device_token = await _enroll(self.scheduler_url, enrollment_token, self.robot_id)
|
|
394
463
|
|
|
395
464
|
if not self.device_token:
|
|
396
465
|
logger.error("no DEVICE_TOKEN and no ENROLLMENT_TOKEN; cannot poll scheduler")
|
|
397
466
|
sys.exit(1)
|
|
398
467
|
|
|
399
|
-
self._session = httpx.AsyncClient()
|
|
468
|
+
self._session = httpx.AsyncClient(headers=_mesh_proxy_headers())
|
|
400
469
|
|
|
401
470
|
# Must come after self._session exists — _resolve_device_id() guards
|
|
402
471
|
# on it and silently no-ops otherwise. On a fresh enroll (no cached
|
|
@@ -703,13 +772,18 @@ class PreviewAgent:
|
|
|
703
772
|
await asyncio.to_thread(_play_audio_effect, self.audio_output_device, "disconnect")
|
|
704
773
|
self._current_state = PreviewState()
|
|
705
774
|
|
|
706
|
-
async def _heartbeat_loop(self) -> None:
|
|
707
|
-
while not self._shutdown.is_set():
|
|
708
|
-
if
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
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:
|
|
713
787
|
self.production_mode = production_mode
|
|
714
788
|
if not production_mode and self._vision_session_id:
|
|
715
789
|
await self._end_vision_session()
|
|
@@ -1630,7 +1704,9 @@ def main() -> None:
|
|
|
1630
1704
|
"video_device": args.video_device,
|
|
1631
1705
|
"audio_device": args.audio_device,
|
|
1632
1706
|
"robovision_url": args.robovision_url or os.getenv("ROBOVISION_URL", "http://127.0.0.1:5000"),
|
|
1633
|
-
"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"),
|
|
1634
1710
|
"video_width": args.width,
|
|
1635
1711
|
"video_height": args.height,
|
|
1636
1712
|
"video_fps": args.fps,
|