infinicode 2.8.96 → 2.8.98
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 +471 -54
- package/dist/robopark/python-env.d.ts +1 -0
- package/dist/robopark/python-env.js +3 -0
- package/dist/robopark/robot-runtime.js +1 -1
- package/dist/robopark/vision-agent-launcher.js +19 -3
- package/docs/ROBOPARK_PRODUCTION_MEMORY.md +25 -0
- package/package.json +1 -1
- package/packages/robopark/scheduler/main.py +278 -22
- package/packages/robopark/scheduler/preview_agent.py +53 -18
- package/packages/robopark/scheduler/robot_supervisor.py +151 -9
- package/packages/robopark/vision/motor_server.py +204 -88
- package/packages/robopark/vision/requirements_vision_agent.txt +1 -0
|
@@ -3,6 +3,7 @@ export declare const VISION_REQUIRED_MODULES: string[];
|
|
|
3
3
|
export declare function findSchedulerPath(): Promise<string | null>;
|
|
4
4
|
export declare function findVisionPath(): Promise<string | null>;
|
|
5
5
|
export declare function findVisionAudioPath(): Promise<string | null>;
|
|
6
|
+
export declare function findVisionMotorPath(): Promise<string | null>;
|
|
6
7
|
export declare function findPython(): string;
|
|
7
8
|
/**
|
|
8
9
|
* Ensure a robot-side script's Python deps are importable, installing them
|
|
@@ -42,6 +42,9 @@ export async function findVisionPath() {
|
|
|
42
42
|
export async function findVisionAudioPath() {
|
|
43
43
|
return findPackageScript('vision/audio_server_pi.py');
|
|
44
44
|
}
|
|
45
|
+
export async function findVisionMotorPath() {
|
|
46
|
+
return findPackageScript('vision/motor_server.py');
|
|
47
|
+
}
|
|
45
48
|
export function findPython() {
|
|
46
49
|
const names = process.platform === 'win32'
|
|
47
50
|
? ['python', 'python3', 'py']
|
|
@@ -265,7 +265,7 @@ export async function roboparkRobotRuntime(opts) {
|
|
|
265
265
|
console.log(` robot: ${chalk.cyan(opts.name)}`);
|
|
266
266
|
console.log(` scheduler: ${chalk.cyan(opts.schedulerUrl)}`);
|
|
267
267
|
console.log(` network: ${chalk.cyan(network)}`);
|
|
268
|
-
console.log(` services: ${opts.vision === false ? 'mesh + preview' : 'mesh + RoboVision + preview'}`);
|
|
268
|
+
console.log(` services: ${opts.vision === false ? 'mesh + preview' : 'mesh + RoboVision camera/audio/motors + preview'}`);
|
|
269
269
|
console.log();
|
|
270
270
|
if (opts.enrollmentToken) {
|
|
271
271
|
console.log(chalk.dim(' clearing stale device identity for fresh enrollment…'));
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { spawn } from 'node:child_process';
|
|
9
9
|
import chalk from 'chalk';
|
|
10
|
-
import { findPython, findVisionPath, findVisionAudioPath, prepareRobotPython, VISION_REQUIRED_MODULES } from './python-env.js';
|
|
10
|
+
import { findPython, findVisionPath, findVisionAudioPath, findVisionMotorPath, prepareRobotPython, VISION_REQUIRED_MODULES } from './python-env.js';
|
|
11
11
|
const DEFAULT_VISION_PORT = 5000;
|
|
12
12
|
const DEFAULT_MOTION_WEBHOOK_PORT = 5057; // must match preview-agent's --vision-webhook-port default
|
|
13
13
|
function isPiArm() {
|
|
@@ -24,7 +24,7 @@ export async function roboparkVisionAgent(opts) {
|
|
|
24
24
|
// requirements_pi_unified.txt) — pip-installing it here would fight the
|
|
25
25
|
// ARM-optimized system build. Everywhere else: plain pip install works.
|
|
26
26
|
const reqFile = isPiArm() ? 'requirements_vision_agent.txt' : 'requirements-demo.txt';
|
|
27
|
-
const requiredModules = isPiArm() ? [...VISION_REQUIRED_MODULES, 'fastapi', 'uvicorn', 'sounddevice', 'soundfile', 'groq'] : VISION_REQUIRED_MODULES;
|
|
27
|
+
const requiredModules = isPiArm() ? [...VISION_REQUIRED_MODULES, 'fastapi', 'uvicorn', 'sounddevice', 'soundfile', 'groq', 'lgpio'] : VISION_REQUIRED_MODULES;
|
|
28
28
|
const python = prepareRobotPython(basePython, script, requiredModules, reqFile);
|
|
29
29
|
if (!python)
|
|
30
30
|
process.exit(1);
|
|
@@ -38,8 +38,11 @@ export async function roboparkVisionAgent(opts) {
|
|
|
38
38
|
// Start it beside the camera service on real Pi robots so /devices and
|
|
39
39
|
// /set-device remain the authoritative source for Park dropdowns.
|
|
40
40
|
let audioScript = null;
|
|
41
|
-
|
|
41
|
+
let motorScript = null;
|
|
42
|
+
if (isPiArm()) {
|
|
42
43
|
audioScript = await findVisionAudioPath();
|
|
44
|
+
motorScript = await findVisionMotorPath();
|
|
45
|
+
}
|
|
43
46
|
console.log(chalk.bold('\n robopark vision-agent'));
|
|
44
47
|
console.log(chalk.dim(' ' + '─'.repeat(52)));
|
|
45
48
|
console.log(` port: ${chalk.cyan(port)}`);
|
|
@@ -47,11 +50,16 @@ export async function roboparkVisionAgent(opts) {
|
|
|
47
50
|
console.log(` motion: ${motionActive ? chalk.green('armed') : chalk.dim('off')}`);
|
|
48
51
|
if (audioScript)
|
|
49
52
|
console.log(` audio: ${chalk.green('RoboVision audio server :8000')}`);
|
|
53
|
+
if (motorScript)
|
|
54
|
+
console.log(` motors: ${chalk.green('robot-local relay server :8001')}`);
|
|
50
55
|
console.log();
|
|
51
56
|
if (opts.foreground) {
|
|
52
57
|
const audio = audioScript
|
|
53
58
|
? spawn(python, [audioScript], { stdio: 'inherit', env: { ...process.env, ROBOPARK_AUDIO_SERVER: '1' } })
|
|
54
59
|
: null;
|
|
60
|
+
const motor = motorScript
|
|
61
|
+
? spawn(python, [motorScript], { stdio: 'inherit', env: { ...process.env, ROBOPARK_MOTOR_HOST: '127.0.0.1' } })
|
|
62
|
+
: null;
|
|
55
63
|
const camera = spawn(python, args, { stdio: 'inherit' });
|
|
56
64
|
let stopping = false;
|
|
57
65
|
const stopChildren = () => {
|
|
@@ -60,6 +68,7 @@ export async function roboparkVisionAgent(opts) {
|
|
|
60
68
|
stopping = true;
|
|
61
69
|
camera.kill('SIGTERM');
|
|
62
70
|
audio?.kill('SIGTERM');
|
|
71
|
+
motor?.kill('SIGTERM');
|
|
63
72
|
};
|
|
64
73
|
process.once('SIGINT', stopChildren);
|
|
65
74
|
process.once('SIGTERM', stopChildren);
|
|
@@ -77,6 +86,7 @@ export async function roboparkVisionAgent(opts) {
|
|
|
77
86
|
};
|
|
78
87
|
camera.once('close', finish);
|
|
79
88
|
audio?.once('close', finish);
|
|
89
|
+
motor?.once('close', finish);
|
|
80
90
|
});
|
|
81
91
|
return;
|
|
82
92
|
}
|
|
@@ -86,6 +96,12 @@ export async function roboparkVisionAgent(opts) {
|
|
|
86
96
|
});
|
|
87
97
|
audio.unref();
|
|
88
98
|
}
|
|
99
|
+
if (motorScript) {
|
|
100
|
+
const motor = spawn(python, [motorScript], {
|
|
101
|
+
stdio: 'ignore', detached: true, env: { ...process.env, ROBOPARK_MOTOR_HOST: '127.0.0.1' },
|
|
102
|
+
});
|
|
103
|
+
motor.unref();
|
|
104
|
+
}
|
|
89
105
|
const proc = spawn(python, args, {
|
|
90
106
|
stdio: 'ignore',
|
|
91
107
|
detached: true,
|
|
@@ -172,3 +172,28 @@ Required result: `playback_started ok`.
|
|
|
172
172
|
resampling, and releases the microphone lock after every failed attempt.
|
|
173
173
|
- Pipeline failures include the selected device and exact ALSA startup error.
|
|
174
174
|
A flat browser meter means missing PCM, not a quiet room.
|
|
175
|
+
- Remote `speaker-test` requests support `mode=mic_groundtruth`, which captures
|
|
176
|
+
through the same `arecord -D plughw:X,Y -f S16_LE -r 48000 -c 1` command
|
|
177
|
+
proven onsite and returns bytes, RMS, peak, and nonzero sample counts.
|
|
178
|
+
- Python 3.13 removed `audioop`. Version 2.8.97 replaces microphone gain, peak
|
|
179
|
+
measurement, and fallback resampling with internal PCM16 helpers; production
|
|
180
|
+
capture must not import `audioop`.
|
|
181
|
+
|
|
182
|
+
## Motor and Relay Contract (2.8.98+)
|
|
183
|
+
|
|
184
|
+
- Each robot persists its own motor registry, timed sequences, local motor API
|
|
185
|
+
URL, and optional greeting sequence in the scheduler database.
|
|
186
|
+
- The Park robot drawer is the control surface for registry edits, BCM relay
|
|
187
|
+
tests, sequence authoring, manual runs, and greeting assignment.
|
|
188
|
+
- Greeting motion queues only after the robot reports `playback_started`, so
|
|
189
|
+
movement aligns with audible TTS instead of session allocation.
|
|
190
|
+
- Voice agents may invoke only saved sequences for their active LiveKit room
|
|
191
|
+
through the authenticated room-scoped scheduler endpoint. They cannot submit
|
|
192
|
+
raw GPIO numbers or arbitrary shell commands.
|
|
193
|
+
- The robot supervisor accepts only `127.0.0.1` or `localhost` motor APIs,
|
|
194
|
+
synchronizes the saved registry, executes nodes serially, and verifies that
|
|
195
|
+
every pulse returns to idle before advancing.
|
|
196
|
+
- The motor API uses `lgpio`, accepts only registered BCM pins 2 through 27,
|
|
197
|
+
rejects duplicate assignments, limits pulses to 10 seconds, serializes all
|
|
198
|
+
actuation, and de-energizes every registered relay on errors and shutdown.
|
|
199
|
+
- A missing or busy GPIO driver is a failed test, never a simulated success.
|
package/package.json
CHANGED
|
@@ -178,7 +178,10 @@ class Device(BaseModel):
|
|
|
178
178
|
video_device: Optional[str] = None
|
|
179
179
|
audio_device: Optional[str] = None
|
|
180
180
|
audio_output_device: Optional[str] = None
|
|
181
|
-
greeting_phrases: List[str] = []
|
|
181
|
+
greeting_phrases: List[str] = []
|
|
182
|
+
motor_registry: List[dict] = []
|
|
183
|
+
motor_sequences: List[dict] = []
|
|
184
|
+
greeting_motor_sequence_id: Optional[str] = None
|
|
182
185
|
device_inventory: Optional[dict] = None
|
|
183
186
|
production_mode: bool = False
|
|
184
187
|
supervisor_status: Optional[List[ServiceStatus]] = None
|
|
@@ -579,7 +582,10 @@ async def init_db():
|
|
|
579
582
|
("devices", "audio_device", "TEXT"),
|
|
580
583
|
("devices", "device_inventory", "TEXT"),
|
|
581
584
|
("devices", "audio_output_device", "TEXT"),
|
|
582
|
-
("devices", "greeting_phrases", "TEXT DEFAULT '[]'"),
|
|
585
|
+
("devices", "greeting_phrases", "TEXT DEFAULT '[]'"),
|
|
586
|
+
("devices", "motor_registry", "TEXT DEFAULT '[]'"),
|
|
587
|
+
("devices", "motor_sequences", "TEXT DEFAULT '[]'"),
|
|
588
|
+
("devices", "greeting_motor_sequence_id", "TEXT"),
|
|
583
589
|
# Per-robot production mode: an additional, more specific gate on
|
|
584
590
|
# top of the global settings.production_mode switch. When on for
|
|
585
591
|
# a given device, that robot keeps re-arming its motion-triggered
|
|
@@ -1029,9 +1035,8 @@ async def request_session(robot_id: str,
|
|
|
1029
1035
|
WHERE id = ?
|
|
1030
1036
|
""", (session_id, server["id"], robot_id))
|
|
1031
1037
|
|
|
1032
|
-
await db.commit()
|
|
1033
|
-
|
|
1034
|
-
await broadcast("session_requested", {
|
|
1038
|
+
await db.commit()
|
|
1039
|
+
await broadcast("session_requested", {
|
|
1035
1040
|
"robot_id": robot_id,
|
|
1036
1041
|
"server_id": server["id"],
|
|
1037
1042
|
"session_id": session_id
|
|
@@ -2642,8 +2647,17 @@ def _device_row_to_model(row) -> Device:
|
|
|
2642
2647
|
d["greeting_phrases"] = [str(p) for p in json.loads(greetings) if str(p).strip()]
|
|
2643
2648
|
except (json.JSONDecodeError, TypeError):
|
|
2644
2649
|
d["greeting_phrases"] = []
|
|
2645
|
-
elif not isinstance(greetings, list):
|
|
2646
|
-
d["greeting_phrases"] = []
|
|
2650
|
+
elif not isinstance(greetings, list):
|
|
2651
|
+
d["greeting_phrases"] = []
|
|
2652
|
+
for field in ("motor_registry", "motor_sequences"):
|
|
2653
|
+
value = d.get(field)
|
|
2654
|
+
if isinstance(value, str):
|
|
2655
|
+
try:
|
|
2656
|
+
d[field] = json.loads(value)
|
|
2657
|
+
except (json.JSONDecodeError, TypeError):
|
|
2658
|
+
d[field] = []
|
|
2659
|
+
elif not isinstance(value, list):
|
|
2660
|
+
d[field] = []
|
|
2647
2661
|
sup = d.get("supervisor_status")
|
|
2648
2662
|
if isinstance(sup, str):
|
|
2649
2663
|
try:
|
|
@@ -2701,7 +2715,7 @@ async def get_device(device_id: str):
|
|
|
2701
2715
|
raise HTTPException(404, "Device not found")
|
|
2702
2716
|
return _device_row_to_model(row)
|
|
2703
2717
|
|
|
2704
|
-
class DeviceUpdate(BaseModel):
|
|
2718
|
+
class DeviceUpdate(BaseModel):
|
|
2705
2719
|
name: Optional[str] = None
|
|
2706
2720
|
character_id: Optional[str] = None
|
|
2707
2721
|
motor_server_url: Optional[str] = None
|
|
@@ -2709,7 +2723,10 @@ class DeviceUpdate(BaseModel):
|
|
|
2709
2723
|
video_device: Optional[str] = None
|
|
2710
2724
|
audio_device: Optional[str] = None
|
|
2711
2725
|
audio_output_device: Optional[str] = None
|
|
2712
|
-
greeting_phrases: Optional[List[str]] = None
|
|
2726
|
+
greeting_phrases: Optional[List[str]] = None
|
|
2727
|
+
motor_registry: Optional[List[dict]] = None
|
|
2728
|
+
motor_sequences: Optional[List[dict]] = None
|
|
2729
|
+
greeting_motor_sequence_id: Optional[str] = None
|
|
2713
2730
|
tailscale_ip: Optional[str] = None
|
|
2714
2731
|
lan_ip: Optional[str] = None
|
|
2715
2732
|
notes: Optional[str] = None
|
|
@@ -2720,7 +2737,7 @@ class DeviceUpdate(BaseModel):
|
|
|
2720
2737
|
async def update_device(device_id: str, payload: DeviceUpdate):
|
|
2721
2738
|
"""Partial update for a device (character binding, addresses, notes, status)."""
|
|
2722
2739
|
fields = {k: v for k, v in payload.model_dump(exclude_none=True).items()}
|
|
2723
|
-
if "greeting_phrases" in fields:
|
|
2740
|
+
if "greeting_phrases" in fields:
|
|
2724
2741
|
fields["greeting_phrases"] = json.dumps(
|
|
2725
2742
|
[str(p).strip() for p in fields["greeting_phrases"] if str(p).strip()][:20]
|
|
2726
2743
|
)
|
|
@@ -2973,6 +2990,9 @@ async def bootstrap_device(
|
|
|
2973
2990
|
"INSERT OR IGNORE INTO robots (id, name, status) VALUES (?, ?, 'idle')",
|
|
2974
2991
|
(device_id, name),
|
|
2975
2992
|
)
|
|
2993
|
+
for field in ("motor_registry", "motor_sequences"):
|
|
2994
|
+
if field in fields:
|
|
2995
|
+
fields[field] = json.dumps(fields[field], separators=(",", ":"))
|
|
2976
2996
|
await db.execute("UPDATE robots SET name = ? WHERE id = ?", (name, device_id))
|
|
2977
2997
|
await db.commit()
|
|
2978
2998
|
|
|
@@ -3168,7 +3188,7 @@ PIPELINE_STAGES = (
|
|
|
3168
3188
|
"robot_online", "camera_ready", "microphone_ready", "speaker_ready",
|
|
3169
3189
|
"motion_detected", "scheduler_session", "livekit_join", "camera_published",
|
|
3170
3190
|
"microphone_published", "voice_worker", "stt_listening", "llm_response",
|
|
3171
|
-
"tts_subscribed", "playback_started", "session_ended",
|
|
3191
|
+
"tts_subscribed", "playback_started", "motor_sequence", "session_ended",
|
|
3172
3192
|
)
|
|
3173
3193
|
PIPELINE_STATUSES = {"pending", "running", "ok", "failed", "blocked", "skipped"}
|
|
3174
3194
|
|
|
@@ -3198,7 +3218,10 @@ async def device_pipeline_event(device_id: str, payload: PipelineEventPayload,
|
|
|
3198
3218
|
"""Receive real pipeline stage transitions from the enrolled robot."""
|
|
3199
3219
|
if not await _authorize_device(device_id, authorization):
|
|
3200
3220
|
raise HTTPException(401, "Invalid device token")
|
|
3201
|
-
|
|
3221
|
+
result = await _insert_pipeline_event(device_id, payload)
|
|
3222
|
+
if payload.stage == "playback_started" and payload.status == "ok" and payload.session_id:
|
|
3223
|
+
await _queue_configured_greeting_sequence(device_id, payload.session_id)
|
|
3224
|
+
return {"status": "ok", **result}
|
|
3202
3225
|
|
|
3203
3226
|
@app.get("/api/robots/{robot_id}/pipeline-status")
|
|
3204
3227
|
async def robot_pipeline_status(robot_id: str, limit: int = 80):
|
|
@@ -3275,6 +3298,72 @@ async def robot_pipeline_status(robot_id: str, limit: int = 80):
|
|
|
3275
3298
|
"stages": list(PIPELINE_STAGES),
|
|
3276
3299
|
}
|
|
3277
3300
|
|
|
3301
|
+
@app.get("/api/robots/{robot_id}/pipeline-history")
|
|
3302
|
+
async def robot_pipeline_history(robot_id: str, page: int = 1, page_size: int = 6):
|
|
3303
|
+
"""Return persistent, paged production runs for one robot."""
|
|
3304
|
+
page = max(1, page)
|
|
3305
|
+
page_size = max(1, min(page_size, 25))
|
|
3306
|
+
async with aiosqlite.connect(DB_PATH) as db:
|
|
3307
|
+
db.row_factory = aiosqlite.Row
|
|
3308
|
+
async with db.execute(
|
|
3309
|
+
"SELECT id, name FROM devices WHERE id = ? OR lower(name) = lower(?) "
|
|
3310
|
+
"ORDER BY last_heartbeat DESC LIMIT 1",
|
|
3311
|
+
(robot_id, robot_id),
|
|
3312
|
+
) as cursor:
|
|
3313
|
+
device = await cursor.fetchone()
|
|
3314
|
+
if not device:
|
|
3315
|
+
raise HTTPException(404, "Robot device not found")
|
|
3316
|
+
|
|
3317
|
+
device_id = device["id"]
|
|
3318
|
+
async with db.execute(
|
|
3319
|
+
"SELECT COUNT(*) FROM sessions WHERE robot_id = ?", (device_id,)
|
|
3320
|
+
) as cursor:
|
|
3321
|
+
total = int((await cursor.fetchone())[0])
|
|
3322
|
+
pages = max(1, (total + page_size - 1) // page_size)
|
|
3323
|
+
page = min(page, pages)
|
|
3324
|
+
offset = (page - 1) * page_size
|
|
3325
|
+
async with db.execute(
|
|
3326
|
+
"""SELECT s.*, s.id AS session_id,
|
|
3327
|
+
(SELECT COUNT(*) FROM session_transcripts t
|
|
3328
|
+
WHERE t.session_id = s.id AND t.is_final = 1) AS transcript_count,
|
|
3329
|
+
(SELECT COUNT(*) FROM pipeline_events p
|
|
3330
|
+
WHERE p.session_id = s.id) AS pipeline_event_count,
|
|
3331
|
+
(SELECT COUNT(*) FROM pipeline_events p
|
|
3332
|
+
WHERE p.session_id = s.id AND p.status IN ('failed','blocked')) AS error_count
|
|
3333
|
+
FROM sessions s WHERE s.robot_id = ?
|
|
3334
|
+
ORDER BY s.started_at DESC LIMIT ? OFFSET ?""",
|
|
3335
|
+
(device_id, page_size, offset),
|
|
3336
|
+
) as cursor:
|
|
3337
|
+
sessions = [dict(row) for row in await cursor.fetchall()]
|
|
3338
|
+
|
|
3339
|
+
events_by_session = {item["session_id"]: [] for item in sessions}
|
|
3340
|
+
session_ids = list(events_by_session)
|
|
3341
|
+
if session_ids:
|
|
3342
|
+
placeholders = ",".join("?" for _ in session_ids)
|
|
3343
|
+
async with db.execute(
|
|
3344
|
+
f"SELECT * FROM pipeline_events WHERE session_id IN ({placeholders}) ORDER BY timestamp, id",
|
|
3345
|
+
session_ids,
|
|
3346
|
+
) as cursor:
|
|
3347
|
+
for row in await cursor.fetchall():
|
|
3348
|
+
event = dict(row)
|
|
3349
|
+
try:
|
|
3350
|
+
event["details"] = json.loads(event.get("details") or "{}")
|
|
3351
|
+
except (TypeError, ValueError):
|
|
3352
|
+
event["details"] = {}
|
|
3353
|
+
events_by_session[event["session_id"]].append(event)
|
|
3354
|
+
|
|
3355
|
+
for session in sessions:
|
|
3356
|
+
session["events"] = events_by_session.get(session["session_id"], [])
|
|
3357
|
+
return {
|
|
3358
|
+
"robot_id": device_id,
|
|
3359
|
+
"robot_name": device["name"],
|
|
3360
|
+
"items": sessions,
|
|
3361
|
+
"page": page,
|
|
3362
|
+
"page_size": page_size,
|
|
3363
|
+
"pages": pages,
|
|
3364
|
+
"total": total,
|
|
3365
|
+
}
|
|
3366
|
+
|
|
3278
3367
|
@app.post("/api/robots/{robot_id}/pipeline-test/start")
|
|
3279
3368
|
async def start_pipeline_test(robot_id: str):
|
|
3280
3369
|
snapshot = await robot_pipeline_status(robot_id)
|
|
@@ -3531,9 +3620,17 @@ async def device_supervisor_output(device_id: str, payload: dict, authorization:
|
|
|
3531
3620
|
await db.commit()
|
|
3532
3621
|
kind = payload.get("kind") or "shell"
|
|
3533
3622
|
service = payload.get("service") or ""
|
|
3534
|
-
await _log_history("device", "device", device_id, f"shell_{kind}_completed", "operator",
|
|
3535
|
-
f"service={service}, request_id={request_id}, ok={bool(ok)}")
|
|
3536
|
-
|
|
3623
|
+
await _log_history("device", "device", device_id, f"shell_{kind}_completed", "operator",
|
|
3624
|
+
f"service={service}, request_id={request_id}, ok={bool(ok)}")
|
|
3625
|
+
if kind == "motor_sequence" and result_payload.get("session_id"):
|
|
3626
|
+
await _insert_pipeline_event(device_id, PipelineEventPayload(
|
|
3627
|
+
stage="motor_sequence", status="ok" if ok else "failed",
|
|
3628
|
+
message=(f"Motor sequence {result_payload.get('sequence_id')} completed" if ok
|
|
3629
|
+
else f"Motor sequence failed: {result_payload.get('error', 'unknown error')}")[:500],
|
|
3630
|
+
session_id=result_payload.get("session_id"), source="robot_supervisor",
|
|
3631
|
+
details={"request_id": request_id, "duration_ms": result_payload.get("duration_ms")},
|
|
3632
|
+
))
|
|
3633
|
+
return {"status": "ok"}
|
|
3537
3634
|
|
|
3538
3635
|
|
|
3539
3636
|
# Endpoint the dashboard uses to list the available shell commands
|
|
@@ -3754,7 +3851,7 @@ async def device_config(device_id: str, authorization: Optional[str] = Header(de
|
|
|
3754
3851
|
async with aiosqlite.connect(DB_PATH) as db:
|
|
3755
3852
|
db.row_factory = aiosqlite.Row
|
|
3756
3853
|
async with db.execute(
|
|
3757
|
-
"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 = ?",
|
|
3854
|
+
"SELECT id, name, character_id, motor_server_url, livekit_url, video_device, audio_device, audio_output_device, device_inventory, greeting_phrases, motor_registry, motor_sequences, greeting_motor_sequence_id FROM devices WHERE id = ?",
|
|
3758
3855
|
(device_id,),
|
|
3759
3856
|
) as c:
|
|
3760
3857
|
row = await c.fetchone()
|
|
@@ -3770,10 +3867,169 @@ async def device_config(device_id: str, authorization: Optional[str] = Header(de
|
|
|
3770
3867
|
"video_device": row["video_device"],
|
|
3771
3868
|
"audio_device": row["audio_device"],
|
|
3772
3869
|
"audio_output_device": row["audio_output_device"],
|
|
3773
|
-
"greeting_phrases": _device_row_to_model(row).greeting_phrases,
|
|
3870
|
+
"greeting_phrases": _device_row_to_model(row).greeting_phrases,
|
|
3871
|
+
"motor_registry": _device_row_to_model(row).motor_registry,
|
|
3872
|
+
"motor_sequences": _device_row_to_model(row).motor_sequences,
|
|
3873
|
+
"greeting_motor_sequence_id": row["greeting_motor_sequence_id"],
|
|
3774
3874
|
"device_inventory": json.loads(row["device_inventory"]) if row["device_inventory"] else None,
|
|
3775
3875
|
"production_mode": effective_pm,
|
|
3776
|
-
}
|
|
3876
|
+
}
|
|
3877
|
+
|
|
3878
|
+
class MotorProfilePayload(BaseModel):
|
|
3879
|
+
registry: List[dict] = []
|
|
3880
|
+
sequences: List[dict] = []
|
|
3881
|
+
greeting_sequence_id: Optional[str] = None
|
|
3882
|
+
|
|
3883
|
+
async def _resolve_device_by_id_or_name(robot_id: str):
|
|
3884
|
+
async with aiosqlite.connect(DB_PATH) as db:
|
|
3885
|
+
db.row_factory = aiosqlite.Row
|
|
3886
|
+
async with db.execute(
|
|
3887
|
+
"SELECT * FROM devices WHERE id=? OR lower(name)=lower(?) ORDER BY CASE WHEN id=? THEN 0 ELSE 1 END LIMIT 1",
|
|
3888
|
+
(robot_id, robot_id, robot_id),
|
|
3889
|
+
) as cursor:
|
|
3890
|
+
device = await cursor.fetchone()
|
|
3891
|
+
if not device:
|
|
3892
|
+
raise HTTPException(404, "Robot device not found")
|
|
3893
|
+
return device
|
|
3894
|
+
|
|
3895
|
+
def _sanitize_motor_profile(payload: MotorProfilePayload) -> tuple[list, list, Optional[str]]:
|
|
3896
|
+
import re
|
|
3897
|
+
registry, ids, gpios = [], set(), set()
|
|
3898
|
+
for raw in payload.registry[:32]:
|
|
3899
|
+
motor_id = str(raw.get("id") or raw.get("name") or "").strip().lower()
|
|
3900
|
+
if not re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,31}", motor_id):
|
|
3901
|
+
raise HTTPException(422, f"Invalid motor id: {motor_id!r}")
|
|
3902
|
+
gpio = int(raw.get("gpio", -1))
|
|
3903
|
+
if gpio < 2 or gpio > 27:
|
|
3904
|
+
raise HTTPException(422, f"GPIO {gpio} is outside BCM 2..27")
|
|
3905
|
+
if motor_id in ids or gpio in gpios:
|
|
3906
|
+
raise HTTPException(422, "Motor ids and GPIO assignments must be unique")
|
|
3907
|
+
ids.add(motor_id); gpios.add(gpio)
|
|
3908
|
+
registry.append({"id": motor_id, "name": str(raw.get("name") or motor_id)[:60], "gpio": gpio,
|
|
3909
|
+
"active_high": bool(raw.get("active_high", True)),
|
|
3910
|
+
"max_duration_ms": max(50, min(10000, int(raw.get("max_duration_ms", 3000))))})
|
|
3911
|
+
sequences, sequence_ids = [], set()
|
|
3912
|
+
for raw in payload.sequences[:32]:
|
|
3913
|
+
sequence_id = str(raw.get("id") or raw.get("name") or "").strip().lower()
|
|
3914
|
+
if not re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,31}", sequence_id) or sequence_id in sequence_ids:
|
|
3915
|
+
raise HTTPException(422, f"Invalid or duplicate sequence id: {sequence_id!r}")
|
|
3916
|
+
sequence_ids.add(sequence_id)
|
|
3917
|
+
steps = []
|
|
3918
|
+
for step in (raw.get("steps") or [])[:40]:
|
|
3919
|
+
motor_id = str(step.get("motor_id") or "")
|
|
3920
|
+
if motor_id not in ids:
|
|
3921
|
+
raise HTTPException(422, f"Sequence {sequence_id} references unknown motor {motor_id}")
|
|
3922
|
+
motor = next(item for item in registry if item["id"] == motor_id)
|
|
3923
|
+
duration = max(50, min(motor["max_duration_ms"], int(step.get("duration_ms", 500))))
|
|
3924
|
+
steps.append({"motor_id": motor_id, "delay_ms": max(0, min(30000, int(step.get("delay_ms", 0)))), "duration_ms": duration})
|
|
3925
|
+
sequences.append({"id": sequence_id, "name": str(raw.get("name") or sequence_id)[:80], "steps": steps})
|
|
3926
|
+
greeting = payload.greeting_sequence_id or None
|
|
3927
|
+
if greeting and greeting not in sequence_ids:
|
|
3928
|
+
raise HTTPException(422, "Greeting sequence does not exist")
|
|
3929
|
+
return registry, sequences, greeting
|
|
3930
|
+
|
|
3931
|
+
@app.get("/api/robots/{robot_id}/motor-profile")
|
|
3932
|
+
async def robot_motor_profile(robot_id: str):
|
|
3933
|
+
device = await _resolve_device_by_id_or_name(robot_id)
|
|
3934
|
+
model = _device_row_to_model(device)
|
|
3935
|
+
return {"robot_id": model.id, "motor_server_url": model.motor_server_url,
|
|
3936
|
+
"registry": model.motor_registry, "sequences": model.motor_sequences,
|
|
3937
|
+
"greeting_sequence_id": model.greeting_motor_sequence_id}
|
|
3938
|
+
|
|
3939
|
+
@app.put("/api/robots/{robot_id}/motor-profile")
|
|
3940
|
+
async def save_robot_motor_profile(robot_id: str, payload: MotorProfilePayload):
|
|
3941
|
+
device = await _resolve_device_by_id_or_name(robot_id)
|
|
3942
|
+
registry, sequences, greeting = _sanitize_motor_profile(payload)
|
|
3943
|
+
async with aiosqlite.connect(DB_PATH) as db:
|
|
3944
|
+
await db.execute("UPDATE devices SET motor_registry=?, motor_sequences=?, greeting_motor_sequence_id=? WHERE id=?",
|
|
3945
|
+
(json.dumps(registry), json.dumps(sequences), greeting, device["id"]))
|
|
3946
|
+
await db.commit()
|
|
3947
|
+
await _log_history("motor", "device", device["id"], "profile_saved", "operator", f"motors={len(registry)}, sequences={len(sequences)}")
|
|
3948
|
+
return {"status": "ok", "robot_id": device["id"], "registry": registry, "sequences": sequences, "greeting_sequence_id": greeting}
|
|
3949
|
+
|
|
3950
|
+
async def _queue_motor_sequence(device_id: str, sequence_id: str, session_id: Optional[str] = None) -> dict:
|
|
3951
|
+
device = await _resolve_device_by_id_or_name(device_id)
|
|
3952
|
+
model = _device_row_to_model(device)
|
|
3953
|
+
sequence = next((item for item in model.motor_sequences if item.get("id") == sequence_id), None)
|
|
3954
|
+
if not sequence:
|
|
3955
|
+
raise HTTPException(404, "Motor sequence not found")
|
|
3956
|
+
if not model.motor_server_url:
|
|
3957
|
+
raise HTTPException(409, "Robot has no motor_server_url")
|
|
3958
|
+
request_id = await _queue_shell_request_async(model.id, "motor_sequence", "motor_server", {
|
|
3959
|
+
"motor_server_url": model.motor_server_url, "sequence_id": sequence_id,
|
|
3960
|
+
"steps": sequence.get("steps") or [], "registry": model.motor_registry, "session_id": session_id,
|
|
3961
|
+
})
|
|
3962
|
+
if session_id:
|
|
3963
|
+
await _insert_pipeline_event(model.id, PipelineEventPayload(stage="motor_sequence", status="running", message=f"Motor sequence {sequence_id} queued", session_id=session_id, source="scheduler"))
|
|
3964
|
+
return {"queued": True, "request_id": request_id, "device_id": model.id, "sequence_id": sequence_id}
|
|
3965
|
+
|
|
3966
|
+
async def _queue_configured_greeting_sequence(device_id: str, session_id: str) -> None:
|
|
3967
|
+
try:
|
|
3968
|
+
device = await _resolve_device_by_id_or_name(device_id)
|
|
3969
|
+
sequence_id = device["greeting_motor_sequence_id"]
|
|
3970
|
+
async with aiosqlite.connect(DB_PATH) as db:
|
|
3971
|
+
async with db.execute(
|
|
3972
|
+
"SELECT 1 FROM pipeline_events WHERE robot_id=? AND session_id=? "
|
|
3973
|
+
"AND stage='motor_sequence' LIMIT 1",
|
|
3974
|
+
(device["id"], session_id),
|
|
3975
|
+
) as cursor:
|
|
3976
|
+
if await cursor.fetchone():
|
|
3977
|
+
return
|
|
3978
|
+
if sequence_id:
|
|
3979
|
+
await _queue_motor_sequence(device["id"], sequence_id, session_id)
|
|
3980
|
+
else:
|
|
3981
|
+
await _insert_pipeline_event(device["id"], PipelineEventPayload(
|
|
3982
|
+
stage="motor_sequence", status="skipped",
|
|
3983
|
+
message="No greeting motor sequence assigned",
|
|
3984
|
+
session_id=session_id, source="scheduler",
|
|
3985
|
+
))
|
|
3986
|
+
except Exception as exc:
|
|
3987
|
+
logger.warning("Greeting motor sequence was not queued for %s: %s", device_id, exc)
|
|
3988
|
+
|
|
3989
|
+
@app.post("/api/robots/{robot_id}/motor-sequences/{sequence_id}/run")
|
|
3990
|
+
async def run_robot_motor_sequence(robot_id: str, sequence_id: str):
|
|
3991
|
+
return await _queue_motor_sequence(robot_id, sequence_id)
|
|
3992
|
+
|
|
3993
|
+
@app.post("/api/devices/by-room/{room_name}/motor-sequences/{sequence_id}/run")
|
|
3994
|
+
async def run_room_motor_sequence(
|
|
3995
|
+
room_name: str,
|
|
3996
|
+
sequence_id: str,
|
|
3997
|
+
authorization: Optional[str] = Header(default=None),
|
|
3998
|
+
agent_token: Optional[str] = Header(default=None, alias="X-RoboPark-Agent-Token"),
|
|
3999
|
+
):
|
|
4000
|
+
"""Allow the active voice worker to invoke a saved robot sequence by room."""
|
|
4001
|
+
fleet_authorized = await _authorize_fleet(authorization)
|
|
4002
|
+
agent_authorized = bool(
|
|
4003
|
+
ROBOPARK_AGENT_TOKEN and agent_token
|
|
4004
|
+
and hmac.compare_digest(agent_token.strip(), ROBOPARK_AGENT_TOKEN)
|
|
4005
|
+
)
|
|
4006
|
+
if not fleet_authorized and not agent_authorized:
|
|
4007
|
+
raise HTTPException(401, "Invalid or missing agent token")
|
|
4008
|
+
async with aiosqlite.connect(DB_PATH) as db:
|
|
4009
|
+
db.row_factory = aiosqlite.Row
|
|
4010
|
+
async with db.execute(
|
|
4011
|
+
"SELECT id, robot_id FROM sessions WHERE room_name=? AND ended_at IS NULL "
|
|
4012
|
+
"ORDER BY started_at DESC LIMIT 1",
|
|
4013
|
+
(room_name,),
|
|
4014
|
+
) as cursor:
|
|
4015
|
+
session = await cursor.fetchone()
|
|
4016
|
+
if not session:
|
|
4017
|
+
raise HTTPException(404, "No active session bound to this room")
|
|
4018
|
+
return await _queue_motor_sequence(session["robot_id"], sequence_id, session["id"])
|
|
4019
|
+
|
|
4020
|
+
@app.post("/api/robots/{robot_id}/motors/{motor_id}/test")
|
|
4021
|
+
async def test_robot_motor(robot_id: str, motor_id: str, duration_ms: int = 300):
|
|
4022
|
+
device = await _resolve_device_by_id_or_name(robot_id)
|
|
4023
|
+
model = _device_row_to_model(device)
|
|
4024
|
+
motor = next((item for item in model.motor_registry if item.get("id") == motor_id), None)
|
|
4025
|
+
if not motor or not model.motor_server_url:
|
|
4026
|
+
raise HTTPException(404, "Motor or motor server not configured")
|
|
4027
|
+
duration_ms = max(50, min(int(motor.get("max_duration_ms", 3000)), duration_ms))
|
|
4028
|
+
request_id = await _queue_shell_request_async(model.id, "motor_sequence", "motor_server", {
|
|
4029
|
+
"motor_server_url": model.motor_server_url, "sequence_id": f"test-{motor_id}", "registry": model.motor_registry,
|
|
4030
|
+
"steps": [{"motor_id": motor_id, "delay_ms": 0, "duration_ms": duration_ms}],
|
|
4031
|
+
})
|
|
4032
|
+
return {"queued": True, "request_id": request_id, "device_id": model.id}
|
|
3777
4033
|
|
|
3778
4034
|
@app.post("/api/devices/{device_id}/request-session")
|
|
3779
4035
|
async def device_request_session(device_id: str,
|
|
@@ -3878,10 +4134,10 @@ async def device_request_session(device_id: str,
|
|
|
3878
4134
|
WHERE id = ?
|
|
3879
4135
|
""", (session_id, server["id"], device_id))
|
|
3880
4136
|
|
|
3881
|
-
await db.commit()
|
|
3882
|
-
|
|
3883
|
-
await broadcast("session_requested", {
|
|
3884
|
-
"robot_id": device_id,
|
|
4137
|
+
await db.commit()
|
|
4138
|
+
|
|
4139
|
+
await broadcast("session_requested", {
|
|
4140
|
+
"robot_id": device_id,
|
|
3885
4141
|
"device_id": device_id,
|
|
3886
4142
|
"server_id": server["id"],
|
|
3887
4143
|
"session_id": session_id,
|