infinicode 2.8.1 → 2.8.3

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.
@@ -0,0 +1,2668 @@
1
+ """
2
+ RoboPark Session Scheduler - Backend API
3
+ Manages robot fleet, LiveKit servers, and session orchestration
4
+ """
5
+
6
+ import os
7
+ import asyncio
8
+ import logging
9
+ import re
10
+ import secrets
11
+ import hashlib
12
+ import hmac
13
+ from datetime import datetime, timedelta
14
+ from typing import Optional, List
15
+ from contextlib import asynccontextmanager
16
+
17
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Query, Header
18
+ from fastapi.middleware.cors import CORSMiddleware
19
+ from fastapi.staticfiles import StaticFiles
20
+ from fastapi.responses import HTMLResponse
21
+ from pydantic import BaseModel
22
+ import httpx
23
+ import aiosqlite
24
+
25
+ # Configure logging
26
+ logging.basicConfig(level=logging.INFO)
27
+ logger = logging.getLogger(__name__)
28
+
29
+ # Database path
30
+ DB_PATH = os.path.join(os.getenv("SCHEDULER_DATA_DIR", "/app/data"), "scheduler.db")
31
+ # Path to a ROBOVOICE-style settings file the scheduler reads for characters.
32
+ # In production this should be a bind-mount to ROBOVOICE-main/settings.json (or .default.json).
33
+ ROBOVOICE_SETTINGS_PATH = os.path.join(os.getenv("SCHEDULER_DATA_DIR", "/app/data"), "robovoice_settings.json")
34
+
35
+ # Session end_reasons considered "abnormal" for drop-rate telemetry. A session
36
+ # that ended with one of these reasons is counted as a dropped session; anything
37
+ # that ended cleanly (e.g. "silence", "completed", "user_ended") is not.
38
+ ABNORMAL_END_REASONS = {
39
+ "error", "timeout", "crash", "server_error", "connection_lost",
40
+ "lost", "failed", "disconnect", "unknown",
41
+ }
42
+
43
+ # =============================================================================
44
+ # MODELS
45
+ # =============================================================================
46
+
47
+ class Robot(BaseModel):
48
+ id: str
49
+ name: str
50
+ character_id: Optional[str] = None
51
+ status: str = "idle" # idle, detecting, connecting, running
52
+ current_session_id: Optional[str] = None
53
+ connected_server_id: Optional[str] = None
54
+ ip_address: Optional[str] = None
55
+ last_heartbeat: Optional[datetime] = None
56
+ total_sessions: int = 0
57
+ total_runtime_seconds: int = 0
58
+
59
+ class LiveKitServer(BaseModel):
60
+ id: str
61
+ name: str
62
+ url: str
63
+ webhook_url: str # Internal URL for health/metrics
64
+ api_key: Optional[str] = None
65
+ api_secret: Optional[str] = None
66
+ gpu_name: Optional[str] = None
67
+ gpu_vram_mb: int = 0
68
+ max_sessions: int = 8
69
+ status: str = "unknown" # online, offline, maintenance
70
+
71
+ class Session(BaseModel):
72
+ id: str
73
+ robot_id: str
74
+ server_id: str
75
+ room_name: str
76
+ started_at: datetime
77
+ ended_at: Optional[datetime] = None
78
+ end_reason: Optional[str] = None
79
+ duration_seconds: Optional[int] = None
80
+
81
+ class GpuModel(BaseModel):
82
+ name: str
83
+ size_gb: float
84
+ is_loaded: bool = False
85
+ vram_used_mb: int = 0
86
+
87
+ class ServerMetrics(BaseModel):
88
+ server_id: str
89
+ gpu_utilization: int = 0
90
+ vram_used_mb: int = 0
91
+ vram_total_mb: int = 0
92
+ active_sessions: int = 0
93
+ models: List[GpuModel] = []
94
+
95
+ class SessionRequest(BaseModel):
96
+ robot_id: str
97
+
98
+ class WebhookEvent(BaseModel):
99
+ event: str
100
+ data: dict
101
+
102
+ class Device(BaseModel):
103
+ id: str
104
+ name: str
105
+ tailscale_ip: Optional[str] = None
106
+ lan_ip: Optional[str] = None
107
+ motor_server_url: Optional[str] = None
108
+ character_id: Optional[str] = None
109
+ livekit_url: Optional[str] = None
110
+ status: str = "enrolled" # enrolled, online, offline, disabled
111
+ last_heartbeat: Optional[datetime] = None
112
+ enrolled_at: Optional[datetime] = None
113
+ last_seen_ip: Optional[str] = None
114
+ notes: Optional[str] = None
115
+ created_at: Optional[datetime] = None
116
+
117
+ class DeviceCreate(BaseModel):
118
+ name: str
119
+ tailscale_ip: Optional[str] = None
120
+ lan_ip: Optional[str] = None
121
+ motor_server_url: Optional[str] = None
122
+ character_id: Optional[str] = None
123
+ livekit_url: Optional[str] = None
124
+ notes: Optional[str] = None
125
+ enrollment_token: Optional[str] = None # if omitted, one is auto-generated
126
+
127
+ class DeviceEnrollRequest(BaseModel):
128
+ enrollment_token: str
129
+ name: Optional[str] = None
130
+ tailscale_ip: Optional[str] = None
131
+ lan_ip: Optional[str] = None
132
+ motor_server_url: Optional[str] = None
133
+ livekit_url: Optional[str] = None
134
+ character_id: Optional[str] = None
135
+
136
+ class DeviceEnrollResponse(BaseModel):
137
+ device_id: str
138
+ device_token: str
139
+ scheduler_url: str
140
+
141
+ class DeviceHeartbeat(BaseModel):
142
+ status: Optional[str] = None
143
+ ip: Optional[str] = None
144
+ uptime_seconds: Optional[int] = None
145
+ production_mode: Optional[bool] = None
146
+
147
+ class SettingsPayload(BaseModel):
148
+ production_mode: bool
149
+
150
+ class LiveKitConfig(BaseModel):
151
+ url: Optional[str] = None
152
+ api_key: Optional[str] = None
153
+ has_secret: bool = False
154
+
155
+ class LiveKitTokenRequest(BaseModel):
156
+ room: str
157
+ identity: str
158
+ name: Optional[str] = None
159
+ can_publish: bool = True
160
+ can_subscribe: bool = True
161
+ ttl_seconds: int = 3600
162
+
163
+ class LiveKitTokenResponse(BaseModel):
164
+ url: str
165
+ token: str
166
+ room: str
167
+ identity: str
168
+ expires_at: datetime
169
+
170
+ # =============================================================================
171
+ # DATABASE
172
+ # =============================================================================
173
+
174
+ async def init_db():
175
+ """Initialize SQLite database"""
176
+ os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
177
+
178
+ async with aiosqlite.connect(DB_PATH) as db:
179
+ await db.executescript("""
180
+ CREATE TABLE IF NOT EXISTS robots (
181
+ id TEXT PRIMARY KEY,
182
+ name TEXT NOT NULL,
183
+ character_id TEXT,
184
+ status TEXT DEFAULT 'idle',
185
+ current_session_id TEXT,
186
+ connected_server_id TEXT,
187
+ ip_address TEXT,
188
+ last_heartbeat TEXT,
189
+ total_sessions INTEGER DEFAULT 0,
190
+ total_runtime_seconds INTEGER DEFAULT 0,
191
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
192
+ );
193
+
194
+ CREATE TABLE IF NOT EXISTS livekit_servers (
195
+ id TEXT PRIMARY KEY,
196
+ name TEXT NOT NULL,
197
+ url TEXT NOT NULL,
198
+ webhook_url TEXT NOT NULL,
199
+ api_key TEXT,
200
+ api_secret TEXT,
201
+ gpu_name TEXT,
202
+ gpu_vram_mb INTEGER DEFAULT 0,
203
+ max_sessions INTEGER DEFAULT 8,
204
+ status TEXT DEFAULT 'unknown',
205
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
206
+ );
207
+
208
+ CREATE TABLE IF NOT EXISTS sessions (
209
+ id TEXT PRIMARY KEY,
210
+ robot_id TEXT,
211
+ server_id TEXT,
212
+ room_name TEXT,
213
+ started_at TEXT,
214
+ ended_at TEXT,
215
+ end_reason TEXT,
216
+ duration_seconds INTEGER,
217
+ FOREIGN KEY (robot_id) REFERENCES robots(id),
218
+ FOREIGN KEY (server_id) REFERENCES livekit_servers(id)
219
+ );
220
+
221
+ CREATE TABLE IF NOT EXISTS metrics_history (
222
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
223
+ server_id TEXT,
224
+ gpu_utilization INTEGER,
225
+ vram_used_mb INTEGER,
226
+ active_sessions INTEGER,
227
+ recorded_at TEXT DEFAULT CURRENT_TIMESTAMP
228
+ );
229
+
230
+ CREATE TABLE IF NOT EXISTS devices (
231
+ id TEXT PRIMARY KEY,
232
+ name TEXT NOT NULL,
233
+ tailscale_ip TEXT,
234
+ lan_ip TEXT,
235
+ motor_server_url TEXT,
236
+ character_id TEXT,
237
+ livekit_url TEXT,
238
+ token_hash TEXT,
239
+ enrollment_token_hash TEXT,
240
+ status TEXT DEFAULT 'enrolled',
241
+ last_heartbeat TEXT,
242
+ enrolled_at TEXT,
243
+ last_seen_ip TEXT,
244
+ notes TEXT,
245
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
246
+ );
247
+
248
+ CREATE TABLE IF NOT EXISTS settings (
249
+ key TEXT PRIMARY KEY,
250
+ value TEXT,
251
+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP
252
+ );
253
+
254
+ CREATE TABLE IF NOT EXISTS previews (
255
+ robot_id TEXT PRIMARY KEY,
256
+ room_name TEXT NOT NULL,
257
+ server_id TEXT,
258
+ expires_at TEXT NOT NULL,
259
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
260
+ );
261
+ """)
262
+ await db.commit()
263
+
264
+ # ---- Additive telemetry migrations (safe on existing DBs) ----
265
+ # trigger_count on robots (camera-trigger counter) and joined_at on
266
+ # sessions (room-join timestamp for latency). SQLite's
267
+ # ALTER TABLE ... ADD COLUMN raises if the column already exists, so
268
+ # each one is guarded and idempotent.
269
+ for _table, _column, _coldef in (
270
+ ("robots", "trigger_count", "INTEGER DEFAULT 0"),
271
+ ("sessions", "joined_at", "TEXT"),
272
+ ):
273
+ try:
274
+ await db.execute(f"ALTER TABLE {_table} ADD COLUMN {_column} {_coldef}")
275
+ except Exception:
276
+ pass # column already present
277
+ await db.commit()
278
+
279
+ # Insert default robots if not exist
280
+ default_robots = [
281
+ ("robopanda", "RoboPanda", "panda-character"),
282
+ ("robobear", "RoboBear", "bear-character"),
283
+ ("robocar", "RoboCar", "car-character"),
284
+ ("robodragon", "RoboDragon", "dragon-character"),
285
+ ("robofrog", "RoboFrog", "frog-character"),
286
+ ]
287
+ for robot_id, name, char_id in default_robots:
288
+ await db.execute(
289
+ "INSERT OR IGNORE INTO robots (id, name, character_id) VALUES (?, ?, ?)",
290
+ (robot_id, name, char_id)
291
+ )
292
+ await db.commit()
293
+
294
+ # Seed default enrollment token + production_mode flag (only on first run)
295
+ async with db.execute("SELECT value FROM settings WHERE key = 'default_enrollment_token'") as c:
296
+ row = await c.fetchone()
297
+ if not row:
298
+ token = secrets.token_urlsafe(24)
299
+ token_hash = hashlib.sha256(token.encode()).hexdigest()
300
+ await db.execute(
301
+ "INSERT INTO settings (key, value) VALUES (?, ?)",
302
+ ("default_enrollment_token", token_hash)
303
+ )
304
+ logger.info("=" * 70)
305
+ logger.info(f"DEFAULT ENROLLMENT TOKEN (Pi first-boot): {token}")
306
+ logger.info("Use this on the Pi: --enrollment-token <token>")
307
+ logger.info("Rotate or replace it in the UI under Settings.")
308
+ logger.info("=" * 70)
309
+
310
+ async with db.execute("SELECT value FROM settings WHERE key = 'production_mode'") as c:
311
+ row = await c.fetchone()
312
+ if not row:
313
+ await db.execute(
314
+ "INSERT INTO settings (key, value) VALUES (?, ?)",
315
+ ("production_mode", "false")
316
+ )
317
+
318
+ await db.commit()
319
+
320
+ logger.info("Database initialized")
321
+
322
+ # =============================================================================
323
+ # APP SETUP
324
+ # =============================================================================
325
+
326
+ @asynccontextmanager
327
+ async def lifespan(app: FastAPI):
328
+ await init_db()
329
+ asyncio.create_task(metrics_collector())
330
+ asyncio.create_task(health_checker())
331
+ yield
332
+
333
+ app = FastAPI(
334
+ title="RoboPark Session Scheduler",
335
+ description="Manages robot fleet and LiveKit server orchestration",
336
+ version="1.0.0",
337
+ lifespan=lifespan
338
+ )
339
+
340
+ # CORS: env-driven allowlist. Set ROBOPARK_CORS_ORIGINS to a comma-separated
341
+ # list of allowed origins; defaults to localhost only. A wildcard origin is
342
+ # incompatible with credentialed requests, so credentials are only enabled
343
+ # when an explicit (non-wildcard) allowlist is configured.
344
+ _cors_env = os.getenv("ROBOPARK_CORS_ORIGINS", "http://localhost:3000,http://127.0.0.1:3000")
345
+ _cors_origins = [o.strip() for o in _cors_env.split(",") if o.strip()]
346
+ _allow_credentials = "*" not in _cors_origins
347
+ app.add_middleware(
348
+ CORSMiddleware,
349
+ allow_origins=_cors_origins,
350
+ allow_credentials=_allow_credentials,
351
+ allow_methods=["*"],
352
+ allow_headers=["*"],
353
+ )
354
+
355
+ # WebSocket clients for real-time updates
356
+ ws_clients: List[WebSocket] = []
357
+
358
+ # =============================================================================
359
+ # SETTINGS HELPERS
360
+ # =============================================================================
361
+
362
+ def _hash(token: str) -> str:
363
+ return hashlib.sha256(token.encode()).hexdigest()
364
+
365
+ async def get_setting(key: str, default: Optional[str] = None) -> Optional[str]:
366
+ async with aiosqlite.connect(DB_PATH) as db:
367
+ async with db.execute("SELECT value FROM settings WHERE key = ?", (key,)) as c:
368
+ row = await c.fetchone()
369
+ return row[0] if row else default
370
+
371
+ async def set_setting(key: str, value: str) -> None:
372
+ async with aiosqlite.connect(DB_PATH) as db:
373
+ await db.execute(
374
+ "INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?) "
375
+ "ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
376
+ (key, value, datetime.utcnow().isoformat()),
377
+ )
378
+ await db.commit()
379
+
380
+ async def get_production_mode() -> bool:
381
+ v = await get_setting("production_mode", "false")
382
+ return (v or "false").lower() == "true"
383
+
384
+ # =============================================================================
385
+ # WEBSOCKET
386
+ # =============================================================================
387
+
388
+ @app.websocket("/ws")
389
+ async def websocket_endpoint(websocket: WebSocket):
390
+ await websocket.accept()
391
+ ws_clients.append(websocket)
392
+ logger.info(f"WebSocket client connected. Total: {len(ws_clients)}")
393
+ try:
394
+ while True:
395
+ await websocket.receive_text()
396
+ except WebSocketDisconnect:
397
+ ws_clients.remove(websocket)
398
+ logger.info(f"WebSocket client disconnected. Total: {len(ws_clients)}")
399
+
400
+ async def broadcast(event_type: str, data: dict):
401
+ """Broadcast update to all connected WebSocket clients"""
402
+ if not ws_clients:
403
+ return
404
+ message = {"type": event_type, "data": data, "timestamp": datetime.utcnow().isoformat()}
405
+ for client in ws_clients.copy():
406
+ try:
407
+ await client.send_json(message)
408
+ except:
409
+ ws_clients.remove(client)
410
+
411
+ # =============================================================================
412
+ # ROBOT ENDPOINTS
413
+ # =============================================================================
414
+
415
+ @app.get("/api/robots", response_model=List[Robot])
416
+ async def list_robots():
417
+ """List all robots"""
418
+ async with aiosqlite.connect(DB_PATH) as db:
419
+ db.row_factory = aiosqlite.Row
420
+ async with db.execute("SELECT * FROM robots ORDER BY name") as cursor:
421
+ rows = await cursor.fetchall()
422
+ return [dict(row) for row in rows]
423
+
424
+ @app.get("/api/robots/{robot_id}", response_model=Robot)
425
+ async def get_robot(robot_id: str):
426
+ """Get single robot"""
427
+ async with aiosqlite.connect(DB_PATH) as db:
428
+ db.row_factory = aiosqlite.Row
429
+ async with db.execute("SELECT * FROM robots WHERE id = ?", (robot_id,)) as cursor:
430
+ row = await cursor.fetchone()
431
+ if not row:
432
+ raise HTTPException(404, "Robot not found")
433
+ return dict(row)
434
+
435
+ @app.post("/api/robots/{robot_id}/heartbeat")
436
+ async def robot_heartbeat(robot_id: str, ip_address: Optional[str] = None):
437
+ """Robot heartbeat - call every 5 seconds"""
438
+ async with aiosqlite.connect(DB_PATH) as db:
439
+ await db.execute(
440
+ "UPDATE robots SET last_heartbeat = ?, ip_address = COALESCE(?, ip_address) WHERE id = ?",
441
+ (datetime.utcnow().isoformat(), ip_address, robot_id)
442
+ )
443
+ await db.commit()
444
+ await broadcast("robot_heartbeat", {"robot_id": robot_id})
445
+ return {"status": "ok"}
446
+
447
+ @app.post("/api/robots/{robot_id}/request-session")
448
+ async def request_session(robot_id: str,
449
+ authorization: Optional[str] = Header(default=None)):
450
+ """Robot requests a session after scene detection.
451
+
452
+ Requires a valid enrolled-device Bearer token. The response never contains
453
+ the LiveKit api_key/api_secret; instead the scheduler mints a short-lived
454
+ JWT the robot uses to join the room."""
455
+ if not await _authorize_fleet(authorization):
456
+ raise HTTPException(401, "Invalid or missing device token")
457
+ async with aiosqlite.connect(DB_PATH) as db:
458
+ db.row_factory = aiosqlite.Row
459
+
460
+ # Check robot exists and is idle
461
+ async with db.execute("SELECT * FROM robots WHERE id = ?", (robot_id,)) as cursor:
462
+ robot = await cursor.fetchone()
463
+ if not robot:
464
+ raise HTTPException(404, "Robot not found")
465
+ if robot["status"] != "idle":
466
+ raise HTTPException(400, f"Robot is {robot['status']}, not idle")
467
+
468
+ # Find least-loaded online server
469
+ async with db.execute("""
470
+ SELECT s.*,
471
+ (SELECT COUNT(*) FROM sessions WHERE server_id = s.id AND ended_at IS NULL) as active
472
+ FROM livekit_servers s
473
+ WHERE s.status = 'online'
474
+ ORDER BY active ASC
475
+ LIMIT 1
476
+ """) as cursor:
477
+ server = await cursor.fetchone()
478
+ if not server:
479
+ raise HTTPException(503, "No available servers")
480
+ if server["active"] >= server["max_sessions"]:
481
+ raise HTTPException(503, "All servers at capacity")
482
+
483
+ # Create session
484
+ session_id = f"session_{robot_id}_{int(datetime.utcnow().timestamp())}"
485
+ room_name = f"robopark_{robot_id}_{int(datetime.utcnow().timestamp())}"
486
+
487
+ await db.execute("""
488
+ INSERT INTO sessions (id, robot_id, server_id, room_name, started_at)
489
+ VALUES (?, ?, ?, ?, ?)
490
+ """, (session_id, robot_id, server["id"], room_name, datetime.utcnow().isoformat()))
491
+
492
+ # Update robot status + count this as a camera trigger. request-session
493
+ # is the natural trigger point ("robot requests a session after scene
494
+ # detection"), so the trigger_count populates here with no client change.
495
+ # A client that wants to report a camera-open that does NOT reach
496
+ # request-session can call POST /api/robots/{robot_id}/trigger instead
497
+ # (it should not call both for the same detection, to avoid double count).
498
+ await db.execute("""
499
+ UPDATE robots SET status = 'connecting', current_session_id = ?, connected_server_id = ?,
500
+ trigger_count = COALESCE(trigger_count, 0) + 1
501
+ WHERE id = ?
502
+ """, (session_id, server["id"], robot_id))
503
+
504
+ await db.commit()
505
+
506
+ await broadcast("session_requested", {
507
+ "robot_id": robot_id,
508
+ "server_id": server["id"],
509
+ "session_id": session_id
510
+ })
511
+
512
+ # Mint a short-lived LiveKit token so the robot can join the room without
513
+ # ever receiving the raw api_key/api_secret.
514
+ try:
515
+ from livekit.api import AccessToken, VideoGrants
516
+ except ImportError:
517
+ raise HTTPException(503, "livekit-api not installed on scheduler")
518
+
519
+ lk_key = server["api_key"] or "devkey"
520
+ lk_secret = server["api_secret"] or "secret"
521
+ grants = VideoGrants(
522
+ room=room_name,
523
+ room_join=True,
524
+ can_publish=True,
525
+ can_subscribe=True,
526
+ can_publish_data=True,
527
+ )
528
+ token = (
529
+ AccessToken(lk_key, lk_secret)
530
+ .with_identity(robot_id)
531
+ .with_name(robot_id)
532
+ .with_ttl(timedelta(hours=1))
533
+ .with_grants(grants)
534
+ .to_jwt()
535
+ )
536
+
537
+ return {
538
+ "session_id": session_id,
539
+ "server_id": server["id"],
540
+ "server_url": server["url"],
541
+ "room_name": room_name,
542
+ "token": token,
543
+ }
544
+
545
+ @app.post("/api/robots/{robot_id}/end-session")
546
+ async def end_session(robot_id: str, reason: str = "silence"):
547
+ """Robot ends session"""
548
+ async with aiosqlite.connect(DB_PATH) as db:
549
+ db.row_factory = aiosqlite.Row
550
+
551
+ async with db.execute("SELECT * FROM robots WHERE id = ?", (robot_id,)) as cursor:
552
+ robot = await cursor.fetchone()
553
+ if not robot or not robot["current_session_id"]:
554
+ raise HTTPException(400, "No active session")
555
+
556
+ # Update session
557
+ async with db.execute("SELECT * FROM sessions WHERE id = ?", (robot["current_session_id"],)) as cursor:
558
+ session = await cursor.fetchone()
559
+ if session:
560
+ started = datetime.fromisoformat(session["started_at"])
561
+ duration = int((datetime.utcnow() - started).total_seconds())
562
+
563
+ await db.execute("""
564
+ UPDATE sessions SET ended_at = ?, end_reason = ?, duration_seconds = ?
565
+ WHERE id = ?
566
+ """, (datetime.utcnow().isoformat(), reason, duration, session["id"]))
567
+
568
+ # Update robot stats
569
+ await db.execute("""
570
+ UPDATE robots SET
571
+ status = 'idle',
572
+ current_session_id = NULL,
573
+ connected_server_id = NULL,
574
+ total_sessions = total_sessions + 1,
575
+ total_runtime_seconds = total_runtime_seconds + ?
576
+ WHERE id = ?
577
+ """, (duration, robot_id))
578
+
579
+ await db.commit()
580
+
581
+ await broadcast("session_ended", {"robot_id": robot_id, "reason": reason})
582
+ return {"status": "ok"}
583
+
584
+ @app.post("/api/robots/{robot_id}/trigger")
585
+ async def robot_trigger(robot_id: str,
586
+ authorization: Optional[str] = Header(default=None)):
587
+ """Record a camera trigger for a robot.
588
+
589
+ Called when the robot's camera detects a scene / opens a session, to keep a
590
+ per-robot trigger counter. Requires a valid enrolled-device Bearer token
591
+ (same fleet auth as /request-session).
592
+
593
+ NOTE: /request-session already increments trigger_count as the natural
594
+ trigger point. Use this endpoint only to report a camera-open that does NOT
595
+ proceed to /request-session; do not call both for the same detection or the
596
+ trigger will be counted twice."""
597
+ if not await _authorize_fleet(authorization):
598
+ raise HTTPException(401, "Invalid or missing device token")
599
+ async with aiosqlite.connect(DB_PATH) as db:
600
+ cur = await db.execute(
601
+ "UPDATE robots SET trigger_count = COALESCE(trigger_count, 0) + 1 WHERE id = ?",
602
+ (robot_id,),
603
+ )
604
+ await db.commit()
605
+ if cur.rowcount == 0:
606
+ raise HTTPException(404, "Robot not found")
607
+ async with db.execute("SELECT trigger_count FROM robots WHERE id = ?", (robot_id,)) as c:
608
+ row = await c.fetchone()
609
+ await broadcast("robot_triggered", {"robot_id": robot_id})
610
+ return {"status": "ok", "trigger_count": row[0] if row else None}
611
+
612
+ # =============================================================================
613
+ # SERVER ENDPOINTS
614
+ # =============================================================================
615
+
616
+ @app.get("/api/servers")
617
+ async def list_servers():
618
+ """List all LiveKit servers with current load"""
619
+ async with aiosqlite.connect(DB_PATH) as db:
620
+ db.row_factory = aiosqlite.Row
621
+ async with db.execute("""
622
+ SELECT s.*,
623
+ (SELECT COUNT(*) FROM sessions WHERE server_id = s.id AND ended_at IS NULL) as active_sessions
624
+ FROM livekit_servers s
625
+ ORDER BY s.name
626
+ """) as cursor:
627
+ return [dict(row) for row in await cursor.fetchall()]
628
+
629
+ @app.post("/api/servers")
630
+ async def add_server(server: LiveKitServer):
631
+ """Add a new LiveKit server"""
632
+ async with aiosqlite.connect(DB_PATH) as db:
633
+ await db.execute("""
634
+ INSERT OR REPLACE INTO livekit_servers
635
+ (id, name, url, webhook_url, api_key, api_secret, gpu_name, gpu_vram_mb, max_sessions, status)
636
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
637
+ """, (server.id, server.name, server.url, server.webhook_url,
638
+ server.api_key, server.api_secret, server.gpu_name,
639
+ server.gpu_vram_mb, server.max_sessions, server.status))
640
+ await db.commit()
641
+ await broadcast("server_added", {"server_id": server.id})
642
+ return {"status": "ok"}
643
+
644
+ @app.delete("/api/servers/{server_id}")
645
+ async def remove_server(server_id: str):
646
+ """Remove a LiveKit server"""
647
+ async with aiosqlite.connect(DB_PATH) as db:
648
+ await db.execute("DELETE FROM livekit_servers WHERE id = ?", (server_id,))
649
+ await db.commit()
650
+ await broadcast("server_removed", {"server_id": server_id})
651
+ return {"status": "ok"}
652
+
653
+ @app.get("/api/servers/{server_id}/metrics")
654
+ async def get_server_metrics(server_id: str) -> ServerMetrics:
655
+ """Get real-time metrics from a LiveKit server"""
656
+ async with aiosqlite.connect(DB_PATH) as db:
657
+ db.row_factory = aiosqlite.Row
658
+ async with db.execute("SELECT * FROM livekit_servers WHERE id = ?", (server_id,)) as cursor:
659
+ server = await cursor.fetchone()
660
+ if not server:
661
+ raise HTTPException(404, "Server not found")
662
+
663
+ # Fetch metrics from server
664
+ try:
665
+ async with httpx.AsyncClient(timeout=5.0) as client:
666
+ resp = await client.get(f"{server['webhook_url']}/metrics")
667
+ data = resp.json()
668
+
669
+ # Get models
670
+ models_resp = await client.get(f"{server['webhook_url']}/models")
671
+ models_data = models_resp.json()
672
+
673
+ models = []
674
+ for m in models_data.get("models", []):
675
+ models.append(GpuModel(
676
+ name=m["name"],
677
+ size_gb=m.get("size", 0) / (1024**3),
678
+ is_loaded=True,
679
+ vram_used_mb=int(m.get("size", 0) / (1024**2))
680
+ ))
681
+
682
+ return ServerMetrics(
683
+ server_id=server_id,
684
+ gpu_utilization=data.get("gpu_utilization", 0),
685
+ vram_used_mb=data.get("vram_used_mb", 0),
686
+ vram_total_mb=data.get("vram_total_mb", 0),
687
+ active_sessions=data.get("active_sessions", 0),
688
+ models=models
689
+ )
690
+ except Exception as e:
691
+ logger.error(f"Failed to get metrics from {server_id}: {e}")
692
+ return ServerMetrics(server_id=server_id)
693
+
694
+ @app.post("/api/servers/{server_id}/models/{model_name}/load")
695
+ async def load_model(server_id: str, model_name: str):
696
+ """Load a model on a server"""
697
+ async with aiosqlite.connect(DB_PATH) as db:
698
+ db.row_factory = aiosqlite.Row
699
+ async with db.execute("SELECT webhook_url FROM livekit_servers WHERE id = ?", (server_id,)) as cursor:
700
+ server = await cursor.fetchone()
701
+ if not server:
702
+ raise HTTPException(404, "Server not found")
703
+
704
+ try:
705
+ async with httpx.AsyncClient(timeout=120.0) as client:
706
+ resp = await client.post(f"{server['webhook_url']}/models/{model_name}/load")
707
+ await broadcast("model_loaded", {"server_id": server_id, "model": model_name})
708
+ return resp.json()
709
+ except Exception as e:
710
+ raise HTTPException(500, f"Failed to load model: {e}")
711
+
712
+ @app.post("/api/servers/{server_id}/models/{model_name}/unload")
713
+ async def unload_model(server_id: str, model_name: str):
714
+ """Unload a model from a server"""
715
+ async with aiosqlite.connect(DB_PATH) as db:
716
+ db.row_factory = aiosqlite.Row
717
+ async with db.execute("SELECT webhook_url FROM livekit_servers WHERE id = ?", (server_id,)) as cursor:
718
+ server = await cursor.fetchone()
719
+ if not server:
720
+ raise HTTPException(404, "Server not found")
721
+
722
+ try:
723
+ async with httpx.AsyncClient(timeout=30.0) as client:
724
+ resp = await client.post(f"{server['webhook_url']}/models/{model_name}/unload")
725
+ await broadcast("model_unloaded", {"server_id": server_id, "model": model_name})
726
+ return resp.json()
727
+ except Exception as e:
728
+ raise HTTPException(500, f"Failed to unload model: {e}")
729
+
730
+ # =============================================================================
731
+ # SESSION ENDPOINTS
732
+ # =============================================================================
733
+
734
+ class WebClientSessionRequest(BaseModel):
735
+ """Request body for web client session"""
736
+ client_name: Optional[str] = "Web User"
737
+
738
+ class WebClientSessionResponse(BaseModel):
739
+ """Response for web client session"""
740
+ session_id: str
741
+ server_id: str
742
+ server_url: str
743
+ room_name: str
744
+ api_key: str
745
+ api_secret: str
746
+
747
+ @app.post("/api/sessions/web-client", response_model=WebClientSessionResponse)
748
+ async def create_web_client_session(req: Optional[WebClientSessionRequest] = None):
749
+ """Create a session for a web client (not tied to a robot)"""
750
+ async with aiosqlite.connect(DB_PATH) as db:
751
+ db.row_factory = aiosqlite.Row
752
+
753
+ # Find least-loaded online server
754
+ async with db.execute("""
755
+ SELECT s.*,
756
+ (SELECT COUNT(*) FROM sessions WHERE server_id = s.id AND ended_at IS NULL) as active
757
+ FROM livekit_servers s
758
+ WHERE s.status = 'online'
759
+ ORDER BY active ASC
760
+ LIMIT 1
761
+ """) as cursor:
762
+ server = await cursor.fetchone()
763
+ if not server:
764
+ raise HTTPException(503, "No available servers")
765
+ if server["active"] >= server["max_sessions"]:
766
+ raise HTTPException(503, "All servers at capacity")
767
+
768
+ # Create session
769
+ session_id = f"web_{int(datetime.utcnow().timestamp())}"
770
+ room_name = f"robopark_web_{int(datetime.utcnow().timestamp())}"
771
+
772
+ await db.execute("""
773
+ INSERT INTO sessions (id, robot_id, server_id, room_name, started_at)
774
+ VALUES (?, ?, ?, ?, ?)
775
+ """, (session_id, "web-client", server["id"], room_name, datetime.utcnow().isoformat()))
776
+
777
+ await db.commit()
778
+
779
+ await broadcast("web_session_started", {
780
+ "session_id": session_id,
781
+ "server_id": server["id"],
782
+ })
783
+
784
+ return WebClientSessionResponse(
785
+ session_id=session_id,
786
+ server_id=server["id"],
787
+ server_url=server["url"],
788
+ room_name=room_name,
789
+ api_key=server["api_key"] or "devkey",
790
+ api_secret=server["api_secret"] or "secret",
791
+ )
792
+
793
+ @app.post("/api/sessions/{session_id}/end")
794
+ async def end_web_session(session_id: str, reason: str = "disconnect"):
795
+ """End a web client session"""
796
+ async with aiosqlite.connect(DB_PATH) as db:
797
+ db.row_factory = aiosqlite.Row
798
+
799
+ async with db.execute("SELECT * FROM sessions WHERE id = ?", (session_id,)) as cursor:
800
+ session = await cursor.fetchone()
801
+ if not session:
802
+ return {"status": "not_found"}
803
+
804
+ if session["ended_at"]:
805
+ return {"status": "already_ended"}
806
+
807
+ started = datetime.fromisoformat(session["started_at"])
808
+ duration = int((datetime.utcnow() - started).total_seconds())
809
+
810
+ await db.execute("""
811
+ UPDATE sessions SET ended_at = ?, end_reason = ?, duration_seconds = ?
812
+ WHERE id = ?
813
+ """, (datetime.utcnow().isoformat(), reason, duration, session_id))
814
+
815
+ await db.commit()
816
+
817
+ await broadcast("session_ended", {"session_id": session_id, "reason": reason})
818
+ return {"status": "ok"}
819
+
820
+ def _session_latency_ms(started_at: Optional[str], joined_at: Optional[str]) -> Optional[int]:
821
+ """Milliseconds between session request (started_at) and room join (joined_at)."""
822
+ if not started_at or not joined_at:
823
+ return None
824
+ try:
825
+ s = datetime.fromisoformat(started_at)
826
+ j = datetime.fromisoformat(joined_at)
827
+ return int((j - s).total_seconds() * 1000)
828
+ except Exception:
829
+ return None
830
+
831
+ @app.post("/api/sessions/{session_id}/joined")
832
+ async def session_joined(session_id: str,
833
+ authorization: Optional[str] = Header(default=None)):
834
+ """Mark the moment a robot successfully joined the LiveKit room for a session.
835
+
836
+ Used to compute session latency = joined_at - started_at (the session row's
837
+ started_at is set when the session is requested). Requires a valid
838
+ enrolled-device Bearer token. Idempotent: the first join wins; later calls
839
+ leave the original join time in place."""
840
+ if not await _authorize_fleet(authorization):
841
+ raise HTTPException(401, "Invalid or missing device token")
842
+ now = datetime.utcnow().isoformat()
843
+ async with aiosqlite.connect(DB_PATH) as db:
844
+ db.row_factory = aiosqlite.Row
845
+ async with db.execute("SELECT * FROM sessions WHERE id = ?", (session_id,)) as c:
846
+ session = await c.fetchone()
847
+ if not session:
848
+ raise HTTPException(404, "Session not found")
849
+ if session["joined_at"]:
850
+ return {
851
+ "status": "already_joined",
852
+ "joined_at": session["joined_at"],
853
+ "latency_ms": _session_latency_ms(session["started_at"], session["joined_at"]),
854
+ }
855
+ await db.execute("UPDATE sessions SET joined_at = ? WHERE id = ?", (now, session_id))
856
+ await db.commit()
857
+ latency_ms = _session_latency_ms(session["started_at"], now)
858
+ await broadcast("session_joined", {"session_id": session_id, "latency_ms": latency_ms})
859
+ return {"status": "ok", "joined_at": now, "latency_ms": latency_ms}
860
+
861
+ # ── Back-compat: legacy web-client session token (ported from the deployed OLD
862
+ # scheduler so voice_client.html / the old frontend keep working after the
863
+ # fork reconcile). Raw-PyJWT HS256, publish-capable "Web User" token. ──
864
+ @app.get("/api/sessions/{session_id}/token")
865
+ async def get_session_token(session_id: str):
866
+ """Generate a LiveKit access token for a session"""
867
+ import jwt
868
+
869
+ async with aiosqlite.connect(DB_PATH) as db:
870
+ db.row_factory = aiosqlite.Row
871
+
872
+ async with db.execute("""
873
+ SELECT s.*, srv.api_key, srv.api_secret
874
+ FROM sessions s
875
+ JOIN livekit_servers srv ON s.server_id = srv.id
876
+ WHERE s.id = ?
877
+ """, (session_id,)) as cursor:
878
+ session = await cursor.fetchone()
879
+ if not session:
880
+ raise HTTPException(404, "Session not found")
881
+ if session["ended_at"]:
882
+ raise HTTPException(400, "Session already ended")
883
+
884
+ api_key = session["api_key"] or "devkey"
885
+ api_secret = session["api_secret"] or "secret"
886
+ room_name = session["room_name"]
887
+
888
+ # Generate token
889
+ token = jwt.encode(
890
+ {
891
+ "name": "Web User",
892
+ "video": {
893
+ "room": room_name,
894
+ "roomJoin": True,
895
+ "canPublish": True,
896
+ "canPublishData": True,
897
+ "canSubscribe": True
898
+ },
899
+ "iss": api_key,
900
+ "nbf": 0,
901
+ "exp": int(datetime.utcnow().timestamp()) + 3600, # 1 hour
902
+ "sub": f"web_user_{int(datetime.utcnow().timestamp())}"
903
+ },
904
+ api_secret,
905
+ algorithm="HS256"
906
+ )
907
+
908
+ return {"token": token, "room_name": room_name}
909
+
910
+ @app.get("/api/sessions")
911
+ async def list_sessions(active_only: bool = False, limit: int = 50):
912
+ """List sessions"""
913
+ async with aiosqlite.connect(DB_PATH) as db:
914
+ db.row_factory = aiosqlite.Row
915
+ if active_only:
916
+ query = "SELECT * FROM sessions WHERE ended_at IS NULL ORDER BY started_at DESC"
917
+ else:
918
+ query = f"SELECT * FROM sessions ORDER BY started_at DESC LIMIT {limit}"
919
+ async with db.execute(query) as cursor:
920
+ return [dict(row) for row in await cursor.fetchall()]
921
+
922
+ @app.get("/api/sessions/stats")
923
+ async def session_stats():
924
+ """Get session statistics"""
925
+ async with aiosqlite.connect(DB_PATH) as db:
926
+ db.row_factory = aiosqlite.Row
927
+
928
+ today = datetime.utcnow().date().isoformat()
929
+
930
+ stats = {}
931
+
932
+ # Today's sessions
933
+ async with db.execute(
934
+ "SELECT COUNT(*) as count FROM sessions WHERE started_at LIKE ?", (f"{today}%",)
935
+ ) as cursor:
936
+ row = await cursor.fetchone()
937
+ stats["today_sessions"] = row["count"]
938
+
939
+ # Active sessions
940
+ async with db.execute(
941
+ "SELECT COUNT(*) as count FROM sessions WHERE ended_at IS NULL"
942
+ ) as cursor:
943
+ row = await cursor.fetchone()
944
+ stats["active_sessions"] = row["count"]
945
+
946
+ # Average duration
947
+ async with db.execute(
948
+ "SELECT AVG(duration_seconds) as avg FROM sessions WHERE duration_seconds IS NOT NULL"
949
+ ) as cursor:
950
+ row = await cursor.fetchone()
951
+ stats["avg_duration_seconds"] = int(row["avg"] or 0)
952
+
953
+ # Total sessions
954
+ async with db.execute("SELECT COUNT(*) as count FROM sessions") as cursor:
955
+ row = await cursor.fetchone()
956
+ stats["total_sessions"] = row["count"]
957
+
958
+ return stats
959
+
960
+ # =============================================================================
961
+ # WEBHOOK RECEIVER
962
+ # =============================================================================
963
+
964
+ @app.post("/api/webhooks/livekit")
965
+ async def livekit_webhook(event: WebhookEvent):
966
+ """Receive events from LiveKit servers"""
967
+ logger.info(f"Webhook received: {event.event} - {event.data}")
968
+
969
+ if event.event == "session_started":
970
+ robot_id = event.data.get("robot_id")
971
+ if robot_id:
972
+ async with aiosqlite.connect(DB_PATH) as db:
973
+ await db.execute(
974
+ "UPDATE robots SET status = 'running' WHERE id = ?",
975
+ (robot_id,)
976
+ )
977
+ await db.commit()
978
+ await broadcast("session_started", event.data)
979
+
980
+ elif event.event == "session_ended":
981
+ robot_id = event.data.get("robot_id")
982
+ if robot_id:
983
+ # Use the end_session logic
984
+ try:
985
+ await end_session(robot_id, event.data.get("reason", "unknown"))
986
+ except:
987
+ pass
988
+
989
+ return {"status": "ok"}
990
+
991
+ # =============================================================================
992
+ # BACKGROUND TASKS
993
+ # =============================================================================
994
+
995
+ async def metrics_collector():
996
+ """Collect metrics from all servers periodically"""
997
+ while True:
998
+ try:
999
+ async with aiosqlite.connect(DB_PATH) as db:
1000
+ db.row_factory = aiosqlite.Row
1001
+ async with db.execute("SELECT * FROM livekit_servers WHERE status = 'online'") as cursor:
1002
+ servers = await cursor.fetchall()
1003
+
1004
+ all_metrics = []
1005
+ for server in servers:
1006
+ try:
1007
+ async with httpx.AsyncClient(timeout=5.0) as client:
1008
+ resp = await client.get(f"{server['webhook_url']}/metrics")
1009
+ data = resp.json()
1010
+
1011
+ # Store in history
1012
+ await db.execute("""
1013
+ INSERT INTO metrics_history (server_id, gpu_utilization, vram_used_mb, active_sessions)
1014
+ VALUES (?, ?, ?, ?)
1015
+ """, (server["id"], data.get("gpu_utilization", 0),
1016
+ data.get("vram_used_mb", 0), data.get("active_sessions", 0)))
1017
+
1018
+ all_metrics.append({
1019
+ "server_id": server["id"],
1020
+ **data
1021
+ })
1022
+ except Exception as e:
1023
+ logger.warning(f"Failed to collect metrics from {server['id']}: {e}")
1024
+
1025
+ await db.commit()
1026
+
1027
+ if all_metrics:
1028
+ await broadcast("metrics_update", {"servers": all_metrics})
1029
+
1030
+ except Exception as e:
1031
+ logger.error(f"Metrics collector error: {e}")
1032
+
1033
+ await asyncio.sleep(5) # Collect every 5 seconds
1034
+
1035
+ async def health_checker():
1036
+ """Check server health periodically"""
1037
+ while True:
1038
+ try:
1039
+ async with aiosqlite.connect(DB_PATH) as db:
1040
+ db.row_factory = aiosqlite.Row
1041
+ async with db.execute("SELECT * FROM livekit_servers") as cursor:
1042
+ servers = await cursor.fetchall()
1043
+
1044
+ for server in servers:
1045
+ try:
1046
+ async with httpx.AsyncClient(timeout=5.0) as client:
1047
+ resp = await client.get(f"{server['webhook_url']}/health")
1048
+ if resp.status_code == 200:
1049
+ new_status = "online"
1050
+ else:
1051
+ new_status = "offline"
1052
+ except:
1053
+ new_status = "offline"
1054
+
1055
+ if new_status != server["status"]:
1056
+ await db.execute(
1057
+ "UPDATE livekit_servers SET status = ? WHERE id = ?",
1058
+ (new_status, server["id"])
1059
+ )
1060
+ await broadcast("server_status_changed", {
1061
+ "server_id": server["id"],
1062
+ "status": new_status
1063
+ })
1064
+
1065
+ # Mark devices offline if heartbeat is stale (> 30s)
1066
+ cutoff = (datetime.utcnow() - timedelta(seconds=30)).isoformat()
1067
+ async with db.execute(
1068
+ "SELECT id, status FROM devices WHERE status = 'online' AND (last_heartbeat IS NULL OR last_heartbeat < ?)",
1069
+ (cutoff,),
1070
+ ) as c:
1071
+ stale = await c.fetchall()
1072
+ for (did, _st) in stale:
1073
+ await db.execute("UPDATE devices SET status = 'offline' WHERE id = ?", (did,))
1074
+ await broadcast("device_status_changed", {"device_id": did, "status": "offline"})
1075
+
1076
+ await db.commit()
1077
+
1078
+ except Exception as e:
1079
+ logger.error(f"Health checker error: {e}")
1080
+
1081
+ await asyncio.sleep(10) # Check every 10 seconds
1082
+
1083
+ # =============================================================================
1084
+ # SETTINGS ENDPOINTS
1085
+ # =============================================================================
1086
+
1087
+ @app.get("/api/settings")
1088
+ async def get_settings():
1089
+ """Public settings (production mode + whether default enrollment token exists)."""
1090
+ pm = await get_production_mode()
1091
+ has_default_token = bool(await get_setting("default_enrollment_token"))
1092
+ return {
1093
+ "production_mode": pm,
1094
+ "default_enrollment_token_set": has_default_token,
1095
+ }
1096
+
1097
+ @app.put("/api/settings")
1098
+ async def update_settings(payload: SettingsPayload):
1099
+ """Update scheduler settings (production mode, etc.)."""
1100
+ await set_setting("production_mode", "true" if payload.production_mode else "false")
1101
+ await broadcast("settings_changed", {"production_mode": payload.production_mode})
1102
+ return {"status": "ok", "production_mode": payload.production_mode}
1103
+
1104
+ @app.post("/api/settings/enrollment-token/rotate")
1105
+ async def rotate_enrollment_token():
1106
+ """Generate a fresh default enrollment token (rotates the secret)."""
1107
+ token = secrets.token_urlsafe(24)
1108
+ await set_setting("default_enrollment_token", _hash(token))
1109
+ logger.info("Default enrollment token rotated")
1110
+ return {"enrollment_token": token}
1111
+
1112
+ # =============================================================================
1113
+ # LIVEKIT CONFIG + TOKEN ISSUANCE
1114
+ # =============================================================================
1115
+ #
1116
+ # The scheduler is the single source of truth for LiveKit credentials.
1117
+ # The Pi UI (or any client) calls /api/livekit/token to receive a short-lived
1118
+ # JWT for a given room + identity, signed with the LiveKit API key/secret
1119
+ # that the scheduler was started with.
1120
+ #
1121
+ # Required env vars (set in docker-compose or the host):
1122
+ # LIVEKIT_URL e.g. ws://localhost:7880
1123
+ # LIVEKIT_API_KEY e.g. devkey
1124
+ # LIVEKIT_API_SECRET e.g. <random>
1125
+ #
1126
+ # These can also be set via the API (PUT /api/livekit/config) and are stored
1127
+ # in the settings table; env vars take precedence at startup.
1128
+
1129
+ def _lk_settings() -> tuple[Optional[str], Optional[str], Optional[str]]:
1130
+ """Return (url, api_key, api_secret). Env overrides stored settings."""
1131
+ url = os.getenv("LIVEKIT_URL") or None
1132
+ key = os.getenv("LIVEKIT_API_KEY") or None
1133
+ sec = os.getenv("LIVEKIT_API_SECRET") or None
1134
+ return url, key, sec
1135
+
1136
+ async def _lk_config_async() -> tuple[str, str, str]:
1137
+ """Async lookup that falls back to settings table for any missing env value."""
1138
+ url, key, sec = _lk_settings()
1139
+ if not url:
1140
+ url = await get_setting("livekit_url")
1141
+ if not key:
1142
+ key = await get_setting("livekit_api_key")
1143
+ if not sec:
1144
+ sec = await get_setting("livekit_api_secret")
1145
+ if not (url and key and sec):
1146
+ raise HTTPException(
1147
+ 503,
1148
+ "LiveKit is not configured. Set LIVEKIT_URL / LIVEKIT_API_KEY / "
1149
+ "LIVEKIT_API_SECRET env vars, or PUT /api/livekit/config.",
1150
+ )
1151
+ return url, key, sec
1152
+
1153
+ @app.get("/api/livekit/config", response_model=LiveKitConfig)
1154
+ async def get_livekit_config():
1155
+ """Show the current LiveKit configuration. Secret is never returned."""
1156
+ url, key, sec = _lk_settings()
1157
+ has_secret = bool(sec)
1158
+ if not url:
1159
+ url = await get_setting("livekit_url")
1160
+ if not key:
1161
+ key = await get_setting("livekit_api_key")
1162
+ if not has_secret:
1163
+ has_secret = bool(await get_setting("livekit_api_secret"))
1164
+ return LiveKitConfig(url=url, api_key=key, has_secret=has_secret)
1165
+
1166
+ @app.put("/api/livekit/config")
1167
+ async def update_livekit_config(payload: LiveKitConfig):
1168
+ """Persist LiveKit config. If api_key is omitted, the existing key is kept.
1169
+ If api_secret is None, it is left as-is; if it is the empty string it is cleared."""
1170
+ if payload.url is not None:
1171
+ await set_setting("livekit_url", payload.url)
1172
+ if payload.api_key is not None:
1173
+ await set_setting("livekit_api_key", payload.api_key)
1174
+ # Note: there's no separate endpoint to set the secret via JSON to avoid
1175
+ # accidental overwrites; use POST /api/livekit/config/secret for that.
1176
+ await broadcast("livekit_config_changed", {})
1177
+ return {"status": "ok"}
1178
+
1179
+ @app.post("/api/livekit/config/secret")
1180
+ async def set_livekit_secret(payload: dict):
1181
+ secret = payload.get("api_secret")
1182
+ if not isinstance(secret, str) or len(secret) < 8:
1183
+ raise HTTPException(400, "api_secret must be a string of length >= 8")
1184
+ await set_setting("livekit_api_secret", secret)
1185
+ await broadcast("livekit_config_changed", {})
1186
+ return {"status": "ok"}
1187
+
1188
+ @app.post("/api/livekit/token", response_model=LiveKitTokenResponse)
1189
+ async def issue_livekit_token(payload: LiveKitTokenRequest,
1190
+ authorization: Optional[str] = Header(default=None)):
1191
+ """Sign a short-lived LiveKit access token for a given room + identity.
1192
+ Pi clients (or the Pi UI) use this to join a room in the browser.
1193
+ Requires a valid enrolled-device Bearer token and production_mode to be ON."""
1194
+ if not await _authorize_fleet(authorization):
1195
+ raise HTTPException(401, "Invalid or missing device token")
1196
+ if not await get_production_mode():
1197
+ raise HTTPException(403, "Production mode is disabled")
1198
+
1199
+ if not payload.room or not payload.identity:
1200
+ raise HTTPException(400, "room and identity are required")
1201
+ if not re.match(r"^[A-Za-z0-9_\-=]{1,128}$", payload.room):
1202
+ raise HTTPException(400, "invalid room name")
1203
+ if not re.match(r"^[A-Za-z0-9_\-:.@]{1,128}$", payload.identity):
1204
+ raise HTTPException(400, "invalid identity")
1205
+
1206
+ url, key, sec = await _lk_config_async()
1207
+ ttl = max(60, min(int(payload.ttl_seconds), 6 * 3600))
1208
+
1209
+ try:
1210
+ from livekit.api import AccessToken, VideoGrants
1211
+ except ImportError:
1212
+ raise HTTPException(503, "livekit-api not installed on scheduler")
1213
+
1214
+ grants = VideoGrants(
1215
+ room=payload.room,
1216
+ room_join=True,
1217
+ can_publish=payload.can_publish,
1218
+ can_subscribe=payload.can_subscribe,
1219
+ can_publish_data=True,
1220
+ )
1221
+ at = AccessToken(key, sec) \
1222
+ .with_identity(payload.identity) \
1223
+ .with_name(payload.name or payload.identity) \
1224
+ .with_ttl(timedelta(seconds=ttl)) \
1225
+ .with_grants(grants)
1226
+ token = at.to_jwt()
1227
+ expires = datetime.utcnow() + timedelta(seconds=ttl)
1228
+ return LiveKitTokenResponse(
1229
+ url=url, token=token, room=payload.room,
1230
+ identity=payload.identity, expires_at=expires,
1231
+ )
1232
+
1233
+ @app.get("/api/robots/{robot_id}/stream")
1234
+ async def robot_stream(robot_id: str,
1235
+ authorization: Optional[str] = Header(default=None)):
1236
+ """Mint a SUBSCRIBE-ONLY LiveKit token so an operator dashboard can watch a
1237
+ robot's live camera + audio for its CURRENT session.
1238
+
1239
+ The Pi already publishes a 'cam' (video) and 'mic' (audio) track into its
1240
+ session room (pi-client livekit_bridge _publish_cam/_publish_mic), so a
1241
+ viewer just needs a subscribe-only token for that room. Returns
1242
+ {"active": false} when the robot has no live session (nothing to view yet).
1243
+
1244
+ Token is signed with the robot's ASSIGNED server key (the same key that
1245
+ signed the publisher) so it validates against the room the robot publishes
1246
+ into. Subscribe-only, short TTL, and only while production_mode is ON. This
1247
+ sits at the same (open) tier as the other /api/robots read routes; to
1248
+ tighten, add `if not await _authorize_fleet(authorization): raise
1249
+ HTTPException(401, ...)` like /api/livekit/token."""
1250
+ if not await get_production_mode():
1251
+ return {"active": False, "reason": "production_mode_off"}
1252
+
1253
+ async with aiosqlite.connect(DB_PATH) as db:
1254
+ db.row_factory = aiosqlite.Row
1255
+ async with db.execute("SELECT * FROM robots WHERE id = ?", (robot_id,)) as c:
1256
+ robot = await c.fetchone()
1257
+ if not robot:
1258
+ raise HTTPException(404, "Robot not found")
1259
+ if not robot["current_session_id"]:
1260
+ return {"active": False, "reason": "idle"}
1261
+ async with db.execute("SELECT * FROM sessions WHERE id = ?", (robot["current_session_id"],)) as c:
1262
+ session = await c.fetchone()
1263
+ if not session or session["ended_at"]:
1264
+ return {"active": False, "reason": "idle"}
1265
+ server = None
1266
+ if session["server_id"]:
1267
+ async with db.execute("SELECT * FROM livekit_servers WHERE id = ?", (session["server_id"],)) as c:
1268
+ server = await c.fetchone()
1269
+
1270
+ room_name = session["room_name"]
1271
+ if server:
1272
+ url, lk_key, lk_secret = server["url"], (server["api_key"] or "devkey"), (server["api_secret"] or "secret")
1273
+ else:
1274
+ url, lk_key, lk_secret = await _lk_config_async()
1275
+
1276
+ try:
1277
+ from livekit.api import AccessToken, VideoGrants
1278
+ except ImportError:
1279
+ raise HTTPException(503, "livekit-api not installed on scheduler")
1280
+
1281
+ identity = f"viewer:{robot_id}:{int(datetime.utcnow().timestamp())}"
1282
+ grants = VideoGrants(
1283
+ room=room_name,
1284
+ room_join=True,
1285
+ can_publish=False,
1286
+ can_publish_data=False,
1287
+ can_subscribe=True,
1288
+ )
1289
+ token = (
1290
+ AccessToken(lk_key, lk_secret)
1291
+ .with_identity(identity)
1292
+ .with_name("operator-dashboard")
1293
+ .with_ttl(timedelta(minutes=15))
1294
+ .with_grants(grants)
1295
+ .to_jwt()
1296
+ )
1297
+ return {
1298
+ "active": True,
1299
+ "url": url,
1300
+ "token": token,
1301
+ "room": room_name,
1302
+ "identity": identity,
1303
+ }
1304
+
1305
+ # =============================================================================
1306
+ # ON-DEMAND CAMERA PREVIEW (operator-initiated, independent of scene detection)
1307
+ # =============================================================================
1308
+ # Flow: dashboard POSTs /preview/start -> a preview room is assigned with a short
1309
+ # TTL and the operator gets a SUBSCRIBE-only viewer token. The robot polls
1310
+ # GET /preview/agent; while a preview is active it receives a PUBLISH token and
1311
+ # opens its cam into the same room. The dashboard POSTs /preview/keepalive while
1312
+ # watching; closing it POSTs /preview/stop. If keepalives stop, the preview
1313
+ # auto-expires and the robot's next poll returns {active:false} so it stops the
1314
+ # cam. Cam therefore runs ONLY while an operator is watching.
1315
+
1316
+ PREVIEW_TTL_SECONDS = 45 # preview auto-expires this long after the last keepalive
1317
+
1318
+ async def _pick_preview_server(db, prefer_id: Optional[str] = None):
1319
+ """Return a LiveKit server row for a preview: a preferred one if still present,
1320
+ else the least-loaded online server, else any configured server."""
1321
+ if prefer_id:
1322
+ async with db.execute("SELECT * FROM livekit_servers WHERE id = ?", (prefer_id,)) as c:
1323
+ row = await c.fetchone()
1324
+ if row:
1325
+ return row
1326
+ async with db.execute("""
1327
+ SELECT s.* FROM livekit_servers s
1328
+ WHERE s.status = 'online' OR s.status IS NULL OR s.status = 'unknown'
1329
+ ORDER BY (SELECT COUNT(*) FROM sessions WHERE server_id = s.id AND ended_at IS NULL) ASC
1330
+ LIMIT 1
1331
+ """) as c:
1332
+ row = await c.fetchone()
1333
+ if row:
1334
+ return row
1335
+ async with db.execute("SELECT * FROM livekit_servers LIMIT 1") as c:
1336
+ return await c.fetchone()
1337
+
1338
+ def _preview_active(row) -> bool:
1339
+ if not row:
1340
+ return False
1341
+ try:
1342
+ return datetime.fromisoformat(row["expires_at"]) > datetime.utcnow()
1343
+ except Exception:
1344
+ return False
1345
+
1346
+ def _mint_lk(server, room, identity, name, publish: bool, ttl_min: int):
1347
+ """Mint a LiveKit token via livekit-api, matching the other routes' pattern."""
1348
+ from livekit.api import AccessToken, VideoGrants
1349
+ grants = VideoGrants(
1350
+ room=room, room_join=True,
1351
+ can_publish=publish, can_publish_data=publish, can_subscribe=not publish,
1352
+ )
1353
+ return (
1354
+ AccessToken(server["api_key"] or "devkey", server["api_secret"] or "secret")
1355
+ .with_identity(identity).with_name(name)
1356
+ .with_ttl(timedelta(minutes=ttl_min)).with_grants(grants).to_jwt()
1357
+ )
1358
+
1359
+ @app.post("/api/robots/{robot_id}/preview/start")
1360
+ async def preview_start(robot_id: str):
1361
+ """Operator dashboard: begin an on-demand live camera preview for a robot.
1362
+ Returns a SUBSCRIBE-only viewer token + the preview room."""
1363
+ if not await get_production_mode():
1364
+ return {"active": False, "reason": "production_mode_off"}
1365
+ async with aiosqlite.connect(DB_PATH) as db:
1366
+ db.row_factory = aiosqlite.Row
1367
+ async with db.execute("SELECT * FROM robots WHERE id = ?", (robot_id,)) as c:
1368
+ if not await c.fetchone():
1369
+ raise HTTPException(404, "Robot not found")
1370
+ async with db.execute("SELECT * FROM previews WHERE robot_id = ?", (robot_id,)) as c:
1371
+ existing = await c.fetchone()
1372
+ server = await _pick_preview_server(db, existing["server_id"] if existing else None)
1373
+ if not server:
1374
+ raise HTTPException(503, "No LiveKit server configured")
1375
+ room_name = (existing["room_name"] if existing else None) or f"preview_{robot_id}"
1376
+ expires = (datetime.utcnow() + timedelta(seconds=PREVIEW_TTL_SECONDS)).isoformat()
1377
+ await db.execute("""
1378
+ INSERT INTO previews (robot_id, room_name, server_id, expires_at)
1379
+ VALUES (?, ?, ?, ?)
1380
+ ON CONFLICT(robot_id) DO UPDATE SET
1381
+ room_name=excluded.room_name, server_id=excluded.server_id, expires_at=excluded.expires_at
1382
+ """, (robot_id, room_name, server["id"], expires))
1383
+ await db.commit()
1384
+ try:
1385
+ identity = f"viewer:{robot_id}:{int(datetime.utcnow().timestamp())}"
1386
+ token = _mint_lk(server, room_name, identity, "operator-preview", publish=False, ttl_min=5)
1387
+ except ImportError:
1388
+ raise HTTPException(503, "livekit-api not installed on scheduler")
1389
+ return {"active": True, "url": server["url"], "token": token, "room": room_name,
1390
+ "identity": identity, "expires_in": PREVIEW_TTL_SECONDS}
1391
+
1392
+ @app.post("/api/robots/{robot_id}/preview/keepalive")
1393
+ async def preview_keepalive(robot_id: str):
1394
+ """Dashboard pings while the operator is watching, to keep the preview alive."""
1395
+ expires = (datetime.utcnow() + timedelta(seconds=PREVIEW_TTL_SECONDS)).isoformat()
1396
+ async with aiosqlite.connect(DB_PATH) as db:
1397
+ cur = await db.execute("UPDATE previews SET expires_at = ? WHERE robot_id = ?", (expires, robot_id))
1398
+ await db.commit()
1399
+ if cur.rowcount == 0:
1400
+ return {"active": False}
1401
+ return {"active": True, "expires_in": PREVIEW_TTL_SECONDS}
1402
+
1403
+ @app.post("/api/robots/{robot_id}/preview/stop")
1404
+ async def preview_stop(robot_id: str):
1405
+ """Dashboard closed / operator stopped watching -> end the preview now."""
1406
+ async with aiosqlite.connect(DB_PATH) as db:
1407
+ await db.execute("DELETE FROM previews WHERE robot_id = ?", (robot_id,))
1408
+ await db.commit()
1409
+ return {"stopped": True}
1410
+
1411
+ @app.get("/api/robots/{robot_id}/preview/agent")
1412
+ async def preview_agent(robot_id: str, authorization: Optional[str] = Header(default=None)):
1413
+ """The ROBOT polls this. While an operator preview is active it returns a
1414
+ PUBLISH token + room so the Pi opens its cam and joins; otherwise
1415
+ {active:false} so the Pi stops publishing. Enrolled-device auth."""
1416
+ if not await _authorize_fleet(authorization):
1417
+ raise HTTPException(401, "Invalid or missing device token")
1418
+ async with aiosqlite.connect(DB_PATH) as db:
1419
+ db.row_factory = aiosqlite.Row
1420
+ async with db.execute("SELECT * FROM previews WHERE robot_id = ?", (robot_id,)) as c:
1421
+ row = await c.fetchone()
1422
+ if not _preview_active(row):
1423
+ return {"active": False}
1424
+ # If the robot is in a real scene-detection session, ROBOVOICE owns the
1425
+ # camera device — don't have the preview agent fight it for /dev/video0.
1426
+ # The operator sees the live session cam via /stream instead.
1427
+ async with db.execute("SELECT current_session_id FROM robots WHERE id = ?", (robot_id,)) as c:
1428
+ rob = await c.fetchone()
1429
+ if rob and rob["current_session_id"]:
1430
+ return {"active": False, "reason": "in_session"}
1431
+ server = await _pick_preview_server(db, row["server_id"])
1432
+ if not server:
1433
+ return {"active": False}
1434
+ try:
1435
+ token = _mint_lk(server, row["room_name"], robot_id, robot_id, publish=True, ttl_min=2)
1436
+ except ImportError:
1437
+ raise HTTPException(503, "livekit-api not installed on scheduler")
1438
+ return {"active": True, "url": server["url"], "token": token, "room": row["room_name"]}
1439
+
1440
+ # =============================================================================
1441
+ # DEVICE ENDPOINTS (Pi fleet)
1442
+ # =============================================================================
1443
+
1444
+ def _device_row_to_model(row) -> Device:
1445
+ """Convert a sqlite Row (or dict) to a Device, normalizing fields."""
1446
+ d = dict(row)
1447
+ d.pop("token_hash", None)
1448
+ d.pop("enrollment_token_hash", None)
1449
+ return Device(**d)
1450
+
1451
+ @app.get("/api/devices", response_model=List[Device])
1452
+ async def list_devices():
1453
+ """List all enrolled Pi devices."""
1454
+ async with aiosqlite.connect(DB_PATH) as db:
1455
+ db.row_factory = aiosqlite.Row
1456
+ async with db.execute("SELECT * FROM devices ORDER BY name") as cursor:
1457
+ return [_device_row_to_model(r) for r in await cursor.fetchall()]
1458
+
1459
+ @app.get("/api/devices/by-room/{room_name}")
1460
+ async def get_device_by_room(room_name: str):
1461
+ """Look up the device bound to a LiveKit room. Convention: room = 'robopark-<device_id>'."""
1462
+ if not room_name.startswith("robopark-"):
1463
+ raise HTTPException(404, "Room is not a robopark room")
1464
+ device_id = room_name[len("robopark-"):]
1465
+ async with aiosqlite.connect(DB_PATH) as db:
1466
+ db.row_factory = aiosqlite.Row
1467
+ async with db.execute("SELECT * FROM devices WHERE id = ?", (device_id,)) as c:
1468
+ row = await c.fetchone()
1469
+ if not row:
1470
+ raise HTTPException(404, "No device bound to this room")
1471
+ return _device_row_to_model(row)
1472
+
1473
+ @app.get("/api/devices/{device_id}", response_model=Device)
1474
+ async def get_device(device_id: str):
1475
+ async with aiosqlite.connect(DB_PATH) as db:
1476
+ db.row_factory = aiosqlite.Row
1477
+ async with db.execute("SELECT * FROM devices WHERE id = ?", (device_id,)) as c:
1478
+ row = await c.fetchone()
1479
+ if not row:
1480
+ raise HTTPException(404, "Device not found")
1481
+ return _device_row_to_model(row)
1482
+
1483
+ class DeviceUpdate(BaseModel):
1484
+ name: Optional[str] = None
1485
+ character_id: Optional[str] = None
1486
+ motor_server_url: Optional[str] = None
1487
+ livekit_url: Optional[str] = None
1488
+ tailscale_ip: Optional[str] = None
1489
+ lan_ip: Optional[str] = None
1490
+ notes: Optional[str] = None
1491
+ status: Optional[str] = None
1492
+
1493
+ @app.patch("/api/devices/{device_id}", response_model=Device)
1494
+ async def update_device(device_id: str, payload: DeviceUpdate):
1495
+ """Partial update for a device (character binding, addresses, notes, status)."""
1496
+ fields = {k: v for k, v in payload.model_dump(exclude_none=True).items()}
1497
+ if not fields:
1498
+ return await get_device(device_id)
1499
+ set_clause = ", ".join(f"{k} = ?" for k in fields)
1500
+ values = list(fields.values()) + [device_id]
1501
+ async with aiosqlite.connect(DB_PATH) as db:
1502
+ cur = await db.execute(f"UPDATE devices SET {set_clause} WHERE id = ?", values)
1503
+ await db.commit()
1504
+ if cur.rowcount == 0:
1505
+ raise HTTPException(404, "Device not found")
1506
+ await broadcast("device_updated", {"device_id": device_id, "fields": list(fields.keys())})
1507
+ return await get_device(device_id)
1508
+
1509
+ @app.post("/api/devices", response_model=Device)
1510
+ async def create_device(payload: DeviceCreate):
1511
+ """Manually register a device from the UI. Returns the device record and
1512
+ a one-time enrollment token (if one wasn't provided)."""
1513
+ if not payload.name or not payload.name.strip():
1514
+ raise HTTPException(400, "name is required")
1515
+
1516
+ device_id = f"dev_{secrets.token_hex(4)}"
1517
+
1518
+ # Resolve enrollment token: use provided one, else generate a fresh one.
1519
+ if payload.enrollment_token:
1520
+ enrollment_token = payload.enrollment_token
1521
+ else:
1522
+ enrollment_token = secrets.token_urlsafe(24)
1523
+
1524
+ enrollment_hash = _hash(enrollment_token)
1525
+
1526
+ now = datetime.utcnow().isoformat()
1527
+ async with aiosqlite.connect(DB_PATH) as db:
1528
+ await db.execute(
1529
+ """INSERT INTO devices
1530
+ (id, name, tailscale_ip, lan_ip, motor_server_url, character_id,
1531
+ livekit_url, enrollment_token_hash, status, enrolled_at, notes, created_at)
1532
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'enrolled', ?, ?, ?)""",
1533
+ (
1534
+ device_id, payload.name.strip(), payload.tailscale_ip, payload.lan_ip,
1535
+ payload.motor_server_url, payload.character_id, payload.livekit_url,
1536
+ enrollment_hash, now, payload.notes, now,
1537
+ ),
1538
+ )
1539
+ await db.commit()
1540
+
1541
+ logger.info(f"Device {device_id} ({payload.name}) created")
1542
+ await broadcast("device_added", {"device_id": device_id, "name": payload.name})
1543
+
1544
+ device = await get_device(device_id)
1545
+ return {
1546
+ **device.model_dump(),
1547
+ "enrollment_token": enrollment_token, # returned ONCE, not stored plaintext
1548
+ }
1549
+
1550
+ @app.delete("/api/devices/{device_id}")
1551
+ async def delete_device(device_id: str):
1552
+ async with aiosqlite.connect(DB_PATH) as db:
1553
+ async with db.execute("SELECT id FROM devices WHERE id = ?", (device_id,)) as c:
1554
+ row = await c.fetchone()
1555
+ if not row:
1556
+ raise HTTPException(404, "Device not found")
1557
+ await db.execute("DELETE FROM devices WHERE id = ?", (device_id,))
1558
+ await db.commit()
1559
+ await broadcast("device_removed", {"device_id": device_id})
1560
+ return {"status": "deleted", "device_id": device_id}
1561
+
1562
+ @app.post("/api/devices/{device_id}/token/rotate")
1563
+ async def rotate_device_token(device_id: str):
1564
+ """Issue a new device token; the old one is invalidated."""
1565
+ new_token = secrets.token_urlsafe(32)
1566
+ new_hash = _hash(new_token)
1567
+ async with aiosqlite.connect(DB_PATH) as db:
1568
+ cur = await db.execute("UPDATE devices SET token_hash = ? WHERE id = ?", (new_hash, device_id))
1569
+ await db.commit()
1570
+ if cur.rowcount == 0:
1571
+ raise HTTPException(404, "Device not found")
1572
+ await broadcast("device_token_rotated", {"device_id": device_id})
1573
+ return {"device_id": device_id, "device_token": new_token}
1574
+
1575
+ async def _authorize_device(device_id: str, authorization: Optional[str]) -> bool:
1576
+ """Verify Bearer token matches stored hash for this device."""
1577
+ if not authorization or not authorization.lower().startswith("bearer "):
1578
+ return False
1579
+ token = authorization.split(" ", 1)[1].strip()
1580
+ async with aiosqlite.connect(DB_PATH) as db:
1581
+ async with db.execute("SELECT token_hash FROM devices WHERE id = ?", (device_id,)) as c:
1582
+ row = await c.fetchone()
1583
+ if not row or not row[0]:
1584
+ return False
1585
+ return hmac.compare_digest(row[0], _hash(token))
1586
+
1587
+ async def _authorize_fleet(authorization: Optional[str]) -> bool:
1588
+ """Verify a Bearer token matches ANY enrolled device token (fleet auth).
1589
+
1590
+ Used by endpoints that hand out LiveKit access but are keyed by robot_id
1591
+ (not device_id), so we can't scope to a single device row. Mirrors the
1592
+ hash + constant-time compare used by _authorize_device."""
1593
+ if not authorization or not authorization.lower().startswith("bearer "):
1594
+ return False
1595
+ token_hash = _hash(authorization.split(" ", 1)[1].strip())
1596
+ async with aiosqlite.connect(DB_PATH) as db:
1597
+ async with db.execute(
1598
+ "SELECT token_hash FROM devices WHERE token_hash IS NOT NULL"
1599
+ ) as c:
1600
+ rows = await c.fetchall()
1601
+ return any(r[0] and hmac.compare_digest(r[0], token_hash) for r in rows)
1602
+
1603
+ @app.post("/api/devices/enroll", response_model=DeviceEnrollResponse)
1604
+ async def enroll_device(payload: DeviceEnrollRequest):
1605
+ """First-boot enrollment for a Pi. Requires production_mode = true and a valid enrollment token.
1606
+ Returns a long-lived device_token the Pi will use for heartbeats."""
1607
+ if not await get_production_mode():
1608
+ raise HTTPException(403, "Production mode is disabled. Enable it in Settings before enrolling devices.")
1609
+
1610
+ if not payload.enrollment_token:
1611
+ raise HTTPException(400, "enrollment_token is required")
1612
+
1613
+ token_hash = _hash(payload.enrollment_token)
1614
+
1615
+ # Two paths:
1616
+ # 1. Token matches a pre-registered device row -> reuse that device, rotate its token.
1617
+ # 2. Token matches the GLOBAL default enrollment token -> mint a NEW device on the fly.
1618
+ async with aiosqlite.connect(DB_PATH) as db:
1619
+ db.row_factory = aiosqlite.Row
1620
+ async with db.execute(
1621
+ "SELECT * FROM devices WHERE enrollment_token_hash = ?", (token_hash,)
1622
+ ) as c:
1623
+ existing = await c.fetchone()
1624
+
1625
+ default_token_hash = await get_setting("default_enrollment_token")
1626
+ matches_default = default_token_hash and hmac.compare_digest(default_token_hash, token_hash)
1627
+
1628
+ if not existing and not matches_default:
1629
+ raise HTTPException(401, "Invalid enrollment token")
1630
+
1631
+ if existing:
1632
+ device_id = existing["id"]
1633
+ new_token = secrets.token_urlsafe(32)
1634
+ new_hash = _hash(new_token)
1635
+ now = datetime.utcnow().isoformat()
1636
+ await db.execute(
1637
+ """UPDATE devices
1638
+ SET token_hash = ?, status = 'enrolled',
1639
+ enrolled_at = COALESCE(enrolled_at, ?),
1640
+ tailscale_ip = COALESCE(?, tailscale_ip),
1641
+ lan_ip = COALESCE(?, lan_ip),
1642
+ motor_server_url = COALESCE(?, motor_server_url),
1643
+ livekit_url = COALESCE(?, livekit_url),
1644
+ character_id = COALESCE(?, character_id),
1645
+ name = COALESCE(?, name)
1646
+ WHERE id = ?""",
1647
+ (
1648
+ new_hash, now, payload.tailscale_ip, payload.lan_ip,
1649
+ payload.motor_server_url, payload.livekit_url, payload.character_id,
1650
+ payload.name, device_id,
1651
+ ),
1652
+ )
1653
+ else:
1654
+ # First-boot enroll via the global default token -> create a new device.
1655
+ device_id = f"dev_{secrets.token_hex(4)}"
1656
+ new_token = secrets.token_urlsafe(32)
1657
+ new_hash = _hash(new_token)
1658
+ now = datetime.utcnow().isoformat()
1659
+ await db.execute(
1660
+ """INSERT INTO devices
1661
+ (id, name, tailscale_ip, lan_ip, motor_server_url, character_id,
1662
+ livekit_url, token_hash, enrollment_token_hash, status, enrolled_at, created_at)
1663
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'enrolled', ?, ?)""",
1664
+ (
1665
+ device_id,
1666
+ (payload.name or f"Pi-{device_id[-4:]}").strip(),
1667
+ payload.tailscale_ip, payload.lan_ip,
1668
+ payload.motor_server_url, payload.character_id,
1669
+ payload.livekit_url, new_hash, token_hash, now, now,
1670
+ ),
1671
+ )
1672
+
1673
+ await db.commit()
1674
+
1675
+ await broadcast("device_enrolled", {"device_id": device_id})
1676
+ logger.info(f"Device {device_id} enrolled")
1677
+
1678
+ # Discover our own scheduler URL (best effort)
1679
+ scheduler_url = os.getenv("SCHEDULER_PUBLIC_URL", f"http://localhost:{os.getenv('SCHEDULER_PORT', '8080')}")
1680
+ return DeviceEnrollResponse(device_id=device_id, device_token=new_token, scheduler_url=scheduler_url)
1681
+
1682
+ @app.post("/api/devices/{device_id}/heartbeat")
1683
+ async def device_heartbeat(device_id: str, payload: DeviceHeartbeat,
1684
+ authorization: Optional[str] = Header(default=None)):
1685
+ if not await _authorize_device(device_id, authorization):
1686
+ raise HTTPException(401, "Invalid device token")
1687
+ async with aiosqlite.connect(DB_PATH) as db:
1688
+ async with db.execute(
1689
+ """UPDATE devices
1690
+ SET status = ?, last_heartbeat = ?, last_seen_ip = COALESCE(?, last_seen_ip)
1691
+ WHERE id = ?""",
1692
+ (
1693
+ payload.status or "online",
1694
+ datetime.utcnow().isoformat(),
1695
+ payload.ip,
1696
+ device_id,
1697
+ ),
1698
+ ) as cur:
1699
+ await db.commit()
1700
+ if cur.rowcount == 0:
1701
+ raise HTTPException(404, "Device not found")
1702
+ await broadcast("device_heartbeat", {"device_id": device_id, "status": payload.status})
1703
+ # Tell the Pi whether production mode is on (so it can decide to join LiveKit or stand by)
1704
+ return {
1705
+ "status": "ok",
1706
+ "production_mode": await get_production_mode(),
1707
+ "server_time": datetime.utcnow().isoformat(),
1708
+ }
1709
+
1710
+ @app.get("/api/devices/{device_id}/config")
1711
+ async def device_config(device_id: str, authorization: Optional[str] = Header(default=None)):
1712
+ """Pi polls this to learn current production_mode + assigned character, etc."""
1713
+ if not await _authorize_device(device_id, authorization):
1714
+ raise HTTPException(401, "Invalid device token")
1715
+ async with aiosqlite.connect(DB_PATH) as db:
1716
+ db.row_factory = aiosqlite.Row
1717
+ async with db.execute(
1718
+ "SELECT id, name, character_id, motor_server_url, livekit_url FROM devices WHERE id = ?",
1719
+ (device_id,),
1720
+ ) as c:
1721
+ row = await c.fetchone()
1722
+ if not row:
1723
+ raise HTTPException(404, "Device not found")
1724
+ return {
1725
+ "device_id": row["id"],
1726
+ "name": row["name"],
1727
+ "character_id": row["character_id"],
1728
+ "motor_server_url": row["motor_server_url"],
1729
+ "livekit_url": row["livekit_url"],
1730
+ "production_mode": await get_production_mode(),
1731
+ }
1732
+
1733
+ @app.post("/api/devices/{device_id}/request-session")
1734
+ async def device_request_session(device_id: str,
1735
+ authorization: Optional[str] = Header(default=None)):
1736
+ """Enrolled DEVICE requests a session (presence-triggered LiveKit join).
1737
+
1738
+ This is the device-keyed sibling of POST /api/robots/{robot_id}/request-session.
1739
+ The pi-client is an enrolled *device*, but sessions + assignment are keyed by
1740
+ *robot*. Rather than link the two fleet models with a new schema, we MIRROR the
1741
+ device onto a robot row whose id == device_id (additive + idempotent via
1742
+ INSERT OR IGNORE). Everything downstream (least-loaded server selection,
1743
+ session creation, telemetry) then reuses the existing robot machinery unchanged.
1744
+
1745
+ Auth: the specific enrolled-device Bearer token (_authorize_device), so a
1746
+ device can only request a session for itself.
1747
+
1748
+ Response mirrors the robot path plus is safe to feed straight into the Pi's
1749
+ join path: {session_id, server_id, server_url, room_name, token}. A session
1750
+ row is created with started_at so latency (joined_at - started_at) computes
1751
+ once POST /api/sessions/{session_id}/joined is called.
1752
+
1753
+ NOTE: unlike the robot path, this does NOT hard-fail when the mirrored robot
1754
+ is not 'idle'. Presence can re-trigger while a prior session's end-session was
1755
+ never delivered; blocking would strand the device. Each call creates a fresh
1756
+ session and bumps trigger_count (the natural camera-trigger point)."""
1757
+ if not await _authorize_device(device_id, authorization):
1758
+ raise HTTPException(401, "Invalid or missing device token")
1759
+
1760
+ async with aiosqlite.connect(DB_PATH) as db:
1761
+ db.row_factory = aiosqlite.Row
1762
+
1763
+ # Resolve the enrolled device.
1764
+ async with db.execute("SELECT * FROM devices WHERE id = ?", (device_id,)) as c:
1765
+ device = await c.fetchone()
1766
+ if not device:
1767
+ raise HTTPException(404, "Device not found")
1768
+
1769
+ # Mirror device -> robot identity (id == device_id). Additive + idempotent:
1770
+ # if the robot row already exists it is left untouched.
1771
+ await db.execute(
1772
+ "INSERT OR IGNORE INTO robots (id, name, character_id) VALUES (?, ?, ?)",
1773
+ (device_id, device["name"] or device_id, device["character_id"]),
1774
+ )
1775
+
1776
+ # Find least-loaded online server (identical selection to the robot path).
1777
+ async with db.execute("""
1778
+ SELECT s.*,
1779
+ (SELECT COUNT(*) FROM sessions WHERE server_id = s.id AND ended_at IS NULL) as active
1780
+ FROM livekit_servers s
1781
+ WHERE s.status = 'online'
1782
+ ORDER BY active ASC
1783
+ LIMIT 1
1784
+ """) as cursor:
1785
+ server = await cursor.fetchone()
1786
+ if not server:
1787
+ raise HTTPException(503, "No available servers")
1788
+ if server["active"] >= server["max_sessions"]:
1789
+ raise HTTPException(503, "All servers at capacity")
1790
+
1791
+ ts = int(datetime.utcnow().timestamp())
1792
+ session_id = f"session_{device_id}_{ts}"
1793
+ room_name = f"robopark_{device_id}_{ts}"
1794
+
1795
+ await db.execute("""
1796
+ INSERT INTO sessions (id, robot_id, server_id, room_name, started_at)
1797
+ VALUES (?, ?, ?, ?, ?)
1798
+ """, (session_id, device_id, server["id"], room_name, datetime.utcnow().isoformat()))
1799
+
1800
+ # Mark the mirrored robot connecting + count the camera trigger, exactly
1801
+ # as the robot path does (request-session is the natural trigger point).
1802
+ await db.execute("""
1803
+ UPDATE robots SET status = 'connecting', current_session_id = ?, connected_server_id = ?,
1804
+ trigger_count = COALESCE(trigger_count, 0) + 1
1805
+ WHERE id = ?
1806
+ """, (session_id, server["id"], device_id))
1807
+
1808
+ await db.commit()
1809
+
1810
+ await broadcast("session_requested", {
1811
+ "robot_id": device_id,
1812
+ "device_id": device_id,
1813
+ "server_id": server["id"],
1814
+ "session_id": session_id,
1815
+ })
1816
+
1817
+ # Mint a short-lived room-scoped JWT so the device joins the assigned room
1818
+ # without ever receiving the raw api_key/api_secret. Identity keeps the
1819
+ # existing pi:<device_id> convention so the agent side sees the same participant.
1820
+ try:
1821
+ from livekit.api import AccessToken, VideoGrants
1822
+ except ImportError:
1823
+ raise HTTPException(503, "livekit-api not installed on scheduler")
1824
+
1825
+ lk_key = server["api_key"] or "devkey"
1826
+ lk_secret = server["api_secret"] or "secret"
1827
+ identity = f"pi:{device_id}"
1828
+ grants = VideoGrants(
1829
+ room=room_name,
1830
+ room_join=True,
1831
+ can_publish=True,
1832
+ can_subscribe=True,
1833
+ can_publish_data=True,
1834
+ )
1835
+ token = (
1836
+ AccessToken(lk_key, lk_secret)
1837
+ .with_identity(identity)
1838
+ .with_name(device["name"] or device_id)
1839
+ .with_ttl(timedelta(hours=1))
1840
+ .with_grants(grants)
1841
+ .to_jwt()
1842
+ )
1843
+
1844
+ return {
1845
+ "session_id": session_id,
1846
+ "server_id": server["id"],
1847
+ "server_url": server["url"],
1848
+ "room_name": room_name,
1849
+ "token": token,
1850
+ }
1851
+
1852
+ # =============================================================================
1853
+ # CHARACTER ENDPOINTS (read from ROBOVOICE settings.json)
1854
+ # =============================================================================
1855
+
1856
+ import json as _json
1857
+
1858
+ class CharacterSummary(BaseModel):
1859
+ id: str
1860
+ name: Optional[str] = None
1861
+ description: Optional[str] = None
1862
+ tts_voice: Optional[str] = None
1863
+ motors: List[str] = []
1864
+
1865
+ class Character(CharacterSummary):
1866
+ system_prompt: Optional[str] = None
1867
+ raw: dict = {}
1868
+
1869
+ def _load_robovoice_settings() -> dict:
1870
+ """Read the ROBOVOICE settings file. Returns {} if missing/invalid."""
1871
+ try:
1872
+ if not os.path.exists(ROBOVOICE_SETTINGS_PATH):
1873
+ return {}
1874
+ with open(ROBOVOICE_SETTINGS_PATH, "r", encoding="utf-8") as f:
1875
+ return _json.load(f)
1876
+ except Exception as e:
1877
+ log.warning(f"Could not read ROBOVOICE settings at {ROBOVOICE_SETTINGS_PATH}: {e}")
1878
+ return {}
1879
+
1880
+ def _character_summary(c: dict) -> CharacterSummary:
1881
+ motors = c.get("motors") or []
1882
+ if isinstance(motors, list) and motors and isinstance(motors[0], dict):
1883
+ motor_names = [m.get("name") for m in motors if m.get("name")]
1884
+ else:
1885
+ motor_names = [m for m in motors if isinstance(m, str)]
1886
+ voice = (
1887
+ c.get("tts_voice") or
1888
+ c.get("elevenlabs_voice_id") or
1889
+ c.get("tts_voice_kokoro") or
1890
+ c.get("tts_voice_piper")
1891
+ )
1892
+ return CharacterSummary(
1893
+ id=c.get("id") or c.get("name") or "unknown",
1894
+ name=c.get("name"),
1895
+ description=c.get("description"),
1896
+ tts_voice=voice,
1897
+ motors=motor_names,
1898
+ )
1899
+
1900
+ @app.get("/api/characters", response_model=List[CharacterSummary])
1901
+ async def list_characters():
1902
+ """List all characters available in the bound ROBOVOICE settings file."""
1903
+ s = _load_robovoice_settings()
1904
+ chars = s.get("characters") or []
1905
+ return [_character_summary(c) for c in chars]
1906
+
1907
+ @app.get("/api/characters/{character_id}", response_model=Character)
1908
+ async def get_character(character_id: str):
1909
+ """Get a single character (incl. system_prompt and motor config) by id."""
1910
+ s = _load_robovoice_settings()
1911
+ for c in s.get("characters") or []:
1912
+ if c.get("id") == character_id:
1913
+ summary = _character_summary(c)
1914
+ return Character(
1915
+ **summary.model_dump(),
1916
+ system_prompt=c.get("system_prompt"),
1917
+ raw=c,
1918
+ )
1919
+ raise HTTPException(404, f"Character '{character_id}' not found in {ROBOVOICE_SETTINGS_PATH}")
1920
+
1921
+ # =============================================================================
1922
+ # TELEMETRY (close-the-loop: trigger counts, latency, drop rate, error reasons)
1923
+ # =============================================================================
1924
+
1925
+ async def _robot_telemetry(db, robot_id: str) -> Optional[dict]:
1926
+ """Per-robot telemetry. `db` must have row_factory = aiosqlite.Row set.
1927
+
1928
+ Returns trigger_count, sessions_total, avg_latency_ms, drop_rate, drops and
1929
+ an end_reason breakdown; None if the robot does not exist."""
1930
+ async with db.execute(
1931
+ "SELECT trigger_count FROM robots WHERE id = ?", (robot_id,)
1932
+ ) as c:
1933
+ robot = await c.fetchone()
1934
+ if not robot:
1935
+ return None
1936
+ trigger_count = robot["trigger_count"] or 0
1937
+
1938
+ # Average latency over sessions that recorded a room join.
1939
+ async with db.execute(
1940
+ "SELECT started_at, joined_at FROM sessions WHERE robot_id = ? AND joined_at IS NOT NULL",
1941
+ (robot_id,),
1942
+ ) as c:
1943
+ latency_rows = await c.fetchall()
1944
+ latencies = [
1945
+ ms for ms in (
1946
+ _session_latency_ms(r["started_at"], r["joined_at"]) for r in latency_rows
1947
+ ) if ms is not None
1948
+ ]
1949
+ avg_latency_ms = int(sum(latencies) / len(latencies)) if latencies else None
1950
+
1951
+ async with db.execute(
1952
+ "SELECT COUNT(*) AS n FROM sessions WHERE robot_id = ?", (robot_id,)
1953
+ ) as c:
1954
+ sessions_total = (await c.fetchone())["n"]
1955
+
1956
+ # End-reason breakdown (surfaces "per-robot error reasons"); drops = the
1957
+ # subset whose reason is abnormal.
1958
+ async with db.execute(
1959
+ """SELECT end_reason, COUNT(*) AS n FROM sessions
1960
+ WHERE robot_id = ? AND end_reason IS NOT NULL
1961
+ GROUP BY end_reason""",
1962
+ (robot_id,),
1963
+ ) as c:
1964
+ end_reasons = {r["end_reason"]: r["n"] for r in await c.fetchall()}
1965
+ drops = sum(n for reason, n in end_reasons.items() if reason in ABNORMAL_END_REASONS)
1966
+ drop_rate = round(drops / sessions_total, 4) if sessions_total else 0.0
1967
+
1968
+ return {
1969
+ "robot_id": robot_id,
1970
+ "trigger_count": trigger_count,
1971
+ "sessions_total": sessions_total,
1972
+ "avg_latency_ms": avg_latency_ms,
1973
+ "drop_rate": drop_rate,
1974
+ "drops": drops,
1975
+ "end_reasons": end_reasons,
1976
+ }
1977
+
1978
+ @app.get("/api/robots/{robot_id}/telemetry")
1979
+ async def robot_telemetry(robot_id: str):
1980
+ """Per-robot telemetry: camera-trigger count, average session latency (ms),
1981
+ drop rate, total sessions, and an end-reason breakdown."""
1982
+ async with aiosqlite.connect(DB_PATH) as db:
1983
+ db.row_factory = aiosqlite.Row
1984
+ data = await _robot_telemetry(db, robot_id)
1985
+ if data is None:
1986
+ raise HTTPException(404, "Robot not found")
1987
+ return data
1988
+
1989
+ @app.get("/api/telemetry")
1990
+ async def telemetry():
1991
+ """Fleet-wide telemetry: per-robot trigger counts, latency, drop rates and
1992
+ session totals, plus fleet totals and the latest GPU metrics recorded per
1993
+ LiveKit server (surfaced from the previously write-only metrics_history)."""
1994
+ async with aiosqlite.connect(DB_PATH) as db:
1995
+ db.row_factory = aiosqlite.Row
1996
+ async with db.execute("SELECT id FROM robots ORDER BY name") as c:
1997
+ robot_ids = [r["id"] for r in await c.fetchall()]
1998
+ robots = []
1999
+ for rid in robot_ids:
2000
+ t = await _robot_telemetry(db, rid)
2001
+ if t is not None:
2002
+ robots.append(t)
2003
+
2004
+ # Surface metrics_history (written by metrics_collector, never read
2005
+ # before): the latest sample per server.
2006
+ async with db.execute(
2007
+ """SELECT m.server_id, m.gpu_utilization, m.vram_used_mb,
2008
+ m.active_sessions, m.recorded_at
2009
+ FROM metrics_history m
2010
+ JOIN (
2011
+ SELECT server_id, MAX(recorded_at) AS latest
2012
+ FROM metrics_history GROUP BY server_id
2013
+ ) latest
2014
+ ON m.server_id = latest.server_id AND m.recorded_at = latest.latest"""
2015
+ ) as c:
2016
+ server_metrics = [dict(r) for r in await c.fetchall()]
2017
+
2018
+ totals = {
2019
+ "trigger_count": sum(r["trigger_count"] for r in robots),
2020
+ "sessions_total": sum(r["sessions_total"] for r in robots),
2021
+ "drops": sum(r["drops"] for r in robots),
2022
+ }
2023
+ totals["drop_rate"] = (
2024
+ round(totals["drops"] / totals["sessions_total"], 4)
2025
+ if totals["sessions_total"] else 0.0
2026
+ )
2027
+
2028
+ return {
2029
+ "robots": robots,
2030
+ "servers": server_metrics,
2031
+ "totals": totals,
2032
+ "abnormal_end_reasons": sorted(ABNORMAL_END_REASONS),
2033
+ }
2034
+
2035
+ # =============================================================================
2036
+ # DASHBOARD (Embedded)
2037
+ # =============================================================================
2038
+
2039
+ # ── Back-compat: legacy /voice page (ported verbatim from the deployed OLD
2040
+ # scheduler). Serves voice_client.html if present next to main.py, else an
2041
+ # inline fallback — matches the deployed container exactly (its Dockerfile
2042
+ # copies only main.py, so the fallback is what actually renders). ──
2043
+ @app.get("/voice", response_class=HTMLResponse)
2044
+ async def voice_client():
2045
+ """Serve voice client page for remote access"""
2046
+ import os
2047
+ html_path = os.path.join(os.path.dirname(__file__), "voice_client.html")
2048
+ if os.path.exists(html_path):
2049
+ with open(html_path, "r") as f:
2050
+ return f.read()
2051
+ return """
2052
+ <!DOCTYPE html>
2053
+ <html><head><title>Voice Client</title></head>
2054
+ <body style="background:#111;color:#fff;display:flex;align-items:center;justify-content:center;height:100vh;">
2055
+ <h1>Voice client HTML not found</h1>
2056
+ </body></html>
2057
+ """
2058
+
2059
+ @app.get("/", response_class=HTMLResponse)
2060
+ async def dashboard():
2061
+ """Serve embedded dashboard"""
2062
+ return """
2063
+ <!DOCTYPE html>
2064
+ <html>
2065
+ <head>
2066
+ <title>RoboPark Control Center</title>
2067
+ <meta charset="utf-8">
2068
+ <meta name="viewport" content="width=device-width, initial-scale=1">
2069
+ <script src="https://cdn.tailwindcss.com"></script>
2070
+ <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
2071
+ </head>
2072
+ <body class="bg-gray-900 text-white">
2073
+ <div id="app"></div>
2074
+ <script>
2075
+ const { createApp, ref, computed, onMounted, onUnmounted } = Vue;
2076
+
2077
+ createApp({
2078
+ setup() {
2079
+ const robots = ref([]);
2080
+ const servers = ref([]);
2081
+ const sessions = ref([]);
2082
+ const stats = ref({});
2083
+ const connected = ref(false);
2084
+ const serverMetrics = ref({});
2085
+ const devices = ref([]);
2086
+ const settings = ref({ production_mode: false, default_enrollment_token_set: false });
2087
+ const livekit = ref({ url: null, api_key: null, has_secret: false });
2088
+ const showAddDevice = ref(false);
2089
+ const showDevices = ref(true);
2090
+ const newDevice = ref({ name: '', tailscale_ip: '', lan_ip: '', motor_server_url: '', character_id: '', livekit_url: '', notes: '' });
2091
+ const rotatedToken = ref(null);
2092
+ const showLiveKitConfig = ref(false);
2093
+ const livekitForm = ref({ url: '', api_key: '', api_secret: '' });
2094
+ let ws = null;
2095
+
2096
+ const fetchData = async () => {
2097
+ try {
2098
+ const [robotsRes, serversRes, sessionsRes, statsRes, devsRes, settingsRes, lkRes] = await Promise.all([
2099
+ fetch('/api/robots').then(r => r.json()),
2100
+ fetch('/api/servers').then(r => r.json()),
2101
+ fetch('/api/sessions?active_only=true').then(r => r.json()),
2102
+ fetch('/api/sessions/stats').then(r => r.json()),
2103
+ fetch('/api/devices').then(r => r.json()),
2104
+ fetch('/api/settings').then(r => r.json()),
2105
+ fetch('/api/livekit/config').then(r => r.json()),
2106
+ ]);
2107
+ robots.value = robotsRes;
2108
+ servers.value = serversRes;
2109
+ sessions.value = sessionsRes;
2110
+ stats.value = statsRes;
2111
+ devices.value = devsRes;
2112
+ settings.value = settingsRes;
2113
+ livekit.value = lkRes;
2114
+
2115
+ for (const server of serversRes) {
2116
+ try {
2117
+ const metrics = await fetch(`/api/servers/${server.id}/metrics`).then(r => r.json());
2118
+ serverMetrics.value[server.id] = metrics;
2119
+ } catch (e) {}
2120
+ }
2121
+ } catch (e) {
2122
+ console.error('Failed to fetch data:', e);
2123
+ }
2124
+ };
2125
+
2126
+ const connectWebSocket = () => {
2127
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
2128
+ ws = new WebSocket(`${protocol}//${window.location.host}/ws`);
2129
+
2130
+ ws.onopen = () => {
2131
+ connected.value = true;
2132
+ console.log('WebSocket connected');
2133
+ };
2134
+
2135
+ ws.onclose = () => {
2136
+ connected.value = false;
2137
+ console.log('WebSocket disconnected, reconnecting...');
2138
+ setTimeout(connectWebSocket, 2000);
2139
+ };
2140
+
2141
+ ws.onmessage = (event) => {
2142
+ const msg = JSON.parse(event.data);
2143
+ console.log('WS:', msg.type, msg.data);
2144
+
2145
+ if (msg.type === 'metrics_update') {
2146
+ for (const m of msg.data.servers || []) {
2147
+ serverMetrics.value[m.server_id] = m;
2148
+ }
2149
+ } else if (msg.type === 'settings_changed') {
2150
+ settings.value.production_mode = msg.data.production_mode;
2151
+ } else {
2152
+ fetchData();
2153
+ }
2154
+ };
2155
+ };
2156
+
2157
+ const loadModel = async (serverId, modelName) => {
2158
+ try {
2159
+ await fetch(`/api/servers/${serverId}/models/${modelName}/load`, { method: 'POST' });
2160
+ fetchData();
2161
+ } catch (e) {
2162
+ alert('Failed to load model: ' + e.message);
2163
+ }
2164
+ };
2165
+
2166
+ const unloadModel = async (serverId, modelName) => {
2167
+ try {
2168
+ await fetch(`/api/servers/${serverId}/models/${modelName}/unload`, { method: 'POST' });
2169
+ fetchData();
2170
+ } catch (e) {
2171
+ alert('Failed to unload model: ' + e.message);
2172
+ }
2173
+ };
2174
+
2175
+ const toggleProductionMode = async () => {
2176
+ try {
2177
+ const next = !settings.value.production_mode;
2178
+ await fetch('/api/settings', {
2179
+ method: 'PUT',
2180
+ headers: {'Content-Type': 'application/json'},
2181
+ body: JSON.stringify({ production_mode: next }),
2182
+ });
2183
+ settings.value.production_mode = next;
2184
+ } catch (e) {
2185
+ alert('Failed to toggle production mode: ' + e.message);
2186
+ }
2187
+ };
2188
+
2189
+ const rotateEnrollmentToken = async () => {
2190
+ if (!confirm('Rotate the global default enrollment token? Any Pi waiting to enroll with the previous token will be rejected.')) return;
2191
+ try {
2192
+ const r = await fetch('/api/settings/enrollment-token/rotate', { method: 'POST' }).then(r => r.json());
2193
+ alert('New enrollment token (copy now, shown once):\\n\\n' + r.enrollment_token);
2194
+ settings.value.default_enrollment_token_set = true;
2195
+ } catch (e) {
2196
+ alert('Failed to rotate token: ' + e.message);
2197
+ }
2198
+ };
2199
+
2200
+ const submitNewDevice = async () => {
2201
+ try {
2202
+ const r = await fetch('/api/devices', {
2203
+ method: 'POST',
2204
+ headers: {'Content-Type': 'application/json'},
2205
+ body: JSON.stringify(newDevice.value),
2206
+ }).then(r => r.json());
2207
+ if (r.enrollment_token) {
2208
+ rotatedToken.value = { device_id: r.id, name: r.name, token: r.enrollment_token };
2209
+ }
2210
+ showAddDevice.value = false;
2211
+ newDevice.value = { name: '', tailscale_ip: '', lan_ip: '', motor_server_url: '', character_id: '', livekit_url: '', notes: '' };
2212
+ fetchData();
2213
+ } catch (e) {
2214
+ alert('Failed to add device: ' + e.message);
2215
+ }
2216
+ };
2217
+
2218
+ const deleteDevice = async (id, name) => {
2219
+ if (!confirm(`Delete device "${name}"?`)) return;
2220
+ try {
2221
+ await fetch(`/api/devices/${id}`, { method: 'DELETE' });
2222
+ fetchData();
2223
+ } catch (e) {
2224
+ alert('Failed to delete: ' + e.message);
2225
+ }
2226
+ };
2227
+
2228
+ const rotateDeviceToken = async (id) => {
2229
+ if (!confirm('Rotate this device token? The Pi will need to re-enroll.')) return;
2230
+ try {
2231
+ const r = await fetch(`/api/devices/${id}/token/rotate`, { method: 'POST' }).then(r => r.json());
2232
+ rotatedToken.value = { device_id: id, name: id, token: r.device_token };
2233
+ } catch (e) {
2234
+ alert('Failed to rotate: ' + e.message);
2235
+ }
2236
+ };
2237
+
2238
+ const dismissRotatedToken = () => { rotatedToken.value = null; };
2239
+
2240
+ const joinConversation = async (device) => {
2241
+ try {
2242
+ const room = `robopark-${device.id}`;
2243
+ const identity = `ui:${device.id}:${Date.now()}`;
2244
+ const r = await fetch('/api/livekit/token', {
2245
+ method: 'POST',
2246
+ headers: {'Content-Type': 'application/json'},
2247
+ body: JSON.stringify({
2248
+ room, identity, name: 'Web UI',
2249
+ can_publish: true, can_subscribe: true,
2250
+ ttl_seconds: 3600,
2251
+ }),
2252
+ });
2253
+ if (!r.ok) {
2254
+ const err = await r.json().catch(() => ({}));
2255
+ throw new Error(err.detail || `HTTP ${r.status}`);
2256
+ }
2257
+ const t = await r.json();
2258
+ const url = `https://meet.livekit.io/custom?livekitUrl=${encodeURIComponent(t.url)}&token=${encodeURIComponent(t.token)}`;
2259
+ window.open(url, '_blank');
2260
+ } catch (e) {
2261
+ alert('Failed to join conversation: ' + e.message);
2262
+ }
2263
+ };
2264
+
2265
+ const saveLiveKitConfig = async () => {
2266
+ try {
2267
+ if (livekitForm.value.url || livekitForm.value.api_key) {
2268
+ const body = {};
2269
+ if (livekitForm.value.url) body.url = livekitForm.value.url;
2270
+ if (livekitForm.value.api_key) body.api_key = livekitForm.value.api_key;
2271
+ await fetch('/api/livekit/config', {
2272
+ method: 'PUT',
2273
+ headers: {'Content-Type': 'application/json'},
2274
+ body: JSON.stringify(body),
2275
+ });
2276
+ }
2277
+ if (livekitForm.value.api_secret) {
2278
+ await fetch('/api/livekit/config/secret', {
2279
+ method: 'POST',
2280
+ headers: {'Content-Type': 'application/json'},
2281
+ body: JSON.stringify({ api_secret: livekitForm.value.api_secret }),
2282
+ });
2283
+ }
2284
+ showLiveKitConfig.value = false;
2285
+ livekitForm.value = { url: '', api_key: '', api_secret: '' };
2286
+ fetchData();
2287
+ } catch (e) {
2288
+ alert('Failed to save LiveKit config: ' + e.message);
2289
+ }
2290
+ };
2291
+
2292
+ const openLiveKitConfig = () => {
2293
+ livekitForm.value = {
2294
+ url: livekit.value.url || '',
2295
+ api_key: livekit.value.api_key || '',
2296
+ api_secret: '',
2297
+ };
2298
+ showLiveKitConfig.value = true;
2299
+ };
2300
+
2301
+ const statusColor = (status) => {
2302
+ const colors = {
2303
+ idle: 'bg-gray-500',
2304
+ detecting: 'bg-yellow-500 animate-pulse',
2305
+ connecting: 'bg-blue-500 animate-pulse',
2306
+ running: 'bg-green-500',
2307
+ online: 'bg-green-500',
2308
+ offline: 'bg-red-500',
2309
+ enrolled: 'bg-yellow-500',
2310
+ disabled: 'bg-gray-700',
2311
+ unknown: 'bg-gray-500'
2312
+ };
2313
+ return colors[status] || 'bg-gray-500';
2314
+ };
2315
+
2316
+ const formatDuration = (seconds) => {
2317
+ if (!seconds) return '0s';
2318
+ const m = Math.floor(seconds / 60);
2319
+ const s = seconds % 60;
2320
+ return m > 0 ? `${m}m ${s}s` : `${s}s`;
2321
+ };
2322
+
2323
+ const formatTime = (iso) => {
2324
+ if (!iso) return '—';
2325
+ try {
2326
+ const d = new Date(iso);
2327
+ const now = new Date();
2328
+ const diff = (now - d) / 1000;
2329
+ if (diff < 60) return Math.floor(diff) + 's ago';
2330
+ if (diff < 3600) return Math.floor(diff/60) + 'm ago';
2331
+ return d.toLocaleTimeString();
2332
+ } catch { return iso; }
2333
+ };
2334
+
2335
+ onMounted(() => {
2336
+ fetchData();
2337
+ connectWebSocket();
2338
+ setInterval(fetchData, 30000);
2339
+ });
2340
+
2341
+ onUnmounted(() => {
2342
+ if (ws) ws.close();
2343
+ });
2344
+
2345
+ return {
2346
+ robots, servers, sessions, stats, connected, serverMetrics,
2347
+ devices, settings, livekit, showAddDevice, showDevices, newDevice, rotatedToken,
2348
+ showLiveKitConfig, livekitForm,
2349
+ loadModel, unloadModel, statusColor, formatDuration, formatTime,
2350
+ toggleProductionMode, rotateEnrollmentToken,
2351
+ submitNewDevice, deleteDevice, rotateDeviceToken, dismissRotatedToken,
2352
+ joinConversation, saveLiveKitConfig, openLiveKitConfig,
2353
+ };
2354
+ },
2355
+ template: `
2356
+ <div class="min-h-screen p-6">
2357
+ <!-- Header -->
2358
+ <header class="mb-8 flex justify-between items-center">
2359
+ <div>
2360
+ <h1 class="text-3xl font-bold">🤖 RoboPark Control Center</h1>
2361
+ <p class="text-gray-400">Session Scheduler Dashboard</p>
2362
+ </div>
2363
+ <div class="flex items-center gap-4">
2364
+ <button @click="openLiveKitConfig"
2365
+ :class="livekit.url && livekit.has_secret ? 'bg-cyan-700 hover:bg-cyan-600' : 'bg-gray-700 hover:bg-gray-600'"
2366
+ class="px-3 py-2 rounded-lg flex items-center gap-2 text-sm"
2367
+ :title="livekit.url || 'Not configured'">
2368
+ <span class="w-2 h-2 rounded-full" :class="livekit.url && livekit.has_secret ? 'bg-cyan-200' : 'bg-yellow-400'"></span>
2369
+ LiveKit: <strong>{{ livekit.url && livekit.has_secret ? (livekit.url.replace(/^wss?:[/][/]/, '')) : 'NOT SET' }}</strong>
2370
+ </button>
2371
+ <button @click="toggleProductionMode"
2372
+ :class="settings.production_mode ? 'bg-green-600 hover:bg-green-700' : 'bg-gray-700 hover:bg-gray-600'"
2373
+ class="px-3 py-2 rounded-lg flex items-center gap-2 text-sm">
2374
+ <span class="w-2 h-2 rounded-full" :class="settings.production_mode ? 'bg-green-200 animate-pulse' : 'bg-gray-400'"></span>
2375
+ Production Mode: <strong>{{ settings.production_mode ? 'ON' : 'OFF' }}</strong>
2376
+ </button>
2377
+ <span :class="connected ? 'text-green-400' : 'text-red-400'" class="flex items-center gap-2">
2378
+ <span class="w-2 h-2 rounded-full" :class="connected ? 'bg-green-400' : 'bg-red-400'"></span>
2379
+ {{ connected ? 'Live' : 'Disconnected' }}
2380
+ </span>
2381
+ </div>
2382
+ </header>
2383
+
2384
+ <!-- Stats Row -->
2385
+ <div class="grid grid-cols-5 gap-4 mb-6">
2386
+ <div class="bg-gray-800 rounded-lg p-4">
2387
+ <div class="text-2xl font-bold text-green-400">{{ stats.active_sessions || 0 }}</div>
2388
+ <div class="text-gray-400 text-sm">Active Sessions</div>
2389
+ </div>
2390
+ <div class="bg-gray-800 rounded-lg p-4">
2391
+ <div class="text-2xl font-bold text-blue-400">{{ stats.today_sessions || 0 }}</div>
2392
+ <div class="text-gray-400 text-sm">Today's Sessions</div>
2393
+ </div>
2394
+ <div class="bg-gray-800 rounded-lg p-4">
2395
+ <div class="text-2xl font-bold text-purple-400">{{ formatDuration(stats.avg_duration_seconds) }}</div>
2396
+ <div class="text-gray-400 text-sm">Avg Duration</div>
2397
+ </div>
2398
+ <div class="bg-gray-800 rounded-lg p-4">
2399
+ <div class="text-2xl font-bold text-yellow-400">{{ stats.total_sessions || 0 }}</div>
2400
+ <div class="text-gray-400 text-sm">Total Sessions</div>
2401
+ </div>
2402
+ <div class="bg-gray-800 rounded-lg p-4">
2403
+ <div class="text-2xl font-bold text-cyan-400">{{ devices.length }}</div>
2404
+ <div class="text-gray-400 text-sm">Enrolled Devices</div>
2405
+ </div>
2406
+ </div>
2407
+
2408
+ <!-- Devices Panel -->
2409
+ <div class="bg-gray-800 rounded-lg p-4 mb-6">
2410
+ <div class="flex justify-between items-center mb-4">
2411
+ <h2 class="text-xl font-semibold flex items-center gap-2">
2412
+ 🛰️ Pi Device Fleet
2413
+ <span class="text-xs text-gray-400">({{ devices.length }})</span>
2414
+ </h2>
2415
+ <div class="flex gap-2">
2416
+ <button @click="rotateEnrollmentToken"
2417
+ class="px-3 py-1 bg-gray-700 hover:bg-gray-600 rounded text-xs">
2418
+ Rotate Enrollment Token
2419
+ </button>
2420
+ <button @click="showAddDevice = true"
2421
+ :disabled="!settings.production_mode"
2422
+ :class="settings.production_mode ? 'bg-blue-600 hover:bg-blue-700' : 'bg-gray-700 cursor-not-allowed'"
2423
+ class="px-3 py-1 rounded text-xs">
2424
+ + Add Device
2425
+ </button>
2426
+ </div>
2427
+ </div>
2428
+ <div v-if="!settings.production_mode" class="text-xs text-yellow-400 mb-3">
2429
+ ⚠ Production mode is OFF. New device enrollment via the default token is blocked. Toggle Production Mode above to allow Pi clients to enroll.
2430
+ </div>
2431
+ <div v-if="devices.length === 0" class="text-gray-500 text-center py-6 text-sm">
2432
+ No devices enrolled yet.
2433
+ </div>
2434
+ <div v-else class="overflow-x-auto">
2435
+ <table class="w-full text-sm">
2436
+ <thead class="text-xs text-gray-400 border-b border-gray-700">
2437
+ <tr>
2438
+ <th class="text-left py-2 px-2">Status</th>
2439
+ <th class="text-left py-2 px-2">Name</th>
2440
+ <th class="text-left py-2 px-2">Character</th>
2441
+ <th class="text-left py-2 px-2">Tailscale IP</th>
2442
+ <th class="text-left py-2 px-2">LAN IP</th>
2443
+ <th class="text-left py-2 px-2">Motor Server</th>
2444
+ <th class="text-left py-2 px-2">Last Heartbeat</th>
2445
+ <th class="text-left py-2 px-2">Actions</th>
2446
+ </tr>
2447
+ </thead>
2448
+ <tbody>
2449
+ <tr v-for="d in devices" :key="d.id" class="border-b border-gray-700/50 hover:bg-gray-700/30">
2450
+ <td class="py-2 px-2">
2451
+ <div class="flex items-center gap-2">
2452
+ <div class="w-2 h-2 rounded-full" :class="statusColor(d.status)"></div>
2453
+ <span class="text-xs">{{ d.status }}</span>
2454
+ </div>
2455
+ </td>
2456
+ <td class="py-2 px-2 font-medium">{{ d.name }}</td>
2457
+ <td class="py-2 px-2 text-xs text-gray-400">{{ d.character_id || '—' }}</td>
2458
+ <td class="py-2 px-2 font-mono text-xs">{{ d.tailscale_ip || '—' }}</td>
2459
+ <td class="py-2 px-2 font-mono text-xs">{{ d.lan_ip || '—' }}</td>
2460
+ <td class="py-2 px-2 font-mono text-xs text-gray-400">{{ d.motor_server_url || '—' }}</td>
2461
+ <td class="py-2 px-2 text-xs text-gray-400">{{ formatTime(d.last_heartbeat) }}</td>
2462
+ <td class="py-2 px-2 text-xs space-x-2">
2463
+ <button @click="joinConversation(d)"
2464
+ :disabled="!settings.production_mode || !livekit.url || !livekit.has_secret"
2465
+ :class="(settings.production_mode && livekit.url && livekit.has_secret) ? 'bg-cyan-600 hover:bg-cyan-700' : 'bg-gray-700 cursor-not-allowed'"
2466
+ class="px-2 py-1 rounded"
2467
+ title="Open a LiveKit meet page in a new tab">Join</button>
2468
+ <button @click="rotateDeviceToken(d.id)" class="px-2 py-1 bg-gray-700 hover:bg-gray-600 rounded">Rotate Token</button>
2469
+ <button @click="deleteDevice(d.id, d.name)" class="px-2 py-1 bg-red-600 hover:bg-red-700 rounded">Delete</button>
2470
+ </td>
2471
+ </tr>
2472
+ </tbody>
2473
+ </table>
2474
+ </div>
2475
+ </div>
2476
+
2477
+ <!-- Add Device Modal -->
2478
+ <div v-if="showAddDevice" class="fixed inset-0 bg-black/70 flex items-center justify-center z-50" @click.self="showAddDevice = false">
2479
+ <div class="bg-gray-800 rounded-lg p-6 w-full max-w-lg">
2480
+ <h3 class="text-lg font-semibold mb-4">Add Pi Device</h3>
2481
+ <p class="text-xs text-gray-400 mb-4">After adding, copy the one-time enrollment token shown and pass it to the Pi client.</p>
2482
+ <div class="space-y-3 text-sm">
2483
+ <div>
2484
+ <label class="block text-gray-400 mb-1">Name *</label>
2485
+ <input v-model="newDevice.name" class="w-full bg-gray-700 rounded px-3 py-2" placeholder="pipi" />
2486
+ </div>
2487
+ <div class="grid grid-cols-2 gap-3">
2488
+ <div>
2489
+ <label class="block text-gray-400 mb-1">Tailscale IP</label>
2490
+ <input v-model="newDevice.tailscale_ip" class="w-full bg-gray-700 rounded px-3 py-2" placeholder="100.64.1.10" />
2491
+ </div>
2492
+ <div>
2493
+ <label class="block text-gray-400 mb-1">LAN IP</label>
2494
+ <input v-model="newDevice.lan_ip" class="w-full bg-gray-700 rounded px-3 py-2" placeholder="192.168.1.159" />
2495
+ </div>
2496
+ </div>
2497
+ <div>
2498
+ <label class="block text-gray-400 mb-1">Motor Server URL</label>
2499
+ <input v-model="newDevice.motor_server_url" class="w-full bg-gray-700 rounded px-3 py-2" placeholder="http://192.168.1.159:8001" />
2500
+ </div>
2501
+ <div>
2502
+ <label class="block text-gray-400 mb-1">LiveKit URL (for this device)</label>
2503
+ <input v-model="newDevice.livekit_url" class="w-full bg-gray-700 rounded px-3 py-2" placeholder="ws://100.64.1.5:7880" />
2504
+ </div>
2505
+ <div>
2506
+ <label class="block text-gray-400 mb-1">Character ID</label>
2507
+ <input v-model="newDevice.character_id" class="w-full bg-gray-700 rounded px-3 py-2" placeholder="panda-character" />
2508
+ </div>
2509
+ <div>
2510
+ <label class="block text-gray-400 mb-1">Notes</label>
2511
+ <input v-model="newDevice.notes" class="w-full bg-gray-700 rounded px-3 py-2" />
2512
+ </div>
2513
+ </div>
2514
+ <div class="flex justify-end gap-2 mt-5">
2515
+ <button @click="showAddDevice = false" class="px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded">Cancel</button>
2516
+ <button @click="submitNewDevice" :disabled="!newDevice.name" class="px-3 py-2 bg-blue-600 hover:bg-blue-700 rounded disabled:opacity-50">Add Device</button>
2517
+ </div>
2518
+ </div>
2519
+ </div>
2520
+
2521
+ <!-- One-time token modal -->
2522
+ <div v-if="rotatedToken" class="fixed inset-0 bg-black/70 flex items-center justify-center z-50" @click.self="dismissRotatedToken">
2523
+ <div class="bg-gray-800 rounded-lg p-6 w-full max-w-lg">
2524
+ <h3 class="text-lg font-semibold mb-2">⚠ Save this token now</h3>
2525
+ <p class="text-xs text-gray-400 mb-4">This token is shown <strong>once</strong>. Copy it to the Pi (e.g. into <code>/etc/robopark/device.json</code>) before dismissing.</p>
2526
+ <div class="bg-gray-900 p-3 rounded font-mono text-xs break-all select-all">{{ rotatedToken.token }}</div>
2527
+ <div class="flex justify-end mt-4">
2528
+ <button @click="dismissRotatedToken" class="px-3 py-2 bg-blue-600 hover:bg-blue-700 rounded">I have saved it</button>
2529
+ </div>
2530
+ </div>
2531
+ </div>
2532
+
2533
+ <!-- LiveKit config modal -->
2534
+ <div v-if="showLiveKitConfig" class="fixed inset-0 bg-black/70 flex items-center justify-center z-50" @click.self="showLiveKitConfig = false">
2535
+ <div class="bg-gray-800 rounded-lg p-6 w-full max-w-lg">
2536
+ <h3 class="text-lg font-semibold mb-2">LiveKit Configuration</h3>
2537
+ <p class="text-xs text-gray-400 mb-4">The scheduler signs LiveKit access tokens for enrolled devices. Point it at your LiveKit server (local or Tailscale-reachable).</p>
2538
+ <div class="space-y-3 text-sm">
2539
+ <div>
2540
+ <label class="block text-gray-400 mb-1">LiveKit URL</label>
2541
+ <input v-model="livekitForm.url" class="w-full bg-gray-700 rounded px-3 py-2" placeholder="ws://100.64.1.5:7880" />
2542
+ </div>
2543
+ <div>
2544
+ <label class="block text-gray-400 mb-1">API Key</label>
2545
+ <input v-model="livekitForm.api_key" class="w-full bg-gray-700 rounded px-3 py-2" placeholder="devkey" />
2546
+ </div>
2547
+ <div>
2548
+ <label class="block text-gray-400 mb-1">API Secret (write-only)</label>
2549
+ <input v-model="livekitForm.api_secret" type="password" class="w-full bg-gray-700 rounded px-3 py-2" placeholder="leave blank to keep current" />
2550
+ <p class="text-xs text-gray-500 mt-1">Current: <strong>{{ livekit.has_secret ? 'set' : 'NOT SET' }}</strong></p>
2551
+ </div>
2552
+ </div>
2553
+ <div class="flex justify-end gap-2 mt-5">
2554
+ <button @click="showLiveKitConfig = false" class="px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded">Cancel</button>
2555
+ <button @click="saveLiveKitConfig" class="px-3 py-2 bg-cyan-600 hover:bg-cyan-700 rounded">Save</button>
2556
+ </div>
2557
+ </div>
2558
+ </div>
2559
+
2560
+ <!-- Main Grid -->
2561
+ <div class="grid grid-cols-12 gap-6">
2562
+
2563
+ <!-- Robots Panel -->
2564
+ <div class="col-span-4 bg-gray-800 rounded-lg p-4">
2565
+ <h2 class="text-xl font-semibold mb-4">🤖 Robot Fleet</h2>
2566
+ <div class="space-y-3">
2567
+ <div v-for="robot in robots" :key="robot.id"
2568
+ class="bg-gray-700 rounded-lg p-3 flex items-center gap-3">
2569
+ <div class="w-3 h-3 rounded-full" :class="statusColor(robot.status)"></div>
2570
+ <div class="flex-1">
2571
+ <div class="font-medium">{{ robot.name }}</div>
2572
+ <div class="text-sm text-gray-400">
2573
+ {{ robot.status }}
2574
+ <span v-if="robot.connected_server_id" class="ml-1">
2575
+ → {{ robot.connected_server_id }}
2576
+ </span>
2577
+ </div>
2578
+ </div>
2579
+ <div class="text-right text-sm text-gray-400">
2580
+ {{ robot.total_sessions }} sessions
2581
+ </div>
2582
+ </div>
2583
+ </div>
2584
+ </div>
2585
+
2586
+ <!-- Servers Panel -->
2587
+ <div class="col-span-5 space-y-4">
2588
+ <div v-for="server in servers" :key="server.id" class="bg-gray-800 rounded-lg p-4">
2589
+ <div class="flex justify-between items-center mb-3">
2590
+ <h3 class="font-semibold flex items-center gap-2">
2591
+ <span class="w-2 h-2 rounded-full" :class="statusColor(server.status)"></span>
2592
+ {{ server.name }}
2593
+ </h3>
2594
+ <span class="text-sm text-gray-400">{{ server.gpu_name || 'No GPU' }}</span>
2595
+ </div>
2596
+
2597
+ <!-- Metrics -->
2598
+ <div v-if="serverMetrics[server.id]" class="space-y-2 mb-3">
2599
+ <div>
2600
+ <div class="flex justify-between text-xs text-gray-400 mb-1">
2601
+ <span>GPU</span>
2602
+ <span>{{ serverMetrics[server.id].gpu_utilization || 0 }}%</span>
2603
+ </div>
2604
+ <div class="h-2 bg-gray-700 rounded-full overflow-hidden">
2605
+ <div class="h-full bg-blue-500 transition-all"
2606
+ :style="{width: (serverMetrics[server.id].gpu_utilization || 0) + '%'}"></div>
2607
+ </div>
2608
+ </div>
2609
+ <div>
2610
+ <div class="flex justify-between text-xs text-gray-400 mb-1">
2611
+ <span>VRAM</span>
2612
+ <span>{{ serverMetrics[server.id].vram_used_mb || 0 }} / {{ serverMetrics[server.id].vram_total_mb || 0 }} MB</span>
2613
+ </div>
2614
+ <div class="h-2 bg-gray-700 rounded-full overflow-hidden">
2615
+ <div class="h-full bg-green-500 transition-all"
2616
+ :style="{width: ((serverMetrics[server.id].vram_used_mb / serverMetrics[server.id].vram_total_mb) * 100 || 0) + '%'}"></div>
2617
+ </div>
2618
+ </div>
2619
+ <div class="text-sm text-gray-400">
2620
+ Sessions: {{ serverMetrics[server.id].active_sessions || 0 }} / {{ server.max_sessions }}
2621
+ </div>
2622
+ </div>
2623
+
2624
+ <!-- Models -->
2625
+ <div v-if="serverMetrics[server.id]?.models?.length" class="space-y-2">
2626
+ <div class="text-sm text-gray-400">Loaded Models:</div>
2627
+ <div v-for="model in serverMetrics[server.id].models" :key="model.name"
2628
+ class="flex items-center justify-between bg-gray-700 rounded p-2">
2629
+ <span class="font-mono text-sm">{{ model.name }}</span>
2630
+ <button @click="unloadModel(server.id, model.name)"
2631
+ class="px-2 py-1 bg-red-600 hover:bg-red-700 rounded text-xs">
2632
+ Unload
2633
+ </button>
2634
+ </div>
2635
+ </div>
2636
+ </div>
2637
+ </div>
2638
+
2639
+ <!-- Active Sessions -->
2640
+ <div class="col-span-3 bg-gray-800 rounded-lg p-4">
2641
+ <h2 class="text-xl font-semibold mb-4">🎙️ Active Sessions</h2>
2642
+ <div v-if="sessions.length === 0" class="text-gray-500 text-center py-8">
2643
+ No active sessions
2644
+ </div>
2645
+ <div v-else class="space-y-2">
2646
+ <div v-for="session in sessions" :key="session.id"
2647
+ class="bg-gray-700 rounded p-2 text-sm">
2648
+ <div class="font-medium">{{ session.robot_id }}</div>
2649
+ <div class="text-gray-400 text-xs">
2650
+ {{ session.server_id }} • {{ session.room_name }}
2651
+ </div>
2652
+ </div>
2653
+ </div>
2654
+ </div>
2655
+ </div>
2656
+ </div>
2657
+ `
2658
+ }).mount('#app');
2659
+ </script>
2660
+ </body>
2661
+ </html>
2662
+ """
2663
+
2664
+ if __name__ == "__main__":
2665
+ import uvicorn
2666
+ host = os.getenv("SCHEDULER_HOST", "0.0.0.0")
2667
+ port = int(os.getenv("SCHEDULER_PORT", "8080"))
2668
+ uvicorn.run(app, host=host, port=port)