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.
@@ -9,12 +9,12 @@
9
9
  * robopark setup --control --start
10
10
  */
11
11
  import chalk from 'chalk';
12
- import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs';
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
- const previewAgentArgs = [
215
- 'preview-agent',
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
- '--robot-id', internal.name,
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
- previewAgentArgs.push('--enrollment-token', opts.enrollmentToken);
224
- // Vision (camera/motion detection -> presence-triggered session) is the
225
- // production trigger — on by default. --no-vision opts out (e.g. no camera
226
- // on this box, or RoboVisionAI_PI isn't installed).
227
- const visionEnabled = opts.vision !== false;
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 = await infinicodeArgv(args);
240
- console.log(chalk.dim(' starting robot satellite node…'));
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 = await infinicodeArgv(args);
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',
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinicode",
3
- "version": "2.8.54",
3
+ "version": "2.8.57",
4
4
  "description": "OpenKernel — provider-agnostic AI execution kernel. Native coding agent + mission-driven execution runtime.",
5
5
  "type": "module",
6
6
  "main": "./dist/kernel/index.js",
@@ -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
- async with db.execute("SELECT * FROM robots WHERE id = ?", (robot_id,)) as c:
1887
- robot = await c.fetchone()
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:{robot_id}:{int(datetime.utcnow().timestamp())}"
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
- if not await _mirror_device_to_robot(db, robot_id):
2021
- raise HTTPException(404, "Robot not found")
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
- cur = await db.execute("UPDATE previews SET expires_at = ? WHERE robot_id = ?", (expires, robot_id))
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
- await db.execute("DELETE FROM previews WHERE robot_id = ?", (robot_id,))
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
- async with db.execute("SELECT * FROM previews WHERE robot_id = ?", (robot_id,)) as c:
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
- async with httpx.AsyncClient() as client:
308
- response = await client.post(
309
- f"{scheduler_url.rstrip('/')}/api/devices/{device_id}/heartbeat",
310
- json={"status": "online", "ip": _get_lan_ip(), "device_inventory": _get_device_inventory()},
311
- headers={"Authorization": f"Bearer {token}"},
312
- timeout=10.0,
313
- )
314
- response.raise_for_status()
315
- return bool(response.json().get("production_mode", False))
316
- except Exception as e:
317
- logger.debug(f"heartbeat failed: {e}")
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