infinicode 2.8.102 → 2.8.104

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.
@@ -3192,6 +3192,93 @@ PIPELINE_STAGES = (
3192
3192
  )
3193
3193
  PIPELINE_STATUSES = {"pending", "running", "ok", "failed", "blocked", "skipped"}
3194
3194
 
3195
+ PIPELINE_STAGE_LABELS = {
3196
+ "robot_online": "Robot online",
3197
+ "camera_ready": "Camera detected",
3198
+ "microphone_ready": "Microphone detected",
3199
+ "speaker_ready": "Speaker detected",
3200
+ "motion_detected": "Waiting for motion",
3201
+ "scheduler_session": "Allocating session",
3202
+ "livekit_join": "Joining LiveKit",
3203
+ "camera_published": "Publishing camera",
3204
+ "microphone_published": "Publishing microphone",
3205
+ "voice_worker": "Starting voice worker",
3206
+ "stt_listening": "Listening to visitor",
3207
+ "llm_response": "Preparing response",
3208
+ "tts_subscribed": "Preparing robot voice",
3209
+ "playback_started": "Speaking through robot",
3210
+ "motor_sequence": "Running motor sequence",
3211
+ "session_ended": "Closing session",
3212
+ }
3213
+
3214
+
3215
+ def _pipeline_current_state(events: list[dict], *, active: bool,
3216
+ production_mode: bool, online: bool) -> dict:
3217
+ """Derive one operator-facing live stage from persistent pipeline events."""
3218
+ if not online:
3219
+ return {
3220
+ "stage": "robot_online", "status": "blocked",
3221
+ "label": PIPELINE_STAGE_LABELS["robot_online"],
3222
+ "message": "Robot heartbeat is stale or missing", "since": None,
3223
+ }
3224
+ if not active:
3225
+ return {
3226
+ "stage": "motion_detected",
3227
+ "status": "waiting" if production_mode else "paused",
3228
+ "label": "Armed - waiting for motion" if production_mode else "Production paused",
3229
+ "message": "Motion detection is armed" if production_mode else "Enable production mode to arm this robot",
3230
+ "since": None,
3231
+ }
3232
+
3233
+ scoped = [event for event in events if event.get("session_id")]
3234
+ for event in reversed(scoped):
3235
+ if event.get("status") in {"failed", "blocked"}:
3236
+ return {
3237
+ "stage": event["stage"], "status": event["status"],
3238
+ "label": PIPELINE_STAGE_LABELS.get(event["stage"], event["stage"]),
3239
+ "message": event.get("message") or "Pipeline requires attention",
3240
+ "since": event.get("timestamp"),
3241
+ }
3242
+
3243
+ conversational = {
3244
+ "voice_worker", "stt_listening", "llm_response", "tts_subscribed",
3245
+ "playback_started", "motor_sequence",
3246
+ }
3247
+ latest = next((event for event in reversed(scoped) if event.get("stage") in conversational), None)
3248
+ if latest:
3249
+ stage = latest["stage"]
3250
+ status = latest.get("status") or "running"
3251
+ if status in {"ok", "skipped"}:
3252
+ stage = {
3253
+ "voice_worker": "stt_listening",
3254
+ "stt_listening": "llm_response",
3255
+ "llm_response": "tts_subscribed",
3256
+ "tts_subscribed": "playback_started",
3257
+ "playback_started": "stt_listening",
3258
+ "motor_sequence": "stt_listening",
3259
+ }.get(stage, stage)
3260
+ status = "active"
3261
+ return {
3262
+ "stage": stage, "status": status,
3263
+ "label": PIPELINE_STAGE_LABELS.get(stage, stage),
3264
+ "message": latest.get("message") or PIPELINE_STAGE_LABELS.get(stage, stage),
3265
+ "since": latest.get("timestamp"),
3266
+ }
3267
+
3268
+ startup = (
3269
+ "scheduler_session", "livekit_join", "camera_published",
3270
+ "microphone_published", "voice_worker",
3271
+ )
3272
+ completed = {event.get("stage") for event in scoped if event.get("status") in {"ok", "skipped"}}
3273
+ stage = next((candidate for candidate in startup if candidate not in completed), "voice_worker")
3274
+ latest_startup = next((event for event in reversed(scoped) if event.get("stage") in startup), None)
3275
+ return {
3276
+ "stage": stage, "status": "active",
3277
+ "label": PIPELINE_STAGE_LABELS.get(stage, stage),
3278
+ "message": (latest_startup or {}).get("message") or "Production conversation is starting",
3279
+ "since": (latest_startup or {}).get("timestamp"),
3280
+ }
3281
+
3195
3282
  async def _insert_pipeline_event(device_id: str, payload: PipelineEventPayload) -> dict:
3196
3283
  if payload.stage not in PIPELINE_STAGES:
3197
3284
  raise HTTPException(422, f"Unknown pipeline stage: {payload.stage}")
@@ -3286,16 +3373,29 @@ async def robot_pipeline_status(robot_id: str, limit: int = 80):
3286
3373
  blockers.append("Microphone inventory has not been reported")
3287
3374
  if not prereqs["speaker_ready"]:
3288
3375
  blockers.append("Speaker inventory has not been reported")
3376
+ active = bool(latest_session and not latest_session["ended_at"])
3377
+ active_session_id = latest_session["id"] if active else None
3378
+ current_events = [
3379
+ event for event in events
3380
+ if not active_session_id or event.get("session_id") == active_session_id
3381
+ ]
3382
+ current_stage = _pipeline_current_state(
3383
+ current_events,
3384
+ active=active,
3385
+ production_mode=bool(device["production_mode"]),
3386
+ online=prereqs["robot_online"],
3387
+ )
3289
3388
  return {
3290
3389
  "robot_id": device_id,
3291
3390
  "robot_name": device["name"],
3292
- "overall": "blocked" if blockers else ("running" if latest_session and not latest_session["ended_at"] else "ready"),
3391
+ "overall": "blocked" if blockers else ("running" if active else "ready"),
3293
3392
  "blockers": list(dict.fromkeys(blockers)),
3294
3393
  "prerequisites": prereqs,
3295
3394
  "telemetry": telemetry,
3296
3395
  "latest_session": dict(latest_session) if latest_session else None,
3297
3396
  "events": events,
3298
3397
  "stages": list(PIPELINE_STAGES),
3398
+ "current_stage": current_stage,
3299
3399
  }
3300
3400
 
3301
3401
  @app.get("/api/robots/{robot_id}/pipeline-history")
@@ -4031,6 +4131,22 @@ async def test_robot_motor(robot_id: str, motor_id: str, duration_ms: int = 300)
4031
4131
  })
4032
4132
  return {"queued": True, "request_id": request_id, "device_id": model.id}
4033
4133
 
4134
+ @app.post("/api/robots/{robot_id}/motors/discover")
4135
+ async def discover_robot_motors(robot_id: str):
4136
+ """Import relays already registered by the robot-local motor server.
4137
+
4138
+ This is read-only on the robot. It never scans or pulses unregistered GPIO.
4139
+ The dashboard validates and persists the returned registry before testing.
4140
+ """
4141
+ device = await _resolve_device_by_id_or_name(robot_id)
4142
+ model = _device_row_to_model(device)
4143
+ if not model.motor_server_url:
4144
+ raise HTTPException(409, "Robot has no motor_server_url")
4145
+ request_id = await _queue_shell_request_async(model.id, "motor_discover", "motor_server", {
4146
+ "motor_server_url": model.motor_server_url,
4147
+ })
4148
+ return {"queued": True, "request_id": request_id, "device_id": model.id}
4149
+
4034
4150
  @app.post("/api/robots/{robot_id}/motors/test-all")
4035
4151
  async def test_all_robot_motors(robot_id: str, duration_ms: int = 300, pause_ms: int = 200):
4036
4152
  device = await _resolve_device_by_id_or_name(robot_id)
@@ -5170,13 +5286,20 @@ async def _robot_telemetry(db, robot_id: str) -> Optional[dict]:
5170
5286
 
5171
5287
  device_inventory = parse_json_field(device["device_inventory"], {}) if device else {}
5172
5288
  supervisor_status = parse_json_field(device["supervisor_status"], []) if device else []
5173
- session = None
5174
- if robot["current_session_id"]:
5289
+ session = None
5290
+ current_pipeline_events = []
5291
+ if robot["current_session_id"]:
5175
5292
  async with db.execute(
5176
5293
  "SELECT started_at, last_activity_at, ended_at FROM sessions WHERE id = ?",
5177
5294
  (robot["current_session_id"],),
5178
- ) as c:
5179
- session = await c.fetchone()
5295
+ ) as c:
5296
+ session = await c.fetchone()
5297
+ async with db.execute(
5298
+ "SELECT * FROM pipeline_events WHERE robot_id = ? AND session_id = ? "
5299
+ "ORDER BY timestamp, id",
5300
+ (robot_id, robot["current_session_id"]),
5301
+ ) as c:
5302
+ current_pipeline_events = [dict(row) for row in await c.fetchall()]
5180
5303
 
5181
5304
  now = datetime.utcnow()
5182
5305
  session_age = None
@@ -5259,8 +5382,14 @@ async def _robot_telemetry(db, robot_id: str) -> Optional[dict]:
5259
5382
  (robot_id,),
5260
5383
  ) as c:
5261
5384
  end_reasons = {r["end_reason"]: r["n"] for r in await c.fetchall()}
5262
- drops = sum(n for reason, n in end_reasons.items() if reason in ABNORMAL_END_REASONS)
5263
- drop_rate = round(drops / sessions_total, 4) if sessions_total else 0.0
5385
+ drops = sum(n for reason, n in end_reasons.items() if reason in ABNORMAL_END_REASONS)
5386
+ drop_rate = round(drops / sessions_total, 4) if sessions_total else 0.0
5387
+ current_stage = _pipeline_current_state(
5388
+ current_pipeline_events,
5389
+ active=bool(robot["current_session_id"] and session and not session["ended_at"]),
5390
+ production_mode=bool(device["production_mode"]) if device else False,
5391
+ online=heartbeat_age is not None and heartbeat_age <= 30,
5392
+ )
5264
5393
 
5265
5394
  return {
5266
5395
  "robot_id": robot_id,
@@ -5291,9 +5420,13 @@ async def _robot_telemetry(db, robot_id: str) -> Optional[dict]:
5291
5420
  "supervisor_status": supervisor_status,
5292
5421
  "supervisor_status_at": device["supervisor_status_at"] if device else None,
5293
5422
  "production_mode": bool(device["production_mode"]) if device else False,
5294
- "readiness_code": readiness_code,
5295
- "readiness_message": readiness_message,
5296
- }
5423
+ "readiness_code": readiness_code,
5424
+ "readiness_message": readiness_message,
5425
+ "current_stage": current_stage,
5426
+ "current_stage_id": current_stage["stage"],
5427
+ "current_stage_label": current_stage["label"],
5428
+ "current_stage_status": current_stage["status"],
5429
+ }
5297
5430
 
5298
5431
  @app.get("/api/robots/{robot_id}/telemetry")
5299
5432
  async def robot_telemetry(robot_id: str):
@@ -1303,7 +1303,7 @@ class LiveKitPublisher:
1303
1303
  self._echo_mic_floor = 0.0
1304
1304
  self._barge_in_candidate_frames = 0
1305
1305
  self._speaker_echo_tail = max(
1306
- 0.1, min(float(os.getenv("ROBOPARK_ECHO_TAIL_MS", "700")) / 1000.0, 2.0)
1306
+ 0.1, min(float(os.getenv("ROBOPARK_ECHO_TAIL_MS", "350")) / 1000.0, 2.0)
1307
1307
  )
1308
1308
  self._adaptive_barge_in = str(
1309
1309
  os.getenv("ROBOPARK_ADAPTIVE_BARGE_IN", "true")
@@ -1553,7 +1553,7 @@ class LiveKitPublisher:
1553
1553
  import threading
1554
1554
  write_queue: "queue.Queue[Optional[bytes]]" = queue.Queue()
1555
1555
  written_frames = [0]
1556
- PREBUFFER_CHUNKS = 5
1556
+ PREBUFFER_CHUNKS = 3
1557
1557
  stream_failed = threading.Event()
1558
1558
  playback_reported = threading.Event()
1559
1559
  event_loop = asyncio.get_running_loop()
@@ -427,6 +427,8 @@ def _handle_shell_command(identity, cmd: dict) -> None:
427
427
  result = _run_shell_command(params.get("name", ""), params)
428
428
  elif kind == "speaker_test":
429
429
  result = _speaker_roundtrip_test(params)
430
+ elif kind == "motor_discover":
431
+ result = _discover_motor_registry(params)
430
432
  elif kind == "motor_sequence":
431
433
  result = _run_motor_sequence(params)
432
434
  else:
@@ -434,6 +436,45 @@ def _handle_shell_command(identity, cmd: dict) -> None:
434
436
  _post_supervisor_output(identity, kind, service, result, request_id)
435
437
 
436
438
 
439
+ def _discover_motor_registry(params: dict) -> dict:
440
+ """Read the robot-local motor registry without touching any GPIO output."""
441
+ import httpx
442
+ import re
443
+
444
+ base = str(params.get("motor_server_url") or "http://127.0.0.1:8001").rstrip("/")
445
+ if not (base.startswith("http://127.0.0.1:") or base.startswith("http://localhost:")):
446
+ return {"ok": False, "error": "motor server must be robot-local"}
447
+ try:
448
+ with httpx.Client(timeout=5.0) as client:
449
+ response = client.get(f"{base}/list-motors")
450
+ response.raise_for_status()
451
+ raw_motors = response.json().get("motors", [])
452
+ registry, used_ids, used_gpios = [], set(), set()
453
+ for index, raw in enumerate(raw_motors[:32]):
454
+ gpio = int(raw.get("gpio", -1))
455
+ if gpio < 2 or gpio > 27 or gpio in used_gpios:
456
+ continue
457
+ name = str(raw.get("name") or f"Relay {index + 1}").strip()[:60]
458
+ base_id = re.sub(r"[^a-z0-9_-]+", "-", name.lower()).strip("-") or f"relay-{index + 1}"
459
+ motor_id, suffix = base_id[:32], 2
460
+ while motor_id in used_ids:
461
+ tail = f"-{suffix}"
462
+ motor_id = f"{base_id[:32-len(tail)]}{tail}"
463
+ suffix += 1
464
+ used_ids.add(motor_id)
465
+ used_gpios.add(gpio)
466
+ registry.append({
467
+ "id": motor_id,
468
+ "name": name,
469
+ "gpio": gpio,
470
+ "active_high": bool(raw.get("active_high", True)),
471
+ "max_duration_ms": 3000,
472
+ })
473
+ return {"ok": True, "registry": registry, "count": len(registry), "motor_server_url": base}
474
+ except Exception as exc:
475
+ return {"ok": False, "error": f"motor registry discovery failed: {type(exc).__name__}: {exc}"}
476
+
477
+
437
478
  def _run_motor_sequence(params: dict) -> dict:
438
479
  """Run one validated scheduler sequence against the robot-local motor API."""
439
480
  import httpx