infinicode 2.8.36 → 2.8.38

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.
@@ -5,11 +5,14 @@ Manages robot fleet, LiveKit servers, and session orchestration
5
5
 
6
6
  import os
7
7
  import asyncio
8
+ import contextvars
9
+ import json
8
10
  import logging
9
11
  import re
10
12
  import secrets
11
13
  import hashlib
12
14
  import hmac
15
+ import time
13
16
  from datetime import datetime, timedelta
14
17
  from typing import Optional, List
15
18
  from contextlib import asynccontextmanager
@@ -31,6 +34,32 @@ DB_PATH = os.path.join(os.getenv("SCHEDULER_DATA_DIR", "/app/data"), "scheduler.
31
34
  # Path to a ROBOVOICE-style settings file the scheduler reads for characters.
32
35
  # In production this should be a bind-mount to ROBOVOICE-main/settings.json (or .default.json).
33
36
  ROBOVOICE_SETTINGS_PATH = os.path.join(os.getenv("SCHEDULER_DATA_DIR", "/app/data"), "robovoice_settings.json")
37
+ # Shared credential used only by the ROBOVOICE worker to refresh an active
38
+ # RoboPark session. Device endpoints continue to require device tokens. If the
39
+ # environment does not provide it, lifespan() creates/loads this local secret
40
+ # file so a bare `robopark serve` is safe to run without manual secret wiring.
41
+ ROBOPARK_AGENT_TOKEN = os.getenv("ROBOPARK_AGENT_TOKEN", "").strip()
42
+ ROBOPARK_AGENT_TOKEN_FILE = os.getenv(
43
+ "ROBOPARK_AGENT_TOKEN_FILE",
44
+ os.path.join(os.getenv("SCHEDULER_DATA_DIR", "/app/data"), ".robopark-agent-token"),
45
+ )
46
+
47
+ # ElevenLabs credentials used by the /api/voices/elevenlabs endpoint. We accept
48
+ # both the long-form ELEVENLABS_API_KEY and a shorter ELEVENLABS_KEY alias so
49
+ # operators can drop in whichever naming their tooling already exports. Either
50
+ # may be set in the scheduler's environment.
51
+ ELEVENLABS_API_KEY = (
52
+ os.getenv("ELEVENLABS_API_KEY", "").strip()
53
+ or os.getenv("ELEVENLABS_KEY", "").strip()
54
+ or os.getenv("ELEVEN_API_KEY", "").strip()
55
+ )
56
+ ELEVENLABS_BASE_URL = os.getenv("ELEVENLABS_BASE_URL", "https://api.elevenlabs.io").rstrip("/")
57
+
58
+ # Optional base URL of a local Kokoro TTS server. When set, /api/voices/kokoro
59
+ # will proxy its /v1/audio/voices endpoint to return the dynamic list of voices
60
+ # the server actually has installed; otherwise we fall back to a curated static
61
+ # list of the canonical Kokoro-82M voices.
62
+ KOKORO_BASE_URL = os.getenv("KOKORO_BASE_URL", "").strip().rstrip("/")
34
63
 
35
64
  # Session end_reasons considered "abnormal" for drop-rate telemetry. A session
36
65
  # that ended with one of these reasons is counted as a dropped session; anything
@@ -106,6 +135,15 @@ class WebhookEvent(BaseModel):
106
135
  event: str
107
136
  data: dict
108
137
 
138
+ class ServiceStatus(BaseModel):
139
+ name: str
140
+ enabled: bool
141
+ running: bool
142
+ pid: Optional[int] = None
143
+ uptime_seconds: Optional[float] = None
144
+ failure_count: int = 0
145
+ last_exit_code: Optional[int] = None
146
+
109
147
  class Device(BaseModel):
110
148
  id: str
111
149
  name: str
@@ -116,6 +154,12 @@ class Device(BaseModel):
116
154
  livekit_url: Optional[str] = None
117
155
  video_device: Optional[str] = None
118
156
  audio_device: Optional[str] = None
157
+ audio_output_device: Optional[str] = None
158
+ greeting_phrases: List[str] = []
159
+ device_inventory: Optional[dict] = None
160
+ production_mode: bool = False
161
+ supervisor_status: Optional[List[ServiceStatus]] = None
162
+ supervisor_status_at: Optional[str] = None
119
163
  status: str = "enrolled" # enrolled, online, offline, disabled
120
164
  last_heartbeat: Optional[datetime] = None
121
165
  enrolled_at: Optional[datetime] = None
@@ -132,6 +176,7 @@ class DeviceCreate(BaseModel):
132
176
  livekit_url: Optional[str] = None
133
177
  video_device: Optional[str] = None
134
178
  audio_device: Optional[str] = None
179
+ greeting_phrases: Optional[List[str]] = None
135
180
  notes: Optional[str] = None
136
181
  enrollment_token: Optional[str] = None # if omitted, one is auto-generated
137
182
 
@@ -156,6 +201,10 @@ class DeviceHeartbeat(BaseModel):
156
201
  ip: Optional[str] = None
157
202
  uptime_seconds: Optional[int] = None
158
203
  production_mode: Optional[bool] = None
204
+ device_inventory: Optional[dict] = None
205
+
206
+ class SupervisorStatusReport(BaseModel):
207
+ services: List[ServiceStatus] = []
159
208
 
160
209
  class SettingsPayload(BaseModel):
161
210
  production_mode: bool
@@ -194,14 +243,14 @@ async def _seed_voice_stacks_and_characters(db):
194
243
  (
195
244
  "english-default", "English Default",
196
245
  "speaches", "Systran/faster-whisper-small", "en",
197
- "ollama", "gemma4:e4b",
246
+ "ollama", "gemma3:27b",
198
247
  "elevenlabs", "21m00Tcm4TlvDq8ikWAM", "en",
199
248
  1, 0.7, 20, 0,
200
249
  ),
201
250
  (
202
251
  "hebrew-default", "Hebrew Default",
203
252
  "speaches", "ivrit-ai/whisper-large-v3-turbo-ct2", "he",
204
- "ollama", "gemma4:e4b",
253
+ "ollama", "gemma3:27b",
205
254
  "elevenlabs", "21m00Tcm4TlvDq8ikWAM", "he",
206
255
  1, 0.7, 20, 0,
207
256
  ),
@@ -297,9 +346,11 @@ async def init_db():
297
346
  motor_server_url TEXT,
298
347
  character_id TEXT,
299
348
  livekit_url TEXT,
300
- video_device TEXT,
301
- audio_device TEXT,
302
- token_hash TEXT,
349
+ video_device TEXT,
350
+ audio_device TEXT,
351
+ audio_output_device TEXT,
352
+ device_inventory TEXT,
353
+ token_hash TEXT,
303
354
  enrollment_token_hash TEXT,
304
355
  status TEXT DEFAULT 'enrolled',
305
356
  last_heartbeat TEXT,
@@ -323,6 +374,51 @@ async def init_db():
323
374
  created_at TEXT DEFAULT CURRENT_TIMESTAMP
324
375
  );
325
376
 
377
+ CREATE TABLE IF NOT EXISTS trigger_commands (
378
+ robot_id TEXT PRIMARY KEY,
379
+ source TEXT NOT NULL DEFAULT 'dashboard',
380
+ requested_at TEXT NOT NULL
381
+ );
382
+
383
+ -- One pending remote-control command per (device, service): an
384
+ -- operator clicking "restart" in the dashboard queues a row here;
385
+ -- robot_supervisor.py polls GET .../supervisor-commands (same
386
+ -- request that carries its status report) and executes+clears it.
387
+ -- A second click before the robot polls just overwrites the row
388
+ -- rather than queuing duplicates.
389
+ CREATE TABLE IF NOT EXISTS supervisor_commands (
390
+ device_id TEXT NOT NULL,
391
+ service_name TEXT NOT NULL,
392
+ action TEXT NOT NULL DEFAULT 'restart',
393
+ requested_at TEXT NOT NULL,
394
+ PRIMARY KEY (device_id, service_name)
395
+ );
396
+
397
+ -- ── Operator shell (C1 gap-fill) ──
398
+ -- When the operator clicks "Tail logs" or runs a diagnostic
399
+ -- command in the dashboard, we insert a row here. The robot
400
+ -- supervisor's next status-report poll receives the row in
401
+ -- its `commands` response, runs the request on a background
402
+ -- thread, and POSTs the result back to /api/devices/{id}/
403
+ -- supervisor-output which updates the `result` column. The
404
+ -- dashboard polls GET /api/robots/{id}/shell/result/{req_id}
405
+ -- for the response, with an 8s budget.
406
+ --
407
+ -- `kind` discriminates: tail_logs (read log file) vs
408
+ -- shell_run (execute an allowlisted diagnostic command).
409
+ CREATE TABLE IF NOT EXISTS shell_requests (
410
+ id TEXT PRIMARY KEY,
411
+ device_id TEXT NOT NULL,
412
+ service_name TEXT,
413
+ kind TEXT NOT NULL,
414
+ params TEXT NOT NULL DEFAULT '{}',
415
+ requested_at TEXT NOT NULL,
416
+ completed_at TEXT,
417
+ result_ok INTEGER,
418
+ result_payload TEXT
419
+ );
420
+ CREATE INDEX IF NOT EXISTS idx_shell_requests_device ON shell_requests(device_id, requested_at);
421
+
326
422
  CREATE TABLE IF NOT EXISTS history_log (
327
423
  id INTEGER PRIMARY KEY AUTOINCREMENT,
328
424
  timestamp TEXT DEFAULT CURRENT_TIMESTAMP,
@@ -342,7 +438,7 @@ async def init_db():
342
438
  stt_model TEXT DEFAULT 'Systran/faster-whisper-small',
343
439
  stt_language TEXT DEFAULT 'en',
344
440
  llm_provider TEXT DEFAULT 'ollama',
345
- llm_model TEXT DEFAULT 'gemma4:e4b',
441
+ llm_model TEXT DEFAULT 'gemma3:27b',
346
442
  tts_provider TEXT DEFAULT 'elevenlabs',
347
443
  tts_voice TEXT DEFAULT '21m00Tcm4TlvDq8ikWAM',
348
444
  tts_language TEXT DEFAULT 'en',
@@ -398,6 +494,22 @@ async def init_db():
398
494
  ("sessions", "joined_at", "TEXT"),
399
495
  ("devices", "video_device", "TEXT"),
400
496
  ("devices", "audio_device", "TEXT"),
497
+ ("devices", "device_inventory", "TEXT"),
498
+ ("devices", "audio_output_device", "TEXT"),
499
+ ("devices", "greeting_phrases", "TEXT DEFAULT '[]'"),
500
+ # Per-robot production mode: an additional, more specific gate on
501
+ # top of the global settings.production_mode switch. When on for
502
+ # a given device, that robot keeps re-arming its motion-triggered
503
+ # session loop indefinitely (device_heartbeat below returns the
504
+ # AND of global+per-device as the effective flag the Pi acts on)
505
+ # and the dashboard keeps that robot's camera panel live.
506
+ ("devices", "production_mode", "INTEGER DEFAULT 0"),
507
+ # robot_supervisor.py's own periodic self-report: which services
508
+ # it's managing on this robot (preview_agent, vision_app,
509
+ # motor_server) and each one's up/down state, pid, uptime, and
510
+ # failure count. JSON blob; see POST .../supervisor-status.
511
+ ("devices", "supervisor_status", "TEXT"),
512
+ ("devices", "supervisor_status_at", "TEXT"),
401
513
  # Silence-based session end (see POST /api/sessions/{id}/keepalive
402
514
  # and GET /api/sessions/{id}/status): tracks the last time some
403
515
  # caller signaled this session was still active. Defaults to
@@ -481,8 +593,30 @@ async def init_db():
481
593
  # =============================================================================
482
594
 
483
595
  @asynccontextmanager
484
- async def lifespan(app: FastAPI):
485
- await init_db()
596
+ async def lifespan(app: FastAPI):
597
+ await init_db()
598
+ global ROBOPARK_AGENT_TOKEN
599
+ if not ROBOPARK_AGENT_TOKEN:
600
+ try:
601
+ if os.path.isfile(ROBOPARK_AGENT_TOKEN_FILE):
602
+ with open(ROBOPARK_AGENT_TOKEN_FILE, "r", encoding="utf-8") as f:
603
+ ROBOPARK_AGENT_TOKEN = f.read().strip()
604
+ if not ROBOPARK_AGENT_TOKEN:
605
+ ROBOPARK_AGENT_TOKEN = secrets.token_urlsafe(32)
606
+ os.makedirs(os.path.dirname(ROBOPARK_AGENT_TOKEN_FILE) or ".", exist_ok=True)
607
+ with open(ROBOPARK_AGENT_TOKEN_FILE, "w", encoding="utf-8") as f:
608
+ f.write(ROBOPARK_AGENT_TOKEN + "\n")
609
+ try:
610
+ os.chmod(ROBOPARK_AGENT_TOKEN_FILE, 0o600)
611
+ except OSError:
612
+ pass
613
+ logger.info("Generated RoboPark agent secret in the scheduler data directory")
614
+ except OSError as exc:
615
+ logger.error("Could not load/create RoboPark agent secret: %s", exc)
616
+ if not ROBOPARK_AGENT_TOKEN:
617
+ logger.warning(
618
+ "RoboPark agent secret is unavailable; voice-agent keepalive calls will 401."
619
+ )
486
620
  asyncio.create_task(metrics_collector())
487
621
  asyncio.create_task(health_checker())
488
622
  yield
@@ -507,8 +641,60 @@ app.add_middleware(
507
641
  allow_credentials=_allow_credentials,
508
642
  allow_methods=["*"],
509
643
  allow_headers=["*"],
644
+ # X-Operator carries the per-action attribution. CORS preflight
645
+ # must allow it or the dashboard's rpAction() fetches will fail.
646
+ expose_headers=["X-Operator"],
510
647
  )
511
648
 
649
+
650
+ # ── Operator-actor middleware (C3) ──
651
+ # Reads the X-Operator header on every request and stores a sanitized
652
+ # version in a contextvar. The header is informational only — the
653
+ # scheduler is a single-tenant surface; the actor is recorded in the
654
+ # audit log so "who clicked End-session?" is answerable. The header
655
+ # value is sanitized at this boundary (trimmed, control chars stripped,
656
+ # capped at 64 chars) so the value reaching the database is always a
657
+ # safe printable string. An absent or invalid header leaves the
658
+ # contextvar unset; _log_history then uses the call-site default
659
+ # (usually "operator").
660
+ class _OperatorActorMiddleware:
661
+ def __init__(self, app):
662
+ self.app = app
663
+ async def __call__(self, scope, receive, send):
664
+ if scope.get("type") == "http":
665
+ raw = None
666
+ # ASGI scope headers is a list of (name, value) tuples where
667
+ # value is bytes — except when a header has been repeated, in
668
+ # which case some servers emit (name, [b"a", b"b"]). Build a
669
+ # case-insensitive map that flattens both shapes.
670
+ for k, v in (scope.get("headers") or []):
671
+ if not isinstance(k, (bytes, bytearray)):
672
+ continue
673
+ if k.lower() != b"x-operator":
674
+ continue
675
+ if isinstance(v, (list, tuple)):
676
+ v = b", ".join(bytes(x) for x in v if isinstance(x, (bytes, bytearray)))
677
+ if not isinstance(v, (bytes, bytearray)):
678
+ continue
679
+ raw = bytes(v)
680
+ break
681
+ actor = None
682
+ if raw is not None:
683
+ try:
684
+ actor = _sanitize_actor_name(raw.decode("utf-8", errors="replace"))
685
+ except Exception:
686
+ actor = None
687
+ token = _current_actor_override.set(actor)
688
+ try:
689
+ await self.app(scope, receive, send)
690
+ finally:
691
+ _current_actor_override.reset(token)
692
+ else:
693
+ await self.app(scope, receive, send)
694
+
695
+
696
+ app.add_middleware(_OperatorActorMiddleware)
697
+
512
698
  # WebSocket clients for real-time updates
513
699
  ws_clients: List[WebSocket] = []
514
700
 
@@ -603,15 +789,17 @@ async def robot_heartbeat(robot_id: str, ip_address: Optional[str] = None):
603
789
  return {"status": "ok"}
604
790
 
605
791
  @app.post("/api/robots/{robot_id}/request-session")
606
- async def request_session(robot_id: str,
607
- authorization: Optional[str] = Header(default=None)):
792
+ async def request_session(robot_id: str,
793
+ authorization: Optional[str] = Header(default=None)):
608
794
  """Robot requests a session after scene detection.
609
795
 
610
796
  Requires a valid enrolled-device Bearer token. The response never contains
611
797
  the LiveKit api_key/api_secret; instead the scheduler mints a short-lived
612
798
  JWT the robot uses to join the room."""
613
- if not await _authorize_fleet(authorization):
614
- raise HTTPException(401, "Invalid or missing device token")
799
+ if not await _authorize_fleet(authorization):
800
+ raise HTTPException(401, "Invalid or missing device token")
801
+ if not await get_production_mode():
802
+ raise HTTPException(403, "production_mode_off")
615
803
  async with aiosqlite.connect(DB_PATH) as db:
616
804
  db.row_factory = aiosqlite.Row
617
805
 
@@ -732,9 +920,10 @@ async def end_session(robot_id: str, reason: str = "silence"):
732
920
  robot = await cursor.fetchone()
733
921
  if not robot or not robot["current_session_id"]:
734
922
  raise HTTPException(400, "No active session")
923
+ current_session_id = robot["current_session_id"]
735
924
 
736
925
  # Update session
737
- async with db.execute("SELECT * FROM sessions WHERE id = ?", (robot["current_session_id"],)) as cursor:
926
+ async with db.execute("SELECT * FROM sessions WHERE id = ?", (current_session_id,)) as cursor:
738
927
  session = await cursor.fetchone()
739
928
  if session:
740
929
  started = datetime.fromisoformat(session["started_at"])
@@ -759,9 +948,97 @@ async def end_session(robot_id: str, reason: str = "silence"):
759
948
  await db.commit()
760
949
 
761
950
  await broadcast("session_ended", {"robot_id": robot_id, "reason": reason})
762
- await _log_history("session", "robot", robot_id, "session_ended", "robot", f"reason={reason}, session_id={robot.get('current_session_id')}")
951
+ await _log_history("session", "robot", robot_id, "session_ended", "robot", f"reason={reason}, session_id={current_session_id}")
763
952
  return {"status": "ok"}
764
953
 
954
+ @app.post("/api/robots/{robot_id}/simulate-trigger")
955
+ async def simulate_trigger(robot_id: str):
956
+ """Queue a dashboard motion event for the enrolled robot."""
957
+ if not await get_production_mode():
958
+ return {"queued": False, "reason": "production_mode_off"}
959
+ async with aiosqlite.connect(DB_PATH) as db:
960
+ db.row_factory = aiosqlite.Row
961
+ async with db.execute(
962
+ "SELECT id, current_session_id FROM robots WHERE id = ?", (robot_id,)
963
+ ) as c:
964
+ robot = await c.fetchone()
965
+ if not robot:
966
+ async with db.execute("SELECT id, name, character_id FROM devices WHERE id = ?", (robot_id,)) as c:
967
+ device = await c.fetchone()
968
+ if not device:
969
+ raise HTTPException(404, "Robot not found")
970
+ await db.execute(
971
+ "INSERT OR IGNORE INTO robots (id, name, character_id, status) VALUES (?, ?, ?, 'idle')",
972
+ (device["id"], device["name"], device["character_id"]),
973
+ )
974
+ await db.commit()
975
+ robot = {"id": device["id"], "current_session_id": None}
976
+ if not await _effective_device_production_mode(db, robot_id):
977
+ return {"queued": False, "reason": "device_production_mode_off"}
978
+ if robot["current_session_id"]:
979
+ return {"queued": False, "reason": "session_active"}
980
+ await db.execute(
981
+ "INSERT INTO trigger_commands (robot_id, source, requested_at) VALUES (?, ?, ?) "
982
+ "ON CONFLICT(robot_id) DO UPDATE SET source = excluded.source, requested_at = excluded.requested_at",
983
+ (robot_id, "dashboard", datetime.utcnow().isoformat()),
984
+ )
985
+ await db.commit()
986
+ await broadcast("robot_triggered", {"robot_id": robot_id, "source": "dashboard"})
987
+ await _log_history("robot", "robot", robot_id, "simulate_trigger", "operator")
988
+ return {"queued": True, "source": "dashboard"}
989
+
990
+ @app.post("/api/robots/{robot_id}/simulate-stop")
991
+ async def simulate_stop(robot_id: str):
992
+ """Stop a simulated or production conversation and release the robot."""
993
+ async with aiosqlite.connect(DB_PATH) as db:
994
+ db.row_factory = aiosqlite.Row
995
+ async with db.execute(
996
+ "SELECT current_session_id FROM robots WHERE id = ?", (robot_id,)
997
+ ) as c:
998
+ robot = await c.fetchone()
999
+ if not robot:
1000
+ raise HTTPException(404, "Robot not found")
1001
+ await db.execute("DELETE FROM trigger_commands WHERE robot_id = ?", (robot_id,))
1002
+ await db.commit()
1003
+ stopped = False
1004
+ if robot["current_session_id"]:
1005
+ try:
1006
+ await end_session(robot_id, "operator_stop")
1007
+ stopped = True
1008
+ except HTTPException:
1009
+ pass
1010
+ await _log_history("robot", "robot", robot_id, "simulate_stop", "operator")
1011
+ return {"stopped": stopped}
1012
+
1013
+ @app.post("/api/robots/{robot_id}/recover")
1014
+ async def recover_robot(robot_id: str):
1015
+ """Clear this robot's stale session/trigger state and return it to idle.
1016
+
1017
+ This is intentionally scoped to one robot. It does not restart workers or
1018
+ touch other robots, and is safe to repeat after a failed LiveKit teardown.
1019
+ """
1020
+ now = datetime.utcnow().isoformat()
1021
+ async with aiosqlite.connect(DB_PATH) as db:
1022
+ db.row_factory = aiosqlite.Row
1023
+ async with db.execute("SELECT id, current_session_id FROM robots WHERE id = ?", (robot_id,)) as c:
1024
+ robot = await c.fetchone()
1025
+ if not robot:
1026
+ raise HTTPException(404, "Robot not found")
1027
+ await db.execute(
1028
+ "UPDATE sessions SET ended_at = COALESCE(ended_at, ?), end_reason = COALESCE(end_reason, 'operator_recovery') "
1029
+ "WHERE robot_id = ? AND ended_at IS NULL",
1030
+ (now, robot_id),
1031
+ )
1032
+ await db.execute("DELETE FROM trigger_commands WHERE robot_id = ?", (robot_id,))
1033
+ await db.execute(
1034
+ "UPDATE robots SET status = 'idle', current_session_id = NULL, connected_server_id = NULL WHERE id = ?",
1035
+ (robot_id,),
1036
+ )
1037
+ await db.commit()
1038
+ await broadcast("robot_recovered", {"robot_id": robot_id})
1039
+ await _log_history("robot", "robot", robot_id, "recovered", "operator")
1040
+ return {"status": "ok", "robot_id": robot_id, "previous_session_id": robot["current_session_id"]}
1041
+
765
1042
  @app.post("/api/robots/{robot_id}/trigger")
766
1043
  async def robot_trigger(robot_id: str,
767
1044
  authorization: Optional[str] = Header(default=None)):
@@ -1047,7 +1324,8 @@ async def session_joined(session_id: str,
1047
1324
 
1048
1325
  @app.post("/api/sessions/{session_id}/keepalive")
1049
1326
  async def session_keepalive(session_id: str,
1050
- authorization: Optional[str] = Header(default=None)):
1327
+ authorization: Optional[str] = Header(default=None),
1328
+ agent_token: Optional[str] = Header(default=None, alias="X-RoboPark-Agent-Token")):
1051
1329
  """Silence-based session extension signal.
1052
1330
 
1053
1331
  INTEGRATION POINT (out of scope here — lives in a different repo): the
@@ -1066,7 +1344,13 @@ async def session_keepalive(session_id: str,
1066
1344
  ceiling regardless of activity, so a misbehaving/stuck caller can never
1067
1345
  hold the publisher forever even if it calls this endpoint continuously.
1068
1346
  """
1069
- if not await _authorize_fleet(authorization):
1347
+ device_authorized = await _authorize_fleet(authorization)
1348
+ agent_authorized = bool(
1349
+ ROBOPARK_AGENT_TOKEN
1350
+ and agent_token
1351
+ and hmac.compare_digest(agent_token.strip(), ROBOPARK_AGENT_TOKEN)
1352
+ )
1353
+ if not device_authorized and not agent_authorized:
1070
1354
  raise HTTPException(401, "Invalid or missing device token")
1071
1355
  now = datetime.utcnow().isoformat()
1072
1356
  async with aiosqlite.connect(DB_PATH) as db:
@@ -1248,6 +1532,11 @@ async def metrics_collector():
1248
1532
  try:
1249
1533
  async with httpx.AsyncClient(timeout=5.0) as client:
1250
1534
  resp = await client.get(f"{server['webhook_url']}/metrics")
1535
+ if resp.status_code == 404:
1536
+ # Bare LiveKit servers do not expose the agent metrics endpoint.
1537
+ logger.debug(f"Metrics endpoint unavailable for {server['id']}")
1538
+ continue
1539
+ resp.raise_for_status()
1251
1540
  data = resp.json()
1252
1541
 
1253
1542
  # Store in history
@@ -1323,6 +1612,33 @@ async def health_checker():
1323
1612
  await db.execute("UPDATE devices SET status = 'offline' WHERE id = ?", (did,))
1324
1613
  await broadcast("device_status_changed", {"device_id": did, "status": "offline"})
1325
1614
 
1615
+ # Reap sessions leaked by a crashed worker or broken LiveKit
1616
+ # disconnect. Normal sessions are closed by the worker, but
1617
+ # this prevents stale rooms consuming server capacity forever.
1618
+ session_cutoff = (datetime.utcnow() - timedelta(seconds=120)).isoformat()
1619
+ async with db.execute(
1620
+ "SELECT id, robot_id FROM sessions "
1621
+ "WHERE ended_at IS NULL AND COALESCE(last_activity_at, started_at) < ?",
1622
+ (session_cutoff,),
1623
+ ) as c:
1624
+ stale_sessions = await c.fetchall()
1625
+ for session in stale_sessions:
1626
+ await db.execute(
1627
+ "UPDATE sessions SET ended_at = ?, end_reason = 'silence' "
1628
+ "WHERE id = ? AND ended_at IS NULL",
1629
+ (datetime.utcnow().isoformat(), session["id"]),
1630
+ )
1631
+ await db.execute(
1632
+ "UPDATE robots SET status = 'idle', current_session_id = NULL, "
1633
+ "connected_server_id = NULL WHERE id = ? AND current_session_id = ?",
1634
+ (session["robot_id"], session["id"]),
1635
+ )
1636
+ await broadcast("session_expired", {
1637
+ "session_id": session["id"],
1638
+ "robot_id": session["robot_id"],
1639
+ "reason": "silence",
1640
+ })
1641
+
1326
1642
  await db.commit()
1327
1643
 
1328
1644
  except Exception as e:
@@ -1335,31 +1651,73 @@ async def health_checker():
1335
1651
  # =============================================================================
1336
1652
 
1337
1653
  @app.get("/api/settings")
1338
- async def get_settings():
1339
- """Public settings (production mode + whether default enrollment token exists)."""
1340
- pm = await get_production_mode()
1341
- has_default_token = bool(await get_setting("default_enrollment_token"))
1342
- return {
1343
- "production_mode": pm,
1344
- "default_enrollment_token_set": has_default_token,
1345
- }
1654
+ async def get_settings():
1655
+ """Public settings with secret values reduced to status only."""
1656
+ pm = await get_production_mode()
1657
+ has_default_token = bool(await get_setting("default_enrollment_token"))
1658
+ return {
1659
+ "production_mode": pm,
1660
+ "default_enrollment_token_set": has_default_token,
1661
+ "agent_token_configured": bool(ROBOPARK_AGENT_TOKEN),
1662
+ "agent_token_source": "environment" if os.getenv("ROBOPARK_AGENT_TOKEN", "").strip() else "scheduler_secret_file",
1663
+ }
1346
1664
 
1347
1665
  @app.put("/api/settings")
1348
1666
  async def update_settings(payload: SettingsPayload):
1349
1667
  """Update scheduler settings (production mode, etc.)."""
1350
1668
  await set_setting("production_mode", "true" if payload.production_mode else "false")
1669
+ if not payload.production_mode:
1670
+ async with aiosqlite.connect(DB_PATH) as db:
1671
+ async with db.execute(
1672
+ "SELECT id FROM robots WHERE current_session_id IS NOT NULL"
1673
+ ) as c:
1674
+ active_robot_ids = [row[0] for row in await c.fetchall()]
1675
+ await db.execute("DELETE FROM trigger_commands")
1676
+ await db.execute("DELETE FROM previews")
1677
+ await db.commit()
1678
+ for robot_id in active_robot_ids:
1679
+ try:
1680
+ await end_session(robot_id, "production_mode_off")
1681
+ except HTTPException:
1682
+ pass
1351
1683
  await broadcast("settings_changed", {"production_mode": payload.production_mode})
1352
1684
  await _log_history("settings", "settings", "production_mode", "updated", "operator", f"value={payload.production_mode}")
1353
1685
  return {"status": "ok", "production_mode": payload.production_mode}
1354
1686
 
1355
- @app.post("/api/settings/enrollment-token/rotate")
1356
- async def rotate_enrollment_token():
1687
+ @app.post("/api/settings/enrollment-token/rotate")
1688
+ async def rotate_enrollment_token():
1357
1689
  """Generate a fresh default enrollment token (rotates the secret)."""
1358
1690
  token = secrets.token_urlsafe(24)
1359
1691
  await set_setting("default_enrollment_token", _hash(token))
1360
1692
  logger.info("Default enrollment token rotated")
1361
1693
  await _log_history("settings", "settings", "default_enrollment_token", "rotated", "operator")
1362
- return {"enrollment_token": token}
1694
+ return {"enrollment_token": token}
1695
+
1696
+ @app.post("/api/settings/agent-token/provision")
1697
+ async def provision_agent_token(payload: Optional[dict] = None):
1698
+ """Return the worker secret once so the UI/CLI can provision ROBOVOICE.
1699
+
1700
+ The value is never included in normal settings responses or logs. Rotation
1701
+ is explicit because existing workers would otherwise lose connectivity.
1702
+ """
1703
+ global ROBOPARK_AGENT_TOKEN
1704
+ rotate = bool((payload or {}).get("rotate"))
1705
+ if rotate or not ROBOPARK_AGENT_TOKEN:
1706
+ ROBOPARK_AGENT_TOKEN = secrets.token_urlsafe(32)
1707
+ os.makedirs(os.path.dirname(ROBOPARK_AGENT_TOKEN_FILE) or ".", exist_ok=True)
1708
+ temp_path = ROBOPARK_AGENT_TOKEN_FILE + ".tmp"
1709
+ with open(temp_path, "w", encoding="utf-8") as f:
1710
+ f.write(ROBOPARK_AGENT_TOKEN + "\n")
1711
+ os.replace(temp_path, ROBOPARK_AGENT_TOKEN_FILE)
1712
+ try:
1713
+ os.chmod(ROBOPARK_AGENT_TOKEN_FILE, 0o600)
1714
+ except OSError:
1715
+ pass
1716
+ await _log_history("settings", "settings", "agent_token", "rotated", "operator")
1717
+ return {
1718
+ "agent_token": ROBOPARK_AGENT_TOKEN,
1719
+ "warning": "Shown once by this response. Store it only in the ROBOVOICE worker environment.",
1720
+ }
1363
1721
 
1364
1722
  @app.get("/api/history")
1365
1723
  async def get_history(category: Optional[str] = None, limit: int = 200):
@@ -1589,6 +1947,27 @@ async def robot_stream(robot_id: str,
1589
1947
  PREVIEW_TTL_SECONDS = 45 # preview auto-expires this long after the last keepalive
1590
1948
  DEFAULT_VISION_PORT = 5000 # RoboVisionAI_PI's default Flask port (/video_feed, /api/motion/*)
1591
1949
 
1950
+ async def _mirror_device_to_robot(db, device_id: str):
1951
+ """Ensure a `robots` row exists for an enrolled device, so id-keyed routes
1952
+ (/stream, /preview/*) work even before the device's first real voice
1953
+ session (device_request_session normally creates this mirror, but only on
1954
+ first request-session — operator "Go live" should work before that too)."""
1955
+ async with db.execute("SELECT * FROM robots WHERE id = ?", (device_id,)) as c:
1956
+ robot = await c.fetchone()
1957
+ if robot:
1958
+ return robot
1959
+ async with db.execute("SELECT * FROM devices WHERE id = ?", (device_id,)) as c:
1960
+ device = await c.fetchone()
1961
+ if not device:
1962
+ return None
1963
+ await db.execute(
1964
+ "INSERT OR IGNORE INTO robots (id, name, character_id) VALUES (?, ?, ?)",
1965
+ (device_id, device["name"] or device_id, device["character_id"]),
1966
+ )
1967
+ await db.commit()
1968
+ async with db.execute("SELECT * FROM robots WHERE id = ?", (device_id,)) as c:
1969
+ return await c.fetchone()
1970
+
1592
1971
  async def _pick_preview_server(db, prefer_id: Optional[str] = None):
1593
1972
  """Return a LiveKit server row for a preview: a preferred one if still present,
1594
1973
  else the least-loaded online server, else any configured server."""
@@ -1638,9 +2017,8 @@ async def preview_start(robot_id: str):
1638
2017
  return {"active": False, "reason": "production_mode_off"}
1639
2018
  async with aiosqlite.connect(DB_PATH) as db:
1640
2019
  db.row_factory = aiosqlite.Row
1641
- async with db.execute("SELECT * FROM robots WHERE id = ?", (robot_id,)) as c:
1642
- if not await c.fetchone():
1643
- raise HTTPException(404, "Robot not found")
2020
+ if not await _mirror_device_to_robot(db, robot_id):
2021
+ raise HTTPException(404, "Robot not found")
1644
2022
  async with db.execute("SELECT * FROM previews WHERE robot_id = ?", (robot_id,)) as c:
1645
2023
  existing = await c.fetchone()
1646
2024
  server = await _pick_preview_server(db, existing["server_id"] if existing else None)
@@ -1724,6 +2102,26 @@ def _device_row_to_model(row) -> Device:
1724
2102
  d = dict(row)
1725
2103
  d.pop("token_hash", None)
1726
2104
  d.pop("enrollment_token_hash", None)
2105
+ inventory = d.get("device_inventory")
2106
+ if isinstance(inventory, str):
2107
+ try:
2108
+ d["device_inventory"] = json.loads(inventory)
2109
+ except json.JSONDecodeError:
2110
+ d["device_inventory"] = None
2111
+ greetings = d.get("greeting_phrases")
2112
+ if isinstance(greetings, str):
2113
+ try:
2114
+ d["greeting_phrases"] = [str(p) for p in json.loads(greetings) if str(p).strip()]
2115
+ except (json.JSONDecodeError, TypeError):
2116
+ d["greeting_phrases"] = []
2117
+ elif not isinstance(greetings, list):
2118
+ d["greeting_phrases"] = []
2119
+ sup = d.get("supervisor_status")
2120
+ if isinstance(sup, str):
2121
+ try:
2122
+ d["supervisor_status"] = json.loads(sup)
2123
+ except json.JSONDecodeError:
2124
+ d["supervisor_status"] = None
1727
2125
  return Device(**d)
1728
2126
 
1729
2127
  @app.get("/api/devices", response_model=List[Device])
@@ -1782,15 +2180,24 @@ class DeviceUpdate(BaseModel):
1782
2180
  livekit_url: Optional[str] = None
1783
2181
  video_device: Optional[str] = None
1784
2182
  audio_device: Optional[str] = None
2183
+ audio_output_device: Optional[str] = None
2184
+ greeting_phrases: Optional[List[str]] = None
1785
2185
  tailscale_ip: Optional[str] = None
1786
2186
  lan_ip: Optional[str] = None
1787
2187
  notes: Optional[str] = None
1788
2188
  status: Optional[str] = None
2189
+ production_mode: Optional[bool] = None
1789
2190
 
1790
2191
  @app.patch("/api/devices/{device_id}", response_model=Device)
1791
2192
  async def update_device(device_id: str, payload: DeviceUpdate):
1792
2193
  """Partial update for a device (character binding, addresses, notes, status)."""
1793
2194
  fields = {k: v for k, v in payload.model_dump(exclude_none=True).items()}
2195
+ if "greeting_phrases" in fields:
2196
+ fields["greeting_phrases"] = json.dumps(
2197
+ [str(p).strip() for p in fields["greeting_phrases"] if str(p).strip()][:20]
2198
+ )
2199
+ if "production_mode" in fields:
2200
+ fields["production_mode"] = 1 if fields["production_mode"] else 0
1794
2201
  if not fields:
1795
2202
  return await get_device(device_id)
1796
2203
  set_clause = ", ".join(f"{k} = ?" for k in fields)
@@ -1847,13 +2254,13 @@ async def create_device(payload: DeviceCreate):
1847
2254
  """INSERT INTO devices
1848
2255
  (id, name, tailscale_ip, lan_ip, motor_server_url, character_id,
1849
2256
  livekit_url, video_device, audio_device, enrollment_token_hash,
1850
- status, enrolled_at, notes, created_at)
1851
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'enrolled', ?, ?, ?)""",
2257
+ greeting_phrases, status, enrolled_at, notes, created_at)
2258
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'enrolled', ?, ?, ?)""",
1852
2259
  (
1853
2260
  device_id, payload.name.strip(), payload.tailscale_ip, payload.lan_ip,
1854
2261
  payload.motor_server_url, payload.character_id, payload.livekit_url,
1855
2262
  payload.video_device, payload.audio_device,
1856
- enrollment_hash, now, payload.notes, now,
2263
+ enrollment_hash, json.dumps(payload.greeting_phrases or []), now, payload.notes, now,
1857
2264
  ),
1858
2265
  )
1859
2266
  await db.commit()
@@ -1911,18 +2318,63 @@ async def _authorize_device(device_id: str, authorization: Optional[str]) -> boo
1911
2318
 
1912
2319
  async def _log_history(category: str, entity_type: Optional[str] = None, entity_id: Optional[str] = None,
1913
2320
  action: Optional[str] = None, actor: Optional[str] = None, details: Optional[str] = None):
1914
- """Append an immutable audit/history record."""
2321
+ """Append an immutable audit/history record.
2322
+
2323
+ The actor argument is overridden by `_current_actor_override` (a
2324
+ contextvar set by the FastAPI middleware from the X-Operator
2325
+ header) when present. The middleware is the only writer to the
2326
+ contextvar, so an untrusted caller cannot impersonate another
2327
+ operator by setting it directly. Falls back to the call-site
2328
+ `actor` argument when no override is set (e.g. system events)."""
2329
+ final_actor = _current_actor_override.get() or actor
1915
2330
  try:
1916
2331
  async with aiosqlite.connect(DB_PATH) as db:
1917
2332
  await db.execute(
1918
2333
  """INSERT INTO history_log (timestamp, category, entity_type, entity_id, action, actor, details)
1919
2334
  VALUES (?, ?, ?, ?, ?, ?, ?)""",
1920
- (datetime.utcnow().isoformat(), category, entity_type, entity_id, action, actor, details),
2335
+ (datetime.utcnow().isoformat(), category, entity_type, entity_id, action, final_actor, details),
1921
2336
  )
1922
2337
  await db.commit()
1923
2338
  except Exception as e:
1924
2339
  logger.warning(f"history log failed: {e}")
1925
2340
 
2341
+
2342
+ # Per-request operator name. Set by the operator_actor_middleware (which
2343
+ # reads the X-Operator header) and read by _log_history. Sanitized at
2344
+ # the middleware boundary so the value reaching the database is always
2345
+ # a safe printable string.
2346
+ _current_actor_override: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar("current_actor", default=None)
2347
+
2348
+
2349
+ def _sanitize_actor_name(name: Optional[str]) -> Optional[str]:
2350
+ """Trim, strip control chars, cap at 64 chars. Returns None if the
2351
+ result is empty so the caller can fall back to the default actor."""
2352
+ if not name:
2353
+ return None
2354
+ s = str(name).strip()
2355
+ if not s:
2356
+ return None
2357
+ # Allow printable ASCII + tab; strip everything else. Newlines and
2358
+ # CR would split a single history row into two display lines, so
2359
+ # we collapse them to a single space.
2360
+ out = []
2361
+ for c in s:
2362
+ if c == "\t" or c == " ":
2363
+ out.append(c)
2364
+ elif 0x20 <= ord(c) < 0x7f:
2365
+ out.append(c)
2366
+ else:
2367
+ # collapse any other whitespace-ish char to a space
2368
+ if c in ("\n", "\r", "\v", "\f"):
2369
+ out.append(" ")
2370
+ # drop anything else silently
2371
+ cleaned = "".join(out).strip()
2372
+ if not cleaned:
2373
+ return None
2374
+ if len(cleaned) > 64:
2375
+ cleaned = cleaned[:64]
2376
+ return cleaned
2377
+
1926
2378
  async def _authorize_fleet(authorization: Optional[str]) -> bool:
1927
2379
  """Verify a Bearer token matches ANY enrolled device token (fleet auth).
1928
2380
 
@@ -2051,35 +2503,412 @@ async def enroll_device(payload: DeviceEnrollRequest):
2051
2503
  scheduler_url = os.getenv("SCHEDULER_PUBLIC_URL", f"http://localhost:{os.getenv('SCHEDULER_PORT', '8080')}")
2052
2504
  return DeviceEnrollResponse(device_id=device_id, device_token=new_token, scheduler_url=scheduler_url)
2053
2505
 
2506
+ async def _effective_device_production_mode(db, device_id: str) -> bool:
2507
+ """Global master switch AND this robot's own toggle. Used everywhere a
2508
+ robot decides whether to auto-trigger/stay in its motion-triggered loop."""
2509
+ if not await get_production_mode():
2510
+ return False
2511
+ db.row_factory = aiosqlite.Row
2512
+ async with db.execute("SELECT production_mode FROM devices WHERE id = ?", (device_id,)) as c:
2513
+ row = await c.fetchone()
2514
+ return bool(row["production_mode"]) if row else False
2515
+
2516
+ @app.get("/api/devices/{device_id}/trigger-command")
2517
+ async def device_trigger_command(device_id: str,
2518
+ authorization: Optional[str] = Header(default=None)):
2519
+ """Authenticated robot poll for dashboard-simulated motion events."""
2520
+ if not await _authorize_device(device_id, authorization):
2521
+ raise HTTPException(401, "Invalid device token")
2522
+ async with aiosqlite.connect(DB_PATH) as db:
2523
+ if not await _effective_device_production_mode(db, device_id):
2524
+ return {"trigger": False, "production_mode": False}
2525
+ db.row_factory = aiosqlite.Row
2526
+ async with db.execute(
2527
+ "SELECT source FROM trigger_commands WHERE robot_id = ?", (device_id,)
2528
+ ) as c:
2529
+ command = await c.fetchone()
2530
+ if not command:
2531
+ return {"trigger": False, "production_mode": True}
2532
+ await db.execute("DELETE FROM trigger_commands WHERE robot_id = ?", (device_id,))
2533
+ await db.commit()
2534
+ return {"trigger": True, "source": command["source"], "production_mode": True}
2535
+
2054
2536
  @app.post("/api/devices/{device_id}/heartbeat")
2055
2537
  async def device_heartbeat(device_id: str, payload: DeviceHeartbeat,
2056
2538
  authorization: Optional[str] = Header(default=None)):
2057
2539
  if not await _authorize_device(device_id, authorization):
2058
2540
  raise HTTPException(401, "Invalid device token")
2059
2541
  async with aiosqlite.connect(DB_PATH) as db:
2542
+ db.row_factory = aiosqlite.Row
2060
2543
  async with db.execute(
2061
2544
  """UPDATE devices
2062
- SET status = ?, last_heartbeat = ?, last_seen_ip = COALESCE(?, last_seen_ip)
2545
+ SET status = ?, last_heartbeat = ?,
2546
+ lan_ip = COALESCE(?, lan_ip),
2547
+ last_seen_ip = COALESCE(?, last_seen_ip),
2548
+ device_inventory = COALESCE(?, device_inventory)
2063
2549
  WHERE id = ?""",
2064
2550
  (
2065
2551
  payload.status or "online",
2066
2552
  datetime.utcnow().isoformat(),
2067
2553
  payload.ip,
2554
+ payload.ip,
2555
+ json.dumps(payload.device_inventory) if payload.device_inventory is not None else None,
2068
2556
  device_id,
2069
2557
  ),
2070
2558
  ) as cur:
2071
2559
  await db.commit()
2072
2560
  if cur.rowcount == 0:
2073
2561
  raise HTTPException(404, "Device not found")
2562
+ effective_pm = await _effective_device_production_mode(db, device_id)
2074
2563
  await broadcast("device_heartbeat", {"device_id": device_id, "status": payload.status})
2075
2564
  await _log_history("device", "device", device_id, "heartbeat", "pi", f"status={payload.status or 'online'}, ip={payload.ip}")
2076
- # Tell the Pi whether production mode is on (so it can decide to join LiveKit or stand by)
2565
+ # Effective production mode = the global master switch AND this specific
2566
+ # robot's own toggle. A robot with its own toggle off never auto-triggers
2567
+ # sessions from motion, even while the fleet-wide switch is on.
2077
2568
  return {
2078
2569
  "status": "ok",
2079
- "production_mode": await get_production_mode(),
2570
+ "production_mode": effective_pm,
2080
2571
  "server_time": datetime.utcnow().isoformat(),
2081
2572
  }
2082
2573
 
2574
+ @app.post("/api/devices/{device_id}/supervisor-status")
2575
+ async def device_supervisor_status(device_id: str, payload: SupervisorStatusReport,
2576
+ authorization: Optional[str] = Header(default=None)):
2577
+ """robot_supervisor.py's periodic self-report: which services it's
2578
+ managing on this robot and each one's current up/down state. Reported
2579
+ independently of device_heartbeat (which comes from preview_agent.py --
2580
+ if preview_agent itself is down, it can't report that fact, only the
2581
+ supervisor watching it from outside can)."""
2582
+ if not await _authorize_device(device_id, authorization):
2583
+ raise HTTPException(401, "Invalid device token")
2584
+ blob = json.dumps([s.model_dump() for s in payload.services])
2585
+ now = datetime.utcnow().isoformat()
2586
+ async with aiosqlite.connect(DB_PATH) as db:
2587
+ db.row_factory = aiosqlite.Row
2588
+ cur = await db.execute(
2589
+ "UPDATE devices SET supervisor_status = ?, supervisor_status_at = ? WHERE id = ?",
2590
+ (blob, now, device_id),
2591
+ )
2592
+ await db.commit()
2593
+ if cur.rowcount == 0:
2594
+ raise HTTPException(404, "Device not found")
2595
+ # Piggyback any pending remote-control commands (operator clicked
2596
+ # "restart" on a service) onto this same request/response cycle --
2597
+ # the robot already calls this every 10s, no separate poll needed.
2598
+ # Consumed immediately: read then delete, so a command fires once.
2599
+ # Backward-compat: rows may or may not have a `kind` column on
2600
+ # older DBs. Default to the original "supervisor_action" kind
2601
+ # (treated as restart / start / stop on the service) when missing.
2602
+ pending = []
2603
+ try:
2604
+ async with db.execute(
2605
+ "SELECT service_name, action, kind, params, id FROM shell_requests "
2606
+ "WHERE device_id = ? AND completed_at IS NULL ORDER BY requested_at",
2607
+ (device_id,),
2608
+ ) as c:
2609
+ shell_rows = [dict(r) for r in await c.fetchall()]
2610
+ for r in shell_rows:
2611
+ pending.append({
2612
+ "service_name": r.get("service_name") or "",
2613
+ "action": "run",
2614
+ "kind": r.get("kind") or "shell_run",
2615
+ "params": json.loads(r["params"]) if r.get("params") else {},
2616
+ "id": r["id"],
2617
+ })
2618
+ except Exception:
2619
+ shell_rows = []
2620
+ try:
2621
+ async with db.execute(
2622
+ "SELECT service_name, action FROM supervisor_commands WHERE device_id = ?", (device_id,)
2623
+ ) as c:
2624
+ for r in await c.fetchall():
2625
+ pending.append({"service_name": r["service_name"], "action": r["action"], "kind": "supervisor_action"})
2626
+ except Exception:
2627
+ pass
2628
+ if pending:
2629
+ # Mark shell requests as "in flight" so the dashboard can
2630
+ # distinguish "queued" from "still queued" (the row is read
2631
+ # but not yet completed). supervisor_commands rows are
2632
+ # one-shot and are deleted to mirror the previous behavior.
2633
+ try:
2634
+ ids = [p["id"] for p in pending if p.get("id")]
2635
+ if ids:
2636
+ placeholders = ",".join("?" for _ in ids)
2637
+ await db.execute(
2638
+ f"UPDATE shell_requests SET requested_at = requested_at "
2639
+ f"WHERE id IN ({placeholders})",
2640
+ ids,
2641
+ )
2642
+ except Exception:
2643
+ pass
2644
+ try:
2645
+ await db.execute("DELETE FROM supervisor_commands WHERE device_id = ?", (device_id,))
2646
+ except Exception:
2647
+ pass
2648
+ await db.commit()
2649
+ await broadcast("supervisor_status", {"device_id": device_id, "services": blob})
2650
+ return {"status": "ok", "commands": pending}
2651
+
2652
+ @app.post("/api/devices/{device_id}/services/{service_name}/restart")
2653
+ async def restart_device_service(device_id: str, service_name: str):
2654
+ """Operator dashboard: queue a restart for one service on one robot's
2655
+ supervisor. Picked up on the robot's next status-report poll (up to
2656
+ ~10s later, not instant -- there's no persistent connection to the
2657
+ robot, only its periodic check-ins)."""
2658
+ now = datetime.utcnow().isoformat()
2659
+ async with aiosqlite.connect(DB_PATH) as db:
2660
+ cur = await db.execute("SELECT 1 FROM devices WHERE id = ?", (device_id,))
2661
+ if not await cur.fetchone():
2662
+ raise HTTPException(404, "Device not found")
2663
+ await db.execute(
2664
+ """INSERT INTO supervisor_commands (device_id, service_name, action, requested_at)
2665
+ VALUES (?, ?, 'restart', ?)
2666
+ ON CONFLICT(device_id, service_name) DO UPDATE SET
2667
+ action='restart', requested_at=excluded.requested_at""",
2668
+ (device_id, service_name, now),
2669
+ )
2670
+ await db.commit()
2671
+ await _log_history("device", "device", device_id, "service_restart_queued", "operator", f"service={service_name}")
2672
+ return {"status": "queued", "service": service_name}
2673
+
2674
+
2675
+ # ── Operator shell (C1) ──
2676
+ # Endpoints the dashboard calls to tail logs and run a small allowlisted
2677
+ # set of diagnostic commands on a specific robot. Commands are queued in
2678
+ # the `shell_requests` table; the robot's supervisor picks them up on its
2679
+ # next status-report poll, runs them, and POSTs results back to
2680
+ # /api/devices/{id}/supervisor-output. The dashboard polls
2681
+ # /api/robots/{id}/shell/result/{request_id} with an 8s budget.
2682
+
2683
+ class ShellRequest(BaseModel):
2684
+ kind: str # "tail_logs" | "shell_run"
2685
+ service: Optional[str] = None
2686
+ params: Optional[dict] = None
2687
+
2688
+
2689
+ async def _queue_shell_request_async(device_id: str, kind: str, service: Optional[str], params: dict) -> str:
2690
+ """Insert a shell request row and return its id. Picked up by the
2691
+ robot's supervisor on its next status-report poll."""
2692
+ request_id = secrets.token_urlsafe(12)
2693
+ now = datetime.utcnow().isoformat()
2694
+ async with aiosqlite.connect(DB_PATH) as db:
2695
+ await db.execute(
2696
+ """INSERT INTO shell_requests
2697
+ (id, device_id, service_name, kind, params, requested_at)
2698
+ VALUES (?, ?, ?, ?, ?, ?)""",
2699
+ (request_id, device_id, service, kind, json.dumps(params or {}), now),
2700
+ )
2701
+ await db.commit()
2702
+ return request_id
2703
+
2704
+
2705
+ @app.post("/api/robots/{robot_id}/shell/tail")
2706
+ async def robot_shell_tail(robot_id: str, payload: ShellRequest):
2707
+ """Queue a tail-logs request for a robot. Returns a request_id the
2708
+ dashboard polls to get the result.
2709
+
2710
+ robot_id == device_id in this codebase (the dashboard's "robot" id is
2711
+ the same value as /api/devices rows)."""
2712
+ device_id = robot_id
2713
+ params = dict(payload.params or {})
2714
+ if "lines" not in params:
2715
+ params["lines"] = 200
2716
+ request_id = await _queue_shell_request_async(device_id, "tail_logs", payload.service, params)
2717
+ return {"status": "queued", "request_id": request_id, "device_id": device_id}
2718
+
2719
+
2720
+ @app.post("/api/robots/{robot_id}/shell/exec")
2721
+ async def robot_shell_exec(robot_id: str, payload: ShellRequest):
2722
+ """Queue a shell-run request for a robot. Returns a request_id the
2723
+ dashboard polls to get the result."""
2724
+ device_id = robot_id
2725
+ params = dict(payload.params or {})
2726
+ if not params.get("name"):
2727
+ raise HTTPException(400, "params.name is required (e.g. 'uptime', 'free', 'ps', 'tail')")
2728
+ request_id = await _queue_shell_request_async(device_id, "shell_run", payload.service, params)
2729
+ return {"status": "queued", "request_id": request_id, "device_id": device_id}
2730
+
2731
+
2732
+ @app.get("/api/robots/{robot_id}/shell/result/{request_id}")
2733
+ async def robot_shell_result(robot_id: str, request_id: str):
2734
+ """Return the result of a previously-queued shell request. The
2735
+ dashboard polls this with an 8s budget; we reply as soon as the
2736
+ robot has reported the result back."""
2737
+ async with aiosqlite.connect(DB_PATH) as db:
2738
+ db.row_factory = aiosqlite.Row
2739
+ async with db.execute("SELECT * FROM shell_requests WHERE id = ?", (request_id,)) as c:
2740
+ r = await c.fetchone()
2741
+ if not r or r["device_id"] != robot_id:
2742
+ raise HTTPException(404, "No such shell request for this robot")
2743
+ out = {
2744
+ "request_id": r["id"],
2745
+ "device_id": r["device_id"],
2746
+ "service": r["service_name"],
2747
+ "kind": r["kind"],
2748
+ "params": json.loads(r["params"]) if r["params"] else {},
2749
+ "requested_at": r["requested_at"],
2750
+ "completed": r["completed_at"] is not None,
2751
+ "completed_at": r["completed_at"],
2752
+ "result": None,
2753
+ }
2754
+ if r["completed_at"] is not None:
2755
+ try:
2756
+ out["result"] = json.loads(r["result_payload"]) if r["result_payload"] else {"ok": bool(r["result_ok"])}
2757
+ except Exception:
2758
+ out["result"] = {"ok": bool(r["result_ok"]), "error": "(result payload not parseable)"}
2759
+ return out
2760
+
2761
+
2762
+ # Long-poll variant: holds the connection open until the result arrives
2763
+ # (or the 8s budget elapses), so the dashboard can wait without polling
2764
+ # in a tight loop.
2765
+ @app.get("/api/robots/{robot_id}/shell/wait/{request_id}")
2766
+ async def robot_shell_wait(robot_id: str, request_id: str, timeout: float = 8.0):
2767
+ deadline = asyncio.get_event_loop().time() + max(0.5, min(15.0, timeout))
2768
+ while True:
2769
+ async with aiosqlite.connect(DB_PATH) as db:
2770
+ db.row_factory = aiosqlite.Row
2771
+ async with db.execute("SELECT * FROM shell_requests WHERE id = ?", (request_id,)) as c:
2772
+ r = await c.fetchone()
2773
+ if not r or r["device_id"] != robot_id:
2774
+ raise HTTPException(404, "No such shell request for this robot")
2775
+ if r["completed_at"] is not None:
2776
+ try:
2777
+ payload = json.loads(r["result_payload"]) if r["result_payload"] else {"ok": bool(r["result_ok"])}
2778
+ except Exception:
2779
+ payload = {"ok": bool(r["result_ok"]), "error": "(result payload not parseable)"}
2780
+ return {"request_id": request_id, "completed": True, "result": payload}
2781
+ if asyncio.get_event_loop().time() >= deadline:
2782
+ return {"request_id": request_id, "completed": False, "timed_out": True}
2783
+ await asyncio.sleep(0.4)
2784
+
2785
+
2786
+ @app.post("/api/devices/{device_id}/supervisor-output")
2787
+ async def device_supervisor_output(device_id: str, payload: dict, authorization: Optional[str] = Header(default=None)):
2788
+ """Robot supervisor posts back the result of a tail_logs or shell_run
2789
+ request. We just record it on the shell_requests row; the dashboard
2790
+ picks it up via /api/robots/{id}/shell/result/{request_id}."""
2791
+ if not await _authorize_device(device_id, authorization):
2792
+ raise HTTPException(401, "Invalid device token")
2793
+ request_id = payload.get("request_id")
2794
+ if not request_id:
2795
+ raise HTTPException(400, "request_id is required")
2796
+ now = datetime.utcnow().isoformat()
2797
+ result_payload = payload.get("payload") or {}
2798
+ ok = 1 if result_payload.get("ok") else 0
2799
+ async with aiosqlite.connect(DB_PATH) as db:
2800
+ db.row_factory = aiosqlite.Row
2801
+ async with db.execute("SELECT device_id FROM shell_requests WHERE id = ?", (request_id,)) as c:
2802
+ r = await c.fetchone()
2803
+ if not r:
2804
+ raise HTTPException(404, "No such shell request")
2805
+ if r["device_id"] != device_id:
2806
+ raise HTTPException(403, "request_id does not belong to this device")
2807
+ await db.execute(
2808
+ """UPDATE shell_requests SET completed_at = ?, result_ok = ?, result_payload = ?
2809
+ WHERE id = ?""",
2810
+ (now, ok, json.dumps(result_payload), request_id),
2811
+ )
2812
+ await db.commit()
2813
+ kind = payload.get("kind") or "shell"
2814
+ service = payload.get("service") or ""
2815
+ await _log_history("device", "device", device_id, f"shell_{kind}_completed", "operator",
2816
+ f"service={service}, request_id={request_id}, ok={bool(ok)}")
2817
+ return {"status": "ok"}
2818
+
2819
+
2820
+ # Endpoint the dashboard uses to list the available shell commands
2821
+ # (allowlist) so the UI can populate a picker instead of typing names.
2822
+ # The list mirrors the SHELL_ALLOWLIST in robot_supervisor.py — that
2823
+ # file is the authoritative source for what's actually allowed at
2824
+ # run time. Keep these two lists in sync when adding/removing commands.
2825
+ SHELL_DASHBOARD_ALLOWLIST = [
2826
+ {"name": "uptime", "argv": ["uptime"]},
2827
+ {"name": "free", "argv": ["free", "-m"]},
2828
+ {"name": "df", "argv": ["df", "-h"]},
2829
+ {"name": "ps", "argv": ["ps", "aux"]},
2830
+ {"name": "uname", "argv": ["uname", "-a"]},
2831
+ {"name": "date", "argv": ["date"]},
2832
+ {"name": "whoami", "argv": ["whoami"]},
2833
+ {"name": "hostname", "argv": ["hostname"]},
2834
+ {"name": "ip", "argv": ["ip", "addr"]},
2835
+ {"name": "ss", "argv": ["ss", "-tlnp"]},
2836
+ {"name": "os-release", "argv": ["cat", "/etc/os-release"]},
2837
+ {"name": "ls-logs", "argv": ["ls", "-la", "<dir>"]},
2838
+ {"name": "systemctl-status", "argv": ["systemctl", "status", "<name>", "--no-pager", "-n", "30"]},
2839
+ {"name": "journalctl", "argv": ["journalctl", "-u", "<name>", "-n", "200", "--no-pager"]},
2840
+ {"name": "tail", "argv": ["tail", "-n", "<n>", "<path>"]},
2841
+ ]
2842
+ SHELL_DASHBOARD_OUTPUT_MAX = 64 * 1024
2843
+ SHELL_DASHBOARD_TIMEOUT = 8.0
2844
+
2845
+ @app.get("/api/shell/allowlist")
2846
+ async def shell_allowlist():
2847
+ """Return the list of shell command names the robot's supervisor
2848
+ supports. The dashboard uses this to populate the picker; the
2849
+ robot side does the actual allowlist enforcement at run time."""
2850
+ return {
2851
+ "commands": [
2852
+ {"name": s["name"], "argv": s["argv"]}
2853
+ for s in SHELL_DASHBOARD_ALLOWLIST
2854
+ ],
2855
+ "max_output_bytes": SHELL_DASHBOARD_OUTPUT_MAX,
2856
+ "timeout_seconds": SHELL_DASHBOARD_TIMEOUT,
2857
+ }
2858
+
2859
+
2860
+ # ── Speaker roundtrip test (C2) ──
2861
+ # The dashboard's "Test speaker" button hits this. We queue a
2862
+ # speaker_test request on the robot's shell_requests table; the
2863
+ # robot's next status-report poll runs the round-trip (play tone,
2864
+ # record mic) and POSTs the result back via /supervisor-output.
2865
+ # The dashboard polls the same /shell/wait endpoint used for the
2866
+ # other shell commands (the supervisor treats speaker_test as
2867
+ # just another request kind).
2868
+
2869
+ class SpeakerTestRequest(BaseModel):
2870
+ frequency: Optional[float] = None
2871
+ duration: Optional[float] = None
2872
+ amplitude: Optional[float] = None
2873
+ threshold_db: Optional[float] = None
2874
+ output: Optional[str] = None
2875
+ input: Optional[str] = None
2876
+ sample_rate: Optional[int] = None
2877
+
2878
+
2879
+ @app.post("/api/robots/{robot_id}/shell/speaker-test")
2880
+ async def robot_speaker_test(robot_id: str, payload: SpeakerTestRequest):
2881
+ """Queue a speaker + mic round-trip test on the robot. Returns a
2882
+ request_id; poll /api/robots/{id}/shell/wait/{rid} for the result."""
2883
+ device_id = robot_id
2884
+ params = payload.model_dump(exclude_none=True)
2885
+ request_id = await _queue_shell_request_async(device_id, "speaker_test", None, params)
2886
+ return {"status": "queued", "request_id": request_id, "device_id": device_id}
2887
+
2888
+
2889
+ # ── Incident acknowledgement (C4) ──
2890
+ # The dashboard's Incidents sub-tab lets an operator acknowledge an
2891
+ # active incident (a robot stuck in stuck_session, server_unavailable,
2892
+ # etc). The acknowledgement is stored client-side (localStorage) so
2893
+ # each operator on their own browser gets their own list; this
2894
+ # endpoint is the "central log" mirror so the ack also shows up in
2895
+ # the audit history with the operator's name (via X-Operator).
2896
+ class IncidentAck(BaseModel):
2897
+ robot_id: str
2898
+ code: str
2899
+ message: Optional[str] = None
2900
+
2901
+ @app.post("/api/incidents/ack")
2902
+ async def incidents_ack(payload: IncidentAck, x_operator: Optional[str] = Header(default=None, alias="X-Operator")):
2903
+ """Log an operator ack of an active incident. The dashboard
2904
+ stores its own list; this endpoint mirrors the ack in the audit
2905
+ log so the action is visible centrally with the operator's name."""
2906
+ actor = _sanitize_actor_name(x_operator) or 'operator'
2907
+ await _log_history("incident", "robot", payload.robot_id, "acked", actor,
2908
+ f"code={payload.code}, message={payload.message or ''}")
2909
+ return {"status": "ok", "robot_id": payload.robot_id, "code": payload.code, "by": actor, "at": datetime.utcnow().isoformat()}
2910
+
2911
+
2083
2912
  @app.get("/api/devices/{device_id}/config")
2084
2913
  async def device_config(device_id: str, authorization: Optional[str] = Header(default=None)):
2085
2914
  """Pi polls this to learn current production_mode + assigned character, etc."""
@@ -2088,19 +2917,25 @@ async def device_config(device_id: str, authorization: Optional[str] = Header(de
2088
2917
  async with aiosqlite.connect(DB_PATH) as db:
2089
2918
  db.row_factory = aiosqlite.Row
2090
2919
  async with db.execute(
2091
- "SELECT id, name, character_id, motor_server_url, livekit_url FROM devices WHERE id = ?",
2920
+ "SELECT id, name, character_id, motor_server_url, livekit_url, video_device, audio_device, audio_output_device, device_inventory, greeting_phrases FROM devices WHERE id = ?",
2092
2921
  (device_id,),
2093
2922
  ) as c:
2094
2923
  row = await c.fetchone()
2095
2924
  if not row:
2096
2925
  raise HTTPException(404, "Device not found")
2926
+ effective_pm = await _effective_device_production_mode(db, device_id)
2097
2927
  return {
2098
2928
  "device_id": row["id"],
2099
2929
  "name": row["name"],
2100
2930
  "character_id": row["character_id"],
2101
2931
  "motor_server_url": row["motor_server_url"],
2102
2932
  "livekit_url": row["livekit_url"],
2103
- "production_mode": await get_production_mode(),
2933
+ "video_device": row["video_device"],
2934
+ "audio_device": row["audio_device"],
2935
+ "audio_output_device": row["audio_output_device"],
2936
+ "greeting_phrases": _device_row_to_model(row).greeting_phrases,
2937
+ "device_inventory": json.loads(row["device_inventory"]) if row["device_inventory"] else None,
2938
+ "production_mode": effective_pm,
2104
2939
  }
2105
2940
 
2106
2941
  @app.post("/api/devices/{device_id}/request-session")
@@ -2134,10 +2969,14 @@ async def device_request_session(device_id: str,
2134
2969
  db.row_factory = aiosqlite.Row
2135
2970
 
2136
2971
  # Resolve the enrolled device.
2137
- async with db.execute("SELECT * FROM devices WHERE id = ?", (device_id,)) as c:
2138
- device = await c.fetchone()
2139
- if not device:
2140
- raise HTTPException(404, "Device not found")
2972
+ async with db.execute("SELECT * FROM devices WHERE id = ?", (device_id,)) as c:
2973
+ device = await c.fetchone()
2974
+ if not device:
2975
+ raise HTTPException(404, "Device not found")
2976
+ if not await get_production_mode():
2977
+ raise HTTPException(403, "production_mode_off")
2978
+ if not bool(device["production_mode"]):
2979
+ raise HTTPException(403, "device_production_mode_off")
2141
2980
 
2142
2981
  # Mirror device -> robot identity (id == device_id). Additive + idempotent:
2143
2982
  # if the robot row already exists it is left untouched.
@@ -2150,6 +2989,21 @@ async def device_request_session(device_id: str,
2150
2989
  (device_id, device["name"] or device_id, device["character_id"]),
2151
2990
  )
2152
2991
 
2992
+ # A robot can only physically be in one room at a time -- it always
2993
+ # disconnects from its previous room before joining a new one (see
2994
+ # preview_agent.py's _apply_state). But this endpoint deliberately
2995
+ # never hard-fails on a prior un-ended session (see docstring), so
2996
+ # without this, repeated presence-triggers (e.g. continuous motion
2997
+ # detection) each leave the previous session's row with
2998
+ # ended_at IS NULL forever -- permanently consuming a slot against
2999
+ # the server's max_sessions capacity until every slot leaks away and
3000
+ # every future request-session 503s. Close them out here instead.
3001
+ await db.execute(
3002
+ """UPDATE sessions SET ended_at = ?, end_reason = 'superseded'
3003
+ WHERE robot_id = ? AND ended_at IS NULL""",
3004
+ (datetime.utcnow().isoformat(), device_id),
3005
+ )
3006
+
2153
3007
  # Find least-loaded online server (identical selection to the robot path).
2154
3008
  async with db.execute("""
2155
3009
  SELECT s.*,
@@ -2287,7 +3141,7 @@ class VoiceStack(BaseModel):
2287
3141
  stt_model: str = "Systran/faster-whisper-small"
2288
3142
  stt_language: str = "en"
2289
3143
  llm_provider: str = "ollama"
2290
- llm_model: str = "gemma4:e4b"
3144
+ llm_model: str = "gemma3:27b"
2291
3145
  llm_api_key: Optional[str] = None
2292
3146
  temperature: float = 0.7
2293
3147
  num_ctx: int = 8192
@@ -2311,7 +3165,7 @@ class VoiceStackCreate(BaseModel):
2311
3165
  stt_model: str = "Systran/faster-whisper-small"
2312
3166
  stt_language: str = "en"
2313
3167
  llm_provider: str = "ollama"
2314
- llm_model: str = "gemma4:e4b"
3168
+ llm_model: str = "gemma3:27b"
2315
3169
  llm_api_key: Optional[str] = None
2316
3170
  temperature: float = 0.7
2317
3171
  num_ctx: int = 8192
@@ -2333,6 +3187,7 @@ class RobotVoiceConfig(BaseModel):
2333
3187
  system_prompt: Optional[str] = None
2334
3188
  voice_stack_id: Optional[str] = None
2335
3189
  voice_stack: Optional[VoiceStack] = None
3190
+ greeting_phrases: List[str] = []
2336
3191
 
2337
3192
  def _load_robovoice_settings() -> dict:
2338
3193
  """Read the ROBOVOICE settings file. Returns {} if missing/invalid."""
@@ -2453,6 +3308,48 @@ async def _resolve_voice_stack_for_robot(db, robot_id: str) -> dict:
2453
3308
  return result
2454
3309
 
2455
3310
 
3311
+ _ELEVENLABS_NAME_CACHE: tuple[float, list[dict]] = (0.0, [])
3312
+
3313
+
3314
+ async def _auto_elevenlabs_voice(robot_name: Optional[str], current: Optional[str]) -> Optional[str]:
3315
+ """Resolve a custom ElevenLabs voice by robot name without overwriting a manual choice."""
3316
+ if current and current != "21m00Tcm4TlvDq8ikWAM":
3317
+ return current
3318
+ if not robot_name:
3319
+ return current
3320
+ normalized = "".join(ch for ch in robot_name.lower() if ch.isalnum())
3321
+ try:
3322
+ mapping = json.loads(os.getenv("ROBOPARK_ELEVENLABS_VOICE_MAP", "{}"))
3323
+ for key, voice_id in mapping.items():
3324
+ if "".join(ch for ch in str(key).lower() if ch.isalnum()) in (normalized, "".join(ch for ch in robot_name.lower() if ch.isalnum())):
3325
+ return str(voice_id)
3326
+ except Exception:
3327
+ pass
3328
+ global _ELEVENLABS_NAME_CACHE
3329
+ now = time.monotonic()
3330
+ voices = _ELEVENLABS_NAME_CACHE[1]
3331
+ if now - _ELEVENLABS_NAME_CACHE[0] > 300 and ELEVENLABS_API_KEY:
3332
+ try:
3333
+ async with httpx.AsyncClient(timeout=8.0) as client:
3334
+ response = await client.get(
3335
+ f"{ELEVENLABS_BASE_URL}/v1/voices",
3336
+ headers={"xi-api-key": ELEVENLABS_API_KEY, "Accept": "application/json"},
3337
+ )
3338
+ if response.status_code == 200:
3339
+ voices = response.json().get("voices") or []
3340
+ _ELEVENLABS_NAME_CACHE = (now, voices)
3341
+ except Exception as e:
3342
+ logger.debug(f"ElevenLabs voice auto-assignment unavailable: {e}")
3343
+ aliases = {normalized}
3344
+ if normalized == "vixenbmw":
3345
+ aliases.add("vixen")
3346
+ for voice in voices:
3347
+ voice_name = "".join(ch for ch in str(voice.get("name", "")).lower() if ch.isalnum())
3348
+ if voice_name in aliases or any(alias in voice_name for alias in aliases):
3349
+ return str(voice.get("voice_id") or current)
3350
+ return current
3351
+
3352
+
2456
3353
  async def _resolve_robot_voice_config(robot_id: str) -> RobotVoiceConfig:
2457
3354
  """Public helper to resolve the effective voice config for a robot id."""
2458
3355
  async with aiosqlite.connect(DB_PATH) as db:
@@ -2494,6 +3391,16 @@ async def _resolve_robot_voice_config(robot_id: str) -> RobotVoiceConfig:
2494
3391
  if row:
2495
3392
  character_name = row["name"]
2496
3393
 
3394
+ async with db.execute("SELECT greeting_phrases FROM devices WHERE id = ?", (robot_id,)) as c:
3395
+ device_row = await c.fetchone()
3396
+ greeting_phrases = []
3397
+ if device_row and device_row["greeting_phrases"]:
3398
+ try:
3399
+ greeting_phrases = [str(p) for p in _json.loads(device_row["greeting_phrases"]) if str(p).strip()]
3400
+ except Exception:
3401
+ greeting_phrases = []
3402
+ if stack_dict:
3403
+ stack_dict["tts_voice"] = await _auto_elevenlabs_voice(character_name or character_id, stack_dict.get("tts_voice"))
2497
3404
  voice_stack = VoiceStack(**stack_dict) if stack_dict else None
2498
3405
  return RobotVoiceConfig(
2499
3406
  character_id=character_id,
@@ -2501,6 +3408,7 @@ async def _resolve_robot_voice_config(robot_id: str) -> RobotVoiceConfig:
2501
3408
  system_prompt=system_prompt,
2502
3409
  voice_stack_id=voice_stack.id if voice_stack else None,
2503
3410
  voice_stack=voice_stack,
3411
+ greeting_phrases=greeting_phrases,
2504
3412
  )
2505
3413
 
2506
3414
 
@@ -2594,6 +3502,260 @@ async def delete_voice_stack(stack_id: str):
2594
3502
  return {"ok": True}
2595
3503
 
2596
3504
 
3505
+ # =============================================================================
3506
+ # TTS VOICE CATALOG
3507
+ # =============================================================================
3508
+ # These endpoints let the dashboard populate its voice selector with the real
3509
+ # set of available voices per provider. ElevenLabs is fetched live from the
3510
+ # ElevenLabs REST API (using ELEVENLABS_API_KEY); Kokoro is served from a
3511
+ # curated static list (or proxied from a local Kokoro server if KOKORO_BASE_URL
3512
+ # is set). Both endpoints are public on the scheduler and require no auth —
3513
+ # the ElevenLabs key is only used server-side.
3514
+
3515
+ class TtsVoice(BaseModel):
3516
+ id: str
3517
+ name: str
3518
+ language: Optional[str] = None
3519
+ gender: Optional[str] = None
3520
+ description: Optional[str] = None
3521
+ preview_url: Optional[str] = None
3522
+ category: Optional[str] = None
3523
+ labels: Optional[dict] = None
3524
+
3525
+
3526
+ class TtsVoiceList(BaseModel):
3527
+ provider: str
3528
+ source: str # "api", "static", or "proxy"
3529
+ count: int
3530
+ voices: List[TtsVoice]
3531
+ warning: Optional[str] = None
3532
+
3533
+
3534
+ # Canonical Kokoro-82M voice list. The id is the value written into
3535
+ # voice_stacks.tts_voice; name is what the dashboard shows in the dropdown.
3536
+ # Sources: https://huggingface.co/hexgrad/Kokoro-82M and the Kokoro repo
3537
+ # voices.json manifest (v1.0). Grouped by language for the dropdown label.
3538
+ KOKORO_STATIC_VOICES: List[dict] = [
3539
+ # American English
3540
+ {"id": "af_heart", "name": "Heart (American English · Female)", "language": "en-US", "gender": "female"},
3541
+ {"id": "af_bella", "name": "Bella (American English · Female)", "language": "en-US", "gender": "female"},
3542
+ {"id": "af_nicole", "name": "Nicole (American English · Female)", "language": "en-US", "gender": "female"},
3543
+ {"id": "af_sarah", "name": "Sarah (American English · Female)", "language": "en-US", "gender": "female"},
3544
+ {"id": "af_sky", "name": "Sky (American English · Female)", "language": "en-US", "gender": "female"},
3545
+ {"id": "am_adam", "name": "Adam (American English · Male)", "language": "en-US", "gender": "male"},
3546
+ {"id": "am_michael", "name": "Michael (American English · Male)", "language": "en-US", "gender": "male"},
3547
+ # British English
3548
+ {"id": "bf_emma", "name": "Emma (British English · Female)", "language": "en-GB", "gender": "female"},
3549
+ {"id": "bf_isabella","name": "Isabella (British English · Female)", "language": "en-GB", "gender": "female"},
3550
+ {"id": "bm_george", "name": "George (British English · Male)", "language": "en-GB", "gender": "male"},
3551
+ {"id": "bm_lewis", "name": "Lewis (British English · Male)", "language": "en-GB", "gender": "male"},
3552
+ # Spanish
3553
+ {"id": "ef_dora", "name": "Dora (Spanish · Female)", "language": "es", "gender": "female"},
3554
+ {"id": "em_alex", "name": "Alex (Spanish · Male)", "language": "es", "gender": "male"},
3555
+ {"id": "em_santa", "name": "Santa (Spanish · Male)", "language": "es", "gender": "male"},
3556
+ # French
3557
+ {"id": "ff_siwis", "name": "Siwis (French · Female)", "language": "fr", "gender": "female"},
3558
+ # Hindi
3559
+ {"id": "hf_alpha", "name": "Alpha (Hindi · Female)", "language": "hi", "gender": "female"},
3560
+ {"id": "hf_beta", "name": "Beta (Hindi · Female)", "language": "hi", "gender": "female"},
3561
+ {"id": "hm_omega", "name": "Omega (Hindi · Male)", "language": "hi", "gender": "male"},
3562
+ {"id": "hm_psi", "name": "Psi (Hindi · Male)", "language": "hi", "gender": "male"},
3563
+ # Italian
3564
+ {"id": "if_sara", "name": "Sara (Italian · Female)", "language": "it", "gender": "female"},
3565
+ {"id": "im_nicola", "name": "Nicola (Italian · Male)", "language": "it", "gender": "male"},
3566
+ # Japanese
3567
+ {"id": "jf_alpha", "name": "Alpha (Japanese · Female)", "language": "ja", "gender": "female"},
3568
+ {"id": "jf_gongitsune","name": "Gongitsune (Japanese · Female)", "language": "ja", "gender": "female"},
3569
+ {"id": "jf_nezumi", "name": "Nezumi (Japanese · Female)", "language": "ja", "gender": "female"},
3570
+ {"id": "jf_tebukuro","name": "Tebukuro (Japanese · Female)", "language": "ja", "gender": "female"},
3571
+ {"id": "jm_kumo", "name": "Kumo (Japanese · Male)", "language": "ja", "gender": "male"},
3572
+ # Brazilian Portuguese
3573
+ {"id": "pf_dora", "name": "Dora (Brazilian Portuguese · Female)","language": "pt-BR", "gender": "female"},
3574
+ {"id": "pm_alex", "name": "Alex (Brazilian Portuguese · Male)", "language": "pt-BR", "gender": "male"},
3575
+ {"id": "pm_santa", "name": "Santa (Brazilian Portuguese · Male)", "language": "pt-BR", "gender": "male"},
3576
+ # Mandarin Chinese
3577
+ {"id": "zf_xiaobei", "name": "Xiaobei (Mandarin · Female)", "language": "zh", "gender": "female"},
3578
+ {"id": "zf_xiaoni", "name": "Xiaoni (Mandarin · Female)", "language": "zh", "gender": "female"},
3579
+ {"id": "zf_xiaoxiao","name": "Xiaoxiao (Mandarin · Female)", "language": "zh", "gender": "female"},
3580
+ {"id": "zf_xiaoyi", "name": "Xiaoyi (Mandarin · Female)", "language": "zh", "gender": "female"},
3581
+ {"id": "zm_yunjian", "name": "Yunjian (Mandarin · Male)", "language": "zh", "gender": "male"},
3582
+ {"id": "zm_yunxi", "name": "Yunxi (Mandarin · Male)", "language": "zh", "gender": "male"},
3583
+ {"id": "zm_yunyang", "name": "Yunyang (Mandarin · Male)", "language": "zh", "gender": "male"},
3584
+ {"id": "zm_yunze", "name": "Yunze (Mandarin · Male)", "language": "zh", "gender": "male"},
3585
+ ]
3586
+
3587
+
3588
+ def _kokoro_static_payload() -> TtsVoiceList:
3589
+ voices = [TtsVoice(**v) for v in KOKORO_STATIC_VOICES]
3590
+ return TtsVoiceList(provider="kokoro", source="static", count=len(voices), voices=voices)
3591
+
3592
+
3593
+ def _normalize_elevenlabs_voices(raw: dict) -> List[TtsVoice]:
3594
+ """Translate ElevenLabs' /v1/voices response into our TtsVoice shape."""
3595
+ out: List[TtsVoice] = []
3596
+ for v in (raw.get("voices") or []):
3597
+ labels = v.get("labels") or {}
3598
+ # Build a friendly display name. ElevenLabs' "name" is usually the
3599
+ # human label (e.g. "Rachel"); we suffix the category so it's obvious
3600
+ # whether it's premade/cloned/etc.
3601
+ category = v.get("category") or labels.get("category") or ""
3602
+ desc_parts = []
3603
+ if labels.get("accent"): desc_parts.append(str(labels["accent"]))
3604
+ if labels.get("gender"): desc_parts.append(str(labels["gender"]).lower())
3605
+ if labels.get("age"): desc_parts.append(str(labels["age"]).lower())
3606
+ if labels.get("use_case"): desc_parts.append("use: " + str(labels["use_case"]))
3607
+ if labels.get("descriptive"): desc_parts.append(str(labels["descriptive"]))
3608
+ description = ", ".join([p for p in desc_parts if p])
3609
+ preview = v.get("preview_url") or ""
3610
+ out.append(TtsVoice(
3611
+ id=str(v.get("voice_id") or ""),
3612
+ name=str(v.get("name") or v.get("voice_id") or "voice"),
3613
+ language=labels.get("language") or labels.get("accent") or None,
3614
+ gender=labels.get("gender") or None,
3615
+ description=description or None,
3616
+ preview_url=preview or None,
3617
+ category=str(category) if category else None,
3618
+ labels=labels or None,
3619
+ ))
3620
+ # Sort: by category then by name for a stable, predictable dropdown order.
3621
+ out.sort(key=lambda x: ((x.category or ""), (x.name or "").lower()))
3622
+ return out
3623
+
3624
+
3625
+ async def _fetch_elevenlabs_voices() -> TtsVoiceList:
3626
+ """Call the ElevenLabs /v1/voices endpoint and normalize the result.
3627
+
3628
+ On any failure (missing key, network error, non-2xx) we surface a clear
3629
+ warning alongside an empty list so the dashboard can show the error in
3630
+ the picker rather than silently showing zero voices."""
3631
+ if not ELEVENLABS_API_KEY:
3632
+ return TtsVoiceList(
3633
+ provider="elevenlabs",
3634
+ source="api",
3635
+ count=0,
3636
+ voices=[],
3637
+ warning=(
3638
+ "ELEVENLABS_API_KEY is not set on the scheduler — set it in the "
3639
+ "scheduler's environment to fetch the live voice list."
3640
+ ),
3641
+ )
3642
+ url = f"{ELEVENLABS_BASE_URL}/v1/voices"
3643
+ headers = {"xi-api-key": ELEVENLABS_API_KEY, "Accept": "application/json"}
3644
+ try:
3645
+ async with httpx.AsyncClient(timeout=10.0) as client:
3646
+ r = await client.get(url, headers=headers)
3647
+ except Exception as e:
3648
+ return TtsVoiceList(
3649
+ provider="elevenlabs",
3650
+ source="api",
3651
+ count=0,
3652
+ voices=[],
3653
+ warning=f"Could not reach ElevenLabs: {e}",
3654
+ )
3655
+ if r.status_code != 200:
3656
+ # Try to extract a useful message from the response body.
3657
+ msg = (r.text or "").strip()[:200]
3658
+ return TtsVoiceList(
3659
+ provider="elevenlabs",
3660
+ source="api",
3661
+ count=0,
3662
+ voices=[],
3663
+ warning=f"ElevenLabs returned {r.status_code}: {msg or 'no body'}",
3664
+ )
3665
+ try:
3666
+ payload = r.json()
3667
+ except Exception as e:
3668
+ return TtsVoiceList(
3669
+ provider="elevenlabs",
3670
+ source="api",
3671
+ count=0,
3672
+ voices=[],
3673
+ warning=f"ElevenLabs returned non-JSON body: {e}",
3674
+ )
3675
+ voices = _normalize_elevenlabs_voices(payload)
3676
+ return TtsVoiceList(provider="elevenlabs", source="api", count=len(voices), voices=voices)
3677
+
3678
+
3679
+ async def _fetch_kokoro_voices() -> TtsVoiceList:
3680
+ """Return the Kokoro voice catalog.
3681
+
3682
+ If KOKORO_BASE_URL is set we proxy to that server's /v1/audio/voices
3683
+ (or whatever path it exposes) and adapt the result. Otherwise we serve
3684
+ the curated static list so the dropdown is always populated."""
3685
+ if not KOKORO_BASE_URL:
3686
+ return _kokoro_static_payload()
3687
+ url = f"{KOKORO_BASE_URL}/v1/audio/voices"
3688
+ try:
3689
+ async with httpx.AsyncClient(timeout=6.0) as client:
3690
+ r = await client.get(url)
3691
+ if r.status_code != 200:
3692
+ warning = f"Kokoro server returned {r.status_code}; falling back to static list"
3693
+ fallback = _kokoro_static_payload()
3694
+ return TtsVoiceList(
3695
+ provider=fallback.provider,
3696
+ source="static",
3697
+ count=fallback.count,
3698
+ voices=fallback.voices,
3699
+ warning=warning,
3700
+ )
3701
+ data = r.json()
3702
+ # Kokoro's API shape varies by server; be permissive.
3703
+ raw_list = data.get("voices") if isinstance(data, dict) else data
3704
+ if not isinstance(raw_list, list):
3705
+ raw_list = []
3706
+ voices: List[TtsVoice] = []
3707
+ for v in raw_list:
3708
+ if isinstance(v, str):
3709
+ voices.append(TtsVoice(id=v, name=v))
3710
+ elif isinstance(v, dict):
3711
+ voices.append(TtsVoice(
3712
+ id=str(v.get("id") or v.get("voice_id") or v.get("name") or ""),
3713
+ name=str(v.get("name") or v.get("id") or v.get("voice_id") or ""),
3714
+ language=v.get("language") or v.get("lang"),
3715
+ gender=v.get("gender"),
3716
+ ))
3717
+ if not voices:
3718
+ fallback = _kokoro_static_payload()
3719
+ return TtsVoiceList(
3720
+ provider=fallback.provider,
3721
+ source="static",
3722
+ count=fallback.count,
3723
+ voices=fallback.voices,
3724
+ warning="Kokoro server returned no voices; falling back to static list",
3725
+ )
3726
+ return TtsVoiceList(provider="kokoro", source="proxy", count=len(voices), voices=voices)
3727
+ except Exception as e:
3728
+ fallback = _kokoro_static_payload()
3729
+ return TtsVoiceList(
3730
+ provider=fallback.provider,
3731
+ source="static",
3732
+ count=fallback.count,
3733
+ voices=fallback.voices,
3734
+ warning=f"Could not reach Kokoro server ({KOKORO_BASE_URL}): {e}",
3735
+ )
3736
+
3737
+
3738
+ @app.get("/api/voices/elevenlabs", response_model=TtsVoiceList)
3739
+ async def list_elevenlabs_voices():
3740
+ """Return the catalog of ElevenLabs voices for the dashboard picker.
3741
+
3742
+ Proxies the call to ElevenLabs' /v1/voices endpoint using the scheduler's
3743
+ ELEVENLABS_API_KEY env var. Returns a TtsVoiceList with a `warning` field
3744
+ if the key is missing or the upstream call fails — the dashboard surfaces
3745
+ the warning inside the picker so the operator knows what to fix."""
3746
+ return await _fetch_elevenlabs_voices()
3747
+
3748
+
3749
+ @app.get("/api/voices/kokoro", response_model=TtsVoiceList)
3750
+ async def list_kokoro_voices():
3751
+ """Return the catalog of Kokoro voices for the dashboard picker.
3752
+
3753
+ Served from a curated static list of the canonical Kokoro-82M voices.
3754
+ If KOKORO_BASE_URL is set we proxy to that local server's voices endpoint
3755
+ and surface the live set instead; the static list is the fallback."""
3756
+ return await _fetch_kokoro_voices()
3757
+
3758
+
2597
3759
  @app.get("/api/character-presets", response_model=List[CharacterPreset])
2598
3760
  async def list_character_presets():
2599
3761
  async with aiosqlite.connect(DB_PATH) as db:
@@ -2762,13 +3924,70 @@ async def _robot_telemetry(db, robot_id: str) -> Optional[dict]:
2762
3924
  Returns trigger_count, sessions_total, avg_latency_ms, drop_rate, drops and
2763
3925
  an end_reason breakdown; None if the robot does not exist."""
2764
3926
  async with db.execute(
2765
- "SELECT trigger_count, name FROM robots WHERE id = ?", (robot_id,)
3927
+ "SELECT trigger_count, name, status, current_session_id, connected_server_id FROM robots WHERE id = ?", (robot_id,)
2766
3928
  ) as c:
2767
3929
  robot = await c.fetchone()
2768
3930
  if not robot:
2769
3931
  return None
2770
3932
  trigger_count = robot["trigger_count"] or 0
2771
3933
 
3934
+ async with db.execute(
3935
+ "SELECT status, production_mode, last_heartbeat FROM devices WHERE id = ?", (robot_id,)
3936
+ ) as c:
3937
+ device = await c.fetchone()
3938
+ session = None
3939
+ if robot["current_session_id"]:
3940
+ async with db.execute(
3941
+ "SELECT started_at, last_activity_at, ended_at FROM sessions WHERE id = ?",
3942
+ (robot["current_session_id"],),
3943
+ ) as c:
3944
+ session = await c.fetchone()
3945
+
3946
+ now = datetime.utcnow()
3947
+ session_age = None
3948
+ activity_age = None
3949
+ if session:
3950
+ try:
3951
+ session_age = max(0, int((now - datetime.fromisoformat(session["started_at"])).total_seconds()))
3952
+ activity_age = max(0, int((now - datetime.fromisoformat(session["last_activity_at"] or session["started_at"])).total_seconds()))
3953
+ except (TypeError, ValueError):
3954
+ pass
3955
+ heartbeat_age = None
3956
+ if device and device["last_heartbeat"]:
3957
+ try:
3958
+ heartbeat_age = max(0, int((now - datetime.fromisoformat(device["last_heartbeat"])).total_seconds()))
3959
+ except (TypeError, ValueError):
3960
+ pass
3961
+
3962
+ server_status = None
3963
+ if robot["connected_server_id"]:
3964
+ async with db.execute(
3965
+ "SELECT status FROM livekit_servers WHERE id = ?", (robot["connected_server_id"],)
3966
+ ) as c:
3967
+ server_row = await c.fetchone()
3968
+ server_status = server_row["status"] if server_row else None
3969
+
3970
+ readiness_code = "ready"
3971
+ readiness_message = "ready for motion trigger"
3972
+ if not device:
3973
+ readiness_code, readiness_message = "device_missing", "no enrolled device heartbeat"
3974
+ elif not await get_production_mode():
3975
+ readiness_code, readiness_message = "production_off", "global production mode is off"
3976
+ elif not device["production_mode"]:
3977
+ readiness_code, readiness_message = "robot_production_off", "robot production mode is off"
3978
+ elif heartbeat_age is None or heartbeat_age > 30:
3979
+ readiness_code, readiness_message = "heartbeat_stale", f"robot heartbeat is {heartbeat_age or 'unknown'}s old"
3980
+ elif robot["connected_server_id"] and server_status != "online":
3981
+ readiness_code, readiness_message = "server_unavailable", f"LiveKit server {robot['connected_server_id']} is {server_status or 'missing'}"
3982
+ elif not robot["current_session_id"]:
3983
+ readiness_code, readiness_message = "ready", "ready for motion trigger"
3984
+ elif robot["status"] == "connecting" and session_age is not None and session_age > 30:
3985
+ readiness_code, readiness_message = "stuck_session", f"joining LiveKit for {session_age}s; recover robot"
3986
+ elif activity_age is not None and activity_age > 180:
3987
+ readiness_code, readiness_message = "stale_session", f"no session activity for {activity_age}s; recover robot"
3988
+ else:
3989
+ readiness_code, readiness_message = "in_session", "conversation session is active"
3990
+
2772
3991
  # Pull the device's freshest known LAN address (heartbeat > enrollment) so
2773
3992
  # the dashboard can reach the robot directly for e.g. the vision overlay
2774
3993
  # feed, which the scheduler doesn't proxy.
@@ -2819,6 +4038,17 @@ async def _robot_telemetry(db, robot_id: str) -> Optional[dict]:
2819
4038
  "drop_rate": drop_rate,
2820
4039
  "drops": drops,
2821
4040
  "end_reasons": end_reasons,
4041
+ "status": robot["status"],
4042
+ "current_session_id": robot["current_session_id"],
4043
+ "connected_server_id": robot["connected_server_id"],
4044
+ "server_status": server_status,
4045
+ "session_age_seconds": session_age,
4046
+ "session_activity_age_seconds": activity_age,
4047
+ "heartbeat_age_seconds": heartbeat_age,
4048
+ "device_status": device["status"] if device else None,
4049
+ "production_mode": bool(device["production_mode"]) if device else False,
4050
+ "readiness_code": readiness_code,
4051
+ "readiness_message": readiness_message,
2822
4052
  }
2823
4053
 
2824
4054
  @app.get("/api/robots/{robot_id}/telemetry")
@@ -2930,13 +4160,18 @@ async def dashboard():
2930
4160
  const connected = ref(false);
2931
4161
  const serverMetrics = ref({});
2932
4162
  const devices = ref([]);
2933
- const settings = ref({ production_mode: false, default_enrollment_token_set: false });
4163
+ const settings = ref({ production_mode: false, default_enrollment_token_set: false, agent_token_configured: false, agent_token_source: null });
2934
4164
  const livekit = ref({ url: null, api_key: null, has_secret: false });
2935
- const showAddDevice = ref(false);
2936
- const showDevices = ref(true);
4165
+ const showAddDevice = ref(false);
4166
+ const showDevices = ref(true);
4167
+ const showCommands = ref(false);
4168
+ const commandNetwork = ref('lan');
4169
+ const commandRobotId = ref('');
2937
4170
  const newDevice = ref({ name: '', tailscale_ip: '', lan_ip: '', motor_server_url: '', character_id: '', livekit_url: '', video_device: 'auto', audio_device: 'default', notes: '' });
2938
- const rotatedToken = ref(null);
4171
+ const rotatedToken = ref(null);
4172
+ const enrollmentNetwork = ref('lan');
2939
4173
  const preview = ref({ robot: null, active: false, url: null, token: null, room: null, keepalive: null });
4174
+ const simulation = ref({ robotId: null, busy: false });
2940
4175
  const deviceInventory = ref({});
2941
4176
  const showLiveKitConfig = ref(false);
2942
4177
  const livekitForm = ref({ url: '', api_key: '', api_secret: '' });
@@ -2948,7 +4183,7 @@ async def dashboard():
2948
4183
  const editingCharacter = ref(null);
2949
4184
  const voiceStackForm = ref({
2950
4185
  id: '', name: '', stt_provider: 'speaches', stt_model: 'Systran/faster-whisper-small', stt_language: 'en',
2951
- llm_provider: 'ollama', llm_model: 'gemma4:e4b', tts_provider: 'elevenlabs',
4186
+ llm_provider: 'ollama', llm_model: 'gemma3:27b', tts_provider: 'elevenlabs',
2952
4187
  tts_voice: '21m00Tcm4TlvDq8ikWAM', tts_language: 'en',
2953
4188
  allow_interruptions: true, min_endpointing_delay: 0.7, max_turns: 20, wake_word_enabled: false,
2954
4189
  });
@@ -2992,18 +4227,25 @@ async def dashboard():
2992
4227
 
2993
4228
  const buildDeviceInventory = () => {
2994
4229
  const map = {};
2995
- for (const robot of robots.value) {
2996
- const device = devices.value.find(d => d.name === robot.name || d.id === robot.id);
2997
- const savedVideo = device?.video_device || 'auto';
2998
- const savedAudio = device?.audio_device || 'default';
2999
- map[robot.id] = {
3000
- deviceId: device?.id || null,
3001
- robotName: robot.name,
3002
- selectedVideo: savedVideo,
3003
- selectedAudio: savedAudio,
3004
- videoOptions: ['auto', 'none', 'picamera', '/dev/video0', '/dev/video1', '/dev/video2'],
3005
- audioOptions: ['default', 'none'],
3006
- };
4230
+ for (const robot of robots.value) {
4231
+ const device = devices.value.find(d => d.name === robot.name || d.id === robot.id);
4232
+ const savedVideo = device?.video_device || 'auto';
4233
+ const savedAudio = device?.audio_device || 'default';
4234
+ const hardware = device?.device_inventory || {};
4235
+ const videoOptions = (hardware.video || []).map(x => typeof x === 'string' ? x : x.id).filter(Boolean);
4236
+ const audioOptions = (hardware.audio_input || []).map(x => typeof x === 'string' ? x : x.id).filter(Boolean);
4237
+ const audioOutputOptions = (hardware.audio_output || []).map(x => typeof x === 'string' ? x : x.id).filter(Boolean);
4238
+ map[robot.id] = {
4239
+ deviceId: device?.id || null,
4240
+ robotName: robot.name,
4241
+ selectedVideo: savedVideo,
4242
+ selectedAudio: savedAudio,
4243
+ selectedAudioOutput: device?.audio_output_device || 'default',
4244
+ greetingText: (device?.greeting_phrases || []).join('\\n'),
4245
+ videoOptions: [...new Set(['auto', 'none', savedVideo, ...videoOptions])],
4246
+ audioOptions: [...new Set(['default', 'none', savedAudio, ...audioOptions])],
4247
+ audioOutputOptions: [...new Set(['default', 'none', device?.audio_output_device || 'default', ...audioOutputOptions])],
4248
+ };
3007
4249
  }
3008
4250
  deviceInventory.value = map;
3009
4251
  };
@@ -3060,18 +4302,20 @@ async def dashboard():
3060
4302
  const toggleProductionMode = async () => {
3061
4303
  try {
3062
4304
  const next = !settings.value.production_mode;
3063
- await fetch('/api/settings', {
4305
+ const response = await fetch('/api/settings', {
3064
4306
  method: 'PUT',
3065
4307
  headers: {'Content-Type': 'application/json'},
3066
4308
  body: JSON.stringify({ production_mode: next }),
3067
4309
  });
4310
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
3068
4311
  settings.value.production_mode = next;
4312
+ await fetchData();
3069
4313
  } catch (e) {
3070
4314
  alert('Failed to toggle production mode: ' + e.message);
3071
4315
  }
3072
4316
  };
3073
4317
 
3074
- const rotateEnrollmentToken = async () => {
4318
+ const rotateEnrollmentToken = async () => {
3075
4319
  if (!confirm('Rotate the global default enrollment token? Any Pi waiting to enroll with the previous token will be rejected.')) return;
3076
4320
  try {
3077
4321
  const r = await fetch('/api/settings/enrollment-token/rotate', { method: 'POST' }).then(r => r.json());
@@ -3080,7 +4324,23 @@ async def dashboard():
3080
4324
  } catch (e) {
3081
4325
  alert('Failed to rotate token: ' + e.message);
3082
4326
  }
3083
- };
4327
+ };
4328
+
4329
+ const provisionAgentToken = async (rotate = false) => {
4330
+ if (rotate && !confirm('Rotate the ROBOVOICE authentication secret? Existing workers must be restarted after synchronization.')) return;
4331
+ try {
4332
+ const r = await fetch('/api/settings/agent-token/provision', {
4333
+ method: 'POST', headers: {'Content-Type': 'application/json'},
4334
+ body: JSON.stringify({ rotate }),
4335
+ }).then(r => r.json());
4336
+ if (!r.agent_token) throw new Error(r.detail || 'No secret returned');
4337
+ alert('ROBOVOICE secret (shown once):\\n\\n' + r.agent_token + '\\n\\nCopy it into the worker environment or run: robopark secrets --scheduler-url ' + window.location.origin + ' --worker-env <ROBOVOICE>/.env');
4338
+ settings.value.agent_token_configured = true;
4339
+ await fetchData();
4340
+ } catch (e) {
4341
+ alert('Failed to provision agent secret: ' + e.message);
4342
+ }
4343
+ };
3084
4344
 
3085
4345
  const submitNewDevice = async () => {
3086
4346
  try {
@@ -3100,7 +4360,7 @@ async def dashboard():
3100
4360
  }
3101
4361
  };
3102
4362
 
3103
- const updateDeviceMedia = async (robotId) => {
4363
+ const updateDeviceMedia = async (robotId) => {
3104
4364
  const inv = deviceInventory.value[robotId];
3105
4365
  if (!inv || !inv.deviceId) return;
3106
4366
  try {
@@ -3108,18 +4368,20 @@ async def dashboard():
3108
4368
  method: 'PATCH',
3109
4369
  headers: {'Content-Type': 'application/json'},
3110
4370
  body: JSON.stringify({
3111
- video_device: inv.selectedVideo,
3112
- audio_device: inv.selectedAudio,
3113
- }),
3114
- });
3115
- fetchData();
4371
+ video_device: inv.selectedVideo,
4372
+ audio_device: inv.selectedAudio,
4373
+ audio_output_device: inv.selectedAudioOutput,
4374
+ }),
4375
+ });
4376
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
4377
+ fetchData();
3116
4378
  } catch (e) {
3117
4379
  alert('Failed to update device media: ' + e.message);
3118
4380
  }
3119
4381
  };
3120
4382
 
3121
- const startPreview = async (robot) => {
3122
- stopPreview();
4383
+ const startPreview = async (robot) => {
4384
+ await stopPreview();
3123
4385
  try {
3124
4386
  const r = await fetch(`/api/robots/${robot.id}/preview/start`, { method: 'POST' }).then(r => r.json());
3125
4387
  if (!r.active) {
@@ -3139,14 +4401,94 @@ async def dashboard():
3139
4401
  }
3140
4402
  };
3141
4403
 
3142
- const stopPreview = () => {
3143
- if (preview.value.keepalive) clearInterval(preview.value.keepalive);
3144
- if (preview.value.room) {
3145
- try { window.__lkPreviewRoom?.disconnect(); } catch (e) {}
4404
+ const stopPreview = async () => {
4405
+ const robotId = preview.value.robot?.id;
4406
+ if (preview.value.keepalive) clearInterval(preview.value.keepalive);
4407
+ if (preview.value.room) {
4408
+ try { window.__lkPreviewRoom?.disconnect(); } catch (e) {}
4409
+ }
4410
+ preview.value = { robot: null, active: false, url: null, token: null, room: null, keepalive: null };
4411
+ if (robotId) {
4412
+ try {
4413
+ await fetch(`/api/robots/${robotId}/preview/stop`, { method: 'POST' });
4414
+ } catch (e) { console.warn('preview stop failed', e); }
4415
+ }
4416
+ };
4417
+
4418
+ const simulateTrigger = async (robot) => {
4419
+ if (!settings.value.production_mode || simulation.value.busy) return;
4420
+ simulation.value = { robotId: robot.id, busy: true };
4421
+ try {
4422
+ const response = await fetch(`/api/robots/${robot.id}/simulate-trigger`, { method: 'POST' });
4423
+ const result = await response.json();
4424
+ if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
4425
+ if (!result.queued) {
4426
+ alert('Trigger not queued: ' + (result.reason || 'unknown'));
4427
+ }
4428
+ await fetchData();
4429
+ } catch (e) {
4430
+ alert('Failed to simulate motion: ' + e.message);
4431
+ } finally {
4432
+ simulation.value = { robotId: null, busy: false };
3146
4433
  }
3147
- preview.value = { robot: null, active: false, url: null, token: null, room: null, keepalive: null };
3148
4434
  };
3149
4435
 
4436
+ const stopConversation = async (robot) => {
4437
+ try {
4438
+ const response = await fetch(`/api/robots/${robot.id}/simulate-stop`, { method: 'POST' });
4439
+ const result = await response.json();
4440
+ if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
4441
+ await fetchData();
4442
+ } catch (e) {
4443
+ alert('Failed to stop conversation: ' + e.message);
4444
+ }
4445
+ };
4446
+
4447
+ const recoverRobot = async (robot) => {
4448
+ if (!confirm(`Clear stale session state for ${robot.name}?`)) return;
4449
+ try {
4450
+ const response = await fetch(`/api/robots/${robot.id}/recover`, { method: 'POST' });
4451
+ const result = await response.json();
4452
+ if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
4453
+ await fetchData();
4454
+ } catch (e) {
4455
+ alert('Failed to recover robot: ' + e.message);
4456
+ }
4457
+ };
4458
+
4459
+ const toggleDeviceProduction = async (robotId) => {
4460
+ const inv = deviceInventory.value[robotId];
4461
+ if (!inv || !inv.deviceId) return;
4462
+ await setDeviceProduction(inv.deviceId);
4463
+ };
4464
+
4465
+ const setDeviceProduction = async (deviceId) => {
4466
+ const device = devices.value.find(d => d.id === deviceId);
4467
+ if (!device) return;
4468
+ try {
4469
+ const response = await fetch(`/api/devices/${deviceId}`, {
4470
+ method: 'PATCH', headers: {'Content-Type': 'application/json'},
4471
+ body: JSON.stringify({ production_mode: !device?.production_mode }),
4472
+ });
4473
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
4474
+ await fetchData();
4475
+ } catch (e) { alert('Failed to change robot production mode: ' + e.message); }
4476
+ };
4477
+
4478
+ const saveDeviceGreetings = async (robotId) => {
4479
+ const inv = deviceInventory.value[robotId];
4480
+ if (!inv || !inv.deviceId) return;
4481
+ try {
4482
+ const greeting_phrases = inv.greetingText.split(/\\r?\\n/).map(s => s.trim()).filter(Boolean).slice(0, 20);
4483
+ const response = await fetch(`/api/devices/${inv.deviceId}`, {
4484
+ method: 'PATCH', headers: {'Content-Type': 'application/json'},
4485
+ body: JSON.stringify({ greeting_phrases }),
4486
+ });
4487
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
4488
+ await fetchData();
4489
+ } catch (e) { alert('Failed to save greetings: ' + e.message); }
4490
+ };
4491
+
3150
4492
  const connectLiveKitPreview = async (url, token) => {
3151
4493
  const { Room } = LiveKitClient;
3152
4494
  const room = new Room({ adaptiveStream: true });
@@ -3182,7 +4524,15 @@ async def dashboard():
3182
4524
  }
3183
4525
  };
3184
4526
 
3185
- const dismissRotatedToken = () => { rotatedToken.value = null; };
4527
+ const dismissRotatedToken = () => { rotatedToken.value = null; };
4528
+ const enrollmentCommand = (token) => {
4529
+ if (!token) return '';
4530
+ const schedulerPort = window.location.port || '8080';
4531
+ const networkFlag = enrollmentNetwork.value === 'tailscale' ? '--tailscale' : '--lan';
4532
+ const hubProtocol = window.location.protocol === 'https:' ? 'https://' : 'http://';
4533
+ const hubUrl = `${hubProtocol}${window.location.hostname}:47913`;
4534
+ return `robopark setup --robot --name "${token.name || token.device_id}" --hub-url ${hubUrl} --scheduler-port ${schedulerPort} ${networkFlag} --enrollment-token ${token.token} --start --auto-start`;
4535
+ };
3186
4536
 
3187
4537
  const joinConversation = async (device) => {
3188
4538
  try {
@@ -3285,9 +4635,10 @@ async def dashboard():
3285
4635
  setInterval(fetchData, 30000);
3286
4636
  });
3287
4637
 
3288
- onUnmounted(() => {
3289
- if (ws) ws.close();
3290
- });
4638
+ onUnmounted(() => {
4639
+ if (ws) ws.close();
4640
+ stopPreview();
4641
+ });
3291
4642
 
3292
4643
  const openVoiceStackForm = (stack = null) => {
3293
4644
  if (stack) {
@@ -3297,7 +4648,7 @@ async def dashboard():
3297
4648
  editingVoiceStack.value = null;
3298
4649
  voiceStackForm.value = {
3299
4650
  id: '', name: '', stt_provider: 'speaches', stt_model: 'Systran/faster-whisper-small', stt_language: 'en',
3300
- llm_provider: 'ollama', llm_model: 'gemma4:e4b', tts_provider: 'elevenlabs',
4651
+ llm_provider: 'ollama', llm_model: 'gemma3:27b', tts_provider: 'elevenlabs',
3301
4652
  tts_voice: '21m00Tcm4TlvDq8ikWAM', tts_language: 'en',
3302
4653
  allow_interruptions: true, min_endpointing_delay: 0.7, max_turns: 20, wake_word_enabled: false,
3303
4654
  };
@@ -3381,17 +4732,44 @@ async def dashboard():
3381
4732
  const stackName = (id) => voiceStacks.value.find(s => s.id === id)?.name || '—';
3382
4733
  const characterName = (id) => characterPresets.value.find(c => c.id === id)?.name || '—';
3383
4734
 
3384
- return {
3385
- robots, servers, sessions, stats, connected, serverMetrics,
3386
- devices, settings, livekit, showAddDevice, showDevices, newDevice, rotatedToken,
4735
+ const schedulerBase = () => window.location.origin;
4736
+ const selectedCommandRobot = computed(() => devices.value.find(d => d.id === commandRobotId.value) || devices.value[0] || null);
4737
+ const commandRows = computed(() => {
4738
+ const base = schedulerBase();
4739
+ const robot = selectedCommandRobot.value;
4740
+ const robotId = robot?.id || '<DEVICE_ID>';
4741
+ const robotName = robot?.name || '<ROBOT_NAME>';
4742
+ const networkFlag = commandNetwork.value === 'tailscale' ? '--tailscale' : '--lan';
4743
+ const tokenPlaceholder = '<ENROLLMENT_TOKEN_FROM_ADD_DEVICE>';
4744
+ return [
4745
+ { group: 'Scheduler', name: 'Health check', description: 'Confirm the control center API is reachable.', command: `curl -s ${base}/api/settings` },
4746
+ { group: 'Scheduler', name: 'Start scheduler', description: 'Start the RoboPark scheduler and dashboard on port 8080.', command: 'robopark serve --port 8080' },
4747
+ { group: 'Robot', name: 'Enroll robot', description: `Generated for ${robotName} over ${commandNetwork.value}. Paste the one-time token from the Add Device dialog.`, command: `robopark setup --robot --name "${robotName}" --hub-url ${base.replace(/:\\d+$/, '')}:47913 --scheduler-port ${window.location.port || '8080'} ${networkFlag} --enrollment-token ${tokenPlaceholder} --start --auto-start` },
4748
+ { group: 'Robot', name: 'Simulate motion', description: 'Queue a full production trigger for the selected robot.', command: `curl -s -X POST ${base}/api/robots/${robotId}/simulate-trigger` },
4749
+ { group: 'Robot', name: 'Stop conversation', description: 'Stop the active conversation and clear queued trigger state.', command: `curl -s -X POST ${base}/api/robots/${robotId}/simulate-stop` },
4750
+ { group: 'Robot', name: 'Recover stale state', description: 'Release a robot stuck in connecting/running after a failed teardown.', command: `curl -s -X POST ${base}/api/robots/${robotId}/recover` },
4751
+ { group: 'Worker', name: 'Sync agent secret', description: 'Provision the scheduler-managed secret into ROBOVOICE-main.', command: `robopark secrets --scheduler-url ${base} --worker-env C:\\path\\to\\ROBOVOICE-main\\.env` },
4752
+ { group: 'Diagnostics', name: 'Tail agent logs', description: 'Inspect the local ROBOVOICE worker.', command: 'docker logs --tail 100 caal-agent' },
4753
+ { group: 'Diagnostics', name: 'Check Docker', description: 'List containers, health, ports, and networks.', command: 'docker ps -a --format "table {{.Names}}\\t{{.Status}}\\t{{.Ports}}\\t{{.Networks}}"' },
4754
+ { group: 'Diagnostics', name: 'Check LiveKit port', description: 'Confirm the local LiveKit listener is present.', command: 'Get-NetTCPConnection -LocalPort 7883 -ErrorAction SilentlyContinue' },
4755
+ ];
4756
+ });
4757
+ const copyCommand = async (command) => {
4758
+ try { await navigator.clipboard.writeText(command); }
4759
+ catch (e) { window.prompt('Copy command:', command); }
4760
+ };
4761
+
4762
+ return {
4763
+ robots, servers, sessions, stats, connected, serverMetrics,
4764
+ devices, settings, livekit, showAddDevice, showDevices, showCommands, commandNetwork, commandRobotId, selectedCommandRobot, commandRows, copyCommand, newDevice, rotatedToken, enrollmentNetwork, enrollmentCommand, simulation,
3387
4765
  showLiveKitConfig, livekitForm, preview, deviceInventory,
3388
4766
  voiceStacks, characterPresets, showVoiceStackForm, showCharacterForm,
3389
4767
  editingVoiceStack, editingCharacter, voiceStackForm, characterForm,
3390
4768
  loadModel, unloadModel, statusColor, formatDuration, formatTime,
3391
- toggleProductionMode, rotateEnrollmentToken,
4769
+ toggleProductionMode, rotateEnrollmentToken, provisionAgentToken,
3392
4770
  submitNewDevice, deleteDevice, rotateDeviceToken, dismissRotatedToken,
3393
4771
  joinConversation, saveLiveKitConfig, openLiveKitConfig,
3394
- startPreview, stopPreview, updateDeviceMedia,
4772
+ startPreview, stopPreview, simulateTrigger, stopConversation, recoverRobot, toggleDeviceProduction, setDeviceProduction, saveDeviceGreetings, updateDeviceMedia,
3395
4773
  openVoiceStackForm, saveVoiceStack, deleteVoiceStack,
3396
4774
  openCharacterForm, saveCharacter, deleteCharacter,
3397
4775
  assignRobotCharacter, stackName, characterName,
@@ -3405,20 +4783,30 @@ async def dashboard():
3405
4783
  <h1 class="text-3xl font-bold">🤖 RoboPark Control Center</h1>
3406
4784
  <p class="text-gray-400">Session Scheduler Dashboard — Shenzhen Bay Park (test site)</p>
3407
4785
  </div>
3408
- <div class="flex items-center gap-4">
3409
- <button @click="openLiveKitConfig"
4786
+ <div class="flex items-center gap-4">
4787
+ <button @click="showCommands = !showCommands"
4788
+ :class="showCommands ? 'bg-blue-700 hover:bg-blue-600' : 'bg-gray-700 hover:bg-gray-600'"
4789
+ class="px-3 py-2 rounded-lg text-sm">Commands</button>
4790
+ <button @click="openLiveKitConfig"
3410
4791
  :class="livekit.url && livekit.has_secret ? 'bg-cyan-700 hover:bg-cyan-600' : 'bg-gray-700 hover:bg-gray-600'"
3411
4792
  class="px-3 py-2 rounded-lg flex items-center gap-2 text-sm"
3412
4793
  :title="livekit.url || 'Not configured'">
3413
4794
  <span class="w-2 h-2 rounded-full" :class="livekit.url && livekit.has_secret ? 'bg-cyan-200' : 'bg-yellow-400'"></span>
3414
4795
  LiveKit: <strong>{{ livekit.url && livekit.has_secret ? (livekit.url.replace(/^wss?:[/][/]/, '')) : 'NOT SET' }}</strong>
3415
4796
  </button>
3416
- <button @click="toggleProductionMode"
4797
+ <button @click="toggleProductionMode"
3417
4798
  :class="settings.production_mode ? 'bg-green-600 hover:bg-green-700' : 'bg-gray-700 hover:bg-gray-600'"
3418
4799
  class="px-3 py-2 rounded-lg flex items-center gap-2 text-sm">
3419
4800
  <span class="w-2 h-2 rounded-full" :class="settings.production_mode ? 'bg-green-200 animate-pulse' : 'bg-gray-400'"></span>
3420
4801
  Production Mode: <strong>{{ settings.production_mode ? 'ON' : 'OFF' }}</strong>
3421
- </button>
4802
+ </button>
4803
+ <button @click="provisionAgentToken(false)"
4804
+ :class="settings.agent_token_configured ? 'bg-emerald-800 hover:bg-emerald-700' : 'bg-red-800 hover:bg-red-700'"
4805
+ class="px-3 py-2 rounded-lg flex items-center gap-2 text-sm"
4806
+ title="Provision the scheduler-managed ROBOVOICE authentication secret">
4807
+ <span class="w-2 h-2 rounded-full" :class="settings.agent_token_configured ? 'bg-emerald-200' : 'bg-red-300'"></span>
4808
+ Agent Secret: <strong>{{ settings.agent_token_configured ? 'SET' : 'MISSING' }}</strong>
4809
+ </button>
3422
4810
  <span :class="connected ? 'text-green-400' : 'text-red-400'" class="flex items-center gap-2">
3423
4811
  <span class="w-2 h-2 rounded-full" :class="connected ? 'bg-green-400' : 'bg-red-400'"></span>
3424
4812
  {{ connected ? 'Live' : 'Disconnected' }}
@@ -3426,7 +4814,38 @@ async def dashboard():
3426
4814
  </div>
3427
4815
  </header>
3428
4816
 
3429
- <!-- Stats Row -->
4817
+ <!-- Command Center -->
4818
+ <section v-if="showCommands" class="bg-gray-800 rounded-lg p-5 mb-6">
4819
+ <div class="flex flex-wrap items-center justify-between gap-3 mb-4">
4820
+ <div>
4821
+ <h2 class="text-xl font-semibold">Command Center</h2>
4822
+ <p class="text-xs text-gray-400">Copy-ready operational commands. Secrets are never included; enrollment uses a one-time token from the Add Device dialog.</p>
4823
+ </div>
4824
+ <div class="flex gap-2 items-center">
4825
+ <select v-model="commandRobotId" class="bg-gray-700 rounded px-3 py-2 text-sm">
4826
+ <option value="">Select robot</option>
4827
+ <option v-for="d in devices" :key="d.id" :value="d.id">{{ d.name }} ({{ d.id }})</option>
4828
+ </select>
4829
+ <button @click="commandNetwork = 'lan'" :class="commandNetwork === 'lan' ? 'bg-blue-600' : 'bg-gray-700'" class="px-3 py-2 rounded text-sm">LAN</button>
4830
+ <button @click="commandNetwork = 'tailscale'" :class="commandNetwork === 'tailscale' ? 'bg-cyan-600' : 'bg-gray-700'" class="px-3 py-2 rounded text-sm">Tailscale</button>
4831
+ </div>
4832
+ </div>
4833
+ <div class="grid md:grid-cols-2 gap-3">
4834
+ <div v-for="cmd in commandRows" :key="cmd.group + cmd.name" class="bg-gray-900 rounded p-3">
4835
+ <div class="flex items-start justify-between gap-2">
4836
+ <div>
4837
+ <div class="text-xs text-cyan-400">{{ cmd.group }}</div>
4838
+ <div class="font-medium text-sm">{{ cmd.name }}</div>
4839
+ <div class="text-xs text-gray-500 mt-1">{{ cmd.description }}</div>
4840
+ </div>
4841
+ <button @click="copyCommand(cmd.command)" class="shrink-0 px-2 py-1 rounded bg-gray-700 hover:bg-gray-600 text-xs">Copy</button>
4842
+ </div>
4843
+ <code class="block mt-2 text-xs text-green-300 break-all select-all">{{ cmd.command }}</code>
4844
+ </div>
4845
+ </div>
4846
+ </section>
4847
+
4848
+ <!-- Stats Row -->
3430
4849
  <div class="grid grid-cols-5 gap-4 mb-6">
3431
4850
  <div class="bg-gray-800 rounded-lg p-4">
3432
4851
  <div class="text-2xl font-bold text-green-400">{{ stats.active_sessions || 0 }}</div>
@@ -3503,9 +4922,12 @@ async def dashboard():
3503
4922
  <td class="py-2 px-2 font-mono text-xs">{{ d.tailscale_ip || '—' }}</td>
3504
4923
  <td class="py-2 px-2 font-mono text-xs">{{ d.lan_ip || '—' }}</td>
3505
4924
  <td class="py-2 px-2 font-mono text-xs text-gray-400">{{ d.motor_server_url || '—' }}</td>
3506
- <td class="py-2 px-2 text-xs text-gray-400">{{ formatTime(d.last_heartbeat) }}</td>
3507
- <td class="py-2 px-2 text-xs space-x-2">
3508
- <button @click="joinConversation(d)"
4925
+ <td class="py-2 px-2 text-xs text-gray-400">{{ formatTime(d.last_heartbeat) }}</td>
4926
+ <td class="py-2 px-2 text-xs space-x-2">
4927
+ <button @click="setDeviceProduction(d.id)"
4928
+ :class="d.production_mode ? 'bg-green-700 hover:bg-green-600' : 'bg-gray-700 hover:bg-gray-600'"
4929
+ class="px-2 py-1 rounded">Prod {{ d.production_mode ? 'ON' : 'OFF' }}</button>
4930
+ <button @click="joinConversation(d)"
3509
4931
  :disabled="!settings.production_mode || !livekit.url || !livekit.has_secret"
3510
4932
  :class="(settings.production_mode && livekit.url && livekit.has_secret) ? 'bg-cyan-600 hover:bg-cyan-700' : 'bg-gray-700 cursor-not-allowed'"
3511
4933
  class="px-2 py-1 rounded"
@@ -3586,9 +5008,18 @@ async def dashboard():
3586
5008
  <div v-if="rotatedToken" class="fixed inset-0 bg-black/70 flex items-center justify-center z-50" @click.self="dismissRotatedToken">
3587
5009
  <div class="bg-gray-800 rounded-lg p-6 w-full max-w-lg">
3588
5010
  <h3 class="text-lg font-semibold mb-2">⚠ Save this token now</h3>
3589
- <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>
3590
- <div class="bg-gray-900 p-3 rounded font-mono text-xs break-all select-all">{{ rotatedToken.token }}</div>
3591
- <div class="flex justify-end mt-4">
5011
+ <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>
5012
+ <div class="bg-gray-900 p-3 rounded font-mono text-xs break-all select-all">{{ rotatedToken.token }}</div>
5013
+ <div class="mt-4">
5014
+ <div class="text-xs text-gray-400 mb-2">Robot network for generated enrollment command</div>
5015
+ <div class="flex gap-2 mb-2">
5016
+ <button @click="enrollmentNetwork = 'lan'" :class="enrollmentNetwork === 'lan' ? 'bg-blue-600' : 'bg-gray-700'" class="px-3 py-1 rounded text-xs">LAN</button>
5017
+ <button @click="enrollmentNetwork = 'tailscale'" :class="enrollmentNetwork === 'tailscale' ? 'bg-cyan-600' : 'bg-gray-700'" class="px-3 py-1 rounded text-xs">Tailscale</button>
5018
+ </div>
5019
+ <div class="bg-gray-900 p-3 rounded font-mono text-xs break-all select-all">{{ enrollmentCommand(rotatedToken) }}</div>
5020
+ <p class="text-xs text-gray-500 mt-1">LAN requires the robot and scheduler on the same subnet. Tailscale requires Tailscale on both devices and a Tailscale scheduler URL/host.</p>
5021
+ </div>
5022
+ <div class="flex justify-end mt-4">
3592
5023
  <button @click="dismissRotatedToken" class="px-3 py-2 bg-blue-600 hover:bg-blue-700 rounded">I have saved it</button>
3593
5024
  </div>
3594
5025
  </div>
@@ -3649,7 +5080,11 @@ async def dashboard():
3649
5080
  Character: <strong>{{ characterName(robot.character_id) }}</strong> ·
3650
5081
  Voice Stack: <strong>{{ stackName(robot.voice_stack_id) }}</strong>
3651
5082
  </div>
3652
- <div class="grid grid-cols-2 gap-2 mb-2 text-sm">
5083
+ <div v-if="deviceInventory[robot.id]" class="flex items-center justify-between mb-2 text-xs">
5084
+ <span>Robot production: <strong :class="devices.find(d => d.id === deviceInventory[robot.id].deviceId)?.production_mode ? 'text-green-400' : 'text-gray-400'">{{ devices.find(d => d.id === deviceInventory[robot.id].deviceId)?.production_mode ? 'ON' : 'OFF' }}</strong></span>
5085
+ <button @click="toggleDeviceProduction(robot.id)" class="px-2 py-1 rounded bg-gray-600 hover:bg-gray-500">Toggle</button>
5086
+ </div>
5087
+ <div class="grid grid-cols-2 gap-2 mb-2 text-sm">
3653
5088
  <div>
3654
5089
  <label class="text-xs text-gray-500">Character</label>
3655
5090
  <select :value="robot.character_id"
@@ -3665,7 +5100,7 @@ async def dashboard():
3665
5100
  </div>
3666
5101
  </div>
3667
5102
  <div v-if="deviceInventory[robot.id]" class="space-y-2 text-sm">
3668
- <div class="grid grid-cols-2 gap-2">
5103
+ <div class="grid grid-cols-3 gap-2">
3669
5104
  <div>
3670
5105
  <label class="text-xs text-gray-500">Camera</label>
3671
5106
  <select v-model="deviceInventory[robot.id].selectedVideo"
@@ -3674,21 +5109,51 @@ async def dashboard():
3674
5109
  <option v-for="opt in deviceInventory[robot.id].videoOptions" :key="opt" :value="opt">{{ opt }}</option>
3675
5110
  </select>
3676
5111
  </div>
3677
- <div>
3678
- <label class="text-xs text-gray-500">Microphone</label>
5112
+ <div>
5113
+ <label class="text-xs text-gray-500">Microphone</label>
3679
5114
  <select v-model="deviceInventory[robot.id].selectedAudio"
3680
5115
  @change="updateDeviceMedia(robot.id)"
3681
5116
  class="w-full bg-gray-800 rounded px-2 py-1 text-xs">
3682
5117
  <option v-for="opt in deviceInventory[robot.id].audioOptions" :key="opt" :value="opt">{{ opt }}</option>
3683
- </select>
3684
- </div>
3685
- </div>
5118
+ </select>
5119
+ </div>
5120
+ <div>
5121
+ <label class="text-xs text-gray-500">Speaker</label>
5122
+ <select v-model="deviceInventory[robot.id].selectedAudioOutput"
5123
+ @change="updateDeviceMedia(robot.id)"
5124
+ class="w-full bg-gray-800 rounded px-2 py-1 text-xs">
5125
+ <option v-for="opt in deviceInventory[robot.id].audioOutputOptions" :key="opt" :value="opt">{{ opt }}</option>
5126
+ </select>
5127
+ </div>
5128
+ </div>
5129
+ <div>
5130
+ <label class="text-xs text-gray-500">Cached greeting phrases (one per line)</label>
5131
+ <textarea v-model="deviceInventory[robot.id].greetingText" rows="2" class="w-full bg-gray-800 rounded px-2 py-1 text-xs" placeholder="Hello! Want to go for a ride?"></textarea>
5132
+ <button @click="saveDeviceGreetings(robot.id)" class="mt-1 px-2 py-1 rounded bg-gray-600 hover:bg-gray-500 text-xs">Save Greetings</button>
5133
+ </div>
3686
5134
  <button @click="startPreview(robot)"
3687
5135
  :disabled="!settings.production_mode || !livekit.url || !livekit.has_secret || preview.active"
3688
5136
  :class="(settings.production_mode && livekit.url && livekit.has_secret && !preview.active) ? 'bg-cyan-600 hover:bg-cyan-700' : 'bg-gray-700 cursor-not-allowed'"
3689
5137
  class="w-full px-2 py-1 rounded text-xs">
3690
5138
  {{ preview.active && preview.robot?.id === robot.id ? 'Preview Active' : 'Start Preview' }}
3691
5139
  </button>
5140
+ <div class="grid grid-cols-2 gap-2">
5141
+ <button @click="simulateTrigger(robot)"
5142
+ :disabled="!settings.production_mode || simulation.busy || robot.status === 'connecting' || robot.status === 'running'"
5143
+ :class="settings.production_mode && !simulation.busy && robot.status !== 'connecting' && robot.status !== 'running' ? 'bg-amber-600 hover:bg-amber-500' : 'bg-gray-700 cursor-not-allowed'"
5144
+ class="px-2 py-1 rounded text-xs">
5145
+ {{ simulation.robotId === robot.id ? 'Triggering…' : 'Simulate Motion' }}
5146
+ </button>
5147
+ <button @click="stopConversation(robot)"
5148
+ :disabled="robot.status !== 'connecting' && robot.status !== 'running'"
5149
+ :class="robot.status === 'connecting' || robot.status === 'running' ? 'bg-red-600 hover:bg-red-500' : 'bg-gray-700 cursor-not-allowed'"
5150
+ class="px-2 py-1 rounded text-xs">
5151
+ Stop Conversation
5152
+ </button>
5153
+ </div>
5154
+ <button @click="recoverRobot(robot)" class="w-full px-2 py-1 rounded text-xs bg-gray-600 hover:bg-gray-500">
5155
+ Recover Stale State
5156
+ </button>
3692
5157
  <button v-if="preview.active && preview.robot?.id === robot.id"
3693
5158
  @click="stopPreview"
3694
5159
  class="w-full px-2 py-1 bg-red-600 hover:bg-red-700 rounded text-xs">