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.
- package/dist/robopark/auto-start.d.ts +1 -1
- package/dist/robopark/enroll.d.ts +7 -0
- package/dist/robopark/enroll.js +101 -0
- package/dist/robopark/preview-agent-launcher.d.ts +14 -0
- package/dist/robopark/preview-agent-launcher.js +102 -0
- package/dist/robopark/probe.d.ts +7 -0
- package/dist/robopark/probe.js +138 -0
- package/dist/robopark/serve.js +23 -3
- package/dist/robopark/setup-livekit.d.ts +8 -0
- package/dist/robopark/setup-livekit.js +233 -0
- package/dist/robopark/setup.d.ts +3 -0
- package/dist/robopark/setup.js +32 -0
- package/dist/robopark/verify.d.ts +6 -0
- package/dist/robopark/verify.js +192 -0
- package/dist/robopark-cli.js +68 -1
- package/package.json +1 -1
- package/packages/robopark/scheduler/__pycache__/main.cpython-312.pyc +0 -0
- package/packages/robopark/scheduler/__pycache__/preview_agent.cpython-312.pyc +0 -0
- package/packages/robopark/scheduler/main.py +276 -25
- package/packages/robopark/scheduler/preview_agent.py +639 -0
- package/packages/robopark/scheduler/requirements.txt +4 -0
|
@@ -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
|
-
|
|
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,
|
|
1522
|
-
|
|
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
|
-
|
|
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,
|
|
1653
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
2559
|
-
<div class="
|
|
2560
|
-
|
|
2561
|
-
<div class="
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
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="
|
|
2570
|
-
|
|
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
|
-
|
|
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">
|