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.
- package/dist/kernel/federation/dashboard-html.d.ts +1 -1
- package/dist/kernel/federation/dashboard-html.js +673 -82
- 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 +21 -0
- package/package.json +1 -1
- package/packages/robopark/scheduler/main.py +298 -22
- package/packages/robopark/scheduler/robot_supervisor.py +83 -5
- package/packages/robopark/vision/motor_server.py +204 -88
- package/packages/robopark/vision/requirements_vision_agent.txt +1 -0
|
@@ -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:
|
|
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:
|
|
79
|
-
seconds_pause:
|
|
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
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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.
|
|
128
|
-
raise HTTPException(status_code=
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
"
|
|
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
|
|
208
|
-
raise HTTPException(status_code=
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
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
|
|
259
|
-
raise HTTPException(status_code=
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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://
|
|
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="
|
|
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'
|