robopark 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "robopark",
|
|
3
|
-
"version": "2.8.
|
|
3
|
+
"version": "2.8.9",
|
|
4
4
|
"description": "RoboPark fleet control CLI — set up, watch, and drive a fleet of talking robots. The operator front-end over the infinicode mesh.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"node": ">=20"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"infinicode": "^2.8.
|
|
18
|
+
"infinicode": "^2.8.9"
|
|
19
19
|
},
|
|
20
20
|
"keywords": [
|
|
21
21
|
"robopark",
|
|
Binary file
|
|
Binary file
|
package/scheduler/main.py
CHANGED
|
@@ -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">
|
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
RoboPark Preview Agent — runs on each robot/satellite Pi.
|
|
4
|
+
|
|
5
|
+
Polls the scheduler for an active operator preview request. When active,
|
|
6
|
+
opens the selected camera + microphone and publishes them to the returned
|
|
7
|
+
LiveKit room. When the preview expires or is stopped, the agent leaves the
|
|
8
|
+
room and releases the hardware.
|
|
9
|
+
|
|
10
|
+
Configuration (env / ~/.robopark/preview_agent.json):
|
|
11
|
+
SCHEDULER_URL base URL of the RoboPark scheduler
|
|
12
|
+
ROBOT_ID robot identity in the scheduler (defaults to hostname)
|
|
13
|
+
DEVICE_TOKEN long-lived device token (after enrollment)
|
|
14
|
+
ENROLLMENT_TOKEN one-time enrollment token (device token will be saved)
|
|
15
|
+
VIDEO_DEVICE camera path/index/name (e.g. /dev/video0, 0, "PiCamera")
|
|
16
|
+
AUDIO_DEVICE microphone name/index or "default"
|
|
17
|
+
VIDEO_WIDTH / HEIGHT capture resolution (default 640x480)
|
|
18
|
+
VIDEO_FPS capture fps (default 15)
|
|
19
|
+
POLL_INTERVAL seconds between scheduler polls (default 3)
|
|
20
|
+
HEARTBEAT_INTERVAL seconds between device heartbeats (default 30)
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import asyncio
|
|
26
|
+
import json
|
|
27
|
+
import logging
|
|
28
|
+
import os
|
|
29
|
+
import signal
|
|
30
|
+
import sys
|
|
31
|
+
import time
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Optional
|
|
35
|
+
|
|
36
|
+
import httpx
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger("robopark.preview_agent")
|
|
39
|
+
|
|
40
|
+
CONFIG_DIR = Path.home() / ".robopark"
|
|
41
|
+
CONFIG_FILE = CONFIG_DIR / "preview_agent.json"
|
|
42
|
+
TOKEN_FILE = CONFIG_DIR / "device_token"
|
|
43
|
+
|
|
44
|
+
DEFAULT_VIDEO_WIDTH = 640
|
|
45
|
+
DEFAULT_VIDEO_HEIGHT = 480
|
|
46
|
+
DEFAULT_FPS = 15
|
|
47
|
+
DEFAULT_POLL_INTERVAL = 3.0
|
|
48
|
+
DEFAULT_HEARTBEAT_INTERVAL = 30.0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _load_config() -> dict:
|
|
52
|
+
cfg: dict = {}
|
|
53
|
+
if CONFIG_FILE.exists():
|
|
54
|
+
try:
|
|
55
|
+
cfg = json.loads(CONFIG_FILE.read_text(encoding="utf8"))
|
|
56
|
+
except Exception as e:
|
|
57
|
+
logger.warning(f"failed to read {CONFIG_FILE}: {e}")
|
|
58
|
+
return cfg
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _save_config(cfg: dict) -> None:
|
|
62
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
CONFIG_FILE.write_text(json.dumps(cfg, indent=2), encoding="utf8")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _load_token() -> Optional[str]:
|
|
67
|
+
if TOKEN_FILE.exists():
|
|
68
|
+
return TOKEN_FILE.read_text(encoding="utf8").strip() or None
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _save_token(token: str) -> None:
|
|
73
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
TOKEN_FILE.write_text(token, encoding="utf8")
|
|
75
|
+
os.chmod(TOKEN_FILE, 0o600)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def _enroll(scheduler_url: str, enrollment_token: str, robot_id: str) -> str:
|
|
79
|
+
"""Enroll this Pi with the scheduler and return the device token."""
|
|
80
|
+
import socket
|
|
81
|
+
payload = {
|
|
82
|
+
"enrollment_token": enrollment_token,
|
|
83
|
+
"name": robot_id,
|
|
84
|
+
"lan_ip": _get_lan_ip(),
|
|
85
|
+
}
|
|
86
|
+
async with httpx.AsyncClient() as client:
|
|
87
|
+
r = await client.post(
|
|
88
|
+
f"{scheduler_url.rstrip('/')}/api/devices/enroll",
|
|
89
|
+
json=payload,
|
|
90
|
+
timeout=30.0,
|
|
91
|
+
)
|
|
92
|
+
r.raise_for_status()
|
|
93
|
+
data = r.json()
|
|
94
|
+
device_token = data.get("device_token")
|
|
95
|
+
if not device_token:
|
|
96
|
+
raise RuntimeError("enrollment did not return a device_token")
|
|
97
|
+
_save_token(device_token)
|
|
98
|
+
cfg = _load_config()
|
|
99
|
+
cfg["device_id"] = data.get("device_id")
|
|
100
|
+
cfg["scheduler_url"] = data.get("scheduler_url", scheduler_url)
|
|
101
|
+
_save_config(cfg)
|
|
102
|
+
logger.info(f"enrolled as device {data.get('device_id')}")
|
|
103
|
+
return device_token
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _get_lan_ip() -> Optional[str]:
|
|
107
|
+
try:
|
|
108
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
109
|
+
s.settimeout(0.5)
|
|
110
|
+
s.connect(("10.255.255.255", 1))
|
|
111
|
+
ip = s.getsockname()[0]
|
|
112
|
+
s.close()
|
|
113
|
+
return ip
|
|
114
|
+
except Exception:
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
async def _send_heartbeat(scheduler_url: str, device_id: str, token: str) -> None:
|
|
119
|
+
try:
|
|
120
|
+
async with httpx.AsyncClient() as client:
|
|
121
|
+
await client.post(
|
|
122
|
+
f"{scheduler_url.rstrip('/')}/api/devices/{device_id}/heartbeat",
|
|
123
|
+
json={"status": "online"},
|
|
124
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
125
|
+
timeout=10.0,
|
|
126
|
+
)
|
|
127
|
+
except Exception as e:
|
|
128
|
+
logger.debug(f"heartbeat failed: {e}")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass
|
|
132
|
+
class PreviewState:
|
|
133
|
+
active: bool = False
|
|
134
|
+
url: Optional[str] = None
|
|
135
|
+
token: Optional[str] = None
|
|
136
|
+
room: Optional[str] = None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class PreviewAgent:
|
|
140
|
+
def __init__(self, cfg: dict):
|
|
141
|
+
self.scheduler_url = cfg.get("scheduler_url", os.getenv("SCHEDULER_URL", "http://localhost:8080"))
|
|
142
|
+
self.robot_id = cfg.get("robot_id", os.getenv("ROBOT_ID", _hostname()))
|
|
143
|
+
self.device_token: Optional[str] = cfg.get("device_token") or os.getenv("DEVICE_TOKEN") or _load_token()
|
|
144
|
+
self.device_id: Optional[str] = cfg.get("device_id")
|
|
145
|
+
self.video_device = cfg.get("video_device", os.getenv("VIDEO_DEVICE", "auto"))
|
|
146
|
+
self.audio_device = cfg.get("audio_device", os.getenv("AUDIO_DEVICE", "default"))
|
|
147
|
+
self.width = int(cfg.get("video_width", os.getenv("VIDEO_WIDTH", DEFAULT_VIDEO_WIDTH)))
|
|
148
|
+
self.height = int(cfg.get("video_height", os.getenv("VIDEO_HEIGHT", DEFAULT_VIDEO_HEIGHT)))
|
|
149
|
+
self.fps = int(cfg.get("video_fps", os.getenv("VIDEO_FPS", DEFAULT_FPS)))
|
|
150
|
+
self.poll_interval = float(cfg.get("poll_interval", os.getenv("POLL_INTERVAL", DEFAULT_POLL_INTERVAL)))
|
|
151
|
+
self.heartbeat_interval = float(cfg.get("heartbeat_interval", os.getenv("HEARTBEAT_INTERVAL", DEFAULT_HEARTBEAT_INTERVAL)))
|
|
152
|
+
|
|
153
|
+
self._shutdown = asyncio.Event()
|
|
154
|
+
self._task: Optional[asyncio.Task] = None
|
|
155
|
+
self._session: Optional[httpx.AsyncClient] = None
|
|
156
|
+
self._current_state: PreviewState = PreviewState()
|
|
157
|
+
self._publisher: Optional["LiveKitPublisher"] = None
|
|
158
|
+
|
|
159
|
+
async def run(self) -> None:
|
|
160
|
+
enrollment_token = os.getenv("ENROLLMENT_TOKEN") or cfg.get("enrollment_token")
|
|
161
|
+
if not self.device_token and enrollment_token:
|
|
162
|
+
self.device_token = await _enroll(self.scheduler_url, enrollment_token, self.robot_id)
|
|
163
|
+
|
|
164
|
+
if not self.device_token:
|
|
165
|
+
logger.error("no DEVICE_TOKEN and no ENROLLMENT_TOKEN; cannot poll scheduler")
|
|
166
|
+
sys.exit(1)
|
|
167
|
+
|
|
168
|
+
if not self.device_id:
|
|
169
|
+
await self._resolve_device_id()
|
|
170
|
+
|
|
171
|
+
self._session = httpx.AsyncClient()
|
|
172
|
+
|
|
173
|
+
tasks = [
|
|
174
|
+
asyncio.create_task(self._poll_loop()),
|
|
175
|
+
asyncio.create_task(self._heartbeat_loop()),
|
|
176
|
+
]
|
|
177
|
+
await self._shutdown.wait()
|
|
178
|
+
for t in tasks:
|
|
179
|
+
t.cancel()
|
|
180
|
+
try:
|
|
181
|
+
await t
|
|
182
|
+
except asyncio.CancelledError:
|
|
183
|
+
pass
|
|
184
|
+
await self._stop_publisher()
|
|
185
|
+
await self._session.aclose()
|
|
186
|
+
|
|
187
|
+
async def _resolve_device_id(self) -> None:
|
|
188
|
+
"""Look up our device_id from the scheduler using the token."""
|
|
189
|
+
if not self._session or not self.device_token:
|
|
190
|
+
return
|
|
191
|
+
try:
|
|
192
|
+
r = await self._session.get(
|
|
193
|
+
f"{self.scheduler_url.rstrip('/')}/api/devices",
|
|
194
|
+
headers={"Authorization": f"Bearer {self.device_token}"},
|
|
195
|
+
timeout=10.0,
|
|
196
|
+
)
|
|
197
|
+
r.raise_for_status()
|
|
198
|
+
for d in r.json():
|
|
199
|
+
if d.get("name") == self.robot_id:
|
|
200
|
+
self.device_id = d.get("id")
|
|
201
|
+
cfg = _load_config()
|
|
202
|
+
cfg["device_id"] = self.device_id
|
|
203
|
+
_save_config(cfg)
|
|
204
|
+
return
|
|
205
|
+
except Exception as e:
|
|
206
|
+
logger.warning(f"could not resolve device_id: {e}")
|
|
207
|
+
|
|
208
|
+
async def _poll_loop(self) -> None:
|
|
209
|
+
while not self._shutdown.is_set():
|
|
210
|
+
try:
|
|
211
|
+
state = await self._fetch_preview_state()
|
|
212
|
+
await self._apply_state(state)
|
|
213
|
+
except Exception as e:
|
|
214
|
+
logger.warning(f"poll error: {e}")
|
|
215
|
+
try:
|
|
216
|
+
await asyncio.wait_for(self._shutdown.wait(), timeout=self.poll_interval)
|
|
217
|
+
except asyncio.TimeoutError:
|
|
218
|
+
pass
|
|
219
|
+
|
|
220
|
+
async def _heartbeat_loop(self) -> None:
|
|
221
|
+
while not self._shutdown.is_set():
|
|
222
|
+
if self.device_id and self.device_token:
|
|
223
|
+
await _send_heartbeat(self.scheduler_url, self.device_id, self.device_token)
|
|
224
|
+
try:
|
|
225
|
+
await asyncio.wait_for(self._shutdown.wait(), timeout=self.heartbeat_interval)
|
|
226
|
+
except asyncio.TimeoutError:
|
|
227
|
+
pass
|
|
228
|
+
|
|
229
|
+
async def _fetch_preview_state(self) -> PreviewState:
|
|
230
|
+
if not self._session or not self.device_token:
|
|
231
|
+
return PreviewState()
|
|
232
|
+
r = await self._session.get(
|
|
233
|
+
f"{self.scheduler_url.rstrip('/')}/api/robots/{self.robot_id}/preview/agent",
|
|
234
|
+
headers={"Authorization": f"Bearer {self.device_token}"},
|
|
235
|
+
timeout=10.0,
|
|
236
|
+
)
|
|
237
|
+
r.raise_for_status()
|
|
238
|
+
data = r.json()
|
|
239
|
+
if not data.get("active"):
|
|
240
|
+
return PreviewState()
|
|
241
|
+
return PreviewState(
|
|
242
|
+
active=True,
|
|
243
|
+
url=data.get("url"),
|
|
244
|
+
token=data.get("token"),
|
|
245
|
+
room=data.get("room"),
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
async def _apply_state(self, state: PreviewState) -> None:
|
|
249
|
+
same = (
|
|
250
|
+
state.active == self._current_state.active
|
|
251
|
+
and state.room == self._current_state.room
|
|
252
|
+
and state.token == self._current_state.token
|
|
253
|
+
and state.url == self._current_state.url
|
|
254
|
+
)
|
|
255
|
+
if same:
|
|
256
|
+
return
|
|
257
|
+
self._current_state = state
|
|
258
|
+
if not state.active:
|
|
259
|
+
await self._stop_publisher()
|
|
260
|
+
return
|
|
261
|
+
await self._start_publisher(state)
|
|
262
|
+
|
|
263
|
+
async def _start_publisher(self, state: PreviewState) -> None:
|
|
264
|
+
await self._stop_publisher()
|
|
265
|
+
if not state.url or not state.token or not state.room:
|
|
266
|
+
return
|
|
267
|
+
try:
|
|
268
|
+
pub = LiveKitPublisher(state.url, state.token, state.room, self)
|
|
269
|
+
await pub.start()
|
|
270
|
+
self._publisher = pub
|
|
271
|
+
logger.info(f"joined preview room {state.room}")
|
|
272
|
+
except Exception as e:
|
|
273
|
+
logger.error(f"failed to start publisher: {e}")
|
|
274
|
+
|
|
275
|
+
async def _stop_publisher(self) -> None:
|
|
276
|
+
if self._publisher:
|
|
277
|
+
try:
|
|
278
|
+
await self._publisher.stop()
|
|
279
|
+
except Exception as e:
|
|
280
|
+
logger.warning(f"publisher stop error: {e}")
|
|
281
|
+
self._publisher = None
|
|
282
|
+
|
|
283
|
+
def shutdown(self) -> None:
|
|
284
|
+
self._shutdown.set()
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class LiveKitPublisher:
|
|
288
|
+
"""Encapsulates LiveKit room connection, camera capture and mic capture."""
|
|
289
|
+
|
|
290
|
+
def __init__(self, url: str, token: str, room_name: str, agent: PreviewAgent):
|
|
291
|
+
from livekit import rtc
|
|
292
|
+
self.url = url
|
|
293
|
+
self.token = token
|
|
294
|
+
self.room_name = room_name
|
|
295
|
+
self.agent = agent
|
|
296
|
+
self.rtc = rtc
|
|
297
|
+
self.room: Optional[rtc.Room] = None
|
|
298
|
+
self.video_source: Optional[rtc.VideoSource] = None
|
|
299
|
+
self.video_track: Optional[rtc.LocalVideoTrack] = None
|
|
300
|
+
self.audio_source: Optional[rtc.AudioSource] = None
|
|
301
|
+
self.audio_track: Optional[rtc.LocalAudioTrack] = None
|
|
302
|
+
self._stop_event = asyncio.Event()
|
|
303
|
+
self._tasks: list[asyncio.Task] = []
|
|
304
|
+
self._capture: Optional["VideoCapture"] = None
|
|
305
|
+
|
|
306
|
+
async def start(self) -> None:
|
|
307
|
+
self.room = self.rtc.Room()
|
|
308
|
+
await self.room.connect(self.url, self.token)
|
|
309
|
+
|
|
310
|
+
# Video
|
|
311
|
+
self.video_source = self.rtc.VideoSource(self.agent.width, self.agent.height)
|
|
312
|
+
self.video_track = self.rtc.LocalVideoTrack.create_video_track("camera", self.video_source)
|
|
313
|
+
vopts = self.rtc.TrackPublishOptions()
|
|
314
|
+
vopts.source = self.rtc.TrackSource.SOURCE_CAMERA
|
|
315
|
+
await self.room.local_participant.publish_track(self.video_track, vopts)
|
|
316
|
+
|
|
317
|
+
# Audio
|
|
318
|
+
self.audio_source = self.rtc.AudioSource(48000, 1)
|
|
319
|
+
self.audio_track = self.rtc.LocalAudioTrack.create_audio_track("microphone", self.audio_source)
|
|
320
|
+
aopts = self.rtc.TrackPublishOptions()
|
|
321
|
+
aopts.source = self.rtc.TrackSource.SOURCE_MICROPHONE
|
|
322
|
+
await self.room.local_participant.publish_track(self.audio_track, aopts)
|
|
323
|
+
|
|
324
|
+
self._capture = create_video_capture(self.agent.video_device, self.agent.width, self.agent.height, self.agent.fps)
|
|
325
|
+
if self._capture:
|
|
326
|
+
self._tasks.append(asyncio.create_task(self._video_loop()))
|
|
327
|
+
if has_audio():
|
|
328
|
+
self._tasks.append(asyncio.create_task(self._audio_loop()))
|
|
329
|
+
|
|
330
|
+
async def stop(self) -> None:
|
|
331
|
+
self._stop_event.set()
|
|
332
|
+
for t in self._tasks:
|
|
333
|
+
t.cancel()
|
|
334
|
+
try:
|
|
335
|
+
await t
|
|
336
|
+
except asyncio.CancelledError:
|
|
337
|
+
pass
|
|
338
|
+
self._tasks.clear()
|
|
339
|
+
if self._capture:
|
|
340
|
+
self._capture.stop()
|
|
341
|
+
self._capture = None
|
|
342
|
+
if self.room:
|
|
343
|
+
await self.room.disconnect()
|
|
344
|
+
self.room = None
|
|
345
|
+
|
|
346
|
+
async def _video_loop(self) -> None:
|
|
347
|
+
assert self._capture is not None and self.video_source is not None
|
|
348
|
+
frame_interval = 1.0 / self.agent.fps
|
|
349
|
+
while not self._stop_event.is_set():
|
|
350
|
+
start = time.monotonic()
|
|
351
|
+
frame = self._capture.read()
|
|
352
|
+
if frame is not None:
|
|
353
|
+
self.video_source.capture_frame(frame)
|
|
354
|
+
elapsed = time.monotonic() - start
|
|
355
|
+
sleep_for = frame_interval - elapsed
|
|
356
|
+
if sleep_for > 0:
|
|
357
|
+
try:
|
|
358
|
+
await asyncio.wait_for(self._stop_event.wait(), timeout=sleep_for)
|
|
359
|
+
except asyncio.TimeoutError:
|
|
360
|
+
pass
|
|
361
|
+
|
|
362
|
+
async def _audio_loop(self) -> None:
|
|
363
|
+
assert self.audio_source is not None
|
|
364
|
+
mic = create_audio_capture(self.agent.audio_device)
|
|
365
|
+
if mic is None:
|
|
366
|
+
return
|
|
367
|
+
frame_duration = 0.02 # 20ms
|
|
368
|
+
samples_per_frame = int(48000 * frame_duration)
|
|
369
|
+
while not self._stop_event.is_set():
|
|
370
|
+
frame = mic.read(samples_per_frame)
|
|
371
|
+
if frame is not None:
|
|
372
|
+
self.audio_source.capture_frame(frame)
|
|
373
|
+
try:
|
|
374
|
+
await asyncio.wait_for(self._stop_event.wait(), timeout=frame_duration)
|
|
375
|
+
except asyncio.TimeoutError:
|
|
376
|
+
pass
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
# -----------------------------------------------------------------------------
|
|
380
|
+
# Video capture abstraction: V4L2/USB via OpenCV, or picamera2 on Pi.
|
|
381
|
+
# -----------------------------------------------------------------------------
|
|
382
|
+
|
|
383
|
+
class VideoCapture:
|
|
384
|
+
def read(self) -> Optional["rtc.VideoFrame"]:
|
|
385
|
+
raise NotImplementedError
|
|
386
|
+
|
|
387
|
+
def stop(self) -> None:
|
|
388
|
+
raise NotImplementedError
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
class OpencvVideoCapture(VideoCapture):
|
|
392
|
+
def __init__(self, device: int | str, width: int, height: int, fps: int):
|
|
393
|
+
import cv2
|
|
394
|
+
self.cv2 = cv2
|
|
395
|
+
if isinstance(device, str) and device.startswith("/dev/video"):
|
|
396
|
+
device = int(device.replace("/dev/video", ""))
|
|
397
|
+
self.cap = cv2.VideoCapture(device)
|
|
398
|
+
self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, width)
|
|
399
|
+
self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height)
|
|
400
|
+
self.cap.set(cv2.CAP_PROP_FPS, fps)
|
|
401
|
+
self._ensure_frame()
|
|
402
|
+
|
|
403
|
+
def _ensure_frame(self):
|
|
404
|
+
ok, _ = self.cap.read()
|
|
405
|
+
if not ok:
|
|
406
|
+
raise RuntimeError("cannot read from camera")
|
|
407
|
+
|
|
408
|
+
def read(self):
|
|
409
|
+
from livekit import rtc
|
|
410
|
+
ok, bgr = self.cap.read()
|
|
411
|
+
if not ok or bgr is None:
|
|
412
|
+
return None
|
|
413
|
+
return rtc.VideoFrame(
|
|
414
|
+
width=bgr.shape[1],
|
|
415
|
+
height=bgr.shape[0],
|
|
416
|
+
type=rtc.VideoBufferType.BGR,
|
|
417
|
+
data=bgr.tobytes(),
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
def stop(self):
|
|
421
|
+
self.cap.release()
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
class Picamera2Capture(VideoCapture):
|
|
425
|
+
def __init__(self, width: int, height: int, fps: int):
|
|
426
|
+
from picamera2 import Picamera2
|
|
427
|
+
self.picam = Picamera2()
|
|
428
|
+
config = self.picam.create_video_configuration(
|
|
429
|
+
main={"format": "RGB888", "size": (width, height)},
|
|
430
|
+
controls={"FrameRate": fps},
|
|
431
|
+
)
|
|
432
|
+
self.picam.configure(config)
|
|
433
|
+
self.picam.start()
|
|
434
|
+
self.width = width
|
|
435
|
+
self.height = height
|
|
436
|
+
|
|
437
|
+
def read(self):
|
|
438
|
+
from livekit import rtc
|
|
439
|
+
arr = self.picam.capture_array()
|
|
440
|
+
if arr is None:
|
|
441
|
+
return None
|
|
442
|
+
return rtc.VideoFrame(
|
|
443
|
+
width=self.width,
|
|
444
|
+
height=self.height,
|
|
445
|
+
type=rtc.VideoBufferType.RGB,
|
|
446
|
+
data=arr.tobytes(),
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
def stop(self):
|
|
450
|
+
try:
|
|
451
|
+
self.picam.stop()
|
|
452
|
+
except Exception:
|
|
453
|
+
pass
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def create_video_capture(device: str, width: int, height: int, fps: int) -> Optional[VideoCapture]:
|
|
457
|
+
if device.lower() in ("none", "", "false", "null"):
|
|
458
|
+
return None
|
|
459
|
+
try:
|
|
460
|
+
from livekit import rtc
|
|
461
|
+
_ = rtc.VideoSource
|
|
462
|
+
except Exception as e:
|
|
463
|
+
logger.error(f"livekit python sdk not installed: {e}")
|
|
464
|
+
return None
|
|
465
|
+
|
|
466
|
+
# Auto-detect: prefer first V4L2 device, fall back to picamera2 if available.
|
|
467
|
+
if device.lower() in ("auto", "default", "first"):
|
|
468
|
+
for i in range(4):
|
|
469
|
+
try:
|
|
470
|
+
cap = OpencvVideoCapture(i, width, height, fps)
|
|
471
|
+
logger.info(f"auto-selected USB camera /dev/video{i}")
|
|
472
|
+
return cap
|
|
473
|
+
except Exception:
|
|
474
|
+
continue
|
|
475
|
+
try:
|
|
476
|
+
cap = Picamera2Capture(width, height, fps)
|
|
477
|
+
logger.info("auto-selected Pi Camera")
|
|
478
|
+
return cap
|
|
479
|
+
except Exception:
|
|
480
|
+
pass
|
|
481
|
+
logger.error("no camera found (tried V4L2 and picamera2)")
|
|
482
|
+
return None
|
|
483
|
+
|
|
484
|
+
if device.lower() in ("picamera", "picamera2", "pi", "rpi"):
|
|
485
|
+
return Picamera2Capture(width, height, fps)
|
|
486
|
+
|
|
487
|
+
# Treat as V4L2 index or path
|
|
488
|
+
return OpencvVideoCapture(device, width, height, fps)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
# -----------------------------------------------------------------------------
|
|
492
|
+
# Audio capture abstraction: PyAudio or sounddevice -> LiveKit AudioFrame.
|
|
493
|
+
# -----------------------------------------------------------------------------
|
|
494
|
+
|
|
495
|
+
class AudioCapture:
|
|
496
|
+
def read(self, samples_per_frame: int) -> Optional["rtc.AudioFrame"]:
|
|
497
|
+
raise NotImplementedError
|
|
498
|
+
|
|
499
|
+
def stop(self) -> None:
|
|
500
|
+
raise NotImplementedError
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
class PyAudioCapture(AudioCapture):
|
|
504
|
+
def __init__(self, device: str | int | None):
|
|
505
|
+
import pyaudio
|
|
506
|
+
self.pa = pyaudio.PyAudio()
|
|
507
|
+
self.device_index = self._resolve_device(device)
|
|
508
|
+
self.buffer = bytearray()
|
|
509
|
+
self.stream = self.pa.open(
|
|
510
|
+
format=pyaudio.paInt16,
|
|
511
|
+
channels=1,
|
|
512
|
+
rate=48000,
|
|
513
|
+
input=True,
|
|
514
|
+
input_device_index=self.device_index,
|
|
515
|
+
frames_per_buffer=960,
|
|
516
|
+
stream_callback=self._callback,
|
|
517
|
+
)
|
|
518
|
+
self.stream.start_stream()
|
|
519
|
+
|
|
520
|
+
def _resolve_device(self, device: str | int | None) -> Optional[int]:
|
|
521
|
+
if device is None or device == "default":
|
|
522
|
+
return None
|
|
523
|
+
if isinstance(device, int):
|
|
524
|
+
return device
|
|
525
|
+
# Try exact name match
|
|
526
|
+
for i in range(self.pa.get_device_count()):
|
|
527
|
+
info = self.pa.get_device_info_by_index(i)
|
|
528
|
+
if info.get("maxInputChannels", 0) > 0 and device.lower() in str(info.get("name", "")).lower():
|
|
529
|
+
return i
|
|
530
|
+
return None
|
|
531
|
+
|
|
532
|
+
def _callback(self, in_data, frame_count, time_info, status):
|
|
533
|
+
self.buffer.extend(in_data)
|
|
534
|
+
return (None, pyaudio.paContinue)
|
|
535
|
+
|
|
536
|
+
def read(self, samples_per_frame: int):
|
|
537
|
+
from livekit import rtc
|
|
538
|
+
bytes_needed = samples_per_frame * 2 # int16 mono
|
|
539
|
+
while len(self.buffer) < bytes_needed:
|
|
540
|
+
time.sleep(0.005)
|
|
541
|
+
chunk = bytes(self.buffer[:bytes_needed])
|
|
542
|
+
self.buffer = self.buffer[bytes_needed:]
|
|
543
|
+
return rtc.AudioFrame(
|
|
544
|
+
data=chunk,
|
|
545
|
+
sample_rate=48000,
|
|
546
|
+
num_channels=1,
|
|
547
|
+
samples_per_channel=samples_per_frame,
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
def stop(self):
|
|
551
|
+
if self.stream:
|
|
552
|
+
self.stream.stop_stream()
|
|
553
|
+
self.stream.close()
|
|
554
|
+
self.pa.terminate()
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def has_audio() -> bool:
|
|
558
|
+
try:
|
|
559
|
+
import pyaudio # noqa: F401
|
|
560
|
+
return True
|
|
561
|
+
except Exception:
|
|
562
|
+
return False
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def create_audio_capture(device: str) -> Optional[AudioCapture]:
|
|
566
|
+
if device.lower() in ("none", "", "false", "null"):
|
|
567
|
+
return None
|
|
568
|
+
try:
|
|
569
|
+
import pyaudio # noqa: F401
|
|
570
|
+
return PyAudioCapture(device if device != "default" else None)
|
|
571
|
+
except Exception as e:
|
|
572
|
+
logger.warning(f"pyaudio not available: {e}")
|
|
573
|
+
return None
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _hostname() -> str:
|
|
577
|
+
import socket
|
|
578
|
+
return socket.gethostname().split(".")[0]
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def main() -> None:
|
|
582
|
+
parser = argparse.ArgumentParser(description="RoboPark preview agent")
|
|
583
|
+
parser.add_argument("--scheduler-url", default=os.getenv("SCHEDULER_URL", "http://localhost:8080"))
|
|
584
|
+
parser.add_argument("--robot-id", default=os.getenv("ROBOT_ID", _hostname()))
|
|
585
|
+
parser.add_argument("--device-token", default=os.getenv("DEVICE_TOKEN"))
|
|
586
|
+
parser.add_argument("--enrollment-token", default=os.getenv("ENROLLMENT_TOKEN"))
|
|
587
|
+
parser.add_argument("--video-device", default=os.getenv("VIDEO_DEVICE", "auto"))
|
|
588
|
+
parser.add_argument("--audio-device", default=os.getenv("AUDIO_DEVICE", "default"))
|
|
589
|
+
parser.add_argument("--width", type=int, default=int(os.getenv("VIDEO_WIDTH", DEFAULT_VIDEO_WIDTH)))
|
|
590
|
+
parser.add_argument("--height", type=int, default=int(os.getenv("VIDEO_HEIGHT", DEFAULT_VIDEO_HEIGHT)))
|
|
591
|
+
parser.add_argument("--fps", type=int, default=int(os.getenv("VIDEO_FPS", DEFAULT_FPS)))
|
|
592
|
+
parser.add_argument("--poll-interval", type=float, default=float(os.getenv("POLL_INTERVAL", DEFAULT_POLL_INTERVAL)))
|
|
593
|
+
parser.add_argument("--heartbeat-interval", type=float, default=float(os.getenv("HEARTBEAT_INTERVAL", DEFAULT_HEARTBEAT_INTERVAL)))
|
|
594
|
+
parser.add_argument("--save-config", action="store_true", help="write CLI args to ~/.robopark/preview_agent.json")
|
|
595
|
+
parser.add_argument("-v", "--verbose", action="store_true")
|
|
596
|
+
args = parser.parse_args()
|
|
597
|
+
|
|
598
|
+
logging.basicConfig(
|
|
599
|
+
level=logging.DEBUG if args.verbose else logging.INFO,
|
|
600
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
cfg = _load_config()
|
|
604
|
+
cfg.update({
|
|
605
|
+
"scheduler_url": args.scheduler_url,
|
|
606
|
+
"robot_id": args.robot_id,
|
|
607
|
+
"video_device": args.video_device,
|
|
608
|
+
"audio_device": args.audio_device,
|
|
609
|
+
"video_width": args.width,
|
|
610
|
+
"video_height": args.height,
|
|
611
|
+
"video_fps": args.fps,
|
|
612
|
+
"poll_interval": args.poll_interval,
|
|
613
|
+
"heartbeat_interval": args.heartbeat_interval,
|
|
614
|
+
})
|
|
615
|
+
if args.device_token:
|
|
616
|
+
cfg["device_token"] = args.device_token
|
|
617
|
+
_save_token(args.device_token)
|
|
618
|
+
if args.enrollment_token:
|
|
619
|
+
cfg["enrollment_token"] = args.enrollment_token
|
|
620
|
+
|
|
621
|
+
if args.save_config:
|
|
622
|
+
_save_config(cfg)
|
|
623
|
+
logger.info(f"saved config to {CONFIG_FILE}")
|
|
624
|
+
|
|
625
|
+
agent = PreviewAgent(cfg)
|
|
626
|
+
|
|
627
|
+
loop = asyncio.new_event_loop()
|
|
628
|
+
asyncio.set_event_loop(loop)
|
|
629
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
630
|
+
loop.add_signal_handler(sig, agent.shutdown)
|
|
631
|
+
|
|
632
|
+
try:
|
|
633
|
+
loop.run_until_complete(agent.run())
|
|
634
|
+
finally:
|
|
635
|
+
loop.close()
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
if __name__ == "__main__":
|
|
639
|
+
main()
|