infinicode 2.8.36 → 2.8.37

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,13 @@ Manages robot fleet, LiveKit servers, and session orchestration
5
5
 
6
6
  import os
7
7
  import asyncio
8
+ import json
8
9
  import logging
9
10
  import re
10
11
  import secrets
11
12
  import hashlib
12
13
  import hmac
14
+ import time
13
15
  from datetime import datetime, timedelta
14
16
  from typing import Optional, List
15
17
  from contextlib import asynccontextmanager
@@ -31,6 +33,26 @@ DB_PATH = os.path.join(os.getenv("SCHEDULER_DATA_DIR", "/app/data"), "scheduler.
31
33
  # Path to a ROBOVOICE-style settings file the scheduler reads for characters.
32
34
  # In production this should be a bind-mount to ROBOVOICE-main/settings.json (or .default.json).
33
35
  ROBOVOICE_SETTINGS_PATH = os.path.join(os.getenv("SCHEDULER_DATA_DIR", "/app/data"), "robovoice_settings.json")
36
+ # Optional shared credential used only by the ROBOVOICE worker to refresh an
37
+ # active RoboPark session. Device endpoints continue to require device tokens.
38
+ ROBOPARK_AGENT_TOKEN = os.getenv("ROBOPARK_AGENT_TOKEN", "").strip()
39
+
40
+ # ElevenLabs credentials used by the /api/voices/elevenlabs endpoint. We accept
41
+ # both the long-form ELEVENLABS_API_KEY and a shorter ELEVENLABS_KEY alias so
42
+ # operators can drop in whichever naming their tooling already exports. Either
43
+ # may be set in the scheduler's environment.
44
+ ELEVENLABS_API_KEY = (
45
+ os.getenv("ELEVENLABS_API_KEY", "").strip()
46
+ or os.getenv("ELEVENLABS_KEY", "").strip()
47
+ or os.getenv("ELEVEN_API_KEY", "").strip()
48
+ )
49
+ ELEVENLABS_BASE_URL = os.getenv("ELEVENLABS_BASE_URL", "https://api.elevenlabs.io").rstrip("/")
50
+
51
+ # Optional base URL of a local Kokoro TTS server. When set, /api/voices/kokoro
52
+ # will proxy its /v1/audio/voices endpoint to return the dynamic list of voices
53
+ # the server actually has installed; otherwise we fall back to a curated static
54
+ # list of the canonical Kokoro-82M voices.
55
+ KOKORO_BASE_URL = os.getenv("KOKORO_BASE_URL", "").strip().rstrip("/")
34
56
 
35
57
  # Session end_reasons considered "abnormal" for drop-rate telemetry. A session
36
58
  # that ended with one of these reasons is counted as a dropped session; anything
@@ -106,6 +128,15 @@ class WebhookEvent(BaseModel):
106
128
  event: str
107
129
  data: dict
108
130
 
131
+ class ServiceStatus(BaseModel):
132
+ name: str
133
+ enabled: bool
134
+ running: bool
135
+ pid: Optional[int] = None
136
+ uptime_seconds: Optional[float] = None
137
+ failure_count: int = 0
138
+ last_exit_code: Optional[int] = None
139
+
109
140
  class Device(BaseModel):
110
141
  id: str
111
142
  name: str
@@ -116,6 +147,12 @@ class Device(BaseModel):
116
147
  livekit_url: Optional[str] = None
117
148
  video_device: Optional[str] = None
118
149
  audio_device: Optional[str] = None
150
+ audio_output_device: Optional[str] = None
151
+ greeting_phrases: List[str] = []
152
+ device_inventory: Optional[dict] = None
153
+ production_mode: bool = False
154
+ supervisor_status: Optional[List[ServiceStatus]] = None
155
+ supervisor_status_at: Optional[str] = None
119
156
  status: str = "enrolled" # enrolled, online, offline, disabled
120
157
  last_heartbeat: Optional[datetime] = None
121
158
  enrolled_at: Optional[datetime] = None
@@ -132,6 +169,7 @@ class DeviceCreate(BaseModel):
132
169
  livekit_url: Optional[str] = None
133
170
  video_device: Optional[str] = None
134
171
  audio_device: Optional[str] = None
172
+ greeting_phrases: Optional[List[str]] = None
135
173
  notes: Optional[str] = None
136
174
  enrollment_token: Optional[str] = None # if omitted, one is auto-generated
137
175
 
@@ -156,6 +194,10 @@ class DeviceHeartbeat(BaseModel):
156
194
  ip: Optional[str] = None
157
195
  uptime_seconds: Optional[int] = None
158
196
  production_mode: Optional[bool] = None
197
+ device_inventory: Optional[dict] = None
198
+
199
+ class SupervisorStatusReport(BaseModel):
200
+ services: List[ServiceStatus] = []
159
201
 
160
202
  class SettingsPayload(BaseModel):
161
203
  production_mode: bool
@@ -194,14 +236,14 @@ async def _seed_voice_stacks_and_characters(db):
194
236
  (
195
237
  "english-default", "English Default",
196
238
  "speaches", "Systran/faster-whisper-small", "en",
197
- "ollama", "gemma4:e4b",
239
+ "ollama", "gemma3:27b",
198
240
  "elevenlabs", "21m00Tcm4TlvDq8ikWAM", "en",
199
241
  1, 0.7, 20, 0,
200
242
  ),
201
243
  (
202
244
  "hebrew-default", "Hebrew Default",
203
245
  "speaches", "ivrit-ai/whisper-large-v3-turbo-ct2", "he",
204
- "ollama", "gemma4:e4b",
246
+ "ollama", "gemma3:27b",
205
247
  "elevenlabs", "21m00Tcm4TlvDq8ikWAM", "he",
206
248
  1, 0.7, 20, 0,
207
249
  ),
@@ -297,9 +339,11 @@ async def init_db():
297
339
  motor_server_url TEXT,
298
340
  character_id TEXT,
299
341
  livekit_url TEXT,
300
- video_device TEXT,
301
- audio_device TEXT,
302
- token_hash TEXT,
342
+ video_device TEXT,
343
+ audio_device TEXT,
344
+ audio_output_device TEXT,
345
+ device_inventory TEXT,
346
+ token_hash TEXT,
303
347
  enrollment_token_hash TEXT,
304
348
  status TEXT DEFAULT 'enrolled',
305
349
  last_heartbeat TEXT,
@@ -323,6 +367,26 @@ async def init_db():
323
367
  created_at TEXT DEFAULT CURRENT_TIMESTAMP
324
368
  );
325
369
 
370
+ CREATE TABLE IF NOT EXISTS trigger_commands (
371
+ robot_id TEXT PRIMARY KEY,
372
+ source TEXT NOT NULL DEFAULT 'dashboard',
373
+ requested_at TEXT NOT NULL
374
+ );
375
+
376
+ -- One pending remote-control command per (device, service): an
377
+ -- operator clicking "restart" in the dashboard queues a row here;
378
+ -- robot_supervisor.py polls GET .../supervisor-commands (same
379
+ -- request that carries its status report) and executes+clears it.
380
+ -- A second click before the robot polls just overwrites the row
381
+ -- rather than queuing duplicates.
382
+ CREATE TABLE IF NOT EXISTS supervisor_commands (
383
+ device_id TEXT NOT NULL,
384
+ service_name TEXT NOT NULL,
385
+ action TEXT NOT NULL DEFAULT 'restart',
386
+ requested_at TEXT NOT NULL,
387
+ PRIMARY KEY (device_id, service_name)
388
+ );
389
+
326
390
  CREATE TABLE IF NOT EXISTS history_log (
327
391
  id INTEGER PRIMARY KEY AUTOINCREMENT,
328
392
  timestamp TEXT DEFAULT CURRENT_TIMESTAMP,
@@ -342,7 +406,7 @@ async def init_db():
342
406
  stt_model TEXT DEFAULT 'Systran/faster-whisper-small',
343
407
  stt_language TEXT DEFAULT 'en',
344
408
  llm_provider TEXT DEFAULT 'ollama',
345
- llm_model TEXT DEFAULT 'gemma4:e4b',
409
+ llm_model TEXT DEFAULT 'gemma3:27b',
346
410
  tts_provider TEXT DEFAULT 'elevenlabs',
347
411
  tts_voice TEXT DEFAULT '21m00Tcm4TlvDq8ikWAM',
348
412
  tts_language TEXT DEFAULT 'en',
@@ -398,6 +462,22 @@ async def init_db():
398
462
  ("sessions", "joined_at", "TEXT"),
399
463
  ("devices", "video_device", "TEXT"),
400
464
  ("devices", "audio_device", "TEXT"),
465
+ ("devices", "device_inventory", "TEXT"),
466
+ ("devices", "audio_output_device", "TEXT"),
467
+ ("devices", "greeting_phrases", "TEXT DEFAULT '[]'"),
468
+ # Per-robot production mode: an additional, more specific gate on
469
+ # top of the global settings.production_mode switch. When on for
470
+ # a given device, that robot keeps re-arming its motion-triggered
471
+ # session loop indefinitely (device_heartbeat below returns the
472
+ # AND of global+per-device as the effective flag the Pi acts on)
473
+ # and the dashboard keeps that robot's camera panel live.
474
+ ("devices", "production_mode", "INTEGER DEFAULT 0"),
475
+ # robot_supervisor.py's own periodic self-report: which services
476
+ # it's managing on this robot (preview_agent, vision_app,
477
+ # motor_server) and each one's up/down state, pid, uptime, and
478
+ # failure count. JSON blob; see POST .../supervisor-status.
479
+ ("devices", "supervisor_status", "TEXT"),
480
+ ("devices", "supervisor_status_at", "TEXT"),
401
481
  # Silence-based session end (see POST /api/sessions/{id}/keepalive
402
482
  # and GET /api/sessions/{id}/status): tracks the last time some
403
483
  # caller signaled this session was still active. Defaults to
@@ -483,6 +563,16 @@ async def init_db():
483
563
  @asynccontextmanager
484
564
  async def lifespan(app: FastAPI):
485
565
  await init_db()
566
+ if not ROBOPARK_AGENT_TOKEN:
567
+ logger.warning(
568
+ "ROBOPARK_AGENT_TOKEN is not set -- the voice agent's "
569
+ "POST /api/sessions/{id}/keepalive calls will all 401. Without "
570
+ "keepalive succeeding, every motion-triggered session's "
571
+ "last_activity_at never updates and gets cut off by its own "
572
+ "silence timeout within ~15s, before a greeting or any real "
573
+ "conversation can complete. Set it to match ROBOPARK_AGENT_TOKEN "
574
+ "in the voice agent's own .env before relying on production mode."
575
+ )
486
576
  asyncio.create_task(metrics_collector())
487
577
  asyncio.create_task(health_checker())
488
578
  yield
@@ -732,9 +822,10 @@ async def end_session(robot_id: str, reason: str = "silence"):
732
822
  robot = await cursor.fetchone()
733
823
  if not robot or not robot["current_session_id"]:
734
824
  raise HTTPException(400, "No active session")
825
+ current_session_id = robot["current_session_id"]
735
826
 
736
827
  # Update session
737
- async with db.execute("SELECT * FROM sessions WHERE id = ?", (robot["current_session_id"],)) as cursor:
828
+ async with db.execute("SELECT * FROM sessions WHERE id = ?", (current_session_id,)) as cursor:
738
829
  session = await cursor.fetchone()
739
830
  if session:
740
831
  started = datetime.fromisoformat(session["started_at"])
@@ -759,9 +850,86 @@ async def end_session(robot_id: str, reason: str = "silence"):
759
850
  await db.commit()
760
851
 
761
852
  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')}")
853
+ await _log_history("session", "robot", robot_id, "session_ended", "robot", f"reason={reason}, session_id={current_session_id}")
763
854
  return {"status": "ok"}
764
855
 
856
+ @app.post("/api/robots/{robot_id}/simulate-trigger")
857
+ async def simulate_trigger(robot_id: str):
858
+ """Queue a dashboard motion event for the enrolled robot."""
859
+ if not await get_production_mode():
860
+ return {"queued": False, "reason": "production_mode_off"}
861
+ async with aiosqlite.connect(DB_PATH) as db:
862
+ db.row_factory = aiosqlite.Row
863
+ async with db.execute(
864
+ "SELECT id, current_session_id FROM robots WHERE id = ?", (robot_id,)
865
+ ) as c:
866
+ robot = await c.fetchone()
867
+ if not robot:
868
+ raise HTTPException(404, "Robot not found")
869
+ if robot["current_session_id"]:
870
+ return {"queued": False, "reason": "session_active"}
871
+ await db.execute(
872
+ "INSERT INTO trigger_commands (robot_id, source, requested_at) VALUES (?, ?, ?) "
873
+ "ON CONFLICT(robot_id) DO UPDATE SET source = excluded.source, requested_at = excluded.requested_at",
874
+ (robot_id, "dashboard", datetime.utcnow().isoformat()),
875
+ )
876
+ await db.commit()
877
+ await broadcast("robot_triggered", {"robot_id": robot_id, "source": "dashboard"})
878
+ await _log_history("robot", "robot", robot_id, "simulate_trigger", "operator")
879
+ return {"queued": True, "source": "dashboard"}
880
+
881
+ @app.post("/api/robots/{robot_id}/simulate-stop")
882
+ async def simulate_stop(robot_id: str):
883
+ """Stop a simulated or production conversation and release the robot."""
884
+ async with aiosqlite.connect(DB_PATH) as db:
885
+ db.row_factory = aiosqlite.Row
886
+ async with db.execute(
887
+ "SELECT current_session_id FROM robots WHERE id = ?", (robot_id,)
888
+ ) as c:
889
+ robot = await c.fetchone()
890
+ if not robot:
891
+ raise HTTPException(404, "Robot not found")
892
+ await db.execute("DELETE FROM trigger_commands WHERE robot_id = ?", (robot_id,))
893
+ await db.commit()
894
+ stopped = False
895
+ if robot["current_session_id"]:
896
+ try:
897
+ await end_session(robot_id, "operator_stop")
898
+ stopped = True
899
+ except HTTPException:
900
+ pass
901
+ await _log_history("robot", "robot", robot_id, "simulate_stop", "operator")
902
+ return {"stopped": stopped}
903
+
904
+ @app.post("/api/robots/{robot_id}/recover")
905
+ async def recover_robot(robot_id: str):
906
+ """Clear this robot's stale session/trigger state and return it to idle.
907
+
908
+ This is intentionally scoped to one robot. It does not restart workers or
909
+ touch other robots, and is safe to repeat after a failed LiveKit teardown.
910
+ """
911
+ now = datetime.utcnow().isoformat()
912
+ async with aiosqlite.connect(DB_PATH) as db:
913
+ db.row_factory = aiosqlite.Row
914
+ async with db.execute("SELECT id, current_session_id FROM robots WHERE id = ?", (robot_id,)) as c:
915
+ robot = await c.fetchone()
916
+ if not robot:
917
+ raise HTTPException(404, "Robot not found")
918
+ await db.execute(
919
+ "UPDATE sessions SET ended_at = COALESCE(ended_at, ?), end_reason = COALESCE(end_reason, 'operator_recovery') "
920
+ "WHERE robot_id = ? AND ended_at IS NULL",
921
+ (now, robot_id),
922
+ )
923
+ await db.execute("DELETE FROM trigger_commands WHERE robot_id = ?", (robot_id,))
924
+ await db.execute(
925
+ "UPDATE robots SET status = 'idle', current_session_id = NULL, connected_server_id = NULL WHERE id = ?",
926
+ (robot_id,),
927
+ )
928
+ await db.commit()
929
+ await broadcast("robot_recovered", {"robot_id": robot_id})
930
+ await _log_history("robot", "robot", robot_id, "recovered", "operator")
931
+ return {"status": "ok", "robot_id": robot_id, "previous_session_id": robot["current_session_id"]}
932
+
765
933
  @app.post("/api/robots/{robot_id}/trigger")
766
934
  async def robot_trigger(robot_id: str,
767
935
  authorization: Optional[str] = Header(default=None)):
@@ -1047,7 +1215,8 @@ async def session_joined(session_id: str,
1047
1215
 
1048
1216
  @app.post("/api/sessions/{session_id}/keepalive")
1049
1217
  async def session_keepalive(session_id: str,
1050
- authorization: Optional[str] = Header(default=None)):
1218
+ authorization: Optional[str] = Header(default=None),
1219
+ agent_token: Optional[str] = Header(default=None, alias="X-RoboPark-Agent-Token")):
1051
1220
  """Silence-based session extension signal.
1052
1221
 
1053
1222
  INTEGRATION POINT (out of scope here — lives in a different repo): the
@@ -1066,7 +1235,13 @@ async def session_keepalive(session_id: str,
1066
1235
  ceiling regardless of activity, so a misbehaving/stuck caller can never
1067
1236
  hold the publisher forever even if it calls this endpoint continuously.
1068
1237
  """
1069
- if not await _authorize_fleet(authorization):
1238
+ device_authorized = await _authorize_fleet(authorization)
1239
+ agent_authorized = bool(
1240
+ ROBOPARK_AGENT_TOKEN
1241
+ and agent_token
1242
+ and hmac.compare_digest(agent_token.strip(), ROBOPARK_AGENT_TOKEN)
1243
+ )
1244
+ if not device_authorized and not agent_authorized:
1070
1245
  raise HTTPException(401, "Invalid or missing device token")
1071
1246
  now = datetime.utcnow().isoformat()
1072
1247
  async with aiosqlite.connect(DB_PATH) as db:
@@ -1248,6 +1423,11 @@ async def metrics_collector():
1248
1423
  try:
1249
1424
  async with httpx.AsyncClient(timeout=5.0) as client:
1250
1425
  resp = await client.get(f"{server['webhook_url']}/metrics")
1426
+ if resp.status_code == 404:
1427
+ # Bare LiveKit servers do not expose the agent metrics endpoint.
1428
+ logger.debug(f"Metrics endpoint unavailable for {server['id']}")
1429
+ continue
1430
+ resp.raise_for_status()
1251
1431
  data = resp.json()
1252
1432
 
1253
1433
  # Store in history
@@ -1323,6 +1503,33 @@ async def health_checker():
1323
1503
  await db.execute("UPDATE devices SET status = 'offline' WHERE id = ?", (did,))
1324
1504
  await broadcast("device_status_changed", {"device_id": did, "status": "offline"})
1325
1505
 
1506
+ # Reap sessions leaked by a crashed worker or broken LiveKit
1507
+ # disconnect. Normal sessions are closed by the worker, but
1508
+ # this prevents stale rooms consuming server capacity forever.
1509
+ session_cutoff = (datetime.utcnow() - timedelta(seconds=120)).isoformat()
1510
+ async with db.execute(
1511
+ "SELECT id, robot_id FROM sessions "
1512
+ "WHERE ended_at IS NULL AND COALESCE(last_activity_at, started_at) < ?",
1513
+ (session_cutoff,),
1514
+ ) as c:
1515
+ stale_sessions = await c.fetchall()
1516
+ for session in stale_sessions:
1517
+ await db.execute(
1518
+ "UPDATE sessions SET ended_at = ?, end_reason = 'silence' "
1519
+ "WHERE id = ? AND ended_at IS NULL",
1520
+ (datetime.utcnow().isoformat(), session["id"]),
1521
+ )
1522
+ await db.execute(
1523
+ "UPDATE robots SET status = 'idle', current_session_id = NULL, "
1524
+ "connected_server_id = NULL WHERE id = ? AND current_session_id = ?",
1525
+ (session["robot_id"], session["id"]),
1526
+ )
1527
+ await broadcast("session_expired", {
1528
+ "session_id": session["id"],
1529
+ "robot_id": session["robot_id"],
1530
+ "reason": "silence",
1531
+ })
1532
+
1326
1533
  await db.commit()
1327
1534
 
1328
1535
  except Exception as e:
@@ -1348,6 +1555,20 @@ async def get_settings():
1348
1555
  async def update_settings(payload: SettingsPayload):
1349
1556
  """Update scheduler settings (production mode, etc.)."""
1350
1557
  await set_setting("production_mode", "true" if payload.production_mode else "false")
1558
+ if not payload.production_mode:
1559
+ async with aiosqlite.connect(DB_PATH) as db:
1560
+ async with db.execute(
1561
+ "SELECT id FROM robots WHERE current_session_id IS NOT NULL"
1562
+ ) as c:
1563
+ active_robot_ids = [row[0] for row in await c.fetchall()]
1564
+ await db.execute("DELETE FROM trigger_commands")
1565
+ await db.execute("DELETE FROM previews")
1566
+ await db.commit()
1567
+ for robot_id in active_robot_ids:
1568
+ try:
1569
+ await end_session(robot_id, "production_mode_off")
1570
+ except HTTPException:
1571
+ pass
1351
1572
  await broadcast("settings_changed", {"production_mode": payload.production_mode})
1352
1573
  await _log_history("settings", "settings", "production_mode", "updated", "operator", f"value={payload.production_mode}")
1353
1574
  return {"status": "ok", "production_mode": payload.production_mode}
@@ -1589,6 +1810,27 @@ async def robot_stream(robot_id: str,
1589
1810
  PREVIEW_TTL_SECONDS = 45 # preview auto-expires this long after the last keepalive
1590
1811
  DEFAULT_VISION_PORT = 5000 # RoboVisionAI_PI's default Flask port (/video_feed, /api/motion/*)
1591
1812
 
1813
+ async def _mirror_device_to_robot(db, device_id: str):
1814
+ """Ensure a `robots` row exists for an enrolled device, so id-keyed routes
1815
+ (/stream, /preview/*) work even before the device's first real voice
1816
+ session (device_request_session normally creates this mirror, but only on
1817
+ first request-session — operator "Go live" should work before that too)."""
1818
+ async with db.execute("SELECT * FROM robots WHERE id = ?", (device_id,)) as c:
1819
+ robot = await c.fetchone()
1820
+ if robot:
1821
+ return robot
1822
+ async with db.execute("SELECT * FROM devices WHERE id = ?", (device_id,)) as c:
1823
+ device = await c.fetchone()
1824
+ if not device:
1825
+ return None
1826
+ await db.execute(
1827
+ "INSERT OR IGNORE INTO robots (id, name, character_id) VALUES (?, ?, ?)",
1828
+ (device_id, device["name"] or device_id, device["character_id"]),
1829
+ )
1830
+ await db.commit()
1831
+ async with db.execute("SELECT * FROM robots WHERE id = ?", (device_id,)) as c:
1832
+ return await c.fetchone()
1833
+
1592
1834
  async def _pick_preview_server(db, prefer_id: Optional[str] = None):
1593
1835
  """Return a LiveKit server row for a preview: a preferred one if still present,
1594
1836
  else the least-loaded online server, else any configured server."""
@@ -1638,9 +1880,8 @@ async def preview_start(robot_id: str):
1638
1880
  return {"active": False, "reason": "production_mode_off"}
1639
1881
  async with aiosqlite.connect(DB_PATH) as db:
1640
1882
  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")
1883
+ if not await _mirror_device_to_robot(db, robot_id):
1884
+ raise HTTPException(404, "Robot not found")
1644
1885
  async with db.execute("SELECT * FROM previews WHERE robot_id = ?", (robot_id,)) as c:
1645
1886
  existing = await c.fetchone()
1646
1887
  server = await _pick_preview_server(db, existing["server_id"] if existing else None)
@@ -1724,6 +1965,26 @@ def _device_row_to_model(row) -> Device:
1724
1965
  d = dict(row)
1725
1966
  d.pop("token_hash", None)
1726
1967
  d.pop("enrollment_token_hash", None)
1968
+ inventory = d.get("device_inventory")
1969
+ if isinstance(inventory, str):
1970
+ try:
1971
+ d["device_inventory"] = json.loads(inventory)
1972
+ except json.JSONDecodeError:
1973
+ d["device_inventory"] = None
1974
+ greetings = d.get("greeting_phrases")
1975
+ if isinstance(greetings, str):
1976
+ try:
1977
+ d["greeting_phrases"] = [str(p) for p in json.loads(greetings) if str(p).strip()]
1978
+ except (json.JSONDecodeError, TypeError):
1979
+ d["greeting_phrases"] = []
1980
+ elif not isinstance(greetings, list):
1981
+ d["greeting_phrases"] = []
1982
+ sup = d.get("supervisor_status")
1983
+ if isinstance(sup, str):
1984
+ try:
1985
+ d["supervisor_status"] = json.loads(sup)
1986
+ except json.JSONDecodeError:
1987
+ d["supervisor_status"] = None
1727
1988
  return Device(**d)
1728
1989
 
1729
1990
  @app.get("/api/devices", response_model=List[Device])
@@ -1782,15 +2043,24 @@ class DeviceUpdate(BaseModel):
1782
2043
  livekit_url: Optional[str] = None
1783
2044
  video_device: Optional[str] = None
1784
2045
  audio_device: Optional[str] = None
2046
+ audio_output_device: Optional[str] = None
2047
+ greeting_phrases: Optional[List[str]] = None
1785
2048
  tailscale_ip: Optional[str] = None
1786
2049
  lan_ip: Optional[str] = None
1787
2050
  notes: Optional[str] = None
1788
2051
  status: Optional[str] = None
2052
+ production_mode: Optional[bool] = None
1789
2053
 
1790
2054
  @app.patch("/api/devices/{device_id}", response_model=Device)
1791
2055
  async def update_device(device_id: str, payload: DeviceUpdate):
1792
2056
  """Partial update for a device (character binding, addresses, notes, status)."""
1793
2057
  fields = {k: v for k, v in payload.model_dump(exclude_none=True).items()}
2058
+ if "greeting_phrases" in fields:
2059
+ fields["greeting_phrases"] = json.dumps(
2060
+ [str(p).strip() for p in fields["greeting_phrases"] if str(p).strip()][:20]
2061
+ )
2062
+ if "production_mode" in fields:
2063
+ fields["production_mode"] = 1 if fields["production_mode"] else 0
1794
2064
  if not fields:
1795
2065
  return await get_device(device_id)
1796
2066
  set_clause = ", ".join(f"{k} = ?" for k in fields)
@@ -1847,13 +2117,13 @@ async def create_device(payload: DeviceCreate):
1847
2117
  """INSERT INTO devices
1848
2118
  (id, name, tailscale_ip, lan_ip, motor_server_url, character_id,
1849
2119
  livekit_url, video_device, audio_device, enrollment_token_hash,
1850
- status, enrolled_at, notes, created_at)
1851
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'enrolled', ?, ?, ?)""",
2120
+ greeting_phrases, status, enrolled_at, notes, created_at)
2121
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'enrolled', ?, ?, ?)""",
1852
2122
  (
1853
2123
  device_id, payload.name.strip(), payload.tailscale_ip, payload.lan_ip,
1854
2124
  payload.motor_server_url, payload.character_id, payload.livekit_url,
1855
2125
  payload.video_device, payload.audio_device,
1856
- enrollment_hash, now, payload.notes, now,
2126
+ enrollment_hash, json.dumps(payload.greeting_phrases or []), now, payload.notes, now,
1857
2127
  ),
1858
2128
  )
1859
2129
  await db.commit()
@@ -2051,35 +2321,131 @@ async def enroll_device(payload: DeviceEnrollRequest):
2051
2321
  scheduler_url = os.getenv("SCHEDULER_PUBLIC_URL", f"http://localhost:{os.getenv('SCHEDULER_PORT', '8080')}")
2052
2322
  return DeviceEnrollResponse(device_id=device_id, device_token=new_token, scheduler_url=scheduler_url)
2053
2323
 
2324
+ async def _effective_device_production_mode(db, device_id: str) -> bool:
2325
+ """Global master switch AND this robot's own toggle. Used everywhere a
2326
+ robot decides whether to auto-trigger/stay in its motion-triggered loop."""
2327
+ if not await get_production_mode():
2328
+ return False
2329
+ db.row_factory = aiosqlite.Row
2330
+ async with db.execute("SELECT production_mode FROM devices WHERE id = ?", (device_id,)) as c:
2331
+ row = await c.fetchone()
2332
+ return bool(row["production_mode"]) if row else False
2333
+
2334
+ @app.get("/api/devices/{device_id}/trigger-command")
2335
+ async def device_trigger_command(device_id: str,
2336
+ authorization: Optional[str] = Header(default=None)):
2337
+ """Authenticated robot poll for dashboard-simulated motion events."""
2338
+ if not await _authorize_device(device_id, authorization):
2339
+ raise HTTPException(401, "Invalid device token")
2340
+ async with aiosqlite.connect(DB_PATH) as db:
2341
+ if not await _effective_device_production_mode(db, device_id):
2342
+ return {"trigger": False, "production_mode": False}
2343
+ db.row_factory = aiosqlite.Row
2344
+ async with db.execute(
2345
+ "SELECT source FROM trigger_commands WHERE robot_id = ?", (device_id,)
2346
+ ) as c:
2347
+ command = await c.fetchone()
2348
+ if not command:
2349
+ return {"trigger": False, "production_mode": True}
2350
+ await db.execute("DELETE FROM trigger_commands WHERE robot_id = ?", (device_id,))
2351
+ await db.commit()
2352
+ return {"trigger": True, "source": command["source"], "production_mode": True}
2353
+
2054
2354
  @app.post("/api/devices/{device_id}/heartbeat")
2055
2355
  async def device_heartbeat(device_id: str, payload: DeviceHeartbeat,
2056
2356
  authorization: Optional[str] = Header(default=None)):
2057
2357
  if not await _authorize_device(device_id, authorization):
2058
2358
  raise HTTPException(401, "Invalid device token")
2059
2359
  async with aiosqlite.connect(DB_PATH) as db:
2360
+ db.row_factory = aiosqlite.Row
2060
2361
  async with db.execute(
2061
2362
  """UPDATE devices
2062
- SET status = ?, last_heartbeat = ?, last_seen_ip = COALESCE(?, last_seen_ip)
2363
+ SET status = ?, last_heartbeat = ?,
2364
+ lan_ip = COALESCE(?, lan_ip),
2365
+ last_seen_ip = COALESCE(?, last_seen_ip),
2366
+ device_inventory = COALESCE(?, device_inventory)
2063
2367
  WHERE id = ?""",
2064
2368
  (
2065
2369
  payload.status or "online",
2066
2370
  datetime.utcnow().isoformat(),
2067
2371
  payload.ip,
2372
+ payload.ip,
2373
+ json.dumps(payload.device_inventory) if payload.device_inventory is not None else None,
2068
2374
  device_id,
2069
2375
  ),
2070
2376
  ) as cur:
2071
2377
  await db.commit()
2072
2378
  if cur.rowcount == 0:
2073
2379
  raise HTTPException(404, "Device not found")
2380
+ effective_pm = await _effective_device_production_mode(db, device_id)
2074
2381
  await broadcast("device_heartbeat", {"device_id": device_id, "status": payload.status})
2075
2382
  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)
2383
+ # Effective production mode = the global master switch AND this specific
2384
+ # robot's own toggle. A robot with its own toggle off never auto-triggers
2385
+ # sessions from motion, even while the fleet-wide switch is on.
2077
2386
  return {
2078
2387
  "status": "ok",
2079
- "production_mode": await get_production_mode(),
2388
+ "production_mode": effective_pm,
2080
2389
  "server_time": datetime.utcnow().isoformat(),
2081
2390
  }
2082
2391
 
2392
+ @app.post("/api/devices/{device_id}/supervisor-status")
2393
+ async def device_supervisor_status(device_id: str, payload: SupervisorStatusReport,
2394
+ authorization: Optional[str] = Header(default=None)):
2395
+ """robot_supervisor.py's periodic self-report: which services it's
2396
+ managing on this robot and each one's current up/down state. Reported
2397
+ independently of device_heartbeat (which comes from preview_agent.py --
2398
+ if preview_agent itself is down, it can't report that fact, only the
2399
+ supervisor watching it from outside can)."""
2400
+ if not await _authorize_device(device_id, authorization):
2401
+ raise HTTPException(401, "Invalid device token")
2402
+ blob = json.dumps([s.model_dump() for s in payload.services])
2403
+ now = datetime.utcnow().isoformat()
2404
+ async with aiosqlite.connect(DB_PATH) as db:
2405
+ db.row_factory = aiosqlite.Row
2406
+ cur = await db.execute(
2407
+ "UPDATE devices SET supervisor_status = ?, supervisor_status_at = ? WHERE id = ?",
2408
+ (blob, now, device_id),
2409
+ )
2410
+ await db.commit()
2411
+ if cur.rowcount == 0:
2412
+ raise HTTPException(404, "Device not found")
2413
+ # Piggyback any pending remote-control commands (operator clicked
2414
+ # "restart" on a service) onto this same request/response cycle --
2415
+ # the robot already calls this every 10s, no separate poll needed.
2416
+ # Consumed immediately: read then delete, so a command fires once.
2417
+ async with db.execute(
2418
+ "SELECT service_name, action FROM supervisor_commands WHERE device_id = ?", (device_id,)
2419
+ ) as c:
2420
+ pending = [dict(r) for r in await c.fetchall()]
2421
+ if pending:
2422
+ await db.execute("DELETE FROM supervisor_commands WHERE device_id = ?", (device_id,))
2423
+ await db.commit()
2424
+ await broadcast("supervisor_status", {"device_id": device_id, "services": blob})
2425
+ return {"status": "ok", "commands": pending}
2426
+
2427
+ @app.post("/api/devices/{device_id}/services/{service_name}/restart")
2428
+ async def restart_device_service(device_id: str, service_name: str):
2429
+ """Operator dashboard: queue a restart for one service on one robot's
2430
+ supervisor. Picked up on the robot's next status-report poll (up to
2431
+ ~10s later, not instant -- there's no persistent connection to the
2432
+ robot, only its periodic check-ins)."""
2433
+ now = datetime.utcnow().isoformat()
2434
+ async with aiosqlite.connect(DB_PATH) as db:
2435
+ cur = await db.execute("SELECT 1 FROM devices WHERE id = ?", (device_id,))
2436
+ if not await cur.fetchone():
2437
+ raise HTTPException(404, "Device not found")
2438
+ await db.execute(
2439
+ """INSERT INTO supervisor_commands (device_id, service_name, action, requested_at)
2440
+ VALUES (?, ?, 'restart', ?)
2441
+ ON CONFLICT(device_id, service_name) DO UPDATE SET
2442
+ action='restart', requested_at=excluded.requested_at""",
2443
+ (device_id, service_name, now),
2444
+ )
2445
+ await db.commit()
2446
+ await _log_history("device", "device", device_id, "service_restart_queued", "operator", f"service={service_name}")
2447
+ return {"status": "queued", "service": service_name}
2448
+
2083
2449
  @app.get("/api/devices/{device_id}/config")
2084
2450
  async def device_config(device_id: str, authorization: Optional[str] = Header(default=None)):
2085
2451
  """Pi polls this to learn current production_mode + assigned character, etc."""
@@ -2088,19 +2454,25 @@ async def device_config(device_id: str, authorization: Optional[str] = Header(de
2088
2454
  async with aiosqlite.connect(DB_PATH) as db:
2089
2455
  db.row_factory = aiosqlite.Row
2090
2456
  async with db.execute(
2091
- "SELECT id, name, character_id, motor_server_url, livekit_url FROM devices WHERE id = ?",
2457
+ "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
2458
  (device_id,),
2093
2459
  ) as c:
2094
2460
  row = await c.fetchone()
2095
2461
  if not row:
2096
2462
  raise HTTPException(404, "Device not found")
2463
+ effective_pm = await _effective_device_production_mode(db, device_id)
2097
2464
  return {
2098
2465
  "device_id": row["id"],
2099
2466
  "name": row["name"],
2100
2467
  "character_id": row["character_id"],
2101
2468
  "motor_server_url": row["motor_server_url"],
2102
2469
  "livekit_url": row["livekit_url"],
2103
- "production_mode": await get_production_mode(),
2470
+ "video_device": row["video_device"],
2471
+ "audio_device": row["audio_device"],
2472
+ "audio_output_device": row["audio_output_device"],
2473
+ "greeting_phrases": _device_row_to_model(row).greeting_phrases,
2474
+ "device_inventory": json.loads(row["device_inventory"]) if row["device_inventory"] else None,
2475
+ "production_mode": effective_pm,
2104
2476
  }
2105
2477
 
2106
2478
  @app.post("/api/devices/{device_id}/request-session")
@@ -2150,6 +2522,21 @@ async def device_request_session(device_id: str,
2150
2522
  (device_id, device["name"] or device_id, device["character_id"]),
2151
2523
  )
2152
2524
 
2525
+ # A robot can only physically be in one room at a time -- it always
2526
+ # disconnects from its previous room before joining a new one (see
2527
+ # preview_agent.py's _apply_state). But this endpoint deliberately
2528
+ # never hard-fails on a prior un-ended session (see docstring), so
2529
+ # without this, repeated presence-triggers (e.g. continuous motion
2530
+ # detection) each leave the previous session's row with
2531
+ # ended_at IS NULL forever -- permanently consuming a slot against
2532
+ # the server's max_sessions capacity until every slot leaks away and
2533
+ # every future request-session 503s. Close them out here instead.
2534
+ await db.execute(
2535
+ """UPDATE sessions SET ended_at = ?, end_reason = 'superseded'
2536
+ WHERE robot_id = ? AND ended_at IS NULL""",
2537
+ (datetime.utcnow().isoformat(), device_id),
2538
+ )
2539
+
2153
2540
  # Find least-loaded online server (identical selection to the robot path).
2154
2541
  async with db.execute("""
2155
2542
  SELECT s.*,
@@ -2287,7 +2674,7 @@ class VoiceStack(BaseModel):
2287
2674
  stt_model: str = "Systran/faster-whisper-small"
2288
2675
  stt_language: str = "en"
2289
2676
  llm_provider: str = "ollama"
2290
- llm_model: str = "gemma4:e4b"
2677
+ llm_model: str = "gemma3:27b"
2291
2678
  llm_api_key: Optional[str] = None
2292
2679
  temperature: float = 0.7
2293
2680
  num_ctx: int = 8192
@@ -2311,7 +2698,7 @@ class VoiceStackCreate(BaseModel):
2311
2698
  stt_model: str = "Systran/faster-whisper-small"
2312
2699
  stt_language: str = "en"
2313
2700
  llm_provider: str = "ollama"
2314
- llm_model: str = "gemma4:e4b"
2701
+ llm_model: str = "gemma3:27b"
2315
2702
  llm_api_key: Optional[str] = None
2316
2703
  temperature: float = 0.7
2317
2704
  num_ctx: int = 8192
@@ -2333,6 +2720,7 @@ class RobotVoiceConfig(BaseModel):
2333
2720
  system_prompt: Optional[str] = None
2334
2721
  voice_stack_id: Optional[str] = None
2335
2722
  voice_stack: Optional[VoiceStack] = None
2723
+ greeting_phrases: List[str] = []
2336
2724
 
2337
2725
  def _load_robovoice_settings() -> dict:
2338
2726
  """Read the ROBOVOICE settings file. Returns {} if missing/invalid."""
@@ -2453,6 +2841,48 @@ async def _resolve_voice_stack_for_robot(db, robot_id: str) -> dict:
2453
2841
  return result
2454
2842
 
2455
2843
 
2844
+ _ELEVENLABS_NAME_CACHE: tuple[float, list[dict]] = (0.0, [])
2845
+
2846
+
2847
+ async def _auto_elevenlabs_voice(robot_name: Optional[str], current: Optional[str]) -> Optional[str]:
2848
+ """Resolve a custom ElevenLabs voice by robot name without overwriting a manual choice."""
2849
+ if current and current != "21m00Tcm4TlvDq8ikWAM":
2850
+ return current
2851
+ if not robot_name:
2852
+ return current
2853
+ normalized = "".join(ch for ch in robot_name.lower() if ch.isalnum())
2854
+ try:
2855
+ mapping = json.loads(os.getenv("ROBOPARK_ELEVENLABS_VOICE_MAP", "{}"))
2856
+ for key, voice_id in mapping.items():
2857
+ 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())):
2858
+ return str(voice_id)
2859
+ except Exception:
2860
+ pass
2861
+ global _ELEVENLABS_NAME_CACHE
2862
+ now = time.monotonic()
2863
+ voices = _ELEVENLABS_NAME_CACHE[1]
2864
+ if now - _ELEVENLABS_NAME_CACHE[0] > 300 and ELEVENLABS_API_KEY:
2865
+ try:
2866
+ async with httpx.AsyncClient(timeout=8.0) as client:
2867
+ response = await client.get(
2868
+ f"{ELEVENLABS_BASE_URL}/v1/voices",
2869
+ headers={"xi-api-key": ELEVENLABS_API_KEY, "Accept": "application/json"},
2870
+ )
2871
+ if response.status_code == 200:
2872
+ voices = response.json().get("voices") or []
2873
+ _ELEVENLABS_NAME_CACHE = (now, voices)
2874
+ except Exception as e:
2875
+ logger.debug(f"ElevenLabs voice auto-assignment unavailable: {e}")
2876
+ aliases = {normalized}
2877
+ if normalized == "vixenbmw":
2878
+ aliases.add("vixen")
2879
+ for voice in voices:
2880
+ voice_name = "".join(ch for ch in str(voice.get("name", "")).lower() if ch.isalnum())
2881
+ if voice_name in aliases or any(alias in voice_name for alias in aliases):
2882
+ return str(voice.get("voice_id") or current)
2883
+ return current
2884
+
2885
+
2456
2886
  async def _resolve_robot_voice_config(robot_id: str) -> RobotVoiceConfig:
2457
2887
  """Public helper to resolve the effective voice config for a robot id."""
2458
2888
  async with aiosqlite.connect(DB_PATH) as db:
@@ -2494,6 +2924,16 @@ async def _resolve_robot_voice_config(robot_id: str) -> RobotVoiceConfig:
2494
2924
  if row:
2495
2925
  character_name = row["name"]
2496
2926
 
2927
+ async with db.execute("SELECT greeting_phrases FROM devices WHERE id = ?", (robot_id,)) as c:
2928
+ device_row = await c.fetchone()
2929
+ greeting_phrases = []
2930
+ if device_row and device_row["greeting_phrases"]:
2931
+ try:
2932
+ greeting_phrases = [str(p) for p in _json.loads(device_row["greeting_phrases"]) if str(p).strip()]
2933
+ except Exception:
2934
+ greeting_phrases = []
2935
+ if stack_dict:
2936
+ stack_dict["tts_voice"] = await _auto_elevenlabs_voice(character_name or character_id, stack_dict.get("tts_voice"))
2497
2937
  voice_stack = VoiceStack(**stack_dict) if stack_dict else None
2498
2938
  return RobotVoiceConfig(
2499
2939
  character_id=character_id,
@@ -2501,6 +2941,7 @@ async def _resolve_robot_voice_config(robot_id: str) -> RobotVoiceConfig:
2501
2941
  system_prompt=system_prompt,
2502
2942
  voice_stack_id=voice_stack.id if voice_stack else None,
2503
2943
  voice_stack=voice_stack,
2944
+ greeting_phrases=greeting_phrases,
2504
2945
  )
2505
2946
 
2506
2947
 
@@ -2594,6 +3035,260 @@ async def delete_voice_stack(stack_id: str):
2594
3035
  return {"ok": True}
2595
3036
 
2596
3037
 
3038
+ # =============================================================================
3039
+ # TTS VOICE CATALOG
3040
+ # =============================================================================
3041
+ # These endpoints let the dashboard populate its voice selector with the real
3042
+ # set of available voices per provider. ElevenLabs is fetched live from the
3043
+ # ElevenLabs REST API (using ELEVENLABS_API_KEY); Kokoro is served from a
3044
+ # curated static list (or proxied from a local Kokoro server if KOKORO_BASE_URL
3045
+ # is set). Both endpoints are public on the scheduler and require no auth —
3046
+ # the ElevenLabs key is only used server-side.
3047
+
3048
+ class TtsVoice(BaseModel):
3049
+ id: str
3050
+ name: str
3051
+ language: Optional[str] = None
3052
+ gender: Optional[str] = None
3053
+ description: Optional[str] = None
3054
+ preview_url: Optional[str] = None
3055
+ category: Optional[str] = None
3056
+ labels: Optional[dict] = None
3057
+
3058
+
3059
+ class TtsVoiceList(BaseModel):
3060
+ provider: str
3061
+ source: str # "api", "static", or "proxy"
3062
+ count: int
3063
+ voices: List[TtsVoice]
3064
+ warning: Optional[str] = None
3065
+
3066
+
3067
+ # Canonical Kokoro-82M voice list. The id is the value written into
3068
+ # voice_stacks.tts_voice; name is what the dashboard shows in the dropdown.
3069
+ # Sources: https://huggingface.co/hexgrad/Kokoro-82M and the Kokoro repo
3070
+ # voices.json manifest (v1.0). Grouped by language for the dropdown label.
3071
+ KOKORO_STATIC_VOICES: List[dict] = [
3072
+ # American English
3073
+ {"id": "af_heart", "name": "Heart (American English · Female)", "language": "en-US", "gender": "female"},
3074
+ {"id": "af_bella", "name": "Bella (American English · Female)", "language": "en-US", "gender": "female"},
3075
+ {"id": "af_nicole", "name": "Nicole (American English · Female)", "language": "en-US", "gender": "female"},
3076
+ {"id": "af_sarah", "name": "Sarah (American English · Female)", "language": "en-US", "gender": "female"},
3077
+ {"id": "af_sky", "name": "Sky (American English · Female)", "language": "en-US", "gender": "female"},
3078
+ {"id": "am_adam", "name": "Adam (American English · Male)", "language": "en-US", "gender": "male"},
3079
+ {"id": "am_michael", "name": "Michael (American English · Male)", "language": "en-US", "gender": "male"},
3080
+ # British English
3081
+ {"id": "bf_emma", "name": "Emma (British English · Female)", "language": "en-GB", "gender": "female"},
3082
+ {"id": "bf_isabella","name": "Isabella (British English · Female)", "language": "en-GB", "gender": "female"},
3083
+ {"id": "bm_george", "name": "George (British English · Male)", "language": "en-GB", "gender": "male"},
3084
+ {"id": "bm_lewis", "name": "Lewis (British English · Male)", "language": "en-GB", "gender": "male"},
3085
+ # Spanish
3086
+ {"id": "ef_dora", "name": "Dora (Spanish · Female)", "language": "es", "gender": "female"},
3087
+ {"id": "em_alex", "name": "Alex (Spanish · Male)", "language": "es", "gender": "male"},
3088
+ {"id": "em_santa", "name": "Santa (Spanish · Male)", "language": "es", "gender": "male"},
3089
+ # French
3090
+ {"id": "ff_siwis", "name": "Siwis (French · Female)", "language": "fr", "gender": "female"},
3091
+ # Hindi
3092
+ {"id": "hf_alpha", "name": "Alpha (Hindi · Female)", "language": "hi", "gender": "female"},
3093
+ {"id": "hf_beta", "name": "Beta (Hindi · Female)", "language": "hi", "gender": "female"},
3094
+ {"id": "hm_omega", "name": "Omega (Hindi · Male)", "language": "hi", "gender": "male"},
3095
+ {"id": "hm_psi", "name": "Psi (Hindi · Male)", "language": "hi", "gender": "male"},
3096
+ # Italian
3097
+ {"id": "if_sara", "name": "Sara (Italian · Female)", "language": "it", "gender": "female"},
3098
+ {"id": "im_nicola", "name": "Nicola (Italian · Male)", "language": "it", "gender": "male"},
3099
+ # Japanese
3100
+ {"id": "jf_alpha", "name": "Alpha (Japanese · Female)", "language": "ja", "gender": "female"},
3101
+ {"id": "jf_gongitsune","name": "Gongitsune (Japanese · Female)", "language": "ja", "gender": "female"},
3102
+ {"id": "jf_nezumi", "name": "Nezumi (Japanese · Female)", "language": "ja", "gender": "female"},
3103
+ {"id": "jf_tebukuro","name": "Tebukuro (Japanese · Female)", "language": "ja", "gender": "female"},
3104
+ {"id": "jm_kumo", "name": "Kumo (Japanese · Male)", "language": "ja", "gender": "male"},
3105
+ # Brazilian Portuguese
3106
+ {"id": "pf_dora", "name": "Dora (Brazilian Portuguese · Female)","language": "pt-BR", "gender": "female"},
3107
+ {"id": "pm_alex", "name": "Alex (Brazilian Portuguese · Male)", "language": "pt-BR", "gender": "male"},
3108
+ {"id": "pm_santa", "name": "Santa (Brazilian Portuguese · Male)", "language": "pt-BR", "gender": "male"},
3109
+ # Mandarin Chinese
3110
+ {"id": "zf_xiaobei", "name": "Xiaobei (Mandarin · Female)", "language": "zh", "gender": "female"},
3111
+ {"id": "zf_xiaoni", "name": "Xiaoni (Mandarin · Female)", "language": "zh", "gender": "female"},
3112
+ {"id": "zf_xiaoxiao","name": "Xiaoxiao (Mandarin · Female)", "language": "zh", "gender": "female"},
3113
+ {"id": "zf_xiaoyi", "name": "Xiaoyi (Mandarin · Female)", "language": "zh", "gender": "female"},
3114
+ {"id": "zm_yunjian", "name": "Yunjian (Mandarin · Male)", "language": "zh", "gender": "male"},
3115
+ {"id": "zm_yunxi", "name": "Yunxi (Mandarin · Male)", "language": "zh", "gender": "male"},
3116
+ {"id": "zm_yunyang", "name": "Yunyang (Mandarin · Male)", "language": "zh", "gender": "male"},
3117
+ {"id": "zm_yunze", "name": "Yunze (Mandarin · Male)", "language": "zh", "gender": "male"},
3118
+ ]
3119
+
3120
+
3121
+ def _kokoro_static_payload() -> TtsVoiceList:
3122
+ voices = [TtsVoice(**v) for v in KOKORO_STATIC_VOICES]
3123
+ return TtsVoiceList(provider="kokoro", source="static", count=len(voices), voices=voices)
3124
+
3125
+
3126
+ def _normalize_elevenlabs_voices(raw: dict) -> List[TtsVoice]:
3127
+ """Translate ElevenLabs' /v1/voices response into our TtsVoice shape."""
3128
+ out: List[TtsVoice] = []
3129
+ for v in (raw.get("voices") or []):
3130
+ labels = v.get("labels") or {}
3131
+ # Build a friendly display name. ElevenLabs' "name" is usually the
3132
+ # human label (e.g. "Rachel"); we suffix the category so it's obvious
3133
+ # whether it's premade/cloned/etc.
3134
+ category = v.get("category") or labels.get("category") or ""
3135
+ desc_parts = []
3136
+ if labels.get("accent"): desc_parts.append(str(labels["accent"]))
3137
+ if labels.get("gender"): desc_parts.append(str(labels["gender"]).lower())
3138
+ if labels.get("age"): desc_parts.append(str(labels["age"]).lower())
3139
+ if labels.get("use_case"): desc_parts.append("use: " + str(labels["use_case"]))
3140
+ if labels.get("descriptive"): desc_parts.append(str(labels["descriptive"]))
3141
+ description = ", ".join([p for p in desc_parts if p])
3142
+ preview = v.get("preview_url") or ""
3143
+ out.append(TtsVoice(
3144
+ id=str(v.get("voice_id") or ""),
3145
+ name=str(v.get("name") or v.get("voice_id") or "voice"),
3146
+ language=labels.get("language") or labels.get("accent") or None,
3147
+ gender=labels.get("gender") or None,
3148
+ description=description or None,
3149
+ preview_url=preview or None,
3150
+ category=str(category) if category else None,
3151
+ labels=labels or None,
3152
+ ))
3153
+ # Sort: by category then by name for a stable, predictable dropdown order.
3154
+ out.sort(key=lambda x: ((x.category or ""), (x.name or "").lower()))
3155
+ return out
3156
+
3157
+
3158
+ async def _fetch_elevenlabs_voices() -> TtsVoiceList:
3159
+ """Call the ElevenLabs /v1/voices endpoint and normalize the result.
3160
+
3161
+ On any failure (missing key, network error, non-2xx) we surface a clear
3162
+ warning alongside an empty list so the dashboard can show the error in
3163
+ the picker rather than silently showing zero voices."""
3164
+ if not ELEVENLABS_API_KEY:
3165
+ return TtsVoiceList(
3166
+ provider="elevenlabs",
3167
+ source="api",
3168
+ count=0,
3169
+ voices=[],
3170
+ warning=(
3171
+ "ELEVENLABS_API_KEY is not set on the scheduler — set it in the "
3172
+ "scheduler's environment to fetch the live voice list."
3173
+ ),
3174
+ )
3175
+ url = f"{ELEVENLABS_BASE_URL}/v1/voices"
3176
+ headers = {"xi-api-key": ELEVENLABS_API_KEY, "Accept": "application/json"}
3177
+ try:
3178
+ async with httpx.AsyncClient(timeout=10.0) as client:
3179
+ r = await client.get(url, headers=headers)
3180
+ except Exception as e:
3181
+ return TtsVoiceList(
3182
+ provider="elevenlabs",
3183
+ source="api",
3184
+ count=0,
3185
+ voices=[],
3186
+ warning=f"Could not reach ElevenLabs: {e}",
3187
+ )
3188
+ if r.status_code != 200:
3189
+ # Try to extract a useful message from the response body.
3190
+ msg = (r.text or "").strip()[:200]
3191
+ return TtsVoiceList(
3192
+ provider="elevenlabs",
3193
+ source="api",
3194
+ count=0,
3195
+ voices=[],
3196
+ warning=f"ElevenLabs returned {r.status_code}: {msg or 'no body'}",
3197
+ )
3198
+ try:
3199
+ payload = r.json()
3200
+ except Exception as e:
3201
+ return TtsVoiceList(
3202
+ provider="elevenlabs",
3203
+ source="api",
3204
+ count=0,
3205
+ voices=[],
3206
+ warning=f"ElevenLabs returned non-JSON body: {e}",
3207
+ )
3208
+ voices = _normalize_elevenlabs_voices(payload)
3209
+ return TtsVoiceList(provider="elevenlabs", source="api", count=len(voices), voices=voices)
3210
+
3211
+
3212
+ async def _fetch_kokoro_voices() -> TtsVoiceList:
3213
+ """Return the Kokoro voice catalog.
3214
+
3215
+ If KOKORO_BASE_URL is set we proxy to that server's /v1/audio/voices
3216
+ (or whatever path it exposes) and adapt the result. Otherwise we serve
3217
+ the curated static list so the dropdown is always populated."""
3218
+ if not KOKORO_BASE_URL:
3219
+ return _kokoro_static_payload()
3220
+ url = f"{KOKORO_BASE_URL}/v1/audio/voices"
3221
+ try:
3222
+ async with httpx.AsyncClient(timeout=6.0) as client:
3223
+ r = await client.get(url)
3224
+ if r.status_code != 200:
3225
+ warning = f"Kokoro server returned {r.status_code}; falling back to static list"
3226
+ fallback = _kokoro_static_payload()
3227
+ return TtsVoiceList(
3228
+ provider=fallback.provider,
3229
+ source="static",
3230
+ count=fallback.count,
3231
+ voices=fallback.voices,
3232
+ warning=warning,
3233
+ )
3234
+ data = r.json()
3235
+ # Kokoro's API shape varies by server; be permissive.
3236
+ raw_list = data.get("voices") if isinstance(data, dict) else data
3237
+ if not isinstance(raw_list, list):
3238
+ raw_list = []
3239
+ voices: List[TtsVoice] = []
3240
+ for v in raw_list:
3241
+ if isinstance(v, str):
3242
+ voices.append(TtsVoice(id=v, name=v))
3243
+ elif isinstance(v, dict):
3244
+ voices.append(TtsVoice(
3245
+ id=str(v.get("id") or v.get("voice_id") or v.get("name") or ""),
3246
+ name=str(v.get("name") or v.get("id") or v.get("voice_id") or ""),
3247
+ language=v.get("language") or v.get("lang"),
3248
+ gender=v.get("gender"),
3249
+ ))
3250
+ if not voices:
3251
+ fallback = _kokoro_static_payload()
3252
+ return TtsVoiceList(
3253
+ provider=fallback.provider,
3254
+ source="static",
3255
+ count=fallback.count,
3256
+ voices=fallback.voices,
3257
+ warning="Kokoro server returned no voices; falling back to static list",
3258
+ )
3259
+ return TtsVoiceList(provider="kokoro", source="proxy", count=len(voices), voices=voices)
3260
+ except Exception as e:
3261
+ fallback = _kokoro_static_payload()
3262
+ return TtsVoiceList(
3263
+ provider=fallback.provider,
3264
+ source="static",
3265
+ count=fallback.count,
3266
+ voices=fallback.voices,
3267
+ warning=f"Could not reach Kokoro server ({KOKORO_BASE_URL}): {e}",
3268
+ )
3269
+
3270
+
3271
+ @app.get("/api/voices/elevenlabs", response_model=TtsVoiceList)
3272
+ async def list_elevenlabs_voices():
3273
+ """Return the catalog of ElevenLabs voices for the dashboard picker.
3274
+
3275
+ Proxies the call to ElevenLabs' /v1/voices endpoint using the scheduler's
3276
+ ELEVENLABS_API_KEY env var. Returns a TtsVoiceList with a `warning` field
3277
+ if the key is missing or the upstream call fails — the dashboard surfaces
3278
+ the warning inside the picker so the operator knows what to fix."""
3279
+ return await _fetch_elevenlabs_voices()
3280
+
3281
+
3282
+ @app.get("/api/voices/kokoro", response_model=TtsVoiceList)
3283
+ async def list_kokoro_voices():
3284
+ """Return the catalog of Kokoro voices for the dashboard picker.
3285
+
3286
+ Served from a curated static list of the canonical Kokoro-82M voices.
3287
+ If KOKORO_BASE_URL is set we proxy to that local server's voices endpoint
3288
+ and surface the live set instead; the static list is the fallback."""
3289
+ return await _fetch_kokoro_voices()
3290
+
3291
+
2597
3292
  @app.get("/api/character-presets", response_model=List[CharacterPreset])
2598
3293
  async def list_character_presets():
2599
3294
  async with aiosqlite.connect(DB_PATH) as db:
@@ -2762,13 +3457,70 @@ async def _robot_telemetry(db, robot_id: str) -> Optional[dict]:
2762
3457
  Returns trigger_count, sessions_total, avg_latency_ms, drop_rate, drops and
2763
3458
  an end_reason breakdown; None if the robot does not exist."""
2764
3459
  async with db.execute(
2765
- "SELECT trigger_count, name FROM robots WHERE id = ?", (robot_id,)
3460
+ "SELECT trigger_count, name, status, current_session_id, connected_server_id FROM robots WHERE id = ?", (robot_id,)
2766
3461
  ) as c:
2767
3462
  robot = await c.fetchone()
2768
3463
  if not robot:
2769
3464
  return None
2770
3465
  trigger_count = robot["trigger_count"] or 0
2771
3466
 
3467
+ async with db.execute(
3468
+ "SELECT status, production_mode, last_heartbeat FROM devices WHERE id = ?", (robot_id,)
3469
+ ) as c:
3470
+ device = await c.fetchone()
3471
+ session = None
3472
+ if robot["current_session_id"]:
3473
+ async with db.execute(
3474
+ "SELECT started_at, last_activity_at, ended_at FROM sessions WHERE id = ?",
3475
+ (robot["current_session_id"],),
3476
+ ) as c:
3477
+ session = await c.fetchone()
3478
+
3479
+ now = datetime.utcnow()
3480
+ session_age = None
3481
+ activity_age = None
3482
+ if session:
3483
+ try:
3484
+ session_age = max(0, int((now - datetime.fromisoformat(session["started_at"])).total_seconds()))
3485
+ activity_age = max(0, int((now - datetime.fromisoformat(session["last_activity_at"] or session["started_at"])).total_seconds()))
3486
+ except (TypeError, ValueError):
3487
+ pass
3488
+ heartbeat_age = None
3489
+ if device and device["last_heartbeat"]:
3490
+ try:
3491
+ heartbeat_age = max(0, int((now - datetime.fromisoformat(device["last_heartbeat"])).total_seconds()))
3492
+ except (TypeError, ValueError):
3493
+ pass
3494
+
3495
+ server_status = None
3496
+ if robot["connected_server_id"]:
3497
+ async with db.execute(
3498
+ "SELECT status FROM livekit_servers WHERE id = ?", (robot["connected_server_id"],)
3499
+ ) as c:
3500
+ server_row = await c.fetchone()
3501
+ server_status = server_row["status"] if server_row else None
3502
+
3503
+ readiness_code = "ready"
3504
+ readiness_message = "ready for motion trigger"
3505
+ if not device:
3506
+ readiness_code, readiness_message = "device_missing", "no enrolled device heartbeat"
3507
+ elif not await get_production_mode():
3508
+ readiness_code, readiness_message = "production_off", "global production mode is off"
3509
+ elif not device["production_mode"]:
3510
+ readiness_code, readiness_message = "robot_production_off", "robot production mode is off"
3511
+ elif heartbeat_age is None or heartbeat_age > 30:
3512
+ readiness_code, readiness_message = "heartbeat_stale", f"robot heartbeat is {heartbeat_age or 'unknown'}s old"
3513
+ elif robot["connected_server_id"] and server_status != "online":
3514
+ readiness_code, readiness_message = "server_unavailable", f"LiveKit server {robot['connected_server_id']} is {server_status or 'missing'}"
3515
+ elif not robot["current_session_id"]:
3516
+ readiness_code, readiness_message = "ready", "ready for motion trigger"
3517
+ elif robot["status"] == "connecting" and session_age is not None and session_age > 30:
3518
+ readiness_code, readiness_message = "stuck_session", f"joining LiveKit for {session_age}s; recover robot"
3519
+ elif activity_age is not None and activity_age > 180:
3520
+ readiness_code, readiness_message = "stale_session", f"no session activity for {activity_age}s; recover robot"
3521
+ else:
3522
+ readiness_code, readiness_message = "in_session", "conversation session is active"
3523
+
2772
3524
  # Pull the device's freshest known LAN address (heartbeat > enrollment) so
2773
3525
  # the dashboard can reach the robot directly for e.g. the vision overlay
2774
3526
  # feed, which the scheduler doesn't proxy.
@@ -2819,6 +3571,17 @@ async def _robot_telemetry(db, robot_id: str) -> Optional[dict]:
2819
3571
  "drop_rate": drop_rate,
2820
3572
  "drops": drops,
2821
3573
  "end_reasons": end_reasons,
3574
+ "status": robot["status"],
3575
+ "current_session_id": robot["current_session_id"],
3576
+ "connected_server_id": robot["connected_server_id"],
3577
+ "server_status": server_status,
3578
+ "session_age_seconds": session_age,
3579
+ "session_activity_age_seconds": activity_age,
3580
+ "heartbeat_age_seconds": heartbeat_age,
3581
+ "device_status": device["status"] if device else None,
3582
+ "production_mode": bool(device["production_mode"]) if device else False,
3583
+ "readiness_code": readiness_code,
3584
+ "readiness_message": readiness_message,
2822
3585
  }
2823
3586
 
2824
3587
  @app.get("/api/robots/{robot_id}/telemetry")
@@ -2937,6 +3700,7 @@ async def dashboard():
2937
3700
  const newDevice = ref({ name: '', tailscale_ip: '', lan_ip: '', motor_server_url: '', character_id: '', livekit_url: '', video_device: 'auto', audio_device: 'default', notes: '' });
2938
3701
  const rotatedToken = ref(null);
2939
3702
  const preview = ref({ robot: null, active: false, url: null, token: null, room: null, keepalive: null });
3703
+ const simulation = ref({ robotId: null, busy: false });
2940
3704
  const deviceInventory = ref({});
2941
3705
  const showLiveKitConfig = ref(false);
2942
3706
  const livekitForm = ref({ url: '', api_key: '', api_secret: '' });
@@ -2948,7 +3712,7 @@ async def dashboard():
2948
3712
  const editingCharacter = ref(null);
2949
3713
  const voiceStackForm = ref({
2950
3714
  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',
3715
+ llm_provider: 'ollama', llm_model: 'gemma3:27b', tts_provider: 'elevenlabs',
2952
3716
  tts_voice: '21m00Tcm4TlvDq8ikWAM', tts_language: 'en',
2953
3717
  allow_interruptions: true, min_endpointing_delay: 0.7, max_turns: 20, wake_word_enabled: false,
2954
3718
  });
@@ -3060,12 +3824,14 @@ async def dashboard():
3060
3824
  const toggleProductionMode = async () => {
3061
3825
  try {
3062
3826
  const next = !settings.value.production_mode;
3063
- await fetch('/api/settings', {
3827
+ const response = await fetch('/api/settings', {
3064
3828
  method: 'PUT',
3065
3829
  headers: {'Content-Type': 'application/json'},
3066
3830
  body: JSON.stringify({ production_mode: next }),
3067
3831
  });
3832
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
3068
3833
  settings.value.production_mode = next;
3834
+ await fetchData();
3069
3835
  } catch (e) {
3070
3836
  alert('Failed to toggle production mode: ' + e.message);
3071
3837
  }
@@ -3147,6 +3913,35 @@ async def dashboard():
3147
3913
  preview.value = { robot: null, active: false, url: null, token: null, room: null, keepalive: null };
3148
3914
  };
3149
3915
 
3916
+ const simulateTrigger = async (robot) => {
3917
+ if (!settings.value.production_mode || simulation.value.busy) return;
3918
+ simulation.value = { robotId: robot.id, busy: true };
3919
+ try {
3920
+ const response = await fetch(`/api/robots/${robot.id}/simulate-trigger`, { method: 'POST' });
3921
+ const result = await response.json();
3922
+ if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
3923
+ if (!result.queued) {
3924
+ alert('Trigger not queued: ' + (result.reason || 'unknown'));
3925
+ }
3926
+ await fetchData();
3927
+ } catch (e) {
3928
+ alert('Failed to simulate motion: ' + e.message);
3929
+ } finally {
3930
+ simulation.value = { robotId: null, busy: false };
3931
+ }
3932
+ };
3933
+
3934
+ const stopConversation = async (robot) => {
3935
+ try {
3936
+ const response = await fetch(`/api/robots/${robot.id}/simulate-stop`, { method: 'POST' });
3937
+ const result = await response.json();
3938
+ if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
3939
+ await fetchData();
3940
+ } catch (e) {
3941
+ alert('Failed to stop conversation: ' + e.message);
3942
+ }
3943
+ };
3944
+
3150
3945
  const connectLiveKitPreview = async (url, token) => {
3151
3946
  const { Room } = LiveKitClient;
3152
3947
  const room = new Room({ adaptiveStream: true });
@@ -3297,7 +4092,7 @@ async def dashboard():
3297
4092
  editingVoiceStack.value = null;
3298
4093
  voiceStackForm.value = {
3299
4094
  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',
4095
+ llm_provider: 'ollama', llm_model: 'gemma3:27b', tts_provider: 'elevenlabs',
3301
4096
  tts_voice: '21m00Tcm4TlvDq8ikWAM', tts_language: 'en',
3302
4097
  allow_interruptions: true, min_endpointing_delay: 0.7, max_turns: 20, wake_word_enabled: false,
3303
4098
  };
@@ -3383,7 +4178,7 @@ async def dashboard():
3383
4178
 
3384
4179
  return {
3385
4180
  robots, servers, sessions, stats, connected, serverMetrics,
3386
- devices, settings, livekit, showAddDevice, showDevices, newDevice, rotatedToken,
4181
+ devices, settings, livekit, showAddDevice, showDevices, newDevice, rotatedToken, simulation,
3387
4182
  showLiveKitConfig, livekitForm, preview, deviceInventory,
3388
4183
  voiceStacks, characterPresets, showVoiceStackForm, showCharacterForm,
3389
4184
  editingVoiceStack, editingCharacter, voiceStackForm, characterForm,
@@ -3391,7 +4186,7 @@ async def dashboard():
3391
4186
  toggleProductionMode, rotateEnrollmentToken,
3392
4187
  submitNewDevice, deleteDevice, rotateDeviceToken, dismissRotatedToken,
3393
4188
  joinConversation, saveLiveKitConfig, openLiveKitConfig,
3394
- startPreview, stopPreview, updateDeviceMedia,
4189
+ startPreview, stopPreview, simulateTrigger, stopConversation, updateDeviceMedia,
3395
4190
  openVoiceStackForm, saveVoiceStack, deleteVoiceStack,
3396
4191
  openCharacterForm, saveCharacter, deleteCharacter,
3397
4192
  assignRobotCharacter, stackName, characterName,
@@ -3689,6 +4484,20 @@ async def dashboard():
3689
4484
  class="w-full px-2 py-1 rounded text-xs">
3690
4485
  {{ preview.active && preview.robot?.id === robot.id ? 'Preview Active' : 'Start Preview' }}
3691
4486
  </button>
4487
+ <div class="grid grid-cols-2 gap-2">
4488
+ <button @click="simulateTrigger(robot)"
4489
+ :disabled="!settings.production_mode || simulation.busy || robot.status === 'connecting' || robot.status === 'running'"
4490
+ :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'"
4491
+ class="px-2 py-1 rounded text-xs">
4492
+ {{ simulation.robotId === robot.id ? 'Triggering…' : 'Simulate Motion' }}
4493
+ </button>
4494
+ <button @click="stopConversation(robot)"
4495
+ :disabled="robot.status !== 'connecting' && robot.status !== 'running'"
4496
+ :class="robot.status === 'connecting' || robot.status === 'running' ? 'bg-red-600 hover:bg-red-500' : 'bg-gray-700 cursor-not-allowed'"
4497
+ class="px-2 py-1 rounded text-xs">
4498
+ Stop Conversation
4499
+ </button>
4500
+ </div>
3692
4501
  <button v-if="preview.active && preview.robot?.id === robot.id"
3693
4502
  @click="stopPreview"
3694
4503
  class="w-full px-2 py-1 bg-red-600 hover:bg-red-700 rounded text-xs">