infinicode 2.8.84 → 2.8.86
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/robopark/serve.js
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import chalk from 'chalk';
|
|
11
11
|
import { spawn, execSync } from 'node:child_process';
|
|
12
|
-
import { existsSync } from 'node:fs';
|
|
12
|
+
import { cpSync, existsSync, mkdirSync, statSync } from 'node:fs';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
13
14
|
import { dirname, join } from 'node:path';
|
|
14
15
|
import { fileURLToPath } from 'node:url';
|
|
15
16
|
function pkgDir() {
|
|
@@ -68,6 +69,33 @@ function findPython() {
|
|
|
68
69
|
// ENOENT/"not found" error from the OS rather than a silent fallback.
|
|
69
70
|
return process.platform === 'win32' ? 'python' : 'python3';
|
|
70
71
|
}
|
|
72
|
+
function resolveSchedulerDataDir(explicit) {
|
|
73
|
+
if (explicit)
|
|
74
|
+
return explicit;
|
|
75
|
+
if (process.env.SCHEDULER_DATA_DIR)
|
|
76
|
+
return process.env.SCHEDULER_DATA_DIR;
|
|
77
|
+
const stable = join(homedir(), '.robopark', 'scheduler');
|
|
78
|
+
const stableDb = join(stable, 'scheduler.db');
|
|
79
|
+
if (existsSync(stableDb))
|
|
80
|
+
return stable;
|
|
81
|
+
// Older releases stored data beside whichever npm installation launched
|
|
82
|
+
// the scheduler. Local, global and npx installs therefore created separate
|
|
83
|
+
// databases. Migrate the largest existing DB once into stable user storage.
|
|
84
|
+
const legacyCandidates = [join(pkgDir(), '..', 'data')];
|
|
85
|
+
if (process.platform === 'win32' && process.env.APPDATA) {
|
|
86
|
+
legacyCandidates.push(join(process.env.APPDATA, 'npm', 'node_modules', 'data'));
|
|
87
|
+
}
|
|
88
|
+
const legacy = legacyCandidates
|
|
89
|
+
.filter((candidate, index, all) => all.indexOf(candidate) === index)
|
|
90
|
+
.filter(candidate => existsSync(join(candidate, 'scheduler.db')))
|
|
91
|
+
.sort((a, b) => statSync(join(b, 'scheduler.db')).size - statSync(join(a, 'scheduler.db')).size)[0];
|
|
92
|
+
mkdirSync(stable, { recursive: true });
|
|
93
|
+
if (legacy) {
|
|
94
|
+
cpSync(legacy, stable, { recursive: true, force: false, errorOnExist: false });
|
|
95
|
+
console.log(chalk.yellow(` migrated scheduler data: ${legacy} -> ${stable}`));
|
|
96
|
+
}
|
|
97
|
+
return stable;
|
|
98
|
+
}
|
|
71
99
|
/** Resolve the actual infinicode CLI entry point so `robopark setup --start`
|
|
72
100
|
* works even when global bins are not on PATH.
|
|
73
101
|
*
|
|
@@ -110,6 +138,7 @@ export async function roboparkServe(opts) {
|
|
|
110
138
|
const python = findPython();
|
|
111
139
|
const schedulerDir = dirname(schedulerPath);
|
|
112
140
|
const requirements = join(schedulerDir, 'requirements.txt');
|
|
141
|
+
const dataDir = resolveSchedulerDataDir(opts.dataDir);
|
|
113
142
|
// Ensure scheduler Python dependencies are installed before starting.
|
|
114
143
|
if (existsSync(requirements)) {
|
|
115
144
|
console.log(chalk.dim(' ensuring scheduler Python deps…'));
|
|
@@ -125,7 +154,7 @@ export async function roboparkServe(opts) {
|
|
|
125
154
|
...process.env,
|
|
126
155
|
SCHEDULER_HOST: host,
|
|
127
156
|
SCHEDULER_PORT: String(port),
|
|
128
|
-
SCHEDULER_DATA_DIR:
|
|
157
|
+
SCHEDULER_DATA_DIR: dataDir,
|
|
129
158
|
};
|
|
130
159
|
if (opts.gateway)
|
|
131
160
|
env.INFINIBOT_GATEWAY_URL = opts.gateway;
|
|
@@ -138,6 +167,7 @@ export async function roboparkServe(opts) {
|
|
|
138
167
|
console.log(` scheduler: ${chalk.cyan(schedulerPath)}`);
|
|
139
168
|
console.log(` python: ${chalk.cyan(python)}`);
|
|
140
169
|
console.log(` listen: ${chalk.cyan(`${host}:${port}`)}`);
|
|
170
|
+
console.log(` data: ${chalk.cyan(dataDir)}`);
|
|
141
171
|
if (opts.gateway)
|
|
142
172
|
console.log(` gateway: ${chalk.cyan(opts.gateway)}`);
|
|
143
173
|
console.log();
|
package/package.json
CHANGED
|
@@ -604,6 +604,9 @@ async def init_db():
|
|
|
604
604
|
("sessions", "voice_stack_id", "TEXT"),
|
|
605
605
|
("sessions", "initiated_by", "TEXT"),
|
|
606
606
|
("sessions", "event_token_hash", "TEXT"),
|
|
607
|
+
# Speaker tests are executed only by preview_agent, which can
|
|
608
|
+
# release and restore the active media publisher around ALSA I/O.
|
|
609
|
+
("shell_requests", "claimed_at", "TEXT"),
|
|
607
610
|
):
|
|
608
611
|
try:
|
|
609
612
|
await db.execute(f"ALTER TABLE {_table} ADD COLUMN {_column} {_coldef}")
|
|
@@ -839,10 +842,70 @@ async def broadcast(event_type: str, data: dict):
|
|
|
839
842
|
ws_clients.remove(client)
|
|
840
843
|
|
|
841
844
|
# =============================================================================
|
|
842
|
-
# ROBOT ENDPOINTS
|
|
843
|
-
# =============================================================================
|
|
844
|
-
|
|
845
|
-
|
|
845
|
+
# ROBOT ENDPOINTS
|
|
846
|
+
# =============================================================================
|
|
847
|
+
|
|
848
|
+
CONNECTING_SESSION_LEASE_SECONDS = max(
|
|
849
|
+
15, int(os.getenv("ROBOPARK_CONNECTING_SESSION_LEASE_SECONDS", "45"))
|
|
850
|
+
)
|
|
851
|
+
ACTIVE_SESSION_LEASE_SECONDS = max(
|
|
852
|
+
CONNECTING_SESSION_LEASE_SECONDS,
|
|
853
|
+
int(os.getenv("ROBOPARK_ACTIVE_SESSION_LEASE_SECONDS", "180")),
|
|
854
|
+
)
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
async def _reap_robot_session_if_stale(db, robot_id: str, current_session_id: Optional[str],
|
|
858
|
+
robot_status: Optional[str] = None) -> Optional[str]:
|
|
859
|
+
"""Release an invalid or expired session pointer without operator action."""
|
|
860
|
+
if not current_session_id:
|
|
861
|
+
return None
|
|
862
|
+
|
|
863
|
+
async with db.execute(
|
|
864
|
+
"SELECT id, started_at, last_activity_at, ended_at FROM sessions WHERE id = ?",
|
|
865
|
+
(current_session_id,),
|
|
866
|
+
) as cursor:
|
|
867
|
+
session = await cursor.fetchone()
|
|
868
|
+
|
|
869
|
+
reason = None
|
|
870
|
+
if not session:
|
|
871
|
+
reason = "missing_session"
|
|
872
|
+
elif session["ended_at"]:
|
|
873
|
+
reason = "ended_session"
|
|
874
|
+
else:
|
|
875
|
+
timestamp = session["last_activity_at"] or session["started_at"]
|
|
876
|
+
try:
|
|
877
|
+
activity_age = (datetime.utcnow() - datetime.fromisoformat(timestamp)).total_seconds()
|
|
878
|
+
except (TypeError, ValueError):
|
|
879
|
+
activity_age = ACTIVE_SESSION_LEASE_SECONDS + 1
|
|
880
|
+
lease = (
|
|
881
|
+
CONNECTING_SESSION_LEASE_SECONDS
|
|
882
|
+
if robot_status == "connecting"
|
|
883
|
+
else ACTIVE_SESSION_LEASE_SECONDS
|
|
884
|
+
)
|
|
885
|
+
if activity_age >= lease:
|
|
886
|
+
reason = "stale_connecting" if robot_status == "connecting" else "stale_activity"
|
|
887
|
+
|
|
888
|
+
if not reason:
|
|
889
|
+
return None
|
|
890
|
+
|
|
891
|
+
now = datetime.utcnow().isoformat()
|
|
892
|
+
if session and not session["ended_at"]:
|
|
893
|
+
await db.execute(
|
|
894
|
+
"UPDATE sessions SET ended_at = ?, end_reason = ? WHERE id = ? AND ended_at IS NULL",
|
|
895
|
+
(now, reason, current_session_id),
|
|
896
|
+
)
|
|
897
|
+
await db.execute(
|
|
898
|
+
"UPDATE robots SET status = 'idle', current_session_id = NULL, connected_server_id = NULL "
|
|
899
|
+
"WHERE id = ? AND current_session_id = ?",
|
|
900
|
+
(robot_id, current_session_id),
|
|
901
|
+
)
|
|
902
|
+
logger.warning(
|
|
903
|
+
"Automatically released %s session %s for robot %s",
|
|
904
|
+
reason, current_session_id, robot_id,
|
|
905
|
+
)
|
|
906
|
+
return reason
|
|
907
|
+
|
|
908
|
+
@app.get("/api/robots", response_model=List[Robot])
|
|
846
909
|
async def list_robots():
|
|
847
910
|
"""List all robots"""
|
|
848
911
|
async with aiosqlite.connect(DB_PATH) as db:
|
|
@@ -909,11 +972,23 @@ async def request_session(robot_id: str,
|
|
|
909
972
|
(robot_id, device_row["name"]),
|
|
910
973
|
)
|
|
911
974
|
await db.commit()
|
|
912
|
-
robot = {
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
975
|
+
robot = {
|
|
976
|
+
"id": robot_id,
|
|
977
|
+
"name": device_row["name"],
|
|
978
|
+
"status": "idle",
|
|
979
|
+
"current_session_id": None,
|
|
980
|
+
}
|
|
981
|
+
else:
|
|
982
|
+
raise HTTPException(404, "Robot not found")
|
|
983
|
+
stale_reason = await _reap_robot_session_if_stale(
|
|
984
|
+
db, robot_id, robot["current_session_id"], robot["status"]
|
|
985
|
+
)
|
|
986
|
+
if stale_reason:
|
|
987
|
+
await db.commit()
|
|
988
|
+
async with db.execute("SELECT * FROM robots WHERE id = ?", (robot_id,)) as cursor:
|
|
989
|
+
robot = await cursor.fetchone()
|
|
990
|
+
if robot["current_session_id"] or robot["status"] != "idle":
|
|
991
|
+
raise HTTPException(409, f"Robot is {robot['status']}, not idle")
|
|
917
992
|
|
|
918
993
|
# Find least-loaded online server
|
|
919
994
|
async with db.execute("""
|
|
@@ -1046,7 +1121,7 @@ async def simulate_trigger(robot_id: str):
|
|
|
1046
1121
|
async with aiosqlite.connect(DB_PATH) as db:
|
|
1047
1122
|
db.row_factory = aiosqlite.Row
|
|
1048
1123
|
async with db.execute(
|
|
1049
|
-
"SELECT id, current_session_id FROM robots WHERE id = ?", (robot_id,)
|
|
1124
|
+
"SELECT id, current_session_id, status FROM robots WHERE id = ?", (robot_id,)
|
|
1050
1125
|
) as c:
|
|
1051
1126
|
robot = await c.fetchone()
|
|
1052
1127
|
if not robot:
|
|
@@ -1059,11 +1134,14 @@ async def simulate_trigger(robot_id: str):
|
|
|
1059
1134
|
(device["id"], device["name"], device["character_id"]),
|
|
1060
1135
|
)
|
|
1061
1136
|
await db.commit()
|
|
1062
|
-
robot = {"id": device["id"], "current_session_id": None}
|
|
1137
|
+
robot = {"id": device["id"], "current_session_id": None, "status": "idle"}
|
|
1063
1138
|
if not await _effective_device_production_mode(db, robot_id):
|
|
1064
1139
|
return {"queued": False, "reason": "device_production_mode_off"}
|
|
1065
|
-
|
|
1066
|
-
|
|
1140
|
+
stale_reason = await _reap_robot_session_if_stale(
|
|
1141
|
+
db, robot_id, robot["current_session_id"], robot["status"]
|
|
1142
|
+
)
|
|
1143
|
+
if robot["current_session_id"] and not stale_reason:
|
|
1144
|
+
return {"queued": False, "reason": "session_active"}
|
|
1067
1145
|
await db.execute(
|
|
1068
1146
|
"INSERT INTO trigger_commands (robot_id, source, requested_at) VALUES (?, ?, ?) "
|
|
1069
1147
|
"ON CONFLICT(robot_id) DO UPDATE SET source = excluded.source, requested_at = excluded.requested_at",
|
|
@@ -1072,7 +1150,12 @@ async def simulate_trigger(robot_id: str):
|
|
|
1072
1150
|
await db.commit()
|
|
1073
1151
|
await broadcast("robot_triggered", {"robot_id": robot_id, "source": "dashboard"})
|
|
1074
1152
|
await _log_history("robot", "robot", robot_id, "simulate_trigger", "operator")
|
|
1075
|
-
return {
|
|
1153
|
+
return {
|
|
1154
|
+
"queued": True,
|
|
1155
|
+
"source": "dashboard",
|
|
1156
|
+
"recovered_stale_session": bool(stale_reason),
|
|
1157
|
+
"recovery_reason": stale_reason,
|
|
1158
|
+
}
|
|
1076
1159
|
|
|
1077
1160
|
@app.post("/api/robots/{robot_id}/simulate-stop")
|
|
1078
1161
|
async def simulate_stop(robot_id: str):
|
|
@@ -1487,14 +1570,31 @@ async def session_joined(session_id: str,
|
|
|
1487
1570
|
session = await c.fetchone()
|
|
1488
1571
|
if not session:
|
|
1489
1572
|
raise HTTPException(404, "Session not found")
|
|
1490
|
-
if session["joined_at"]:
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1573
|
+
if session["joined_at"]:
|
|
1574
|
+
if not session["ended_at"]:
|
|
1575
|
+
await db.execute(
|
|
1576
|
+
"UPDATE sessions SET last_activity_at = ? WHERE id = ?",
|
|
1577
|
+
(now, session_id),
|
|
1578
|
+
)
|
|
1579
|
+
await db.execute(
|
|
1580
|
+
"UPDATE robots SET status = 'running' WHERE id = ? AND current_session_id = ?",
|
|
1581
|
+
(session["robot_id"], session_id),
|
|
1582
|
+
)
|
|
1583
|
+
await db.commit()
|
|
1584
|
+
return {
|
|
1585
|
+
"status": "already_joined",
|
|
1586
|
+
"joined_at": session["joined_at"],
|
|
1587
|
+
"latency_ms": _session_latency_ms(session["started_at"], session["joined_at"]),
|
|
1588
|
+
}
|
|
1589
|
+
await db.execute(
|
|
1590
|
+
"UPDATE sessions SET joined_at = ?, last_activity_at = ? WHERE id = ?",
|
|
1591
|
+
(now, now, session_id),
|
|
1592
|
+
)
|
|
1593
|
+
await db.execute(
|
|
1594
|
+
"UPDATE robots SET status = 'running' WHERE id = ? AND current_session_id = ?",
|
|
1595
|
+
(session["robot_id"], session_id),
|
|
1596
|
+
)
|
|
1597
|
+
await db.commit()
|
|
1498
1598
|
latency_ms = _session_latency_ms(session["started_at"], now)
|
|
1499
1599
|
await broadcast("session_joined", {"session_id": session_id, "latency_ms": latency_ms})
|
|
1500
1600
|
await _log_history("session", "session", session_id, "joined", "robot", f"latency_ms={latency_ms}")
|
|
@@ -1955,19 +2055,38 @@ async def health_checker():
|
|
|
1955
2055
|
await db.execute("UPDATE devices SET status = 'offline' WHERE id = ?", (did,))
|
|
1956
2056
|
await broadcast("device_status_changed", {"device_id": did, "status": "offline"})
|
|
1957
2057
|
|
|
1958
|
-
#
|
|
1959
|
-
#
|
|
1960
|
-
#
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
2058
|
+
# Release stale robot session pointers with the same lease rules
|
|
2059
|
+
# used by the trigger path. Failed joins cannot require manual
|
|
2060
|
+
# operator recovery or block an unattended robot indefinitely.
|
|
2061
|
+
async with db.execute(
|
|
2062
|
+
"SELECT id, status, current_session_id FROM robots WHERE current_session_id IS NOT NULL"
|
|
2063
|
+
) as c:
|
|
2064
|
+
session_owners = await c.fetchall()
|
|
2065
|
+
for owner in session_owners:
|
|
2066
|
+
reason = await _reap_robot_session_if_stale(
|
|
2067
|
+
db, owner["id"], owner["current_session_id"], owner["status"]
|
|
2068
|
+
)
|
|
2069
|
+
if reason:
|
|
2070
|
+
await broadcast("session_expired", {
|
|
2071
|
+
"session_id": owner["current_session_id"],
|
|
2072
|
+
"robot_id": owner["id"],
|
|
2073
|
+
"reason": reason,
|
|
2074
|
+
})
|
|
2075
|
+
|
|
2076
|
+
# Reap old orphan rows that are no longer owned by a robot.
|
|
2077
|
+
session_cutoff = (
|
|
2078
|
+
datetime.utcnow() - timedelta(seconds=ACTIVE_SESSION_LEASE_SECONDS)
|
|
2079
|
+
).isoformat()
|
|
2080
|
+
async with db.execute(
|
|
2081
|
+
"SELECT id, robot_id FROM sessions "
|
|
2082
|
+
"WHERE ended_at IS NULL AND COALESCE(last_activity_at, started_at) < ? "
|
|
2083
|
+
"AND NOT EXISTS (SELECT 1 FROM robots r WHERE r.current_session_id = sessions.id)",
|
|
2084
|
+
(session_cutoff,),
|
|
2085
|
+
) as c:
|
|
1967
2086
|
stale_sessions = await c.fetchall()
|
|
1968
2087
|
for session in stale_sessions:
|
|
1969
2088
|
await db.execute(
|
|
1970
|
-
"UPDATE sessions SET ended_at = ?, end_reason = '
|
|
2089
|
+
"UPDATE sessions SET ended_at = ?, end_reason = 'stale_orphan' "
|
|
1971
2090
|
"WHERE id = ? AND ended_at IS NULL",
|
|
1972
2091
|
(datetime.utcnow().isoformat(), session["id"]),
|
|
1973
2092
|
)
|
|
@@ -1979,7 +2098,7 @@ async def health_checker():
|
|
|
1979
2098
|
await broadcast("session_expired", {
|
|
1980
2099
|
"session_id": session["id"],
|
|
1981
2100
|
"robot_id": session["robot_id"],
|
|
1982
|
-
|
|
2101
|
+
"reason": "stale_orphan",
|
|
1983
2102
|
})
|
|
1984
2103
|
|
|
1985
2104
|
await db.commit()
|
|
@@ -3201,8 +3320,9 @@ async def device_supervisor_status(device_id: str, payload: SupervisorStatusRepo
|
|
|
3201
3320
|
pending = []
|
|
3202
3321
|
try:
|
|
3203
3322
|
async with db.execute(
|
|
3204
|
-
"SELECT service_name, action, kind, params, id FROM shell_requests "
|
|
3205
|
-
"WHERE device_id = ? AND completed_at IS NULL
|
|
3323
|
+
"SELECT service_name, action, kind, params, id FROM shell_requests "
|
|
3324
|
+
"WHERE device_id = ? AND completed_at IS NULL AND kind != 'speaker_test' "
|
|
3325
|
+
"ORDER BY requested_at",
|
|
3206
3326
|
(device_id,),
|
|
3207
3327
|
) as c:
|
|
3208
3328
|
shell_rows = [dict(r) for r in await c.fetchall()]
|
|
@@ -3580,13 +3700,26 @@ async def next_device_speaker_test(device_id: str, authorization: Optional[str]
|
|
|
3580
3700
|
"""Allow the always-running preview agent to execute audio tests."""
|
|
3581
3701
|
if not await _authorize_device(device_id, authorization):
|
|
3582
3702
|
raise HTTPException(401, "Invalid device token")
|
|
3703
|
+
now = datetime.utcnow()
|
|
3704
|
+
stale_before = (now - timedelta(seconds=45)).isoformat()
|
|
3583
3705
|
async with aiosqlite.connect(DB_PATH) as db:
|
|
3584
3706
|
db.row_factory = aiosqlite.Row
|
|
3707
|
+
await db.execute("BEGIN IMMEDIATE")
|
|
3585
3708
|
async with db.execute(
|
|
3586
3709
|
"SELECT id, params FROM shell_requests WHERE device_id = ? AND kind = 'speaker_test' "
|
|
3587
|
-
"AND completed_at IS NULL
|
|
3710
|
+
"AND completed_at IS NULL AND (claimed_at IS NULL OR claimed_at < ?) "
|
|
3711
|
+
"ORDER BY requested_at LIMIT 1", (device_id, stale_before),
|
|
3588
3712
|
) as cursor:
|
|
3589
3713
|
row = await cursor.fetchone()
|
|
3714
|
+
if row:
|
|
3715
|
+
claimed = await db.execute(
|
|
3716
|
+
"UPDATE shell_requests SET claimed_at = ? WHERE id = ? AND completed_at IS NULL "
|
|
3717
|
+
"AND (claimed_at IS NULL OR claimed_at < ?)",
|
|
3718
|
+
(now.isoformat(), row["id"], stale_before),
|
|
3719
|
+
)
|
|
3720
|
+
if claimed.rowcount != 1:
|
|
3721
|
+
row = None
|
|
3722
|
+
await db.commit()
|
|
3590
3723
|
return {"request": {"id": row["id"], "params": json.loads(row["params"] or "{}")} if row else None}
|
|
3591
3724
|
|
|
3592
3725
|
|
|
@@ -4055,25 +4188,35 @@ async def _auto_elevenlabs_voice(robot_name: Optional[str], current: Optional[st
|
|
|
4055
4188
|
|
|
4056
4189
|
|
|
4057
4190
|
async def _resolve_robot_voice_config(robot_id: str) -> RobotVoiceConfig:
|
|
4058
|
-
"""Public helper to resolve the effective voice config for a robot id."""
|
|
4059
|
-
async with aiosqlite.connect(DB_PATH) as db:
|
|
4060
|
-
db.row_factory = aiosqlite.Row
|
|
4061
|
-
|
|
4191
|
+
"""Public helper to resolve the effective voice config for a robot id."""
|
|
4192
|
+
async with aiosqlite.connect(DB_PATH) as db:
|
|
4193
|
+
db.row_factory = aiosqlite.Row
|
|
4194
|
+
# Dashboard routes commonly use the human robot name ("bmw"), while
|
|
4195
|
+
# assignments are persisted against the enrolled device id. Resolve
|
|
4196
|
+
# that alias once so character, stack and greetings use one identity.
|
|
4197
|
+
async with db.execute(
|
|
4198
|
+
"SELECT id FROM devices WHERE id = ? OR lower(name) = lower(?) "
|
|
4199
|
+
"ORDER BY CASE WHEN id = ? THEN 0 ELSE 1 END LIMIT 1",
|
|
4200
|
+
(robot_id, robot_id, robot_id),
|
|
4201
|
+
) as c:
|
|
4202
|
+
canonical_device = await c.fetchone()
|
|
4203
|
+
canonical_id = canonical_device["id"] if canonical_device else robot_id
|
|
4204
|
+
stack_dict = await _resolve_voice_stack_for_robot(db, canonical_id)
|
|
4062
4205
|
|
|
4063
4206
|
# Determine character preset + name
|
|
4064
4207
|
character_id = None
|
|
4065
4208
|
character_name = None
|
|
4066
4209
|
system_prompt = None
|
|
4067
4210
|
async with db.execute(
|
|
4068
|
-
"SELECT character_preset_id FROM robot_voice_configs WHERE robot_id = ?", (
|
|
4211
|
+
"SELECT character_preset_id FROM robot_voice_configs WHERE robot_id = ?", (canonical_id,)
|
|
4069
4212
|
) as c:
|
|
4070
4213
|
rvc = await c.fetchone()
|
|
4071
4214
|
if rvc and rvc["character_preset_id"]:
|
|
4072
4215
|
character_id = rvc["character_preset_id"]
|
|
4073
4216
|
else:
|
|
4074
4217
|
async with db.execute(
|
|
4075
|
-
"SELECT character_id FROM robots WHERE id = ? UNION ALL SELECT character_id FROM devices WHERE id = ?",
|
|
4076
|
-
(
|
|
4218
|
+
"SELECT character_id FROM robots WHERE id = ? UNION ALL SELECT character_id FROM devices WHERE id = ?",
|
|
4219
|
+
(canonical_id, canonical_id),
|
|
4077
4220
|
) as c:
|
|
4078
4221
|
row = await c.fetchone()
|
|
4079
4222
|
if row:
|
|
@@ -4088,21 +4231,23 @@ async def _resolve_robot_voice_config(robot_id: str) -> RobotVoiceConfig:
|
|
|
4088
4231
|
system_prompt = row["system_prompt"]
|
|
4089
4232
|
if not character_name:
|
|
4090
4233
|
async with db.execute(
|
|
4091
|
-
"SELECT name FROM robots WHERE id = ? UNION ALL SELECT name FROM devices WHERE id = ?",
|
|
4092
|
-
(
|
|
4234
|
+
"SELECT name FROM robots WHERE id = ? UNION ALL SELECT name FROM devices WHERE id = ?",
|
|
4235
|
+
(canonical_id, canonical_id),
|
|
4093
4236
|
) as c:
|
|
4094
4237
|
row = await c.fetchone()
|
|
4095
4238
|
if row:
|
|
4096
4239
|
character_name = row["name"]
|
|
4097
4240
|
|
|
4098
|
-
async with db.execute("SELECT greeting_phrases FROM devices WHERE id = ?", (
|
|
4241
|
+
async with db.execute("SELECT greeting_phrases FROM devices WHERE id = ?", (canonical_id,)) as c:
|
|
4099
4242
|
device_row = await c.fetchone()
|
|
4100
4243
|
greeting_phrases = []
|
|
4101
4244
|
if device_row and device_row["greeting_phrases"]:
|
|
4102
4245
|
try:
|
|
4103
4246
|
greeting_phrases = [str(p) for p in _json.loads(device_row["greeting_phrases"]) if str(p).strip()]
|
|
4104
|
-
except Exception:
|
|
4105
|
-
greeting_phrases = []
|
|
4247
|
+
except Exception:
|
|
4248
|
+
greeting_phrases = []
|
|
4249
|
+
if not greeting_phrases and (character_name or character_id):
|
|
4250
|
+
greeting_phrases = [f"Hello, I am {character_name or character_id}. How can I help you?"]
|
|
4106
4251
|
if stack_dict:
|
|
4107
4252
|
stack_dict["tts_voice"] = await _auto_elevenlabs_voice(character_name or character_id, stack_dict.get("tts_voice"))
|
|
4108
4253
|
voice_stack = VoiceStack(**stack_dict) if stack_dict else None
|
|
@@ -4748,10 +4893,10 @@ async def _robot_telemetry(db, robot_id: str) -> Optional[dict]:
|
|
|
4748
4893
|
readiness_code, readiness_message = "server_unavailable", f"LiveKit server {robot['connected_server_id']} is {server_status or 'missing'}"
|
|
4749
4894
|
elif not robot["current_session_id"]:
|
|
4750
4895
|
readiness_code, readiness_message = "ready", "ready for motion trigger"
|
|
4751
|
-
elif robot["status"] == "connecting" and session_age is not None and session_age >
|
|
4752
|
-
readiness_code, readiness_message = "stuck_session", f"joining LiveKit for {session_age}s;
|
|
4753
|
-
elif activity_age is not None and activity_age >
|
|
4754
|
-
readiness_code, readiness_message = "stale_session", f"no session activity for {activity_age}s;
|
|
4896
|
+
elif robot["status"] == "connecting" and session_age is not None and session_age > CONNECTING_SESSION_LEASE_SECONDS:
|
|
4897
|
+
readiness_code, readiness_message = "stuck_session", f"joining LiveKit for {session_age}s; automatic recovery pending"
|
|
4898
|
+
elif activity_age is not None and activity_age > ACTIVE_SESSION_LEASE_SECONDS:
|
|
4899
|
+
readiness_code, readiness_message = "stale_session", f"no session activity for {activity_age}s; automatic recovery pending"
|
|
4755
4900
|
else:
|
|
4756
4901
|
readiness_code, readiness_message = "in_session", "conversation session is active"
|
|
4757
4902
|
|
|
@@ -1233,44 +1233,95 @@ class LiveKitPublisher:
|
|
|
1233
1233
|
if self._capture and self.video_source:
|
|
1234
1234
|
self._tasks.append(asyncio.create_task(self._video_loop()))
|
|
1235
1235
|
|
|
1236
|
-
async def _play_remote_audio(self, track, sid: str) -> None:
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
except Exception as e:
|
|
1240
|
-
logger.warning(f"pyaudio unavailable for playback: {e}")
|
|
1241
|
-
return
|
|
1242
|
-
OUT_RATE = 48000
|
|
1243
|
-
OUT_CHANNELS = 2
|
|
1244
|
-
pa = pyaudio.PyAudio()
|
|
1245
|
-
# Same MME-vs-WASAPI gotcha as mic capture (see PyAudioCapture._resolve_device):
|
|
1246
|
-
# PyAudio's global default output device resolves through MME, which on this
|
|
1247
|
-
# machine will accept writes and report success with zero exceptions while
|
|
1248
|
-
# never actually reaching the real speakers. Prefer WASAPI's default output,
|
|
1249
|
-
# which is what Windows' own volume mixer/meter is backed by.
|
|
1250
|
-
output_device_index = None
|
|
1236
|
+
async def _play_remote_audio(self, track, sid: str) -> None:
|
|
1237
|
+
OUT_RATE = 48000
|
|
1238
|
+
OUT_CHANNELS = 2
|
|
1251
1239
|
selected_output = str(self.agent.audio_playback_device or "default")
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1240
|
+
pa = None
|
|
1241
|
+
output_device_index = None
|
|
1242
|
+
|
|
1243
|
+
# RoboVision inventories Linux devices through sounddevice/PortAudio,
|
|
1244
|
+
# but the numeric indices are not stable across PyAudio builds. More
|
|
1245
|
+
# importantly, BMW's production USB adapter is already proven through
|
|
1246
|
+
# ALSA's plughw conversion path. Use that exact endpoint for live TTS
|
|
1247
|
+
# instead of reopening the unrelated PyAudio index.
|
|
1248
|
+
if sys.platform.startswith("linux") and "hw:" in selected_output:
|
|
1249
|
+
import re
|
|
1250
|
+
import subprocess
|
|
1251
|
+
|
|
1252
|
+
match = re.search(r"\b(hw:\d+,\d+)\b", selected_output)
|
|
1253
|
+
if not match:
|
|
1254
|
+
logger.warning(f"audio out: no ALSA hardware address in {selected_output!r}")
|
|
1255
|
+
return
|
|
1256
|
+
alsa_device = f"plug{match.group(1)}"
|
|
1257
|
+
process = subprocess.Popen(
|
|
1258
|
+
[
|
|
1259
|
+
"aplay", "-q", "-D", alsa_device, "-t", "raw",
|
|
1260
|
+
"-f", "S16_LE", "-r", str(OUT_RATE), "-c", str(OUT_CHANNELS),
|
|
1261
|
+
],
|
|
1262
|
+
stdin=subprocess.PIPE,
|
|
1263
|
+
stderr=subprocess.PIPE,
|
|
1264
|
+
)
|
|
1265
|
+
if process.stdin is None:
|
|
1266
|
+
logger.warning(f"audio out: aplay did not expose stdin for {alsa_device}")
|
|
1267
|
+
process.kill()
|
|
1268
|
+
return
|
|
1269
|
+
|
|
1270
|
+
class _AplayOutput:
|
|
1271
|
+
def write(self, chunk: bytes) -> None:
|
|
1272
|
+
if process.poll() is not None:
|
|
1273
|
+
detail = ""
|
|
1274
|
+
if process.stderr is not None:
|
|
1275
|
+
detail = process.stderr.read().decode("utf-8", errors="replace").strip()
|
|
1276
|
+
raise OSError(detail or f"aplay exited {process.returncode}")
|
|
1277
|
+
process.stdin.write(chunk)
|
|
1278
|
+
process.stdin.flush()
|
|
1279
|
+
|
|
1280
|
+
def stop_stream(self) -> None:
|
|
1281
|
+
if process.stdin and not process.stdin.closed:
|
|
1282
|
+
process.stdin.close()
|
|
1283
|
+
try:
|
|
1284
|
+
process.wait(timeout=2)
|
|
1285
|
+
except subprocess.TimeoutExpired:
|
|
1286
|
+
process.terminate()
|
|
1287
|
+
|
|
1288
|
+
def close(self) -> None:
|
|
1289
|
+
return
|
|
1290
|
+
|
|
1291
|
+
out = _AplayOutput()
|
|
1292
|
+
output_device_index = alsa_device
|
|
1293
|
+
else:
|
|
1294
|
+
try:
|
|
1295
|
+
import pyaudio
|
|
1296
|
+
except Exception as e:
|
|
1297
|
+
logger.warning(f"pyaudio unavailable for playback: {e}")
|
|
1298
|
+
return
|
|
1299
|
+
pa = pyaudio.PyAudio()
|
|
1300
|
+
# Same MME-vs-WASAPI gotcha as mic capture (see
|
|
1301
|
+
# PyAudioCapture._resolve_device): prefer the endpoint backing the
|
|
1302
|
+
# Windows volume mixer instead of the silent MME default.
|
|
1303
|
+
if selected_output.strip().isdigit():
|
|
1304
|
+
output_device_index = int(selected_output.strip())
|
|
1305
|
+
try:
|
|
1306
|
+
if selected_output.lower() == "default":
|
|
1307
|
+
wasapi = pa.get_host_api_info_by_type(pyaudio.paWASAPI)
|
|
1308
|
+
idx = wasapi.get("defaultOutputDevice")
|
|
1309
|
+
if idx is not None and idx >= 0:
|
|
1310
|
+
output_device_index = idx
|
|
1311
|
+
elif output_device_index is None:
|
|
1312
|
+
needle = selected_output.lower()
|
|
1313
|
+
for i in range(pa.get_device_count()):
|
|
1314
|
+
info = pa.get_device_info_by_index(i)
|
|
1315
|
+
if info.get("maxOutputChannels", 0) > 0 and needle in str(info.get("name", "")).lower():
|
|
1316
|
+
output_device_index = i
|
|
1317
|
+
break
|
|
1318
|
+
except Exception as e:
|
|
1319
|
+
logger.debug(f"audio out: WASAPI default output lookup failed, using PyAudio default: {e}")
|
|
1320
|
+
out = pa.open(
|
|
1321
|
+
format=pyaudio.paInt16, channels=OUT_CHANNELS, rate=OUT_RATE, output=True,
|
|
1322
|
+
output_device_index=output_device_index,
|
|
1323
|
+
frames_per_buffer=960,
|
|
1324
|
+
)
|
|
1274
1325
|
logger.info(f"audio out: opened playback stream for {sid} (device_index={output_device_index})")
|
|
1275
1326
|
frame_count = 0
|
|
1276
1327
|
mismatch_logged = False
|
|
@@ -1409,9 +1460,10 @@ class LiveKitPublisher:
|
|
|
1409
1460
|
f"audio out: {sid} received {frame_count} frames, "
|
|
1410
1461
|
f"writer wrote {written_frames[0]} chunks, queue backlog at close={write_queue.qsize()}"
|
|
1411
1462
|
)
|
|
1412
|
-
out.stop_stream()
|
|
1413
|
-
out.close()
|
|
1414
|
-
pa
|
|
1463
|
+
out.stop_stream()
|
|
1464
|
+
out.close()
|
|
1465
|
+
if pa is not None:
|
|
1466
|
+
pa.terminate()
|
|
1415
1467
|
|
|
1416
1468
|
async def stop(self) -> None:
|
|
1417
1469
|
self._stop_event.set()
|