infinicode 2.8.97 → 2.8.99

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.
@@ -403,7 +403,7 @@ def _post_supervisor_output(identity, kind: str, service: Optional[str], payload
403
403
  logger.debug(f"supervisor-output POST failed: {e}")
404
404
 
405
405
 
406
- def _handle_shell_command(identity, cmd: dict) -> None:
406
+ def _handle_shell_command(identity, cmd: dict) -> None:
407
407
  """Process a single shell/log request returned by the scheduler.
408
408
 
409
409
  `cmd` is {id, kind: "shell_run"|"tail_logs"|"speaker_test", service,
@@ -425,11 +425,89 @@ def _handle_shell_command(identity, cmd: dict) -> None:
425
425
  result = _tail_log_file(log_path, int(params.get("lines", 200)))
426
426
  elif kind == "shell_run":
427
427
  result = _run_shell_command(params.get("name", ""), params)
428
- elif kind == "speaker_test":
429
- result = _speaker_roundtrip_test(params)
428
+ elif kind == "speaker_test":
429
+ result = _speaker_roundtrip_test(params)
430
+ elif kind == "motor_sequence":
431
+ result = _run_motor_sequence(params)
430
432
  else:
431
433
  result = {"ok": False, "error": f"unknown shell command kind: {kind!r}"}
432
- _post_supervisor_output(identity, kind, service, result, request_id)
434
+ _post_supervisor_output(identity, kind, service, result, request_id)
435
+
436
+
437
+ def _run_motor_sequence(params: dict) -> dict:
438
+ """Run one validated scheduler sequence against the robot-local motor API."""
439
+ import httpx
440
+ base = str(params.get("motor_server_url") or "http://127.0.0.1:8001").rstrip("/")
441
+ if not (base.startswith("http://127.0.0.1:") or base.startswith("http://localhost:")):
442
+ return {"ok": False, "error": "motor server must be robot-local"}
443
+ registry = {str(item.get("id")): item for item in (params.get("registry") or [])}
444
+ steps = params.get("steps") or []
445
+ started = time.monotonic()
446
+ completed = []
447
+ try:
448
+ with httpx.Client(timeout=8.0) as client:
449
+ existing = client.get(f"{base}/list-motors").json().get("motors", [])
450
+ existing_names = {str(item.get("name")) for item in existing}
451
+ for motor_id, motor in registry.items():
452
+ body = {"name": motor_id, "gpio": int(motor["gpio"]), "active_high": bool(motor.get("active_high", True))}
453
+ response = (client.put(f"{base}/update-motor/{motor_id}", json=body)
454
+ if motor_id in existing_names else client.post(f"{base}/add-motor", json=body))
455
+ response.raise_for_status()
456
+ if params.get("test_all"):
457
+ duration_ms = max(50, min(1000, int(params.get("duration_ms", 300))))
458
+ pause_ms = max(0, min(2000, int(params.get("pause_ms", 200))))
459
+ response = client.post(f"{base}/test", json={
460
+ "seconds_on": duration_ms / 1000.0,
461
+ "seconds_pause": pause_ms / 1000.0,
462
+ "pins": [int(motor["gpio"]) for motor in registry.values()],
463
+ })
464
+ response.raise_for_status()
465
+ deadline = time.monotonic() + len(registry) * ((duration_ms + pause_ms) / 1000.0) + 3.0
466
+ while time.monotonic() < deadline:
467
+ status = client.get(f"{base}/status").json()
468
+ if status.get("status") == "idle":
469
+ if status.get("error"):
470
+ raise RuntimeError(f"registered GPIO test failed: {status['error']}")
471
+ completed = [{"motor_id": motor_id, "gpio": int(motor["gpio"])}
472
+ for motor_id, motor in registry.items()]
473
+ break
474
+ time.sleep(0.05)
475
+ else:
476
+ raise TimeoutError("registered GPIO test did not return to idle")
477
+ for index, step in enumerate(steps):
478
+ motor_id = str(step.get("motor_id"))
479
+ motor = registry.get(motor_id)
480
+ if not motor:
481
+ raise ValueError(f"step {index + 1} references unknown motor {motor_id}")
482
+ delay_ms = max(0, min(30000, int(step.get("delay_ms", 0))))
483
+ duration_ms = max(50, min(int(motor.get("max_duration_ms", 3000)), int(step.get("duration_ms", 500))))
484
+ if delay_ms:
485
+ time.sleep(delay_ms / 1000.0)
486
+ response = client.post(f"{base}/trigger-motor", json={"motor_name": motor_id, "seconds": duration_ms / 1000.0})
487
+ response.raise_for_status()
488
+ deadline = time.monotonic() + duration_ms / 1000.0 + 2.0
489
+ while time.monotonic() < deadline:
490
+ status_response = client.get(f"{base}/status")
491
+ status_response.raise_for_status()
492
+ status = status_response.json()
493
+ if status.get("status") == "idle":
494
+ if status.get("error"):
495
+ raise RuntimeError(f"motor {motor_id} failed: {status['error']}")
496
+ break
497
+ time.sleep(0.05)
498
+ else:
499
+ raise TimeoutError(f"motor {motor_id} did not return to idle")
500
+ completed.append({"motor_id": motor_id, "duration_ms": duration_ms})
501
+ except Exception as exc:
502
+ try:
503
+ httpx.post(f"{base}/stop-motors", json={}, timeout=3.0)
504
+ except Exception:
505
+ pass
506
+ return {"ok": False, "error": f"{type(exc).__name__}: {exc}", "completed_steps": completed,
507
+ "duration_ms": int((time.monotonic() - started) * 1000), "session_id": params.get("session_id")}
508
+ return {"ok": True, "pass": True, "sequence_id": params.get("sequence_id"), "completed_steps": completed,
509
+ "duration_ms": int((time.monotonic() - started) * 1000), "motor_server_url": base,
510
+ "session_id": params.get("session_id")}
433
511
 
434
512
 
435
513
  # ── Speaker roundtrip test (C2 gap-fill) ──
@@ -1021,7 +1099,7 @@ def run(config_path: Path) -> None:
1021
1099
  _execute_command(target, cmd.get("action", "restart"))
1022
1100
  elif kind == "supervisor_action":
1023
1101
  logger.warning(f"remote command for unknown service {cmd.get('service_name')!r} ignored")
1024
- elif kind in ("shell_run", "tail_logs", "speaker_test"):
1102
+ elif kind in ("shell_run", "tail_logs", "speaker_test", "motor_sequence"):
1025
1103
  # run on a background thread so we don't block
1026
1104
  # the 2s poll loop on a slow command
1027
1105
  import threading
@@ -1,11 +1,16 @@
1
1
  from fastapi import FastAPI, HTTPException
2
2
  from fastapi.middleware.cors import CORSMiddleware
3
- from pydantic import BaseModel
3
+ from pydantic import BaseModel
4
4
  from typing import Optional, List
5
5
  import time
6
6
  import threading
7
7
  import json
8
- import os
8
+ import os
9
+
10
+ try:
11
+ import lgpio
12
+ except ImportError:
13
+ lgpio = None
9
14
 
10
15
  app = FastAPI(title="Motor Control API", version="1.0.0")
11
16
 
@@ -27,7 +32,11 @@ active_motor = None
27
32
  remaining_seconds = 0
28
33
  last_action = "None"
29
34
  motor_thread = None
30
- stop_flag = False
35
+ stop_flag = False
36
+ motor_error = None
37
+ gpio_handle = None
38
+ claimed_pins = set()
39
+ motor_lock = threading.Lock()
31
40
 
32
41
  # Load motors from file on startup
33
42
  def load_motors():
@@ -35,8 +44,16 @@ def load_motors():
35
44
  global motors
36
45
  try:
37
46
  if os.path.exists(MOTORS_FILE):
38
- with open(MOTORS_FILE, 'r') as f:
39
- motors = json.load(f)
47
+ with open(MOTORS_FILE, 'r') as f:
48
+ motors = json.load(f)
49
+ motors = {
50
+ str(name): ({"name": str(name), "gpio": int(value), "active_high": True}
51
+ if not isinstance(value, dict) else {
52
+ "name": str(value.get("name") or name), "gpio": int(value["gpio"]),
53
+ "active_high": bool(value.get("active_high", True)),
54
+ })
55
+ for name, value in motors.items()
56
+ }
40
57
  print(f"[LOAD] Loaded {len(motors)} motors from {MOTORS_FILE}")
41
58
  for name, gpio in motors.items():
42
59
  print(f" - {name}: GPIO {gpio}")
@@ -59,48 +76,92 @@ def save_motors():
59
76
  load_motors()
60
77
 
61
78
  # Request Models
62
- class MotorConfig(BaseModel):
63
- name: str
64
- gpio: int
79
+ class MotorConfig(BaseModel):
80
+ name: str
81
+ gpio: int
82
+ active_high: bool = True
65
83
 
66
- class UpdateMotorConfig(BaseModel):
67
- name: str
68
- gpio: int
84
+ class UpdateMotorConfig(BaseModel):
85
+ name: str
86
+ gpio: int
87
+ active_high: bool = True
69
88
 
70
- class TriggerMotor(BaseModel):
71
- motor_name: str
72
- seconds: int = 5
89
+ class TriggerMotor(BaseModel):
90
+ motor_name: str
91
+ seconds: float = 1.0
73
92
 
74
93
  class StopMotors(BaseModel):
75
94
  motor_name: Optional[str] = None
76
95
 
77
- class TestConfig(BaseModel):
78
- seconds_on: int = 2
79
- seconds_pause: int = 1
96
+ class TestConfig(BaseModel):
97
+ seconds_on: float = 0.3
98
+ seconds_pause: float = 0.2
80
99
  pins: Optional[List[int]] = None
81
100
 
82
101
  # Helper Functions
83
- def run_motor_thread(motor_name: str, duration: int):
84
- global active_motor, remaining_seconds, last_action, stop_flag
85
-
86
- active_motor = motor_name
87
- remaining_seconds = duration
88
- last_action = f"Triggered {motor_name}"
89
-
90
- print(f"[MOTOR] Starting {motor_name} for {duration} seconds")
91
-
92
- # Simulate motor running
93
- for i in range(duration):
94
- if stop_flag:
95
- print(f"[MOTOR] {motor_name} stopped by user")
96
- break
97
- remaining_seconds = duration - i
98
- time.sleep(1)
99
-
100
- active_motor = None
101
- remaining_seconds = 0
102
- stop_flag = False
103
- print(f"[MOTOR] {motor_name} completed")
102
+ def _motor_value(motor: dict, active: bool) -> int:
103
+ return int(active == bool(motor.get("active_high", True)))
104
+
105
+ def _claim_motor(motor: dict) -> None:
106
+ global gpio_handle
107
+ pin = int(motor["gpio"])
108
+ if pin < 2 or pin > 27:
109
+ raise ValueError(f"BCM GPIO {pin} is outside 2..27")
110
+ if lgpio is None:
111
+ raise RuntimeError("lgpio is not installed; refusing simulated production actuation")
112
+ if gpio_handle is None:
113
+ gpio_handle = lgpio.gpiochip_open(0)
114
+ if pin not in claimed_pins:
115
+ lgpio.gpio_claim_output(gpio_handle, pin, _motor_value(motor, False))
116
+ claimed_pins.add(pin)
117
+
118
+ def _write_motor(motor: dict, active: bool) -> None:
119
+ _claim_motor(motor)
120
+ lgpio.gpio_write(gpio_handle, int(motor["gpio"]), _motor_value(motor, active))
121
+
122
+ def _all_off() -> None:
123
+ for motor in motors.values():
124
+ try:
125
+ if isinstance(motor, dict):
126
+ _write_motor(motor, False)
127
+ except Exception as exc:
128
+ print(f"[GPIO] failed to de-energize {motor}: {exc}")
129
+
130
+ def run_motor_thread(motor_name: str, duration: float):
131
+ global active_motor, remaining_seconds, last_action, stop_flag, motor_error
132
+
133
+ motor = motors[motor_name]
134
+ started = time.monotonic()
135
+ print(f"[MOTOR] Starting {motor_name} for {duration:.3f} seconds")
136
+ try:
137
+ _write_motor(motor, True)
138
+ while not stop_flag and time.monotonic() - started < duration:
139
+ remaining_seconds = max(0, duration - (time.monotonic() - started))
140
+ time.sleep(min(0.05, remaining_seconds or 0.01))
141
+ except Exception as exc:
142
+ motor_error = f"{type(exc).__name__}: {exc}"
143
+ last_action = f"Failed {motor_name}: {motor_error}"
144
+ print(f"[ERROR] Motor {motor_name} failed: {motor_error}")
145
+ finally:
146
+ try:
147
+ _write_motor(motor, False)
148
+ finally:
149
+ with motor_lock:
150
+ active_motor = None
151
+ remaining_seconds = 0
152
+ stop_flag = False
153
+ print(f"[MOTOR] {motor_name} completed and GPIO is OFF")
154
+
155
+ @app.on_event("shutdown")
156
+ def shutdown_gpio() -> None:
157
+ global gpio_handle
158
+ _all_off()
159
+ if lgpio is not None and gpio_handle is not None:
160
+ try:
161
+ lgpio.gpiochip_close(gpio_handle)
162
+ finally:
163
+ gpio_handle = None
164
+ claimed_pins.clear()
104
165
 
105
166
  # API Endpoints
106
167
  @app.get("/")
@@ -118,18 +179,24 @@ async def get_status():
118
179
  "status": status,
119
180
  "active_motor": active_motor,
120
181
  "remaining_seconds": remaining_seconds,
121
- "last_action": last_action,
182
+ "last_action": last_action,
183
+ "error": motor_error,
122
184
  "total_motors": len(motors)
123
185
  }
124
186
 
125
187
  @app.post("/add-motor")
126
- async def add_motor(config: MotorConfig):
127
- if config.name in motors:
128
- raise HTTPException(status_code=400, detail=f"Motor '{config.name}' already exists")
129
-
130
- motors[config.name] = {
131
- "name": config.name,
132
- "gpio": config.gpio
188
+ async def add_motor(config: MotorConfig):
189
+ if config.gpio < 2 or config.gpio > 27:
190
+ raise HTTPException(status_code=422, detail="BCM GPIO must be in range 2..27")
191
+ if config.name in motors:
192
+ raise HTTPException(status_code=400, detail=f"Motor '{config.name}' already exists")
193
+ if any(int(item["gpio"]) == config.gpio for item in motors.values()):
194
+ raise HTTPException(status_code=409, detail=f"BCM GPIO {config.gpio} is already registered")
195
+
196
+ motors[config.name] = {
197
+ "name": config.name,
198
+ "gpio": config.gpio,
199
+ "active_high": config.active_high,
133
200
  }
134
201
  save_motors()
135
202
 
@@ -151,22 +218,27 @@ async def list_motors():
151
218
  }
152
219
 
153
220
  @app.put("/update-motor/{motor_name}")
154
- async def update_motor(motor_name: str, config: UpdateMotorConfig):
221
+ async def update_motor(motor_name: str, config: UpdateMotorConfig):
222
+ if config.gpio < 2 or config.gpio > 27:
223
+ raise HTTPException(status_code=422, detail="BCM GPIO must be in range 2..27")
155
224
  if motor_name not in motors:
156
225
  raise HTTPException(status_code=404, detail=f"Motor '{motor_name}' not found")
157
226
 
158
227
  if active_motor == motor_name:
159
228
  raise HTTPException(status_code=409, detail=f"Cannot update motor '{motor_name}' while it is running")
160
229
 
161
- if config.name != motor_name and config.name in motors:
162
- raise HTTPException(status_code=400, detail=f"Motor name '{config.name}' already exists")
230
+ if config.name != motor_name and config.name in motors:
231
+ raise HTTPException(status_code=400, detail=f"Motor name '{config.name}' already exists")
232
+ if any(name != motor_name and int(item["gpio"]) == config.gpio for name, item in motors.items()):
233
+ raise HTTPException(status_code=409, detail=f"BCM GPIO {config.gpio} is already registered")
163
234
 
164
235
  if config.name != motor_name:
165
236
  del motors[motor_name]
166
237
 
167
- motors[config.name] = {
168
- "name": config.name,
169
- "gpio": config.gpio
238
+ motors[config.name] = {
239
+ "name": config.name,
240
+ "gpio": config.gpio,
241
+ "active_high": config.active_high,
170
242
  }
171
243
  save_motors()
172
244
 
@@ -198,18 +270,30 @@ async def delete_motor(motor_name: str):
198
270
  }
199
271
 
200
272
  @app.post("/trigger-motor")
201
- async def trigger_motor(config: TriggerMotor):
202
- global motor_thread, stop_flag
273
+ async def trigger_motor(config: TriggerMotor):
274
+ global motor_thread, stop_flag, active_motor, remaining_seconds, last_action, motor_error
203
275
 
204
276
  if config.motor_name not in motors:
205
277
  raise HTTPException(status_code=404, detail=f"Motor '{config.motor_name}' not found")
206
278
 
207
- if active_motor:
208
- raise HTTPException(status_code=409, detail=f"Motor '{active_motor}' is already running")
209
-
210
- stop_flag = False
211
- motor_thread = threading.Thread(target=run_motor_thread, args=(config.motor_name, config.seconds))
212
- motor_thread.start()
279
+ if config.seconds < 0.05 or config.seconds > 10.0:
280
+ raise HTTPException(status_code=422, detail="Pulse duration must be 0.05..10 seconds")
281
+
282
+ try:
283
+ _claim_motor(motors[config.motor_name])
284
+ except Exception as exc:
285
+ raise HTTPException(status_code=503, detail=f"GPIO is unavailable: {exc}") from exc
286
+
287
+ with motor_lock:
288
+ if active_motor:
289
+ raise HTTPException(status_code=409, detail=f"Motor '{active_motor}' is already running")
290
+ stop_flag = False
291
+ motor_error = None
292
+ active_motor = config.motor_name
293
+ remaining_seconds = config.seconds
294
+ last_action = f"Triggered {config.motor_name}"
295
+ motor_thread = threading.Thread(target=run_motor_thread, args=(config.motor_name, config.seconds), daemon=True)
296
+ motor_thread.start()
213
297
 
214
298
  return {
215
299
  "status": "success",
@@ -223,18 +307,20 @@ async def stop_motors(config: Optional[StopMotors] = None):
223
307
  global stop_flag, last_action
224
308
 
225
309
  try:
226
- if not active_motor:
227
- return {
310
+ if not active_motor:
311
+ _all_off()
312
+ return {
228
313
  "status": "success",
229
314
  "message": "No motors are currently running"
230
315
  }
231
316
 
232
- stop_flag = True
233
- last_action = f"Stopped {active_motor}"
317
+ stop_flag = True
318
+ last_action = f"Stopped {active_motor}"
234
319
 
235
320
  # Wait for thread to finish
236
- if motor_thread and motor_thread.is_alive():
237
- motor_thread.join(timeout=2)
321
+ if motor_thread and motor_thread.is_alive():
322
+ motor_thread.join(timeout=2)
323
+ _all_off()
238
324
 
239
325
  return {
240
326
  "status": "success",
@@ -252,13 +338,30 @@ async def stop_motors(config: Optional[StopMotors] = None):
252
338
  }
253
339
 
254
340
  @app.post("/test")
255
- async def run_test(config: TestConfig):
256
- global last_action
257
-
258
- if active_motor:
259
- raise HTTPException(status_code=409, detail="A motor is already running")
260
-
261
- pins = config.pins if config.pins else list(range(1, 28))
341
+ async def run_test(config: TestConfig):
342
+ global last_action, active_motor, motor_error
343
+
344
+ if config.seconds_on < 0.05 or config.seconds_on > 10.0:
345
+ raise HTTPException(status_code=422, detail="Test pulse duration must be 0.05..10 seconds")
346
+ if config.seconds_pause < 0 or config.seconds_pause > 10.0:
347
+ raise HTTPException(status_code=422, detail="Test pause must be 0..10 seconds")
348
+
349
+ registered_pins = {int(m["gpio"]) for m in motors.values() if isinstance(m, dict)}
350
+ pins = config.pins if config.pins else sorted(registered_pins)
351
+ if not pins or any(pin not in registered_pins for pin in pins):
352
+ raise HTTPException(status_code=422, detail="Relay tests are limited to registered GPIO pins")
353
+ try:
354
+ for motor in motors.values():
355
+ if int(motor["gpio"]) in pins:
356
+ _claim_motor(motor)
357
+ except Exception as exc:
358
+ raise HTTPException(status_code=503, detail=f"GPIO is unavailable: {exc}") from exc
359
+
360
+ with motor_lock:
361
+ if active_motor:
362
+ raise HTTPException(status_code=409, detail="A motor is already running")
363
+ active_motor = "relay-test"
364
+ motor_error = None
262
365
 
263
366
  print(f"[TEST] Running test sequence:")
264
367
  print(f" - Seconds ON: {config.seconds_on}")
@@ -267,19 +370,32 @@ async def run_test(config: TestConfig):
267
370
 
268
371
  last_action = f"Test sequence started"
269
372
 
270
- # Simulate test sequence
271
- def test_sequence():
272
- global last_action
273
- for pin in pins:
274
- last_action = f"test:gpio:{pin}"
275
- print(f">>> TESTING GPIO {pin}")
276
- time.sleep(config.seconds_on)
277
- print(f"[TEST] Pin {pin} OFF, pausing {config.seconds_pause}s")
278
- time.sleep(config.seconds_pause)
279
- last_action = "Test sequence completed"
280
- print("[TEST] Test sequence completed")
281
-
282
- test_thread = threading.Thread(target=test_sequence)
373
+ def test_sequence():
374
+ global last_action, active_motor, motor_error
375
+ try:
376
+ for pin in pins:
377
+ last_action = f"test:gpio:{pin}"
378
+ motor = next(m for m in motors.values() if int(m["gpio"]) == pin)
379
+ print(f">>> TESTING GPIO {pin}")
380
+ try:
381
+ _write_motor(motor, True)
382
+ time.sleep(config.seconds_on)
383
+ finally:
384
+ _write_motor(motor, False)
385
+ print(f"[TEST] Pin {pin} OFF, pausing {config.seconds_pause}s")
386
+ time.sleep(config.seconds_pause)
387
+ last_action = "Test sequence completed"
388
+ print("[TEST] Test sequence completed")
389
+ except Exception as exc:
390
+ motor_error = f"{type(exc).__name__}: {exc}"
391
+ last_action = f"Relay test failed: {motor_error}"
392
+ print(f"[ERROR] Relay test failed: {motor_error}")
393
+ finally:
394
+ _all_off()
395
+ with motor_lock:
396
+ active_motor = None
397
+
398
+ test_thread = threading.Thread(target=test_sequence, daemon=True)
283
399
  test_thread.start()
284
400
 
285
401
  return {
@@ -297,8 +413,8 @@ if __name__ == "__main__":
297
413
  print("=" * 60)
298
414
  print("Motor Control API Server")
299
415
  print("=" * 60)
300
- print("Starting server on http://0.0.0.0:8001")
416
+ print("Starting server on http://127.0.0.1:8001")
301
417
  print("API Documentation: http://localhost:8001/docs")
302
418
  print("ReDoc: http://localhost:8001/redoc")
303
419
  print("=" * 60)
304
- uvicorn.run(app, host="0.0.0.0", port=8001)
420
+ uvicorn.run(app, host=os.getenv("ROBOPARK_MOTOR_HOST", "127.0.0.1"), port=8001)
@@ -13,6 +13,7 @@ python-multipart>=0.0.12
13
13
  sounddevice>=0.4.6
14
14
  soundfile>=0.12.1
15
15
  groq>=0.11
16
+ lgpio==0.2.2.0; platform_system == 'Linux'
16
17
  # Python 3.13 has no compatible NumPy 1.24 wheel. On older Pi images, use the
17
18
  # distro NumPy provided through system-site-packages instead.
18
19
  numpy>=2.1; python_version >= '3.13'