infinicode 2.8.54 → 2.8.57
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/cli.js +1 -0
- package/dist/commands/serve.d.ts +1 -0
- package/dist/commands/serve.js +1 -0
- package/dist/kernel/config-schema.d.ts +2 -0
- package/dist/kernel/federation/dashboard-html.d.ts +1 -1
- package/dist/kernel/federation/dashboard-html.js +40 -16
- package/dist/kernel/federation/federation.d.ts +5 -0
- package/dist/kernel/federation/federation.js +41 -2
- package/dist/kernel/federation/peer-mesh.d.ts +3 -1
- package/dist/kernel/federation/peer-mesh.js +16 -0
- 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 +219 -0
- package/dist/robopark/setup.js +25 -112
- package/dist/robopark/stop-all.js +3 -0
- 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/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',
|
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
|
@@ -1881,10 +1881,13 @@ async def robot_stream(robot_id: str,
|
|
|
1881
1881
|
if not await get_production_mode():
|
|
1882
1882
|
return {"active": False, "reason": "production_mode_off"}
|
|
1883
1883
|
|
|
1884
|
-
async with aiosqlite.connect(DB_PATH) as db:
|
|
1885
|
-
db.row_factory = aiosqlite.Row
|
|
1886
|
-
|
|
1887
|
-
|
|
1884
|
+
async with aiosqlite.connect(DB_PATH) as db:
|
|
1885
|
+
db.row_factory = aiosqlite.Row
|
|
1886
|
+
resolved_id = await _resolve_robot_reference(db, robot_id)
|
|
1887
|
+
if not resolved_id:
|
|
1888
|
+
raise HTTPException(404, "Robot not found")
|
|
1889
|
+
async with db.execute("SELECT * FROM robots WHERE id = ?", (resolved_id,)) as c:
|
|
1890
|
+
robot = await c.fetchone()
|
|
1888
1891
|
if not robot:
|
|
1889
1892
|
raise HTTPException(404, "Robot not found")
|
|
1890
1893
|
if not robot["current_session_id"]:
|
|
@@ -1909,7 +1912,7 @@ async def robot_stream(robot_id: str,
|
|
|
1909
1912
|
except ImportError:
|
|
1910
1913
|
raise HTTPException(503, "livekit-api not installed on scheduler")
|
|
1911
1914
|
|
|
1912
|
-
identity = f"viewer:{
|
|
1915
|
+
identity = f"viewer:{resolved_id}:{int(datetime.utcnow().timestamp())}"
|
|
1913
1916
|
grants = VideoGrants(
|
|
1914
1917
|
room=room_name,
|
|
1915
1918
|
room_join=True,
|
|
@@ -1947,7 +1950,7 @@ async def robot_stream(robot_id: str,
|
|
|
1947
1950
|
PREVIEW_TTL_SECONDS = 45 # preview auto-expires this long after the last keepalive
|
|
1948
1951
|
DEFAULT_VISION_PORT = 5000 # RoboVisionAI_PI's default Flask port (/video_feed, /api/motion/*)
|
|
1949
1952
|
|
|
1950
|
-
async def _mirror_device_to_robot(db, device_id: str):
|
|
1953
|
+
async def _mirror_device_to_robot(db, device_id: str):
|
|
1951
1954
|
"""Ensure a `robots` row exists for an enrolled device, so id-keyed routes
|
|
1952
1955
|
(/stream, /preview/*) work even before the device's first real voice
|
|
1953
1956
|
session (device_request_session normally creates this mirror, but only on
|
|
@@ -1965,8 +1968,30 @@ async def _mirror_device_to_robot(db, device_id: str):
|
|
|
1965
1968
|
(device_id, device["name"] or device_id, device["character_id"]),
|
|
1966
1969
|
)
|
|
1967
1970
|
await db.commit()
|
|
1968
|
-
async with db.execute("SELECT * FROM robots WHERE id = ?", (device_id,)) as c:
|
|
1969
|
-
return await c.fetchone()
|
|
1971
|
+
async with db.execute("SELECT * FROM robots WHERE id = ?", (device_id,)) as c:
|
|
1972
|
+
return await c.fetchone()
|
|
1973
|
+
|
|
1974
|
+
async def _resolve_robot_reference(db, robot_ref: str) -> Optional[str]:
|
|
1975
|
+
"""Resolve a display name to its active enrolled device id when needed."""
|
|
1976
|
+
async with db.execute("SELECT id FROM devices WHERE id = ?", (robot_ref,)) as c:
|
|
1977
|
+
row = await c.fetchone()
|
|
1978
|
+
if row:
|
|
1979
|
+
return row["id"]
|
|
1980
|
+
async with db.execute(
|
|
1981
|
+
"""SELECT id FROM devices WHERE lower(name) = lower(?)
|
|
1982
|
+
ORDER BY CASE lower(status) WHEN 'online' THEN 0 WHEN 'running' THEN 1 ELSE 2 END,
|
|
1983
|
+
last_heartbeat DESC, enrolled_at DESC LIMIT 1""",
|
|
1984
|
+
(robot_ref,),
|
|
1985
|
+
) as c:
|
|
1986
|
+
row = await c.fetchone()
|
|
1987
|
+
if row:
|
|
1988
|
+
return row["id"]
|
|
1989
|
+
async with db.execute(
|
|
1990
|
+
"SELECT id FROM robots WHERE id = ? OR lower(name) = lower(?) LIMIT 1",
|
|
1991
|
+
(robot_ref, robot_ref),
|
|
1992
|
+
) as c:
|
|
1993
|
+
row = await c.fetchone()
|
|
1994
|
+
return row["id"] if row else None
|
|
1970
1995
|
|
|
1971
1996
|
async def _pick_preview_server(db, prefer_id: Optional[str] = None):
|
|
1972
1997
|
"""Return a LiveKit server row for a preview: a preferred one if still present,
|
|
@@ -2010,15 +2035,18 @@ def _mint_lk(server, room, identity, name, publish: bool, ttl_min: int):
|
|
|
2010
2035
|
)
|
|
2011
2036
|
|
|
2012
2037
|
@app.post("/api/robots/{robot_id}/preview/start")
|
|
2013
|
-
async def preview_start(robot_id: str):
|
|
2038
|
+
async def preview_start(robot_id: str):
|
|
2014
2039
|
"""Operator dashboard: begin an on-demand live camera preview for a robot.
|
|
2015
2040
|
Returns a SUBSCRIBE-only viewer token + the preview room."""
|
|
2016
|
-
if not await get_production_mode():
|
|
2017
|
-
return {"active": False, "reason": "production_mode_off"}
|
|
2018
|
-
async with aiosqlite.connect(DB_PATH) as db:
|
|
2019
|
-
db.row_factory = aiosqlite.Row
|
|
2020
|
-
|
|
2021
|
-
|
|
2041
|
+
if not await get_production_mode():
|
|
2042
|
+
return {"active": False, "reason": "production_mode_off"}
|
|
2043
|
+
async with aiosqlite.connect(DB_PATH) as db:
|
|
2044
|
+
db.row_factory = aiosqlite.Row
|
|
2045
|
+
robot_id = await _resolve_robot_reference(db, robot_id)
|
|
2046
|
+
if not robot_id:
|
|
2047
|
+
raise HTTPException(404, "Robot not found")
|
|
2048
|
+
if not await _mirror_device_to_robot(db, robot_id):
|
|
2049
|
+
raise HTTPException(404, "Robot not found")
|
|
2022
2050
|
async with db.execute("SELECT * FROM previews WHERE robot_id = ?", (robot_id,)) as c:
|
|
2023
2051
|
existing = await c.fetchone()
|
|
2024
2052
|
server = await _pick_preview_server(db, existing["server_id"] if existing else None)
|
|
@@ -2043,11 +2071,15 @@ async def preview_start(robot_id: str):
|
|
|
2043
2071
|
"identity": identity, "expires_in": PREVIEW_TTL_SECONDS}
|
|
2044
2072
|
|
|
2045
2073
|
@app.post("/api/robots/{robot_id}/preview/keepalive")
|
|
2046
|
-
async def preview_keepalive(robot_id: str):
|
|
2047
|
-
"""Dashboard pings while the operator is watching, to keep the preview alive."""
|
|
2048
|
-
expires = (datetime.utcnow() + timedelta(seconds=PREVIEW_TTL_SECONDS)).isoformat()
|
|
2049
|
-
async with aiosqlite.connect(DB_PATH) as db:
|
|
2050
|
-
|
|
2074
|
+
async def preview_keepalive(robot_id: str):
|
|
2075
|
+
"""Dashboard pings while the operator is watching, to keep the preview alive."""
|
|
2076
|
+
expires = (datetime.utcnow() + timedelta(seconds=PREVIEW_TTL_SECONDS)).isoformat()
|
|
2077
|
+
async with aiosqlite.connect(DB_PATH) as db:
|
|
2078
|
+
db.row_factory = aiosqlite.Row
|
|
2079
|
+
robot_id = await _resolve_robot_reference(db, robot_id)
|
|
2080
|
+
if not robot_id:
|
|
2081
|
+
return {"active": False}
|
|
2082
|
+
cur = await db.execute("UPDATE previews SET expires_at = ? WHERE robot_id = ?", (expires, robot_id))
|
|
2051
2083
|
await db.commit()
|
|
2052
2084
|
if cur.rowcount == 0:
|
|
2053
2085
|
return {"active": False}
|
|
@@ -2055,10 +2087,14 @@ async def preview_keepalive(robot_id: str):
|
|
|
2055
2087
|
return {"active": True, "expires_in": PREVIEW_TTL_SECONDS}
|
|
2056
2088
|
|
|
2057
2089
|
@app.post("/api/robots/{robot_id}/preview/stop")
|
|
2058
|
-
async def preview_stop(robot_id: str):
|
|
2059
|
-
"""Dashboard closed / operator stopped watching -> end the preview now."""
|
|
2060
|
-
async with aiosqlite.connect(DB_PATH) as db:
|
|
2061
|
-
|
|
2090
|
+
async def preview_stop(robot_id: str):
|
|
2091
|
+
"""Dashboard closed / operator stopped watching -> end the preview now."""
|
|
2092
|
+
async with aiosqlite.connect(DB_PATH) as db:
|
|
2093
|
+
db.row_factory = aiosqlite.Row
|
|
2094
|
+
robot_id = await _resolve_robot_reference(db, robot_id)
|
|
2095
|
+
if not robot_id:
|
|
2096
|
+
return {"stopped": False}
|
|
2097
|
+
await db.execute("DELETE FROM previews WHERE robot_id = ?", (robot_id,))
|
|
2062
2098
|
await db.commit()
|
|
2063
2099
|
await _log_history("preview", "robot", robot_id, "stop", "operator")
|
|
2064
2100
|
return {"stopped": True}
|
|
@@ -2068,11 +2104,14 @@ async def preview_agent(robot_id: str, authorization: Optional[str] = Header(def
|
|
|
2068
2104
|
"""The ROBOT polls this. While an operator preview is active it returns a
|
|
2069
2105
|
PUBLISH token + room so the Pi opens its cam and joins; otherwise
|
|
2070
2106
|
{active:false} so the Pi stops publishing. Enrolled-device auth."""
|
|
2071
|
-
if not await _authorize_fleet(authorization):
|
|
2072
|
-
raise HTTPException(401, "Invalid or missing device token")
|
|
2073
|
-
async with aiosqlite.connect(DB_PATH) as db:
|
|
2074
|
-
db.row_factory = aiosqlite.Row
|
|
2075
|
-
|
|
2107
|
+
if not await _authorize_fleet(authorization):
|
|
2108
|
+
raise HTTPException(401, "Invalid or missing device token")
|
|
2109
|
+
async with aiosqlite.connect(DB_PATH) as db:
|
|
2110
|
+
db.row_factory = aiosqlite.Row
|
|
2111
|
+
robot_id = await _resolve_robot_reference(db, robot_id)
|
|
2112
|
+
if not robot_id:
|
|
2113
|
+
return {"active": False}
|
|
2114
|
+
async with db.execute("SELECT * FROM previews WHERE robot_id = ?", (robot_id,)) as c:
|
|
2076
2115
|
row = await c.fetchone()
|
|
2077
2116
|
if not _preview_active(row):
|
|
2078
2117
|
return {"active": False}
|
|
@@ -302,19 +302,27 @@ def _get_lan_ip() -> Optional[str]:
|
|
|
302
302
|
return None
|
|
303
303
|
|
|
304
304
|
|
|
305
|
-
async def _send_heartbeat(scheduler_url: str, device_id: str, token: str) -> Optional[bool]:
|
|
306
|
-
try:
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
305
|
+
async def _send_heartbeat(scheduler_url: str, device_id: str, token: str) -> Optional[bool]:
|
|
306
|
+
try:
|
|
307
|
+
inventory = _get_device_inventory()
|
|
308
|
+
async with httpx.AsyncClient() as client:
|
|
309
|
+
response = await client.post(
|
|
310
|
+
f"{scheduler_url.rstrip('/')}/api/devices/{device_id}/heartbeat",
|
|
311
|
+
json={"status": "online", "ip": _get_lan_ip(), "device_inventory": inventory},
|
|
312
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
313
|
+
timeout=10.0,
|
|
314
|
+
)
|
|
315
|
+
response.raise_for_status()
|
|
316
|
+
logger.info(
|
|
317
|
+
"heartbeat accepted: inventory source=%s camera=%d mic=%d speaker=%d",
|
|
318
|
+
inventory.get("source", "local"),
|
|
319
|
+
len(inventory.get("video", [])),
|
|
320
|
+
len(inventory.get("audio_input", [])),
|
|
321
|
+
len(inventory.get("audio_output", [])),
|
|
322
|
+
)
|
|
323
|
+
return bool(response.json().get("production_mode", False))
|
|
324
|
+
except Exception as e:
|
|
325
|
+
logger.warning(f"heartbeat failed: {e}")
|
|
318
326
|
return None
|
|
319
327
|
|
|
320
328
|
|