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