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.
@@ -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
- if (isPiArm())
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinicode",
3
- "version": "2.8.96",
3
+ "version": "2.8.98",
4
4
  "description": "OpenKernel — provider-agnostic AI execution kernel. Native coding agent + mission-driven execution runtime.",
5
5
  "type": "module",
6
6
  "main": "./dist/kernel/index.js",
@@ -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
- return {"status": "ok", **(await _insert_pipeline_event(device_id, payload))}
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
- return {"status": "ok"}
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,