infinicode 2.8.108 → 2.8.113

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.
@@ -219,9 +219,11 @@ class DeviceEnrollRequest(BaseModel):
219
219
 
220
220
  class DeviceBootstrapRequest(BaseModel):
221
221
  name: str
222
+ character_id: Optional[str] = None
222
223
  lan_ip: Optional[str] = None
223
224
  tailscale_ip: Optional[str] = None
224
225
  livekit_url: Optional[str] = None
226
+ motor_server_url: Optional[str] = None
225
227
 
226
228
  class DeviceEnrollResponse(BaseModel):
227
229
  device_id: str
@@ -235,6 +237,7 @@ class DeviceHeartbeat(BaseModel):
235
237
  production_mode: Optional[bool] = None
236
238
  device_inventory: Optional[dict] = None
237
239
  livekit_url: Optional[str] = None
240
+ motor_server_url: Optional[str] = None
238
241
 
239
242
  class PipelineEventPayload(BaseModel):
240
243
  stage: str
@@ -2982,25 +2985,25 @@ async def bootstrap_device(
2982
2985
  await db.execute(
2983
2986
  """UPDATE devices SET token_hash = ?, status = 'enrolled',
2984
2987
  lan_ip = COALESCE(?, lan_ip), tailscale_ip = COALESCE(?, tailscale_ip),
2985
- livekit_url = COALESCE(?, livekit_url),
2988
+ livekit_url = COALESCE(?, livekit_url), motor_server_url = COALESCE(?, motor_server_url),
2989
+ character_id = COALESCE(?, character_id),
2986
2990
  enrolled_at = COALESCE(enrolled_at, ?) WHERE id = ?""",
2987
- (new_hash, payload.lan_ip, payload.tailscale_ip, payload.livekit_url, now, device_id),
2991
+ (new_hash, payload.lan_ip, payload.tailscale_ip, payload.livekit_url,
2992
+ payload.motor_server_url, payload.character_id, now, device_id),
2988
2993
  )
2989
2994
  else:
2990
2995
  device_id = f"dev_{secrets.token_hex(4)}"
2991
2996
  await db.execute(
2992
2997
  """INSERT INTO devices
2993
- (id, name, lan_ip, tailscale_ip, livekit_url, token_hash, status, enrolled_at, created_at)
2994
- VALUES (?, ?, ?, ?, ?, ?, 'enrolled', ?, ?)""",
2995
- (device_id, name, payload.lan_ip, payload.tailscale_ip, payload.livekit_url, new_hash, now, now),
2998
+ (id, name, lan_ip, tailscale_ip, livekit_url, motor_server_url, character_id, token_hash, status, enrolled_at, created_at)
2999
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'enrolled', ?, ?)""",
3000
+ (device_id, name, payload.lan_ip, payload.tailscale_ip, payload.livekit_url,
3001
+ payload.motor_server_url, payload.character_id, new_hash, now, now),
2996
3002
  )
2997
3003
  await db.execute(
2998
3004
  "INSERT OR IGNORE INTO robots (id, name, status) VALUES (?, ?, 'idle')",
2999
3005
  (device_id, name),
3000
3006
  )
3001
- for field in ("motor_registry", "motor_sequences"):
3002
- if field in fields:
3003
- fields[field] = json.dumps(fields[field], separators=(",", ":"))
3004
3007
  await db.execute("UPDATE robots SET name = ? WHERE id = ?", (name, device_id))
3005
3008
  await db.commit()
3006
3009
 
@@ -3165,7 +3168,8 @@ async def device_heartbeat(device_id: str, payload: DeviceHeartbeat,
3165
3168
  lan_ip = COALESCE(?, lan_ip),
3166
3169
  last_seen_ip = COALESCE(?, last_seen_ip),
3167
3170
  device_inventory = COALESCE(?, device_inventory),
3168
- livekit_url = COALESCE(?, livekit_url)
3171
+ livekit_url = COALESCE(?, livekit_url),
3172
+ motor_server_url = COALESCE(?, motor_server_url)
3169
3173
  WHERE id = ?""",
3170
3174
  (
3171
3175
  payload.status or "online",
@@ -3174,6 +3178,7 @@ async def device_heartbeat(device_id: str, payload: DeviceHeartbeat,
3174
3178
  payload.ip,
3175
3179
  json.dumps(payload.device_inventory) if payload.device_inventory is not None else None,
3176
3180
  payload.livekit_url,
3181
+ payload.motor_server_url,
3177
3182
  device_id,
3178
3183
  ),
3179
3184
  ) as cur:
@@ -3738,6 +3743,49 @@ async def device_supervisor_output(device_id: str, payload: dict, authorization:
3738
3743
  session_id=result_payload.get("session_id"), source="robot_supervisor",
3739
3744
  details={"request_id": request_id, "duration_ms": result_payload.get("duration_ms")},
3740
3745
  ))
3746
+ if kind == "motor_discover" and ok and isinstance(result_payload.get("registry"), list):
3747
+ device = await _resolve_device_by_id_or_name(device_id)
3748
+ model = _device_row_to_model(device)
3749
+ discovered_ids = {
3750
+ str(item.get("id")) for item in result_payload["registry"] if isinstance(item, dict)
3751
+ }
3752
+ reconciled_sequences = []
3753
+ for sequence in model.motor_sequences:
3754
+ clean_steps = [
3755
+ step for step in (sequence.get("steps") or [])
3756
+ if str(step.get("motor_id")) in discovered_ids
3757
+ ]
3758
+ reconciled_sequences.append({**sequence, "steps": clean_steps})
3759
+ registry, sequences, greeting = _sanitize_motor_profile(MotorProfilePayload(
3760
+ registry=result_payload["registry"],
3761
+ sequences=reconciled_sequences,
3762
+ greeting_sequence_id=model.greeting_motor_sequence_id,
3763
+ ))
3764
+ result_payload.update({
3765
+ "registry": registry,
3766
+ "sequences": sequences,
3767
+ "greeting_sequence_id": greeting,
3768
+ "persisted": True,
3769
+ })
3770
+ async with aiosqlite.connect(DB_PATH) as db:
3771
+ await db.execute(
3772
+ "UPDATE devices SET motor_registry=?, motor_sequences=?, greeting_motor_sequence_id=? WHERE id=?",
3773
+ (json.dumps(registry), json.dumps(sequences), greeting, device_id),
3774
+ )
3775
+ await db.execute(
3776
+ "UPDATE shell_requests SET result_payload=? WHERE id=?",
3777
+ (json.dumps(result_payload), request_id),
3778
+ )
3779
+ await db.commit()
3780
+ character_id = await _sync_character_motor_sequences(device_id, sequences)
3781
+ result_payload["character_id"] = character_id
3782
+ result_payload["allowed_motor_sequences"] = [
3783
+ item["id"] for item in sequences if item.get("steps")
3784
+ ]
3785
+ await _log_history(
3786
+ "motor", "device", device_id, "registry_discovered", "robot_supervisor",
3787
+ f"motors={len(registry)}",
3788
+ )
3741
3789
  return {"status": "ok"}
3742
3790
 
3743
3791
 
@@ -4030,19 +4078,44 @@ def _sanitize_motor_profile(payload: MotorProfilePayload) -> tuple[list, list, O
4030
4078
  raise HTTPException(422, f"Invalid or duplicate sequence id: {sequence_id!r}")
4031
4079
  sequence_ids.add(sequence_id)
4032
4080
  steps = []
4081
+ total_duration_ms = 0
4033
4082
  for step in (raw.get("steps") or [])[:40]:
4034
4083
  motor_id = str(step.get("motor_id") or "")
4035
4084
  if motor_id not in ids:
4036
4085
  raise HTTPException(422, f"Sequence {sequence_id} references unknown motor {motor_id}")
4037
4086
  motor = next(item for item in registry if item["id"] == motor_id)
4038
4087
  duration = max(50, min(motor["max_duration_ms"], int(step.get("duration_ms", 500))))
4039
- steps.append({"motor_id": motor_id, "delay_ms": max(0, min(30000, int(step.get("delay_ms", 0)))), "duration_ms": duration})
4088
+ delay = max(0, min(30000, int(step.get("delay_ms", 0))))
4089
+ total_duration_ms += delay + duration
4090
+ if total_duration_ms > 60000:
4091
+ raise HTTPException(422, f"Sequence {sequence_id} exceeds the 60 second safety limit")
4092
+ steps.append({"motor_id": motor_id, "delay_ms": delay, "duration_ms": duration})
4040
4093
  sequences.append({"id": sequence_id, "name": str(raw.get("name") or sequence_id)[:80], "steps": steps})
4041
4094
  greeting = payload.greeting_sequence_id or None
4042
4095
  if greeting and greeting not in sequence_ids:
4043
4096
  raise HTTPException(422, "Greeting sequence does not exist")
4044
4097
  return registry, sequences, greeting
4045
4098
 
4099
+ async def _sync_character_motor_sequences(device_id: str, sequences: list) -> Optional[str]:
4100
+ """Point the assigned character at this robot's validated sequence IDs."""
4101
+ sequence_ids = [str(item["id"]) for item in sequences if item.get("id") and item.get("steps")]
4102
+ async with aiosqlite.connect(DB_PATH) as db:
4103
+ db.row_factory = aiosqlite.Row
4104
+ async with db.execute(
4105
+ "SELECT COALESCE(rvc.character_preset_id, d.character_id) AS character_id "
4106
+ "FROM devices d LEFT JOIN robot_voice_configs rvc ON rvc.robot_id=d.id WHERE d.id=?",
4107
+ (device_id,),
4108
+ ) as cursor:
4109
+ row = await cursor.fetchone()
4110
+ character_id = row["character_id"] if row else None
4111
+ if character_id:
4112
+ await db.execute(
4113
+ "UPDATE character_presets SET motors=?, updated_at=? WHERE id=?",
4114
+ (json.dumps(sequence_ids), datetime.utcnow().isoformat(), character_id),
4115
+ )
4116
+ await db.commit()
4117
+ return character_id
4118
+
4046
4119
  @app.get("/api/robots/{robot_id}/motor-profile")
4047
4120
  async def robot_motor_profile(robot_id: str):
4048
4121
  device = await _resolve_device_by_id_or_name(robot_id)
@@ -4059,8 +4132,11 @@ async def save_robot_motor_profile(robot_id: str, payload: MotorProfilePayload):
4059
4132
  await db.execute("UPDATE devices SET motor_registry=?, motor_sequences=?, greeting_motor_sequence_id=? WHERE id=?",
4060
4133
  (json.dumps(registry), json.dumps(sequences), greeting, device["id"]))
4061
4134
  await db.commit()
4135
+ character_id = await _sync_character_motor_sequences(device["id"], sequences)
4062
4136
  await _log_history("motor", "device", device["id"], "profile_saved", "operator", f"motors={len(registry)}, sequences={len(sequences)}")
4063
- return {"status": "ok", "robot_id": device["id"], "registry": registry, "sequences": sequences, "greeting_sequence_id": greeting}
4137
+ return {"status": "ok", "robot_id": device["id"], "registry": registry, "sequences": sequences,
4138
+ "greeting_sequence_id": greeting, "character_id": character_id,
4139
+ "allowed_motor_sequences": [item["id"] for item in sequences if item.get("steps")]}
4064
4140
 
4065
4141
  async def _queue_motor_sequence(device_id: str, sequence_id: str, session_id: Optional[str] = None) -> dict:
4066
4142
  device = await _resolve_device_by_id_or_name(device_id)
@@ -4068,6 +4144,8 @@ async def _queue_motor_sequence(device_id: str, sequence_id: str, session_id: Op
4068
4144
  sequence = next((item for item in model.motor_sequences if item.get("id") == sequence_id), None)
4069
4145
  if not sequence:
4070
4146
  raise HTTPException(404, "Motor sequence not found")
4147
+ if not sequence.get("steps"):
4148
+ raise HTTPException(409, "Motor sequence has no steps")
4071
4149
  if not model.motor_server_url:
4072
4150
  raise HTTPException(409, "Robot has no motor_server_url")
4073
4151
  request_id = await _queue_shell_request_async(model.id, "motor_sequence", "motor_server", {
@@ -4130,8 +4208,45 @@ async def run_room_motor_sequence(
4130
4208
  session = await cursor.fetchone()
4131
4209
  if not session:
4132
4210
  raise HTTPException(404, "No active session bound to this room")
4211
+ if agent_authorized and not fleet_authorized:
4212
+ async with aiosqlite.connect(DB_PATH) as db:
4213
+ db.row_factory = aiosqlite.Row
4214
+ async with db.execute(
4215
+ "SELECT COALESCE(rvc.character_preset_id, d.character_id) AS character_id "
4216
+ "FROM devices d LEFT JOIN robot_voice_configs rvc ON rvc.robot_id=d.id WHERE d.id=?",
4217
+ (session["robot_id"],),
4218
+ ) as cursor:
4219
+ character_row = await cursor.fetchone()
4220
+ character_id = character_row["character_id"] if character_row else None
4221
+ allowed = []
4222
+ if character_id:
4223
+ async with db.execute("SELECT motors FROM character_presets WHERE id=?", (character_id,)) as cursor:
4224
+ preset = await cursor.fetchone()
4225
+ if preset and preset["motors"]:
4226
+ try:
4227
+ allowed = [str(item) for item in json.loads(preset["motors"]) if isinstance(item, str)]
4228
+ except Exception:
4229
+ allowed = []
4230
+ if sequence_id not in allowed:
4231
+ raise HTTPException(
4232
+ 403,
4233
+ f"Character {character_id or 'unassigned'} is not allowed to run motor sequence {sequence_id}",
4234
+ )
4133
4235
  return await _queue_motor_sequence(session["robot_id"], sequence_id, session["id"])
4134
4236
 
4237
+ @app.post("/api/robots/{robot_id}/motors/stop")
4238
+ async def stop_robot_motors(robot_id: str):
4239
+ device = await _resolve_device_by_id_or_name(robot_id)
4240
+ model = _device_row_to_model(device)
4241
+ if not model.motor_server_url:
4242
+ raise HTTPException(409, "Robot has no motor_server_url")
4243
+ request_id = await _queue_shell_request_async(model.id, "motor_sequence", "motor_server", {
4244
+ "motor_server_url": model.motor_server_url,
4245
+ "stop_all": True,
4246
+ })
4247
+ await _log_history("motor", "device", model.id, "emergency_stop_queued", "operator", request_id)
4248
+ return {"queued": True, "request_id": request_id, "device_id": model.id}
4249
+
4135
4250
  @app.post("/api/robots/{robot_id}/motors/{motor_id}/test")
4136
4251
  async def test_robot_motor(robot_id: str, motor_id: str, duration_ms: int = 300):
4137
4252
  device = await _resolve_device_by_id_or_name(robot_id)
@@ -4425,13 +4540,14 @@ class VoiceStackCreate(BaseModel):
4425
4540
  wake_word_timeout: float = 3.0
4426
4541
 
4427
4542
  # Effective configuration returned to the ROBOVOICE agent for a given room/robot.
4428
- class RobotVoiceConfig(BaseModel):
4543
+ class RobotVoiceConfig(BaseModel):
4429
4544
  character_id: Optional[str] = None
4430
4545
  character_name: Optional[str] = None
4431
4546
  system_prompt: Optional[str] = None
4432
4547
  voice_stack_id: Optional[str] = None
4433
4548
  voice_stack: Optional[VoiceStack] = None
4434
- greeting_phrases: List[str] = []
4549
+ greeting_phrases: List[str] = []
4550
+ allowed_motor_sequences: List[str] = []
4435
4551
 
4436
4552
  def _load_robovoice_settings() -> dict:
4437
4553
  """Read the ROBOVOICE settings file. Returns {} if missing/invalid."""
@@ -4612,8 +4728,9 @@ async def _resolve_robot_voice_config(robot_id: str) -> RobotVoiceConfig:
4612
4728
 
4613
4729
  # Determine character preset + name
4614
4730
  character_id = None
4615
- character_name = None
4616
- system_prompt = None
4731
+ character_name = None
4732
+ system_prompt = None
4733
+ allowed_motor_sequences = []
4617
4734
  async with db.execute(
4618
4735
  "SELECT character_preset_id FROM robot_voice_configs WHERE robot_id = ?", (canonical_id,)
4619
4736
  ) as c:
@@ -4630,12 +4747,19 @@ async def _resolve_robot_voice_config(robot_id: str) -> RobotVoiceConfig:
4630
4747
  character_id = row["character_id"]
4631
4748
  if character_id:
4632
4749
  async with db.execute(
4633
- "SELECT name, description, system_prompt FROM character_presets WHERE id = ?", (character_id,)
4750
+ "SELECT name, description, system_prompt, motors FROM character_presets WHERE id = ?", (character_id,)
4634
4751
  ) as c:
4635
4752
  row = await c.fetchone()
4636
4753
  if row:
4637
- character_name = row["name"]
4638
- system_prompt = row["system_prompt"]
4754
+ character_name = row["name"]
4755
+ system_prompt = row["system_prompt"]
4756
+ try:
4757
+ allowed_motor_sequences = [
4758
+ str(item) for item in _json.loads(row["motors"] or "[]")
4759
+ if isinstance(item, str)
4760
+ ]
4761
+ except Exception:
4762
+ allowed_motor_sequences = []
4639
4763
  if not character_name:
4640
4764
  async with db.execute(
4641
4765
  "SELECT name FROM robots WHERE id = ? UNION ALL SELECT name FROM devices WHERE id = ?",
@@ -4663,8 +4787,9 @@ async def _resolve_robot_voice_config(robot_id: str) -> RobotVoiceConfig:
4663
4787
  character_name=character_name,
4664
4788
  system_prompt=system_prompt,
4665
4789
  voice_stack_id=voice_stack.id if voice_stack else None,
4666
- voice_stack=voice_stack,
4790
+ voice_stack=voice_stack,
4667
4791
  greeting_phrases=greeting_phrases,
4792
+ allowed_motor_sequences=allowed_motor_sequences,
4668
4793
  )
4669
4794
 
4670
4795
  async def _resolve_session_voice_config(session_row) -> RobotVoiceConfig:
@@ -59,7 +59,24 @@ BACKOFF_BASE_SECONDS = 2.0
59
59
  BACKOFF_MAX_SECONDS = 60.0
60
60
  LOG_ROTATE_BYTES = 5 * 1024 * 1024 # rotate a service's log past 5MB
61
61
  POLL_INTERVAL_SECONDS = 2.0
62
- STATUS_REPORT_INTERVAL_SECONDS = 10.0
62
+ STATUS_REPORT_INTERVAL_SECONDS = 10.0
63
+
64
+
65
+ def _scheduler_headers(token: str) -> dict[str, str]:
66
+ """Authenticate the enrolled device and, when present, the mesh proxy."""
67
+ headers = {"Authorization": f"Bearer {token}"}
68
+ mesh_token = os.getenv("ROBOPARK_MESH_TOKEN", "").strip()
69
+ if mesh_token:
70
+ headers["X-RoboPark-Mesh-Token"] = mesh_token
71
+ return headers
72
+
73
+
74
+ def _motor_headers() -> dict[str, str]:
75
+ token = (
76
+ os.getenv("ROBOPARK_MOTOR_TOKEN", "").strip()
77
+ or os.getenv("ROBOPARK_MESH_TOKEN", "").strip()
78
+ )
79
+ return {"X-RoboPark-Motor-Token": token} if token else {}
63
80
 
64
81
 
65
82
  def _load_robopark_identity() -> Optional[tuple[str, str, str]]:
@@ -97,7 +114,7 @@ def _report_status(states: "list[ServiceState]", identity: tuple[str, str, str])
97
114
  payload = {"services": [s.status_dict() for s in states]}
98
115
  url = f"{scheduler_url.rstrip('/')}/api/devices/{device_id}/supervisor-status"
99
116
  try:
100
- resp = httpx.post(url, json=payload, headers={"Authorization": f"Bearer {token}"}, timeout=5.0)
117
+ resp = httpx.post(url, json=payload, headers=_scheduler_headers(token), timeout=5.0)
101
118
  resp.raise_for_status()
102
119
  return resp.json().get("commands", [])
103
120
  except Exception as e:
@@ -398,7 +415,7 @@ def _post_supervisor_output(identity, kind: str, service: Optional[str], payload
398
415
  url = f"{scheduler_url.rstrip('/')}/api/devices/{device_id}/supervisor-output"
399
416
  body = {"kind": kind, "service": service, "payload": payload, "request_id": request_id}
400
417
  try:
401
- httpx.post(url, json=body, headers={"Authorization": f"Bearer {token}"}, timeout=5.0)
418
+ httpx.post(url, json=body, headers=_scheduler_headers(token), timeout=5.0)
402
419
  except Exception as e:
403
420
  logger.debug(f"supervisor-output POST failed: {e}")
404
421
 
@@ -445,7 +462,7 @@ def _discover_motor_registry(params: dict) -> dict:
445
462
  if not (base.startswith("http://127.0.0.1:") or base.startswith("http://localhost:")):
446
463
  return {"ok": False, "error": "motor server must be robot-local"}
447
464
  try:
448
- with httpx.Client(timeout=5.0) as client:
465
+ with httpx.Client(timeout=5.0, headers=_motor_headers()) as client:
449
466
  response = client.get(f"{base}/list-motors")
450
467
  response.raise_for_status()
451
468
  raw_motors = response.json().get("motors", [])
@@ -487,7 +504,11 @@ def _run_motor_sequence(params: dict) -> dict:
487
504
  completed = []
488
505
  gpio_log = []
489
506
  try:
490
- with httpx.Client(timeout=8.0) as client:
507
+ with httpx.Client(timeout=8.0, headers=_motor_headers()) as client:
508
+ if params.get("stop_all"):
509
+ response = client.post(f"{base}/stop-motors", json={})
510
+ response.raise_for_status()
511
+ return {"ok": True, "stopped": True, "sequence_id": "emergency-stop"}
491
512
  existing = client.get(f"{base}/list-motors").json().get("motors", [])
492
513
  existing_names = {str(item.get("name")) for item in existing}
493
514
  for motor_id, motor in registry.items():
@@ -556,7 +577,7 @@ def _run_motor_sequence(params: dict) -> dict:
556
577
  completed.append({"motor_id": motor_id, "gpio": int(motor["gpio"]), "duration_ms": duration_ms})
557
578
  except Exception as exc:
558
579
  try:
559
- httpx.post(f"{base}/stop-motors", json={}, timeout=3.0)
580
+ httpx.post(f"{base}/stop-motors", json={}, headers=_motor_headers(), timeout=3.0)
560
581
  except Exception:
561
582
  pass
562
583
  return {"ok": False, "error": f"{type(exc).__name__}: {exc}", "completed_steps": completed,
@@ -1095,12 +1116,17 @@ def _speaker_roundtrip_test(params: dict) -> dict:
1095
1116
  }
1096
1117
 
1097
1118
 
1098
- def run(config_path: Path) -> None:
1099
- global _CURRENT_LOG_DIR
1100
- services, log_dir = _load_config(config_path)
1119
+ def run(config_path: Path, commands_only: bool = False) -> None:
1120
+ global _CURRENT_LOG_DIR
1121
+ if commands_only:
1122
+ # Unified robot-runtime already owns preview, vision, audio and motors.
1123
+ # Do not require a legacy supervisor config or launch duplicate owners.
1124
+ services, log_dir = [], LOG_DIR
1125
+ else:
1126
+ services, log_dir = _load_config(config_path)
1101
1127
  _CURRENT_LOG_DIR = log_dir
1102
1128
  enabled = [s for s in services if s.enabled]
1103
- if not enabled:
1129
+ if not enabled and not commands_only:
1104
1130
  raise SystemExit(f"No enabled services in {config_path} — nothing to supervise.")
1105
1131
 
1106
1132
  states = [ServiceState(spec=s) for s in enabled]
@@ -1116,7 +1142,10 @@ def run(config_path: Path) -> None:
1116
1142
  signal.signal(signal.SIGINT, _on_signal)
1117
1143
  signal.signal(signal.SIGTERM, _on_signal)
1118
1144
 
1119
- logger.info(f"supervising {len(states)} service(s): {', '.join(s.spec.name for s in states)}")
1145
+ if commands_only:
1146
+ logger.info("command-only supervisor active; waiting for scheduler requests")
1147
+ else:
1148
+ logger.info(f"supervising {len(states)} service(s): {', '.join(s.spec.name for s in states)}")
1120
1149
 
1121
1150
  last_status_report_at = 0.0
1122
1151
  try:
@@ -1157,7 +1186,7 @@ def run(config_path: Path) -> None:
1157
1186
  _execute_command(target, cmd.get("action", "restart"))
1158
1187
  elif kind == "supervisor_action":
1159
1188
  logger.warning(f"remote command for unknown service {cmd.get('service_name')!r} ignored")
1160
- elif kind in ("shell_run", "tail_logs", "speaker_test", "motor_sequence"):
1189
+ elif kind in ("shell_run", "tail_logs", "speaker_test", "motor_discover", "motor_sequence"):
1161
1190
  # run on a background thread so we don't block
1162
1191
  # the 2s poll loop on a slow command
1163
1192
  import threading
@@ -1181,9 +1210,11 @@ def main() -> None:
1181
1210
  sys.stderr.reconfigure(line_buffering=True)
1182
1211
  logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
1183
1212
  parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
1184
- parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG_FILE, help="path to supervisor.json")
1185
- args = parser.parse_args()
1186
- run(args.config)
1213
+ parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG_FILE, help="path to supervisor.json")
1214
+ parser.add_argument("--commands-only", action="store_true",
1215
+ help="consume remote commands without launching duplicate robot services")
1216
+ args = parser.parse_args()
1217
+ run(args.config, commands_only=args.commands_only)
1187
1218
 
1188
1219
 
1189
1220
  if __name__ == "__main__":
@@ -1,30 +1,50 @@
1
- from fastapi import FastAPI, HTTPException
1
+ from fastapi import FastAPI, HTTPException, Request
2
+ from fastapi.responses import JSONResponse
2
3
  from fastapi.middleware.cors import CORSMiddleware
3
4
  from pydantic import BaseModel
4
5
  from typing import Optional, List
5
6
  import time
6
7
  import threading
7
- import json
8
+ import json
8
9
  import os
10
+ import hmac
9
11
 
10
12
  try:
11
13
  import lgpio
12
14
  except ImportError:
13
15
  lgpio = None
14
16
 
15
- app = FastAPI(title="Motor Control API", version="1.0.0")
16
-
17
- # CORS Configuration - Allow all origins for cross-origin requests
18
- app.add_middleware(
19
- CORSMiddleware,
20
- allow_origins=["*"], # Allows all origins
21
- allow_credentials=True,
22
- allow_methods=["*"], # Allows all methods (GET, POST, PUT, DELETE, OPTIONS)
23
- allow_headers=["*"], # Allows all headers
24
- )
17
+ app = FastAPI(title="Motor Control API", version="1.1.0")
18
+
19
+ MOTOR_API_TOKEN = (
20
+ os.getenv("ROBOPARK_MOTOR_TOKEN", "").strip()
21
+ or os.getenv("ROBOPARK_MESH_TOKEN", "").strip()
22
+ )
23
+
24
+
25
+ @app.middleware("http")
26
+ async def require_motor_token(request: Request, call_next):
27
+ """Keep actuation private even if an operator accidentally changes the bind host."""
28
+ if MOTOR_API_TOKEN and request.url.path not in {"/", "/status"}:
29
+ supplied = request.headers.get("x-robopark-motor-token", "").strip()
30
+ if not supplied or not hmac.compare_digest(supplied, MOTOR_API_TOKEN):
31
+ return JSONResponse(status_code=401, content={"detail": "Invalid or missing motor token"})
32
+ return await call_next(request)
33
+
34
+ # The dashboard never calls this service directly; robot-local services do.
35
+ app.add_middleware(
36
+ CORSMiddleware,
37
+ allow_origins=["http://127.0.0.1", "http://localhost"],
38
+ allow_credentials=False,
39
+ allow_methods=["GET", "POST", "PUT", "DELETE"],
40
+ allow_headers=["content-type", "x-robopark-motor-token"],
41
+ )
25
42
 
26
43
  # Storage file
27
- MOTORS_FILE = "motors.json"
44
+ MOTORS_FILE = os.getenv(
45
+ "ROBOPARK_MOTORS_FILE",
46
+ os.path.expanduser("~/.robopark/motors.json"),
47
+ )
28
48
 
29
49
  # In-memory storage
30
50
  motors = {}
@@ -63,11 +83,16 @@ def load_motors():
63
83
  print(f"[ERROR] Failed to load motors: {e}")
64
84
  motors = {}
65
85
 
66
- def save_motors():
86
+ def save_motors():
67
87
  """Save motors to motors.json file"""
68
88
  try:
69
- with open(MOTORS_FILE, 'w') as f:
70
- json.dump(motors, f, indent=2)
89
+ os.makedirs(os.path.dirname(os.path.abspath(MOTORS_FILE)), exist_ok=True)
90
+ temporary = f"{MOTORS_FILE}.tmp"
91
+ with open(temporary, 'w') as f:
92
+ json.dump(motors, f, indent=2)
93
+ f.flush()
94
+ os.fsync(f.fileno())
95
+ os.replace(temporary, MOTORS_FILE)
71
96
  print(f"[SAVE] Motors saved to {MOTORS_FILE}")
72
97
  except Exception as e:
73
98
  print(f"[ERROR] Failed to save motors: {e}")
@@ -167,8 +192,9 @@ def shutdown_gpio() -> None:
167
192
  @app.get("/")
168
193
  async def root():
169
194
  return {
170
- "message": "Motor Control API",
171
- "version": "1.0.0",
195
+ "message": "Motor Control API",
196
+ "version": "1.1.0",
197
+ "authentication": "token" if MOTOR_API_TOKEN else "localhost-only",
172
198
  "endpoints": ["/status", "/test", "/add-motor", "/trigger-motor", "/list-motors", "/stop-motors"]
173
199
  }
174
200