infinicode 2.8.7 → 2.8.9

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.
@@ -107,6 +107,8 @@ class Device(BaseModel):
107
107
  motor_server_url: Optional[str] = None
108
108
  character_id: Optional[str] = None
109
109
  livekit_url: Optional[str] = None
110
+ video_device: Optional[str] = None
111
+ audio_device: Optional[str] = None
110
112
  status: str = "enrolled" # enrolled, online, offline, disabled
111
113
  last_heartbeat: Optional[datetime] = None
112
114
  enrolled_at: Optional[datetime] = None
@@ -121,6 +123,8 @@ class DeviceCreate(BaseModel):
121
123
  motor_server_url: Optional[str] = None
122
124
  character_id: Optional[str] = None
123
125
  livekit_url: Optional[str] = None
126
+ video_device: Optional[str] = None
127
+ audio_device: Optional[str] = None
124
128
  notes: Optional[str] = None
125
129
  enrollment_token: Optional[str] = None # if omitted, one is auto-generated
126
130
 
@@ -132,6 +136,8 @@ class DeviceEnrollRequest(BaseModel):
132
136
  motor_server_url: Optional[str] = None
133
137
  livekit_url: Optional[str] = None
134
138
  character_id: Optional[str] = None
139
+ video_device: Optional[str] = None
140
+ audio_device: Optional[str] = None
135
141
 
136
142
  class DeviceEnrollResponse(BaseModel):
137
143
  device_id: str
@@ -235,6 +241,8 @@ async def init_db():
235
241
  motor_server_url TEXT,
236
242
  character_id TEXT,
237
243
  livekit_url TEXT,
244
+ video_device TEXT,
245
+ audio_device TEXT,
238
246
  token_hash TEXT,
239
247
  enrollment_token_hash TEXT,
240
248
  status TEXT DEFAULT 'enrolled',
@@ -258,6 +266,19 @@ async def init_db():
258
266
  expires_at TEXT NOT NULL,
259
267
  created_at TEXT DEFAULT CURRENT_TIMESTAMP
260
268
  );
269
+
270
+ CREATE TABLE IF NOT EXISTS history_log (
271
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
272
+ timestamp TEXT DEFAULT CURRENT_TIMESTAMP,
273
+ category TEXT NOT NULL,
274
+ entity_type TEXT,
275
+ entity_id TEXT,
276
+ action TEXT,
277
+ actor TEXT,
278
+ details TEXT
279
+ );
280
+ CREATE INDEX IF NOT EXISTS idx_history_category ON history_log(category);
281
+ CREATE INDEX IF NOT EXISTS idx_history_entity ON history_log(entity_type, entity_id);
261
282
  """)
262
283
  await db.commit()
263
284
 
@@ -269,6 +290,8 @@ async def init_db():
269
290
  for _table, _column, _coldef in (
270
291
  ("robots", "trigger_count", "INTEGER DEFAULT 0"),
271
292
  ("sessions", "joined_at", "TEXT"),
293
+ ("devices", "video_device", "TEXT"),
294
+ ("devices", "audio_device", "TEXT"),
272
295
  ):
273
296
  try:
274
297
  await db.execute(f"ALTER TABLE {_table} ADD COLUMN {_column} {_coldef}")
@@ -432,6 +455,7 @@ async def robot_heartbeat(robot_id: str, ip_address: Optional[str] = None):
432
455
  )
433
456
  await db.commit()
434
457
  await broadcast("robot_heartbeat", {"robot_id": robot_id})
458
+ await _log_history("robot", "robot", robot_id, "heartbeat", "robot", f"ip={ip_address}")
435
459
  return {"status": "ok"}
436
460
 
437
461
  @app.post("/api/robots/{robot_id}/request-session")
@@ -446,11 +470,22 @@ async def request_session(robot_id: str,
446
470
  raise HTTPException(401, "Invalid or missing device token")
447
471
  async with aiosqlite.connect(DB_PATH) as db:
448
472
  db.row_factory = aiosqlite.Row
449
-
450
- # Check robot exists and is idle
473
+
474
+ # Check robot exists and is idle; auto-create a robot row for an enrolled
475
+ # device that has not yet appeared in the robots table.
451
476
  async with db.execute("SELECT * FROM robots WHERE id = ?", (robot_id,)) as cursor:
452
477
  robot = await cursor.fetchone()
453
- if not robot:
478
+ if not robot:
479
+ async with db.execute("SELECT name FROM devices WHERE id = ?", (robot_id,)) as dc:
480
+ device_row = await dc.fetchone()
481
+ if device_row:
482
+ await db.execute(
483
+ "INSERT OR IGNORE INTO robots (id, name, status) VALUES (?, ?, 'idle')",
484
+ (robot_id, device_row["name"]),
485
+ )
486
+ await db.commit()
487
+ robot = {"id": robot_id, "name": device_row["name"], "status": "idle"}
488
+ else:
454
489
  raise HTTPException(404, "Robot not found")
455
490
  if robot["status"] != "idle":
456
491
  raise HTTPException(400, f"Robot is {robot['status']}, not idle")
@@ -498,6 +533,8 @@ async def request_session(robot_id: str,
498
533
  "server_id": server["id"],
499
534
  "session_id": session_id
500
535
  })
536
+ await _log_history("session", "robot", robot_id, "requested", "robot", f"session_id={session_id}, server={server['id']}, room={room_name}")
537
+
501
538
 
502
539
  # Mint a short-lived LiveKit token so the robot can join the room without
503
540
  # ever receiving the raw api_key/api_secret.
@@ -569,6 +606,7 @@ async def end_session(robot_id: str, reason: str = "silence"):
569
606
  await db.commit()
570
607
 
571
608
  await broadcast("session_ended", {"robot_id": robot_id, "reason": reason})
609
+ await _log_history("session", "robot", robot_id, "session_ended", "robot", f"reason={reason}, session_id={robot.get('current_session_id')}")
572
610
  return {"status": "ok"}
573
611
 
574
612
  @app.post("/api/robots/{robot_id}/trigger")
@@ -597,6 +635,7 @@ async def robot_trigger(robot_id: str,
597
635
  async with db.execute("SELECT trigger_count FROM robots WHERE id = ?", (robot_id,)) as c:
598
636
  row = await c.fetchone()
599
637
  await broadcast("robot_triggered", {"robot_id": robot_id})
638
+ await _log_history("robot", "robot", robot_id, "trigger", "robot", f"trigger_count={row[0] if row else None}")
600
639
  return {"status": "ok", "trigger_count": row[0] if row else None}
601
640
 
602
641
  # =============================================================================
@@ -629,6 +668,7 @@ async def add_server(server: LiveKitServer):
629
668
  server.gpu_vram_mb, server.max_sessions, server.status))
630
669
  await db.commit()
631
670
  await broadcast("server_added", {"server_id": server.id})
671
+ await _log_history("server", "server", server.id, "added", "operator", f"name={server.name}, url={server.url}")
632
672
  return {"status": "ok"}
633
673
 
634
674
  @app.delete("/api/servers/{server_id}")
@@ -638,6 +678,7 @@ async def remove_server(server_id: str):
638
678
  await db.execute("DELETE FROM livekit_servers WHERE id = ?", (server_id,))
639
679
  await db.commit()
640
680
  await broadcast("server_removed", {"server_id": server_id})
681
+ await _log_history("server", "server", server_id, "removed", "operator")
641
682
  return {"status": "ok"}
642
683
 
643
684
  @app.get("/api/servers/{server_id}/metrics")
@@ -805,6 +846,7 @@ async def end_web_session(session_id: str, reason: str = "disconnect"):
805
846
  await db.commit()
806
847
 
807
848
  await broadcast("session_ended", {"session_id": session_id, "reason": reason})
849
+ await _log_history("session", "session", session_id, "ended", "operator/web", f"reason={reason}, duration={duration}")
808
850
  return {"status": "ok"}
809
851
 
810
852
  def _session_latency_ms(started_at: Optional[str], joined_at: Optional[str]) -> Optional[int]:
@@ -846,6 +888,7 @@ async def session_joined(session_id: str,
846
888
  await db.commit()
847
889
  latency_ms = _session_latency_ms(session["started_at"], now)
848
890
  await broadcast("session_joined", {"session_id": session_id, "latency_ms": latency_ms})
891
+ await _log_history("session", "session", session_id, "joined", "robot", f"latency_ms={latency_ms}")
849
892
  return {"status": "ok", "joined_at": now, "latency_ms": latency_ms}
850
893
 
851
894
  # ── Back-compat: legacy web-client session token (ported from the deployed OLD
@@ -1089,6 +1132,7 @@ async def update_settings(payload: SettingsPayload):
1089
1132
  """Update scheduler settings (production mode, etc.)."""
1090
1133
  await set_setting("production_mode", "true" if payload.production_mode else "false")
1091
1134
  await broadcast("settings_changed", {"production_mode": payload.production_mode})
1135
+ await _log_history("settings", "settings", "production_mode", "updated", "operator", f"value={payload.production_mode}")
1092
1136
  return {"status": "ok", "production_mode": payload.production_mode}
1093
1137
 
1094
1138
  @app.post("/api/settings/enrollment-token/rotate")
@@ -1097,8 +1141,28 @@ async def rotate_enrollment_token():
1097
1141
  token = secrets.token_urlsafe(24)
1098
1142
  await set_setting("default_enrollment_token", _hash(token))
1099
1143
  logger.info("Default enrollment token rotated")
1144
+ await _log_history("settings", "settings", "default_enrollment_token", "rotated", "operator")
1100
1145
  return {"enrollment_token": token}
1101
1146
 
1147
+ @app.get("/api/history")
1148
+ async def get_history(category: Optional[str] = None, limit: int = 200):
1149
+ """Audit / history log for devices, robots, sessions, previews and settings."""
1150
+ async with aiosqlite.connect(DB_PATH) as db:
1151
+ db.row_factory = aiosqlite.Row
1152
+ if category:
1153
+ async with db.execute(
1154
+ "SELECT * FROM history_log WHERE category = ? ORDER BY timestamp DESC LIMIT ?",
1155
+ (category, limit),
1156
+ ) as c:
1157
+ rows = await c.fetchall()
1158
+ else:
1159
+ async with db.execute(
1160
+ "SELECT * FROM history_log ORDER BY timestamp DESC LIMIT ?",
1161
+ (limit,),
1162
+ ) as c:
1163
+ rows = await c.fetchall()
1164
+ return [dict(r) for r in rows]
1165
+
1102
1166
  # =============================================================================
1103
1167
  # LIVEKIT CONFIG + TOKEN ISSUANCE
1104
1168
  # =============================================================================
@@ -1164,6 +1228,7 @@ async def update_livekit_config(payload: LiveKitConfig):
1164
1228
  # Note: there's no separate endpoint to set the secret via JSON to avoid
1165
1229
  # accidental overwrites; use POST /api/livekit/config/secret for that.
1166
1230
  await broadcast("livekit_config_changed", {})
1231
+ await _log_history("livekit", "settings", "livekit_config", "updated", "operator", f"url_set={payload.url is not None}, key_set={payload.api_key is not None}")
1167
1232
  return {"status": "ok"}
1168
1233
 
1169
1234
  @app.post("/api/livekit/config/secret")
@@ -1173,6 +1238,7 @@ async def set_livekit_secret(payload: dict):
1173
1238
  raise HTTPException(400, "api_secret must be a string of length >= 8")
1174
1239
  await set_setting("livekit_api_secret", secret)
1175
1240
  await broadcast("livekit_config_changed", {})
1241
+ await _log_history("livekit", "settings", "livekit_secret", "updated", "operator")
1176
1242
  return {"status": "ok"}
1177
1243
 
1178
1244
  @app.post("/api/livekit/token", response_model=LiveKitTokenResponse)
@@ -1376,6 +1442,7 @@ async def preview_start(robot_id: str):
1376
1442
  token = _mint_lk(server, room_name, identity, "operator-preview", publish=False, ttl_min=5)
1377
1443
  except ImportError:
1378
1444
  raise HTTPException(503, "livekit-api not installed on scheduler")
1445
+ await _log_history("preview", "robot", robot_id, "start", "operator", f"room={room_name}, server={server['id']}")
1379
1446
  return {"active": True, "url": server["url"], "token": token, "room": room_name,
1380
1447
  "identity": identity, "expires_in": PREVIEW_TTL_SECONDS}
1381
1448
 
@@ -1388,6 +1455,7 @@ async def preview_keepalive(robot_id: str):
1388
1455
  await db.commit()
1389
1456
  if cur.rowcount == 0:
1390
1457
  return {"active": False}
1458
+ await _log_history("preview", "robot", robot_id, "keepalive", "operator")
1391
1459
  return {"active": True, "expires_in": PREVIEW_TTL_SECONDS}
1392
1460
 
1393
1461
  @app.post("/api/robots/{robot_id}/preview/stop")
@@ -1396,6 +1464,7 @@ async def preview_stop(robot_id: str):
1396
1464
  async with aiosqlite.connect(DB_PATH) as db:
1397
1465
  await db.execute("DELETE FROM previews WHERE robot_id = ?", (robot_id,))
1398
1466
  await db.commit()
1467
+ await _log_history("preview", "robot", robot_id, "stop", "operator")
1399
1468
  return {"stopped": True}
1400
1469
 
1401
1470
  @app.get("/api/robots/{robot_id}/preview/agent")
@@ -1425,6 +1494,7 @@ async def preview_agent(robot_id: str, authorization: Optional[str] = Header(def
1425
1494
  token = _mint_lk(server, row["room_name"], robot_id, robot_id, publish=True, ttl_min=2)
1426
1495
  except ImportError:
1427
1496
  raise HTTPException(503, "livekit-api not installed on scheduler")
1497
+ await _log_history("preview", "robot", robot_id, "agent_token", "pi", f"room={row['room_name']}")
1428
1498
  return {"active": True, "url": server["url"], "token": token, "room": row["room_name"]}
1429
1499
 
1430
1500
  # =============================================================================
@@ -1475,6 +1545,8 @@ class DeviceUpdate(BaseModel):
1475
1545
  character_id: Optional[str] = None
1476
1546
  motor_server_url: Optional[str] = None
1477
1547
  livekit_url: Optional[str] = None
1548
+ video_device: Optional[str] = None
1549
+ audio_device: Optional[str] = None
1478
1550
  tailscale_ip: Optional[str] = None
1479
1551
  lan_ip: Optional[str] = None
1480
1552
  notes: Optional[str] = None
@@ -1494,6 +1566,7 @@ async def update_device(device_id: str, payload: DeviceUpdate):
1494
1566
  if cur.rowcount == 0:
1495
1567
  raise HTTPException(404, "Device not found")
1496
1568
  await broadcast("device_updated", {"device_id": device_id, "fields": list(fields.keys())})
1569
+ await _log_history("device", "device", device_id, "updated", "operator", f"fields={','.join(fields.keys())}")
1497
1570
  return await get_device(device_id)
1498
1571
 
1499
1572
  @app.post("/api/devices", response_model=Device)
@@ -1518,11 +1591,13 @@ async def create_device(payload: DeviceCreate):
1518
1591
  await db.execute(
1519
1592
  """INSERT INTO devices
1520
1593
  (id, name, tailscale_ip, lan_ip, motor_server_url, character_id,
1521
- livekit_url, enrollment_token_hash, status, enrolled_at, notes, created_at)
1522
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'enrolled', ?, ?, ?)""",
1594
+ livekit_url, video_device, audio_device, enrollment_token_hash,
1595
+ status, enrolled_at, notes, created_at)
1596
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'enrolled', ?, ?, ?)""",
1523
1597
  (
1524
1598
  device_id, payload.name.strip(), payload.tailscale_ip, payload.lan_ip,
1525
1599
  payload.motor_server_url, payload.character_id, payload.livekit_url,
1600
+ payload.video_device, payload.audio_device,
1526
1601
  enrollment_hash, now, payload.notes, now,
1527
1602
  ),
1528
1603
  )
@@ -1530,6 +1605,7 @@ async def create_device(payload: DeviceCreate):
1530
1605
 
1531
1606
  logger.info(f"Device {device_id} ({payload.name}) created")
1532
1607
  await broadcast("device_added", {"device_id": device_id, "name": payload.name})
1608
+ await _log_history("device", "device", device_id, "created", "operator", f"name={payload.name.strip()}")
1533
1609
 
1534
1610
  device = await get_device(device_id)
1535
1611
  return {
@@ -1540,13 +1616,16 @@ async def create_device(payload: DeviceCreate):
1540
1616
  @app.delete("/api/devices/{device_id}")
1541
1617
  async def delete_device(device_id: str):
1542
1618
  async with aiosqlite.connect(DB_PATH) as db:
1543
- async with db.execute("SELECT id FROM devices WHERE id = ?", (device_id,)) as c:
1619
+ db.row_factory = aiosqlite.Row
1620
+ async with db.execute("SELECT name FROM devices WHERE id = ?", (device_id,)) as c:
1544
1621
  row = await c.fetchone()
1545
1622
  if not row:
1546
1623
  raise HTTPException(404, "Device not found")
1624
+ name = row["name"]
1547
1625
  await db.execute("DELETE FROM devices WHERE id = ?", (device_id,))
1548
1626
  await db.commit()
1549
1627
  await broadcast("device_removed", {"device_id": device_id})
1628
+ await _log_history("device", "device", device_id, "deleted", "operator", f"name={name}")
1550
1629
  return {"status": "deleted", "device_id": device_id}
1551
1630
 
1552
1631
  @app.post("/api/devices/{device_id}/token/rotate")
@@ -1560,6 +1639,7 @@ async def rotate_device_token(device_id: str):
1560
1639
  if cur.rowcount == 0:
1561
1640
  raise HTTPException(404, "Device not found")
1562
1641
  await broadcast("device_token_rotated", {"device_id": device_id})
1642
+ await _log_history("device", "device", device_id, "token_rotated", "operator")
1563
1643
  return {"device_id": device_id, "device_token": new_token}
1564
1644
 
1565
1645
  async def _authorize_device(device_id: str, authorization: Optional[str]) -> bool:
@@ -1574,6 +1654,20 @@ async def _authorize_device(device_id: str, authorization: Optional[str]) -> boo
1574
1654
  return False
1575
1655
  return hmac.compare_digest(row[0], _hash(token))
1576
1656
 
1657
+ async def _log_history(category: str, entity_type: Optional[str] = None, entity_id: Optional[str] = None,
1658
+ action: Optional[str] = None, actor: Optional[str] = None, details: Optional[str] = None):
1659
+ """Append an immutable audit/history record."""
1660
+ try:
1661
+ async with aiosqlite.connect(DB_PATH) as db:
1662
+ await db.execute(
1663
+ """INSERT INTO history_log (timestamp, category, entity_type, entity_id, action, actor, details)
1664
+ VALUES (?, ?, ?, ?, ?, ?, ?)""",
1665
+ (datetime.utcnow().isoformat(), category, entity_type, entity_id, action, actor, details),
1666
+ )
1667
+ await db.commit()
1668
+ except Exception as e:
1669
+ logger.warning(f"history log failed: {e}")
1670
+
1577
1671
  async def _authorize_fleet(authorization: Optional[str]) -> bool:
1578
1672
  """Verify a Bearer token matches ANY enrolled device token (fleet auth).
1579
1673
 
@@ -1632,14 +1726,21 @@ async def enroll_device(payload: DeviceEnrollRequest):
1632
1726
  motor_server_url = COALESCE(?, motor_server_url),
1633
1727
  livekit_url = COALESCE(?, livekit_url),
1634
1728
  character_id = COALESCE(?, character_id),
1729
+ video_device = COALESCE(?, video_device),
1730
+ audio_device = COALESCE(?, audio_device),
1635
1731
  name = COALESCE(?, name)
1636
1732
  WHERE id = ?""",
1637
1733
  (
1638
1734
  new_hash, now, payload.tailscale_ip, payload.lan_ip,
1639
1735
  payload.motor_server_url, payload.livekit_url, payload.character_id,
1736
+ payload.video_device, payload.audio_device,
1640
1737
  payload.name, device_id,
1641
1738
  ),
1642
1739
  )
1740
+ await db.execute(
1741
+ "INSERT OR IGNORE INTO robots (id, name, character_id, status) VALUES (?, ?, ?, 'idle')",
1742
+ (device_id, existing["name"] or payload.name or device_id, existing["character_id"]),
1743
+ )
1643
1744
  else:
1644
1745
  # First-boot enroll via the global default token -> create a new device.
1645
1746
  device_id = f"dev_{secrets.token_hex(4)}"
@@ -1649,21 +1750,28 @@ async def enroll_device(payload: DeviceEnrollRequest):
1649
1750
  await db.execute(
1650
1751
  """INSERT INTO devices
1651
1752
  (id, name, tailscale_ip, lan_ip, motor_server_url, character_id,
1652
- livekit_url, token_hash, enrollment_token_hash, status, enrolled_at, created_at)
1653
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'enrolled', ?, ?)""",
1753
+ livekit_url, video_device, audio_device, token_hash,
1754
+ enrollment_token_hash, status, enrolled_at, created_at)
1755
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'enrolled', ?, ?)""",
1654
1756
  (
1655
1757
  device_id,
1656
1758
  (payload.name or f"Pi-{device_id[-4:]}").strip(),
1657
1759
  payload.tailscale_ip, payload.lan_ip,
1658
1760
  payload.motor_server_url, payload.character_id,
1659
- payload.livekit_url, new_hash, token_hash, now, now,
1761
+ payload.livekit_url, payload.video_device, payload.audio_device,
1762
+ new_hash, token_hash, now, now,
1660
1763
  ),
1661
1764
  )
1765
+ await db.execute(
1766
+ "INSERT OR IGNORE INTO robots (id, name, character_id, status) VALUES (?, ?, ?, 'idle')",
1767
+ (device_id, (payload.name or f"Pi-{device_id[-4:]}").strip(), payload.character_id),
1768
+ )
1662
1769
 
1663
1770
  await db.commit()
1664
1771
 
1665
1772
  await broadcast("device_enrolled", {"device_id": device_id})
1666
1773
  logger.info(f"Device {device_id} enrolled")
1774
+ await _log_history("device", "device", device_id, "enrolled", "pi", f"name={(payload.name or device_id).strip()}")
1667
1775
 
1668
1776
  # Discover our own scheduler URL (best effort)
1669
1777
  scheduler_url = os.getenv("SCHEDULER_PUBLIC_URL", f"http://localhost:{os.getenv('SCHEDULER_PORT', '8080')}")
@@ -1690,6 +1798,7 @@ async def device_heartbeat(device_id: str, payload: DeviceHeartbeat,
1690
1798
  if cur.rowcount == 0:
1691
1799
  raise HTTPException(404, "Device not found")
1692
1800
  await broadcast("device_heartbeat", {"device_id": device_id, "status": payload.status})
1801
+ await _log_history("device", "device", device_id, "heartbeat", "pi", f"status={payload.status or 'online'}, ip={payload.ip}")
1693
1802
  # Tell the Pi whether production mode is on (so it can decide to join LiveKit or stand by)
1694
1803
  return {
1695
1804
  "status": "ok",
@@ -2058,6 +2167,7 @@ async def dashboard():
2058
2167
  <meta name="viewport" content="width=device-width, initial-scale=1">
2059
2168
  <script src="https://cdn.tailwindcss.com"></script>
2060
2169
  <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
2170
+ <script src="https://cdn.jsdelivr.net/npm/livekit-client@1/dist/livekit-client.umd.min.js"></script>
2061
2171
  </head>
2062
2172
  <body class="bg-gray-900 text-white">
2063
2173
  <div id="app"></div>
@@ -2077,8 +2187,10 @@ async def dashboard():
2077
2187
  const livekit = ref({ url: null, api_key: null, has_secret: false });
2078
2188
  const showAddDevice = ref(false);
2079
2189
  const showDevices = ref(true);
2080
- const newDevice = ref({ name: '', tailscale_ip: '', lan_ip: '', motor_server_url: '', character_id: '', livekit_url: '', notes: '' });
2190
+ const newDevice = ref({ name: '', tailscale_ip: '', lan_ip: '', motor_server_url: '', character_id: '', livekit_url: '', video_device: 'auto', audio_device: 'default', notes: '' });
2081
2191
  const rotatedToken = ref(null);
2192
+ const preview = ref({ robot: null, active: false, url: null, token: null, room: null, keepalive: null });
2193
+ const deviceInventory = ref({});
2082
2194
  const showLiveKitConfig = ref(false);
2083
2195
  const livekitForm = ref({ url: '', api_key: '', api_secret: '' });
2084
2196
  let ws = null;
@@ -2108,11 +2220,30 @@ async def dashboard():
2108
2220
  serverMetrics.value[server.id] = metrics;
2109
2221
  } catch (e) {}
2110
2222
  }
2223
+ buildDeviceInventory();
2111
2224
  } catch (e) {
2112
2225
  console.error('Failed to fetch data:', e);
2113
2226
  }
2114
2227
  };
2115
2228
 
2229
+ const buildDeviceInventory = () => {
2230
+ const map = {};
2231
+ for (const robot of robots.value) {
2232
+ const device = devices.value.find(d => d.name === robot.name || d.id === robot.id);
2233
+ const savedVideo = device?.video_device || 'auto';
2234
+ const savedAudio = device?.audio_device || 'default';
2235
+ map[robot.id] = {
2236
+ deviceId: device?.id || null,
2237
+ robotName: robot.name,
2238
+ selectedVideo: savedVideo,
2239
+ selectedAudio: savedAudio,
2240
+ videoOptions: ['auto', 'none', 'picamera', '/dev/video0', '/dev/video1', '/dev/video2'],
2241
+ audioOptions: ['default', 'none'],
2242
+ };
2243
+ }
2244
+ deviceInventory.value = map;
2245
+ };
2246
+
2116
2247
  const connectWebSocket = () => {
2117
2248
  const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
2118
2249
  ws = new WebSocket(`${protocol}//${window.location.host}/ws`);
@@ -2198,13 +2329,75 @@ async def dashboard():
2198
2329
  rotatedToken.value = { device_id: r.id, name: r.name, token: r.enrollment_token };
2199
2330
  }
2200
2331
  showAddDevice.value = false;
2201
- newDevice.value = { name: '', tailscale_ip: '', lan_ip: '', motor_server_url: '', character_id: '', livekit_url: '', notes: '' };
2332
+ newDevice.value = { name: '', tailscale_ip: '', lan_ip: '', motor_server_url: '', character_id: '', livekit_url: '', video_device: 'auto', audio_device: 'default', notes: '' };
2202
2333
  fetchData();
2203
2334
  } catch (e) {
2204
2335
  alert('Failed to add device: ' + e.message);
2205
2336
  }
2206
2337
  };
2207
2338
 
2339
+ const updateDeviceMedia = async (robotId) => {
2340
+ const inv = deviceInventory.value[robotId];
2341
+ if (!inv || !inv.deviceId) return;
2342
+ try {
2343
+ await fetch(`/api/devices/${inv.deviceId}`, {
2344
+ method: 'PATCH',
2345
+ headers: {'Content-Type': 'application/json'},
2346
+ body: JSON.stringify({
2347
+ video_device: inv.selectedVideo,
2348
+ audio_device: inv.selectedAudio,
2349
+ }),
2350
+ });
2351
+ fetchData();
2352
+ } catch (e) {
2353
+ alert('Failed to update device media: ' + e.message);
2354
+ }
2355
+ };
2356
+
2357
+ const startPreview = async (robot) => {
2358
+ stopPreview();
2359
+ try {
2360
+ const r = await fetch(`/api/robots/${robot.id}/preview/start`, { method: 'POST' }).then(r => r.json());
2361
+ if (!r.active) {
2362
+ alert('Preview not active: ' + (r.reason || 'unknown'));
2363
+ return;
2364
+ }
2365
+ preview.value = { robot, active: true, url: r.url, token: r.token, room: r.room, keepalive: null };
2366
+ await connectLiveKitPreview(r.url, r.token);
2367
+ preview.value.keepalive = setInterval(async () => {
2368
+ if (!preview.value.active) return;
2369
+ try {
2370
+ await fetch(`/api/robots/${robot.id}/preview/keepalive`, { method: 'POST' });
2371
+ } catch (e) { console.warn('keepalive failed', e); }
2372
+ }, 15000);
2373
+ } catch (e) {
2374
+ alert('Failed to start preview: ' + e.message);
2375
+ }
2376
+ };
2377
+
2378
+ const stopPreview = () => {
2379
+ if (preview.value.keepalive) clearInterval(preview.value.keepalive);
2380
+ if (preview.value.room) {
2381
+ try { window.__lkPreviewRoom?.disconnect(); } catch (e) {}
2382
+ }
2383
+ preview.value = { robot: null, active: false, url: null, token: null, room: null, keepalive: null };
2384
+ };
2385
+
2386
+ const connectLiveKitPreview = async (url, token) => {
2387
+ const { Room } = LiveKitClient;
2388
+ const room = new Room({ adaptiveStream: true });
2389
+ window.__lkPreviewRoom = room;
2390
+ room.on('trackSubscribed', (track, publication, participant) => {
2391
+ if (track.kind === 'video') {
2392
+ track.attach(document.getElementById('preview-video'));
2393
+ }
2394
+ if (track.kind === 'audio') {
2395
+ track.attach(document.createElement('audio'));
2396
+ }
2397
+ });
2398
+ await room.connect(url, token);
2399
+ };
2400
+
2208
2401
  const deleteDevice = async (id, name) => {
2209
2402
  if (!confirm(`Delete device "${name}"?`)) return;
2210
2403
  try {
@@ -2335,11 +2528,12 @@ async def dashboard():
2335
2528
  return {
2336
2529
  robots, servers, sessions, stats, connected, serverMetrics,
2337
2530
  devices, settings, livekit, showAddDevice, showDevices, newDevice, rotatedToken,
2338
- showLiveKitConfig, livekitForm,
2531
+ showLiveKitConfig, livekitForm, preview, deviceInventory,
2339
2532
  loadModel, unloadModel, statusColor, formatDuration, formatTime,
2340
2533
  toggleProductionMode, rotateEnrollmentToken,
2341
2534
  submitNewDevice, deleteDevice, rotateDeviceToken, dismissRotatedToken,
2342
2535
  joinConversation, saveLiveKitConfig, openLiveKitConfig,
2536
+ startPreview, stopPreview, updateDeviceMedia,
2343
2537
  };
2344
2538
  },
2345
2539
  template: `
@@ -2492,6 +2686,25 @@ async def dashboard():
2492
2686
  <label class="block text-gray-400 mb-1">LiveKit URL (for this device)</label>
2493
2687
  <input v-model="newDevice.livekit_url" class="w-full bg-gray-700 rounded px-3 py-2" placeholder="ws://100.64.1.5:7880" />
2494
2688
  </div>
2689
+ <div class="grid grid-cols-2 gap-3">
2690
+ <div>
2691
+ <label class="block text-gray-400 mb-1">Default Camera</label>
2692
+ <select v-model="newDevice.video_device" class="w-full bg-gray-700 rounded px-3 py-2">
2693
+ <option value="auto">auto</option>
2694
+ <option value="none">none</option>
2695
+ <option value="picamera">picamera</option>
2696
+ <option value="/dev/video0">/dev/video0</option>
2697
+ <option value="/dev/video1">/dev/video1</option>
2698
+ </select>
2699
+ </div>
2700
+ <div>
2701
+ <label class="block text-gray-400 mb-1">Default Mic</label>
2702
+ <select v-model="newDevice.audio_device" class="w-full bg-gray-700 rounded px-3 py-2">
2703
+ <option value="default">default</option>
2704
+ <option value="none">none</option>
2705
+ </select>
2706
+ </div>
2707
+ </div>
2495
2708
  <div>
2496
2709
  <label class="block text-gray-400 mb-1">Character ID</label>
2497
2710
  <input v-model="newDevice.character_id" class="w-full bg-gray-700 rounded px-3 py-2" placeholder="panda-character" />
@@ -2550,28 +2763,66 @@ async def dashboard():
2550
2763
  <!-- Main Grid -->
2551
2764
  <div class="grid grid-cols-12 gap-6">
2552
2765
 
2553
- <!-- Robots Panel -->
2766
+ <!-- Robots Panel -->
2554
2767
  <div class="col-span-4 bg-gray-800 rounded-lg p-4">
2555
2768
  <h2 class="text-xl font-semibold mb-4">🤖 Robot Fleet</h2>
2556
2769
  <div class="space-y-3">
2557
2770
  <div v-for="robot in robots" :key="robot.id"
2558
- class="bg-gray-700 rounded-lg p-3 flex items-center gap-3">
2559
- <div class="w-3 h-3 rounded-full" :class="statusColor(robot.status)"></div>
2560
- <div class="flex-1">
2561
- <div class="font-medium">{{ robot.name }}</div>
2562
- <div class="text-sm text-gray-400">
2563
- {{ robot.status }}
2564
- <span v-if="robot.connected_server_id" class="ml-1">
2565
- {{ robot.connected_server_id }}
2566
- </span>
2771
+ class="bg-gray-700 rounded-lg p-3">
2772
+ <div class="flex items-center gap-3 mb-2">
2773
+ <div class="w-3 h-3 rounded-full" :class="statusColor(robot.status)"></div>
2774
+ <div class="flex-1">
2775
+ <div class="font-medium">{{ robot.name }}</div>
2776
+ <div class="text-sm text-gray-400">
2777
+ {{ robot.status }}
2778
+ <span v-if="robot.connected_server_id" class="ml-1">
2779
+ → {{ robot.connected_server_id }}
2780
+ </span>
2781
+ </div>
2782
+ </div>
2783
+ <div class="text-right text-sm text-gray-400">
2784
+ {{ robot.total_sessions }} sessions
2567
2785
  </div>
2568
2786
  </div>
2569
- <div class="text-right text-sm text-gray-400">
2570
- {{ robot.total_sessions }} sessions
2787
+ <div v-if="deviceInventory[robot.id]" class="space-y-2 text-sm">
2788
+ <div class="grid grid-cols-2 gap-2">
2789
+ <div>
2790
+ <label class="text-xs text-gray-500">Camera</label>
2791
+ <select v-model="deviceInventory[robot.id].selectedVideo"
2792
+ @change="updateDeviceMedia(robot.id)"
2793
+ class="w-full bg-gray-800 rounded px-2 py-1 text-xs">
2794
+ <option v-for="opt in deviceInventory[robot.id].videoOptions" :key="opt" :value="opt">{{ opt }}</option>
2795
+ </select>
2796
+ </div>
2797
+ <div>
2798
+ <label class="text-xs text-gray-500">Microphone</label>
2799
+ <select v-model="deviceInventory[robot.id].selectedAudio"
2800
+ @change="updateDeviceMedia(robot.id)"
2801
+ class="w-full bg-gray-800 rounded px-2 py-1 text-xs">
2802
+ <option v-for="opt in deviceInventory[robot.id].audioOptions" :key="opt" :value="opt">{{ opt }}</option>
2803
+ </select>
2804
+ </div>
2805
+ </div>
2806
+ <button @click="startPreview(robot)"
2807
+ :disabled="!settings.production_mode || !livekit.url || !livekit.has_secret || preview.active"
2808
+ :class="(settings.production_mode && livekit.url && livekit.has_secret && !preview.active) ? 'bg-cyan-600 hover:bg-cyan-700' : 'bg-gray-700 cursor-not-allowed'"
2809
+ class="w-full px-2 py-1 rounded text-xs">
2810
+ {{ preview.active && preview.robot?.id === robot.id ? 'Preview Active' : 'Start Preview' }}
2811
+ </button>
2812
+ <button v-if="preview.active && preview.robot?.id === robot.id"
2813
+ @click="stopPreview"
2814
+ class="w-full px-2 py-1 bg-red-600 hover:bg-red-700 rounded text-xs">
2815
+ Stop Preview
2816
+ </button>
2571
2817
  </div>
2572
2818
  </div>
2573
2819
  </div>
2574
- </div>
2820
+ <!-- Live preview panel -->
2821
+ <div v-if="preview.active" class="mt-4 bg-black rounded-lg overflow-hidden">
2822
+ <video id="preview-video" class="w-full" autoplay playsinline muted></video>
2823
+ <div class="text-xs text-gray-400 px-2 py-1">{{ preview.room }}</div>
2824
+ </div>
2825
+ </div>
2575
2826
 
2576
2827
  <!-- Servers Panel -->
2577
2828
  <div class="col-span-5 space-y-4">