infinicode 2.8.35 → 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.
- package/dist/kernel/federation/dashboard-html.d.ts +1 -1
- package/dist/kernel/federation/dashboard-html.js +3542 -333
- package/dist/kernel/federation/transport-http.d.ts +2 -0
- package/dist/kernel/federation/transport-http.js +56 -0
- package/package.json +105 -105
- package/packages/robopark/scheduler/main.py +838 -29
- package/packages/robopark/scheduler/preview_agent.py +620 -57
- package/packages/robopark/scheduler/robot_supervisor.py +320 -0
- package/packages/robopark/scheduler/scripts/install-robot-supervisor-linux.sh +33 -0
- package/packages/robopark/scheduler/scripts/install-robot-supervisor-windows.ps1 +49 -0
- package/packages/robopark/scheduler/scripts/robopark-supervisor.service +20 -0
- package/packages/robopark/scheduler/scripts/start-scheduler-local.ps1 +50 -0
- package/packages/robopark/scheduler/supervisor.example.json +26 -0
- package/packages/robopark/scheduler/vision_motion_trigger.py +101 -0
|
@@ -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", "
|
|
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", "
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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 '
|
|
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 = ?", (
|
|
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={
|
|
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
|
-
|
|
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
|
-
|
|
1642
|
-
|
|
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 = ?,
|
|
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
|
-
#
|
|
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":
|
|
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
|
-
"
|
|
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 = "
|
|
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 = "
|
|
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: '
|
|
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: '
|
|
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">
|