robopark 3.1.2 → 3.3.0
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/README.md +66 -95
- package/bin/robopark.js +9 -2
- package/conversation/elevenlabs_agent.py +1617 -1617
- package/conversation/supervisor_store.py +189 -189
- package/dist/{kernel/config-schema.js → config-schema.js} +1 -0
- package/dist/config-schema.js.map +1 -0
- package/dist/robopark/add-robot.js +1 -0
- package/dist/robopark/add-robot.js.map +1 -0
- package/dist/robopark/agent-ctl.js +1 -0
- package/dist/robopark/agent-ctl.js.map +1 -0
- package/dist/robopark/auto-start.js +51 -73
- package/dist/robopark/auto-start.js.map +1 -0
- package/dist/robopark/conversation.js +1 -0
- package/dist/robopark/conversation.js.map +1 -0
- package/dist/robopark/discovery.js +1 -0
- package/dist/robopark/discovery.js.map +1 -0
- package/dist/robopark/doctor.js +1 -0
- package/dist/robopark/doctor.js.map +1 -0
- package/dist/robopark/enroll.js +1 -0
- package/dist/robopark/enroll.js.map +1 -0
- package/dist/robopark/llm-set.js +1 -0
- package/dist/robopark/llm-set.js.map +1 -0
- package/dist/robopark/motor-control.js +1 -0
- package/dist/robopark/motor-control.js.map +1 -0
- package/dist/robopark/preview-agent-launcher.js +1 -0
- package/dist/robopark/preview-agent-launcher.js.map +1 -0
- package/dist/robopark/probe.js +16 -15
- package/dist/robopark/probe.js.map +1 -0
- package/dist/robopark/profile.js +1 -0
- package/dist/robopark/profile.js.map +1 -0
- package/dist/robopark/python-env.js +1 -7
- package/dist/robopark/python-env.js.map +1 -0
- package/dist/robopark/robot-runtime.js +1 -0
- package/dist/robopark/robot-runtime.js.map +1 -0
- package/dist/robopark/scan.js +1 -0
- package/dist/robopark/scan.js.map +1 -0
- package/dist/robopark/screen-control.js +1 -0
- package/dist/robopark/screen-control.js.map +1 -0
- package/dist/robopark/secrets.js +1 -0
- package/dist/robopark/secrets.js.map +1 -0
- package/dist/robopark/serve.js +4 -6
- package/dist/robopark/serve.js.map +1 -0
- package/dist/robopark/server-add.js +1 -0
- package/dist/robopark/server-add.js.map +1 -0
- package/dist/robopark/setup-livekit.js +53 -52
- package/dist/robopark/setup-livekit.js.map +1 -0
- package/dist/robopark/setup.js +1 -0
- package/dist/robopark/setup.js.map +1 -0
- package/dist/robopark/stop-all.js +1 -0
- package/dist/robopark/stop-all.js.map +1 -0
- package/dist/robopark/verify.js +1 -0
- package/dist/robopark/verify.js.map +1 -0
- package/dist/robopark/vision-agent-launcher.js +1 -0
- package/dist/robopark/vision-agent-launcher.js.map +1 -0
- package/dist/robopark/vision-control.js +1 -0
- package/dist/robopark/vision-control.js.map +1 -0
- package/dist/robopark-cli.js +7 -125
- package/dist/robopark-cli.js.map +1 -0
- package/package.json +16 -25
- package/pi-client/_install_steps.sh +29 -29
- package/pi-client/client.py +76 -76
- package/pi-client/install.sh +40 -40
- package/pi-client/join_convo.sh +54 -54
- package/pi-client/livekit_bridge.py +24 -24
- package/pi-client/motor_bridge.py +10 -10
- package/scheduler/__pycache__/main.cpython-312.pyc +0 -0
- package/scheduler/main.py +196 -17
- package/scheduler/media_lock.py +57 -57
- package/scheduler/preview_agent.py +1122 -1122
- package/scheduler/production_config.json +73 -113
- package/scheduler/scripts/install-robot-supervisor-linux.sh +33 -33
- package/{static → scheduler/static}/vendor/elevenlabs-client.iife.js +24199 -24199
- package/scheduler/static/voice-join.html +177 -0
- package/screen/screen_runtime.py +75 -75
- package/vision/app_pi_clean.py +297 -297
- package/vision/audio_server_pi.py +24 -24
- package/vision/install.sh +34 -34
- package/vision/motor_server.py +275 -275
- package/vision/requirements_camera.txt +6 -6
- package/vision/requirements_motor.txt +4 -4
- package/vision/requirements_pi_unified.txt +2 -2
- package/vision/requirements_vision_agent.txt +19 -19
- package/vision/run.sh +244 -244
- package/vision/services/services.sh +12 -12
- package/dist/kernel/types.js +0 -7
- package/dist/robopark/access.js +0 -99
- package/dist/robopark/control-center-html.js +0 -9191
- package/dist/robopark/control-server.js +0 -148
- package/dist/robopark/deployment-commands.js +0 -47
- package/dist/robopark/standalone.js +0 -562
- package/dist/robopark/voice-join-html.js +0 -57
- package/scripts/windows-shim-fix.cjs +0 -58
- package/static/robopark/barracuda.png +0 -0
- package/static/robopark/bmw.png +0 -0
- package/static/robopark/jaguar.png +0 -0
- package/static/robopark/magnus.jpg +0 -0
- package/static/robopark/panda.png +0 -0
- package/static/robopark/tesla.png +0 -0
- package/static/robopark/titan.jpg +0 -0
- package/static/robopark/volt.png +0 -0
- package/static/vendor/elevenlabs-client.LICENSE +0 -21
package/vision/motor_server.py
CHANGED
|
@@ -1,52 +1,52 @@
|
|
|
1
|
-
from fastapi import FastAPI, HTTPException, Request
|
|
2
|
-
from fastapi.responses import JSONResponse
|
|
1
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
2
|
+
from fastapi.responses import JSONResponse
|
|
3
3
|
from fastapi.middleware.cors import CORSMiddleware
|
|
4
|
-
from pydantic import BaseModel
|
|
4
|
+
from pydantic import BaseModel
|
|
5
5
|
from typing import Optional, List
|
|
6
6
|
import time
|
|
7
7
|
import threading
|
|
8
|
-
import json
|
|
9
|
-
import os
|
|
10
|
-
import hmac
|
|
11
|
-
|
|
12
|
-
try:
|
|
13
|
-
import lgpio
|
|
14
|
-
except ImportError:
|
|
15
|
-
lgpio = None
|
|
16
|
-
|
|
17
|
-
app = FastAPI(title="RoboPark Motor Control API", version="1.2.0")
|
|
18
|
-
|
|
19
|
-
ROBOT_NAME = os.getenv("ROBOPARK_ROBOT_NAME", "robot").strip() or "robot"
|
|
20
|
-
|
|
21
|
-
MOTOR_API_TOKEN = (
|
|
22
|
-
os.getenv("ROBOPARK_MOTOR_TOKEN", "").strip()
|
|
23
|
-
or os.getenv("ROBOPARK_MESH_TOKEN", "").strip()
|
|
24
|
-
)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
@app.middleware("http")
|
|
28
|
-
async def require_motor_token(request: Request, call_next):
|
|
29
|
-
"""Keep actuation private even if an operator accidentally changes the bind host."""
|
|
30
|
-
if MOTOR_API_TOKEN and request.url.path not in {"/", "/status"}:
|
|
31
|
-
supplied = request.headers.get("x-robopark-motor-token", "").strip()
|
|
32
|
-
if not supplied or not hmac.compare_digest(supplied, MOTOR_API_TOKEN):
|
|
33
|
-
return JSONResponse(status_code=401, content={"detail": "Invalid or missing motor token"})
|
|
34
|
-
return await call_next(request)
|
|
35
|
-
|
|
36
|
-
# The dashboard never calls this service directly; robot-local services do.
|
|
37
|
-
app.add_middleware(
|
|
38
|
-
CORSMiddleware,
|
|
39
|
-
allow_origins=["http://127.0.0.1", "http://localhost"],
|
|
40
|
-
allow_credentials=False,
|
|
41
|
-
allow_methods=["GET", "POST", "PUT", "DELETE"],
|
|
42
|
-
allow_headers=["content-type", "x-robopark-motor-token"],
|
|
43
|
-
)
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import hmac
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
import lgpio
|
|
14
|
+
except ImportError:
|
|
15
|
+
lgpio = None
|
|
16
|
+
|
|
17
|
+
app = FastAPI(title="RoboPark Motor Control API", version="1.2.0")
|
|
18
|
+
|
|
19
|
+
ROBOT_NAME = os.getenv("ROBOPARK_ROBOT_NAME", "robot").strip() or "robot"
|
|
20
|
+
|
|
21
|
+
MOTOR_API_TOKEN = (
|
|
22
|
+
os.getenv("ROBOPARK_MOTOR_TOKEN", "").strip()
|
|
23
|
+
or os.getenv("ROBOPARK_MESH_TOKEN", "").strip()
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.middleware("http")
|
|
28
|
+
async def require_motor_token(request: Request, call_next):
|
|
29
|
+
"""Keep actuation private even if an operator accidentally changes the bind host."""
|
|
30
|
+
if MOTOR_API_TOKEN and request.url.path not in {"/", "/status"}:
|
|
31
|
+
supplied = request.headers.get("x-robopark-motor-token", "").strip()
|
|
32
|
+
if not supplied or not hmac.compare_digest(supplied, MOTOR_API_TOKEN):
|
|
33
|
+
return JSONResponse(status_code=401, content={"detail": "Invalid or missing motor token"})
|
|
34
|
+
return await call_next(request)
|
|
35
|
+
|
|
36
|
+
# The dashboard never calls this service directly; robot-local services do.
|
|
37
|
+
app.add_middleware(
|
|
38
|
+
CORSMiddleware,
|
|
39
|
+
allow_origins=["http://127.0.0.1", "http://localhost"],
|
|
40
|
+
allow_credentials=False,
|
|
41
|
+
allow_methods=["GET", "POST", "PUT", "DELETE"],
|
|
42
|
+
allow_headers=["content-type", "x-robopark-motor-token"],
|
|
43
|
+
)
|
|
44
44
|
|
|
45
45
|
# Storage file
|
|
46
|
-
MOTORS_FILE = os.getenv(
|
|
47
|
-
"ROBOPARK_MOTORS_FILE",
|
|
48
|
-
os.path.expanduser("~/.robopark/motors.json"),
|
|
49
|
-
)
|
|
46
|
+
MOTORS_FILE = os.getenv(
|
|
47
|
+
"ROBOPARK_MOTORS_FILE",
|
|
48
|
+
os.path.expanduser("~/.robopark/motors.json"),
|
|
49
|
+
)
|
|
50
50
|
|
|
51
51
|
# In-memory storage
|
|
52
52
|
motors = {}
|
|
@@ -54,11 +54,11 @@ active_motor = None
|
|
|
54
54
|
remaining_seconds = 0
|
|
55
55
|
last_action = "None"
|
|
56
56
|
motor_thread = None
|
|
57
|
-
stop_flag = False
|
|
58
|
-
motor_error = None
|
|
59
|
-
gpio_handle = None
|
|
60
|
-
claimed_pins = set()
|
|
61
|
-
motor_lock = threading.Lock()
|
|
57
|
+
stop_flag = False
|
|
58
|
+
motor_error = None
|
|
59
|
+
gpio_handle = None
|
|
60
|
+
claimed_pins = set()
|
|
61
|
+
motor_lock = threading.Lock()
|
|
62
62
|
|
|
63
63
|
# Load motors from file on startup
|
|
64
64
|
def load_motors():
|
|
@@ -66,16 +66,16 @@ def load_motors():
|
|
|
66
66
|
global motors
|
|
67
67
|
try:
|
|
68
68
|
if os.path.exists(MOTORS_FILE):
|
|
69
|
-
with open(MOTORS_FILE, 'r') as f:
|
|
70
|
-
motors = json.load(f)
|
|
71
|
-
motors = {
|
|
72
|
-
str(name): ({"name": str(name), "gpio": int(value), "active_high": True}
|
|
73
|
-
if not isinstance(value, dict) else {
|
|
74
|
-
"name": str(value.get("name") or name), "gpio": int(value["gpio"]),
|
|
75
|
-
"active_high": bool(value.get("active_high", True)),
|
|
76
|
-
})
|
|
77
|
-
for name, value in motors.items()
|
|
78
|
-
}
|
|
69
|
+
with open(MOTORS_FILE, 'r') as f:
|
|
70
|
+
motors = json.load(f)
|
|
71
|
+
motors = {
|
|
72
|
+
str(name): ({"name": str(name), "gpio": int(value), "active_high": True}
|
|
73
|
+
if not isinstance(value, dict) else {
|
|
74
|
+
"name": str(value.get("name") or name), "gpio": int(value["gpio"]),
|
|
75
|
+
"active_high": bool(value.get("active_high", True)),
|
|
76
|
+
})
|
|
77
|
+
for name, value in motors.items()
|
|
78
|
+
}
|
|
79
79
|
print(f"[LOAD] Loaded {len(motors)} motors from {MOTORS_FILE}")
|
|
80
80
|
for name, gpio in motors.items():
|
|
81
81
|
print(f" - {name}: GPIO {gpio}")
|
|
@@ -85,16 +85,16 @@ def load_motors():
|
|
|
85
85
|
print(f"[ERROR] Failed to load motors: {e}")
|
|
86
86
|
motors = {}
|
|
87
87
|
|
|
88
|
-
def save_motors():
|
|
88
|
+
def save_motors():
|
|
89
89
|
"""Save motors to motors.json file"""
|
|
90
90
|
try:
|
|
91
|
-
os.makedirs(os.path.dirname(os.path.abspath(MOTORS_FILE)), exist_ok=True)
|
|
92
|
-
temporary = f"{MOTORS_FILE}.tmp"
|
|
93
|
-
with open(temporary, 'w') as f:
|
|
94
|
-
json.dump(motors, f, indent=2)
|
|
95
|
-
f.flush()
|
|
96
|
-
os.fsync(f.fileno())
|
|
97
|
-
os.replace(temporary, MOTORS_FILE)
|
|
91
|
+
os.makedirs(os.path.dirname(os.path.abspath(MOTORS_FILE)), exist_ok=True)
|
|
92
|
+
temporary = f"{MOTORS_FILE}.tmp"
|
|
93
|
+
with open(temporary, 'w') as f:
|
|
94
|
+
json.dump(motors, f, indent=2)
|
|
95
|
+
f.flush()
|
|
96
|
+
os.fsync(f.fileno())
|
|
97
|
+
os.replace(temporary, MOTORS_FILE)
|
|
98
98
|
print(f"[SAVE] Motors saved to {MOTORS_FILE}")
|
|
99
99
|
except Exception as e:
|
|
100
100
|
print(f"[ERROR] Failed to save motors: {e}")
|
|
@@ -103,101 +103,101 @@ def save_motors():
|
|
|
103
103
|
load_motors()
|
|
104
104
|
|
|
105
105
|
# Request Models
|
|
106
|
-
class MotorConfig(BaseModel):
|
|
107
|
-
name: str
|
|
108
|
-
gpio: int
|
|
109
|
-
active_high: bool = True
|
|
106
|
+
class MotorConfig(BaseModel):
|
|
107
|
+
name: str
|
|
108
|
+
gpio: int
|
|
109
|
+
active_high: bool = True
|
|
110
110
|
|
|
111
|
-
class UpdateMotorConfig(BaseModel):
|
|
112
|
-
name: str
|
|
113
|
-
gpio: int
|
|
114
|
-
active_high: bool = True
|
|
111
|
+
class UpdateMotorConfig(BaseModel):
|
|
112
|
+
name: str
|
|
113
|
+
gpio: int
|
|
114
|
+
active_high: bool = True
|
|
115
115
|
|
|
116
|
-
class TriggerMotor(BaseModel):
|
|
117
|
-
motor_name: str
|
|
118
|
-
seconds: float = 1.0
|
|
116
|
+
class TriggerMotor(BaseModel):
|
|
117
|
+
motor_name: str
|
|
118
|
+
seconds: float = 1.0
|
|
119
119
|
|
|
120
120
|
class StopMotors(BaseModel):
|
|
121
121
|
motor_name: Optional[str] = None
|
|
122
122
|
|
|
123
|
-
class TestConfig(BaseModel):
|
|
124
|
-
seconds_on: float = 0.3
|
|
125
|
-
seconds_pause: float = 0.2
|
|
123
|
+
class TestConfig(BaseModel):
|
|
124
|
+
seconds_on: float = 0.3
|
|
125
|
+
seconds_pause: float = 0.2
|
|
126
126
|
pins: Optional[List[int]] = None
|
|
127
127
|
|
|
128
128
|
# Helper Functions
|
|
129
|
-
def _motor_value(motor: dict, active: bool) -> int:
|
|
130
|
-
return int(active == bool(motor.get("active_high", True)))
|
|
131
|
-
|
|
132
|
-
def _claim_motor(motor: dict) -> None:
|
|
133
|
-
global gpio_handle
|
|
134
|
-
pin = int(motor["gpio"])
|
|
135
|
-
if pin < 2 or pin > 27:
|
|
136
|
-
raise ValueError(f"BCM GPIO {pin} is outside 2..27")
|
|
137
|
-
if lgpio is None:
|
|
138
|
-
raise RuntimeError("lgpio is not installed; refusing simulated production actuation")
|
|
139
|
-
if gpio_handle is None:
|
|
140
|
-
gpio_handle = lgpio.gpiochip_open(0)
|
|
141
|
-
if pin not in claimed_pins:
|
|
142
|
-
lgpio.gpio_claim_output(gpio_handle, pin, _motor_value(motor, False))
|
|
143
|
-
claimed_pins.add(pin)
|
|
144
|
-
|
|
145
|
-
def _write_motor(motor: dict, active: bool) -> None:
|
|
146
|
-
_claim_motor(motor)
|
|
147
|
-
lgpio.gpio_write(gpio_handle, int(motor["gpio"]), _motor_value(motor, active))
|
|
148
|
-
|
|
149
|
-
def _all_off() -> None:
|
|
150
|
-
for motor in motors.values():
|
|
151
|
-
try:
|
|
152
|
-
if isinstance(motor, dict):
|
|
153
|
-
_write_motor(motor, False)
|
|
154
|
-
except Exception as exc:
|
|
155
|
-
print(f"[GPIO] failed to de-energize {motor}: {exc}")
|
|
156
|
-
|
|
157
|
-
def run_motor_thread(motor_name: str, duration: float):
|
|
158
|
-
global active_motor, remaining_seconds, last_action, stop_flag, motor_error
|
|
159
|
-
|
|
160
|
-
motor = motors[motor_name]
|
|
161
|
-
started = time.monotonic()
|
|
162
|
-
print(f"[MOTOR] Starting {motor_name} for {duration:.3f} seconds")
|
|
163
|
-
try:
|
|
164
|
-
_write_motor(motor, True)
|
|
165
|
-
while not stop_flag and time.monotonic() - started < duration:
|
|
166
|
-
remaining_seconds = max(0, duration - (time.monotonic() - started))
|
|
167
|
-
time.sleep(min(0.05, remaining_seconds or 0.01))
|
|
168
|
-
except Exception as exc:
|
|
169
|
-
motor_error = f"{type(exc).__name__}: {exc}"
|
|
170
|
-
last_action = f"Failed {motor_name}: {motor_error}"
|
|
171
|
-
print(f"[ERROR] Motor {motor_name} failed: {motor_error}")
|
|
172
|
-
finally:
|
|
173
|
-
try:
|
|
174
|
-
_write_motor(motor, False)
|
|
175
|
-
finally:
|
|
176
|
-
with motor_lock:
|
|
177
|
-
active_motor = None
|
|
178
|
-
remaining_seconds = 0
|
|
179
|
-
stop_flag = False
|
|
180
|
-
print(f"[MOTOR] {motor_name} completed and GPIO is OFF")
|
|
181
|
-
|
|
182
|
-
@app.on_event("shutdown")
|
|
183
|
-
def shutdown_gpio() -> None:
|
|
184
|
-
global gpio_handle
|
|
185
|
-
_all_off()
|
|
186
|
-
if lgpio is not None and gpio_handle is not None:
|
|
187
|
-
try:
|
|
188
|
-
lgpio.gpiochip_close(gpio_handle)
|
|
189
|
-
finally:
|
|
190
|
-
gpio_handle = None
|
|
191
|
-
claimed_pins.clear()
|
|
129
|
+
def _motor_value(motor: dict, active: bool) -> int:
|
|
130
|
+
return int(active == bool(motor.get("active_high", True)))
|
|
131
|
+
|
|
132
|
+
def _claim_motor(motor: dict) -> None:
|
|
133
|
+
global gpio_handle
|
|
134
|
+
pin = int(motor["gpio"])
|
|
135
|
+
if pin < 2 or pin > 27:
|
|
136
|
+
raise ValueError(f"BCM GPIO {pin} is outside 2..27")
|
|
137
|
+
if lgpio is None:
|
|
138
|
+
raise RuntimeError("lgpio is not installed; refusing simulated production actuation")
|
|
139
|
+
if gpio_handle is None:
|
|
140
|
+
gpio_handle = lgpio.gpiochip_open(0)
|
|
141
|
+
if pin not in claimed_pins:
|
|
142
|
+
lgpio.gpio_claim_output(gpio_handle, pin, _motor_value(motor, False))
|
|
143
|
+
claimed_pins.add(pin)
|
|
144
|
+
|
|
145
|
+
def _write_motor(motor: dict, active: bool) -> None:
|
|
146
|
+
_claim_motor(motor)
|
|
147
|
+
lgpio.gpio_write(gpio_handle, int(motor["gpio"]), _motor_value(motor, active))
|
|
148
|
+
|
|
149
|
+
def _all_off() -> None:
|
|
150
|
+
for motor in motors.values():
|
|
151
|
+
try:
|
|
152
|
+
if isinstance(motor, dict):
|
|
153
|
+
_write_motor(motor, False)
|
|
154
|
+
except Exception as exc:
|
|
155
|
+
print(f"[GPIO] failed to de-energize {motor}: {exc}")
|
|
156
|
+
|
|
157
|
+
def run_motor_thread(motor_name: str, duration: float):
|
|
158
|
+
global active_motor, remaining_seconds, last_action, stop_flag, motor_error
|
|
159
|
+
|
|
160
|
+
motor = motors[motor_name]
|
|
161
|
+
started = time.monotonic()
|
|
162
|
+
print(f"[MOTOR] Starting {motor_name} for {duration:.3f} seconds")
|
|
163
|
+
try:
|
|
164
|
+
_write_motor(motor, True)
|
|
165
|
+
while not stop_flag and time.monotonic() - started < duration:
|
|
166
|
+
remaining_seconds = max(0, duration - (time.monotonic() - started))
|
|
167
|
+
time.sleep(min(0.05, remaining_seconds or 0.01))
|
|
168
|
+
except Exception as exc:
|
|
169
|
+
motor_error = f"{type(exc).__name__}: {exc}"
|
|
170
|
+
last_action = f"Failed {motor_name}: {motor_error}"
|
|
171
|
+
print(f"[ERROR] Motor {motor_name} failed: {motor_error}")
|
|
172
|
+
finally:
|
|
173
|
+
try:
|
|
174
|
+
_write_motor(motor, False)
|
|
175
|
+
finally:
|
|
176
|
+
with motor_lock:
|
|
177
|
+
active_motor = None
|
|
178
|
+
remaining_seconds = 0
|
|
179
|
+
stop_flag = False
|
|
180
|
+
print(f"[MOTOR] {motor_name} completed and GPIO is OFF")
|
|
181
|
+
|
|
182
|
+
@app.on_event("shutdown")
|
|
183
|
+
def shutdown_gpio() -> None:
|
|
184
|
+
global gpio_handle
|
|
185
|
+
_all_off()
|
|
186
|
+
if lgpio is not None and gpio_handle is not None:
|
|
187
|
+
try:
|
|
188
|
+
lgpio.gpiochip_close(gpio_handle)
|
|
189
|
+
finally:
|
|
190
|
+
gpio_handle = None
|
|
191
|
+
claimed_pins.clear()
|
|
192
192
|
|
|
193
193
|
# API Endpoints
|
|
194
194
|
@app.get("/")
|
|
195
195
|
async def root():
|
|
196
196
|
return {
|
|
197
|
-
"message": "Motor Control API",
|
|
198
|
-
"version": "1.2.0",
|
|
199
|
-
"robot": ROBOT_NAME,
|
|
200
|
-
"authentication": "token" if MOTOR_API_TOKEN else "localhost-only",
|
|
197
|
+
"message": "Motor Control API",
|
|
198
|
+
"version": "1.2.0",
|
|
199
|
+
"robot": ROBOT_NAME,
|
|
200
|
+
"authentication": "token" if MOTOR_API_TOKEN else "localhost-only",
|
|
201
201
|
"endpoints": ["/status", "/test", "/add-motor", "/trigger-motor", "/list-motors", "/stop-motors"]
|
|
202
202
|
}
|
|
203
203
|
|
|
@@ -208,26 +208,26 @@ async def get_status():
|
|
|
208
208
|
"status": status,
|
|
209
209
|
"active_motor": active_motor,
|
|
210
210
|
"remaining_seconds": remaining_seconds,
|
|
211
|
-
"last_action": last_action,
|
|
212
|
-
"error": motor_error,
|
|
213
|
-
"total_motors": len(motors),
|
|
214
|
-
"robot": ROBOT_NAME,
|
|
215
|
-
"gpio_available": lgpio is not None,
|
|
211
|
+
"last_action": last_action,
|
|
212
|
+
"error": motor_error,
|
|
213
|
+
"total_motors": len(motors),
|
|
214
|
+
"robot": ROBOT_NAME,
|
|
215
|
+
"gpio_available": lgpio is not None,
|
|
216
216
|
}
|
|
217
217
|
|
|
218
218
|
@app.post("/add-motor")
|
|
219
|
-
async def add_motor(config: MotorConfig):
|
|
220
|
-
if config.gpio < 2 or config.gpio > 27:
|
|
221
|
-
raise HTTPException(status_code=422, detail="BCM GPIO must be in range 2..27")
|
|
222
|
-
if config.name in motors:
|
|
223
|
-
raise HTTPException(status_code=400, detail=f"Motor '{config.name}' already exists")
|
|
224
|
-
if any(int(item["gpio"]) == config.gpio for item in motors.values()):
|
|
225
|
-
raise HTTPException(status_code=409, detail=f"BCM GPIO {config.gpio} is already registered")
|
|
226
|
-
|
|
227
|
-
motors[config.name] = {
|
|
228
|
-
"name": config.name,
|
|
229
|
-
"gpio": config.gpio,
|
|
230
|
-
"active_high": config.active_high,
|
|
219
|
+
async def add_motor(config: MotorConfig):
|
|
220
|
+
if config.gpio < 2 or config.gpio > 27:
|
|
221
|
+
raise HTTPException(status_code=422, detail="BCM GPIO must be in range 2..27")
|
|
222
|
+
if config.name in motors:
|
|
223
|
+
raise HTTPException(status_code=400, detail=f"Motor '{config.name}' already exists")
|
|
224
|
+
if any(int(item["gpio"]) == config.gpio for item in motors.values()):
|
|
225
|
+
raise HTTPException(status_code=409, detail=f"BCM GPIO {config.gpio} is already registered")
|
|
226
|
+
|
|
227
|
+
motors[config.name] = {
|
|
228
|
+
"name": config.name,
|
|
229
|
+
"gpio": config.gpio,
|
|
230
|
+
"active_high": config.active_high,
|
|
231
231
|
}
|
|
232
232
|
save_motors()
|
|
233
233
|
|
|
@@ -239,49 +239,49 @@ async def add_motor(config: MotorConfig):
|
|
|
239
239
|
"motor": motors[config.name]
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
-
@app.get("/list-motors")
|
|
243
|
-
async def list_motors():
|
|
242
|
+
@app.get("/list-motors")
|
|
243
|
+
async def list_motors():
|
|
244
244
|
motor_list = list(motors.values())
|
|
245
245
|
return {
|
|
246
246
|
"status": "success",
|
|
247
247
|
"count": len(motor_list),
|
|
248
|
-
"motors": motor_list
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
@app.get("/discover")
|
|
252
|
-
async def discover_motors():
|
|
253
|
-
"""Return the relay registry and GPIO capability for fleet discovery."""
|
|
254
|
-
motor_list = list(motors.values())
|
|
255
|
-
return {
|
|
256
|
-
"status": "success",
|
|
257
|
-
"robot": ROBOT_NAME,
|
|
258
|
-
"gpio_available": lgpio is not None,
|
|
259
|
-
"count": len(motor_list),
|
|
260
|
-
"motors": motor_list,
|
|
261
|
-
}
|
|
248
|
+
"motors": motor_list
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
@app.get("/discover")
|
|
252
|
+
async def discover_motors():
|
|
253
|
+
"""Return the relay registry and GPIO capability for fleet discovery."""
|
|
254
|
+
motor_list = list(motors.values())
|
|
255
|
+
return {
|
|
256
|
+
"status": "success",
|
|
257
|
+
"robot": ROBOT_NAME,
|
|
258
|
+
"gpio_available": lgpio is not None,
|
|
259
|
+
"count": len(motor_list),
|
|
260
|
+
"motors": motor_list,
|
|
261
|
+
}
|
|
262
262
|
|
|
263
263
|
@app.put("/update-motor/{motor_name}")
|
|
264
|
-
async def update_motor(motor_name: str, config: UpdateMotorConfig):
|
|
265
|
-
if config.gpio < 2 or config.gpio > 27:
|
|
266
|
-
raise HTTPException(status_code=422, detail="BCM GPIO must be in range 2..27")
|
|
264
|
+
async def update_motor(motor_name: str, config: UpdateMotorConfig):
|
|
265
|
+
if config.gpio < 2 or config.gpio > 27:
|
|
266
|
+
raise HTTPException(status_code=422, detail="BCM GPIO must be in range 2..27")
|
|
267
267
|
if motor_name not in motors:
|
|
268
268
|
raise HTTPException(status_code=404, detail=f"Motor '{motor_name}' not found")
|
|
269
269
|
|
|
270
270
|
if active_motor == motor_name:
|
|
271
271
|
raise HTTPException(status_code=409, detail=f"Cannot update motor '{motor_name}' while it is running")
|
|
272
272
|
|
|
273
|
-
if config.name != motor_name and config.name in motors:
|
|
274
|
-
raise HTTPException(status_code=400, detail=f"Motor name '{config.name}' already exists")
|
|
275
|
-
if any(name != motor_name and int(item["gpio"]) == config.gpio for name, item in motors.items()):
|
|
276
|
-
raise HTTPException(status_code=409, detail=f"BCM GPIO {config.gpio} is already registered")
|
|
273
|
+
if config.name != motor_name and config.name in motors:
|
|
274
|
+
raise HTTPException(status_code=400, detail=f"Motor name '{config.name}' already exists")
|
|
275
|
+
if any(name != motor_name and int(item["gpio"]) == config.gpio for name, item in motors.items()):
|
|
276
|
+
raise HTTPException(status_code=409, detail=f"BCM GPIO {config.gpio} is already registered")
|
|
277
277
|
|
|
278
278
|
if config.name != motor_name:
|
|
279
279
|
del motors[motor_name]
|
|
280
280
|
|
|
281
|
-
motors[config.name] = {
|
|
282
|
-
"name": config.name,
|
|
283
|
-
"gpio": config.gpio,
|
|
284
|
-
"active_high": config.active_high,
|
|
281
|
+
motors[config.name] = {
|
|
282
|
+
"name": config.name,
|
|
283
|
+
"gpio": config.gpio,
|
|
284
|
+
"active_high": config.active_high,
|
|
285
285
|
}
|
|
286
286
|
save_motors()
|
|
287
287
|
|
|
@@ -313,30 +313,30 @@ async def delete_motor(motor_name: str):
|
|
|
313
313
|
}
|
|
314
314
|
|
|
315
315
|
@app.post("/trigger-motor")
|
|
316
|
-
async def trigger_motor(config: TriggerMotor):
|
|
317
|
-
global motor_thread, stop_flag, active_motor, remaining_seconds, last_action, motor_error
|
|
316
|
+
async def trigger_motor(config: TriggerMotor):
|
|
317
|
+
global motor_thread, stop_flag, active_motor, remaining_seconds, last_action, motor_error
|
|
318
318
|
|
|
319
319
|
if config.motor_name not in motors:
|
|
320
320
|
raise HTTPException(status_code=404, detail=f"Motor '{config.motor_name}' not found")
|
|
321
321
|
|
|
322
|
-
if config.seconds < 0.05 or config.seconds > 10.0:
|
|
323
|
-
raise HTTPException(status_code=422, detail="Pulse duration must be 0.05..10 seconds")
|
|
324
|
-
|
|
325
|
-
try:
|
|
326
|
-
_claim_motor(motors[config.motor_name])
|
|
327
|
-
except Exception as exc:
|
|
328
|
-
raise HTTPException(status_code=503, detail=f"GPIO is unavailable: {exc}") from exc
|
|
329
|
-
|
|
330
|
-
with motor_lock:
|
|
331
|
-
if active_motor:
|
|
332
|
-
raise HTTPException(status_code=409, detail=f"Motor '{active_motor}' is already running")
|
|
333
|
-
stop_flag = False
|
|
334
|
-
motor_error = None
|
|
335
|
-
active_motor = config.motor_name
|
|
336
|
-
remaining_seconds = config.seconds
|
|
337
|
-
last_action = f"Triggered {config.motor_name}"
|
|
338
|
-
motor_thread = threading.Thread(target=run_motor_thread, args=(config.motor_name, config.seconds), daemon=True)
|
|
339
|
-
motor_thread.start()
|
|
322
|
+
if config.seconds < 0.05 or config.seconds > 10.0:
|
|
323
|
+
raise HTTPException(status_code=422, detail="Pulse duration must be 0.05..10 seconds")
|
|
324
|
+
|
|
325
|
+
try:
|
|
326
|
+
_claim_motor(motors[config.motor_name])
|
|
327
|
+
except Exception as exc:
|
|
328
|
+
raise HTTPException(status_code=503, detail=f"GPIO is unavailable: {exc}") from exc
|
|
329
|
+
|
|
330
|
+
with motor_lock:
|
|
331
|
+
if active_motor:
|
|
332
|
+
raise HTTPException(status_code=409, detail=f"Motor '{active_motor}' is already running")
|
|
333
|
+
stop_flag = False
|
|
334
|
+
motor_error = None
|
|
335
|
+
active_motor = config.motor_name
|
|
336
|
+
remaining_seconds = config.seconds
|
|
337
|
+
last_action = f"Triggered {config.motor_name}"
|
|
338
|
+
motor_thread = threading.Thread(target=run_motor_thread, args=(config.motor_name, config.seconds), daemon=True)
|
|
339
|
+
motor_thread.start()
|
|
340
340
|
|
|
341
341
|
return {
|
|
342
342
|
"status": "success",
|
|
@@ -350,20 +350,20 @@ async def stop_motors(config: Optional[StopMotors] = None):
|
|
|
350
350
|
global stop_flag, last_action
|
|
351
351
|
|
|
352
352
|
try:
|
|
353
|
-
if not active_motor:
|
|
354
|
-
_all_off()
|
|
355
|
-
return {
|
|
353
|
+
if not active_motor:
|
|
354
|
+
_all_off()
|
|
355
|
+
return {
|
|
356
356
|
"status": "success",
|
|
357
357
|
"message": "No motors are currently running"
|
|
358
358
|
}
|
|
359
359
|
|
|
360
|
-
stop_flag = True
|
|
361
|
-
last_action = f"Stopped {active_motor}"
|
|
360
|
+
stop_flag = True
|
|
361
|
+
last_action = f"Stopped {active_motor}"
|
|
362
362
|
|
|
363
363
|
# Wait for thread to finish
|
|
364
|
-
if motor_thread and motor_thread.is_alive():
|
|
365
|
-
motor_thread.join(timeout=2)
|
|
366
|
-
_all_off()
|
|
364
|
+
if motor_thread and motor_thread.is_alive():
|
|
365
|
+
motor_thread.join(timeout=2)
|
|
366
|
+
_all_off()
|
|
367
367
|
|
|
368
368
|
return {
|
|
369
369
|
"status": "success",
|
|
@@ -381,30 +381,30 @@ async def stop_motors(config: Optional[StopMotors] = None):
|
|
|
381
381
|
}
|
|
382
382
|
|
|
383
383
|
@app.post("/test")
|
|
384
|
-
async def run_test(config: TestConfig):
|
|
385
|
-
global last_action, active_motor, motor_error
|
|
386
|
-
|
|
387
|
-
if config.seconds_on < 0.05 or config.seconds_on > 10.0:
|
|
388
|
-
raise HTTPException(status_code=422, detail="Test pulse duration must be 0.05..10 seconds")
|
|
389
|
-
if config.seconds_pause < 0 or config.seconds_pause > 10.0:
|
|
390
|
-
raise HTTPException(status_code=422, detail="Test pause must be 0..10 seconds")
|
|
391
|
-
|
|
392
|
-
registered_pins = {int(m["gpio"]) for m in motors.values() if isinstance(m, dict)}
|
|
393
|
-
pins = config.pins if config.pins else sorted(registered_pins)
|
|
394
|
-
if not pins or any(pin not in registered_pins for pin in pins):
|
|
395
|
-
raise HTTPException(status_code=422, detail="Relay tests are limited to registered GPIO pins")
|
|
396
|
-
try:
|
|
397
|
-
for motor in motors.values():
|
|
398
|
-
if int(motor["gpio"]) in pins:
|
|
399
|
-
_claim_motor(motor)
|
|
400
|
-
except Exception as exc:
|
|
401
|
-
raise HTTPException(status_code=503, detail=f"GPIO is unavailable: {exc}") from exc
|
|
402
|
-
|
|
403
|
-
with motor_lock:
|
|
404
|
-
if active_motor:
|
|
405
|
-
raise HTTPException(status_code=409, detail="A motor is already running")
|
|
406
|
-
active_motor = "relay-test"
|
|
407
|
-
motor_error = None
|
|
384
|
+
async def run_test(config: TestConfig):
|
|
385
|
+
global last_action, active_motor, motor_error
|
|
386
|
+
|
|
387
|
+
if config.seconds_on < 0.05 or config.seconds_on > 10.0:
|
|
388
|
+
raise HTTPException(status_code=422, detail="Test pulse duration must be 0.05..10 seconds")
|
|
389
|
+
if config.seconds_pause < 0 or config.seconds_pause > 10.0:
|
|
390
|
+
raise HTTPException(status_code=422, detail="Test pause must be 0..10 seconds")
|
|
391
|
+
|
|
392
|
+
registered_pins = {int(m["gpio"]) for m in motors.values() if isinstance(m, dict)}
|
|
393
|
+
pins = config.pins if config.pins else sorted(registered_pins)
|
|
394
|
+
if not pins or any(pin not in registered_pins for pin in pins):
|
|
395
|
+
raise HTTPException(status_code=422, detail="Relay tests are limited to registered GPIO pins")
|
|
396
|
+
try:
|
|
397
|
+
for motor in motors.values():
|
|
398
|
+
if int(motor["gpio"]) in pins:
|
|
399
|
+
_claim_motor(motor)
|
|
400
|
+
except Exception as exc:
|
|
401
|
+
raise HTTPException(status_code=503, detail=f"GPIO is unavailable: {exc}") from exc
|
|
402
|
+
|
|
403
|
+
with motor_lock:
|
|
404
|
+
if active_motor:
|
|
405
|
+
raise HTTPException(status_code=409, detail="A motor is already running")
|
|
406
|
+
active_motor = "relay-test"
|
|
407
|
+
motor_error = None
|
|
408
408
|
|
|
409
409
|
print(f"[TEST] Running test sequence:")
|
|
410
410
|
print(f" - Seconds ON: {config.seconds_on}")
|
|
@@ -413,32 +413,32 @@ async def run_test(config: TestConfig):
|
|
|
413
413
|
|
|
414
414
|
last_action = f"Test sequence started"
|
|
415
415
|
|
|
416
|
-
def test_sequence():
|
|
417
|
-
global last_action, active_motor, motor_error
|
|
418
|
-
try:
|
|
419
|
-
for pin in pins:
|
|
420
|
-
last_action = f"test:gpio:{pin}"
|
|
421
|
-
motor = next(m for m in motors.values() if int(m["gpio"]) == pin)
|
|
422
|
-
print(f">>> TESTING GPIO {pin}")
|
|
423
|
-
try:
|
|
424
|
-
_write_motor(motor, True)
|
|
425
|
-
time.sleep(config.seconds_on)
|
|
426
|
-
finally:
|
|
427
|
-
_write_motor(motor, False)
|
|
428
|
-
print(f"[TEST] Pin {pin} OFF, pausing {config.seconds_pause}s")
|
|
429
|
-
time.sleep(config.seconds_pause)
|
|
430
|
-
last_action = "Test sequence completed"
|
|
431
|
-
print("[TEST] Test sequence completed")
|
|
432
|
-
except Exception as exc:
|
|
433
|
-
motor_error = f"{type(exc).__name__}: {exc}"
|
|
434
|
-
last_action = f"Relay test failed: {motor_error}"
|
|
435
|
-
print(f"[ERROR] Relay test failed: {motor_error}")
|
|
436
|
-
finally:
|
|
437
|
-
_all_off()
|
|
438
|
-
with motor_lock:
|
|
439
|
-
active_motor = None
|
|
440
|
-
|
|
441
|
-
test_thread = threading.Thread(target=test_sequence, daemon=True)
|
|
416
|
+
def test_sequence():
|
|
417
|
+
global last_action, active_motor, motor_error
|
|
418
|
+
try:
|
|
419
|
+
for pin in pins:
|
|
420
|
+
last_action = f"test:gpio:{pin}"
|
|
421
|
+
motor = next(m for m in motors.values() if int(m["gpio"]) == pin)
|
|
422
|
+
print(f">>> TESTING GPIO {pin}")
|
|
423
|
+
try:
|
|
424
|
+
_write_motor(motor, True)
|
|
425
|
+
time.sleep(config.seconds_on)
|
|
426
|
+
finally:
|
|
427
|
+
_write_motor(motor, False)
|
|
428
|
+
print(f"[TEST] Pin {pin} OFF, pausing {config.seconds_pause}s")
|
|
429
|
+
time.sleep(config.seconds_pause)
|
|
430
|
+
last_action = "Test sequence completed"
|
|
431
|
+
print("[TEST] Test sequence completed")
|
|
432
|
+
except Exception as exc:
|
|
433
|
+
motor_error = f"{type(exc).__name__}: {exc}"
|
|
434
|
+
last_action = f"Relay test failed: {motor_error}"
|
|
435
|
+
print(f"[ERROR] Relay test failed: {motor_error}")
|
|
436
|
+
finally:
|
|
437
|
+
_all_off()
|
|
438
|
+
with motor_lock:
|
|
439
|
+
active_motor = None
|
|
440
|
+
|
|
441
|
+
test_thread = threading.Thread(target=test_sequence, daemon=True)
|
|
442
442
|
test_thread.start()
|
|
443
443
|
|
|
444
444
|
return {
|
|
@@ -456,12 +456,12 @@ if __name__ == "__main__":
|
|
|
456
456
|
print("=" * 60)
|
|
457
457
|
print("Motor Control API Server")
|
|
458
458
|
print("=" * 60)
|
|
459
|
-
print("Starting server on http://127.0.0.1:8001")
|
|
459
|
+
print("Starting server on http://127.0.0.1:8001")
|
|
460
460
|
print("API Documentation: http://localhost:8001/docs")
|
|
461
461
|
print("ReDoc: http://localhost:8001/redoc")
|
|
462
462
|
print("=" * 60)
|
|
463
|
-
uvicorn.run(
|
|
464
|
-
app,
|
|
465
|
-
host=os.getenv("ROBOPARK_MOTOR_HOST", "127.0.0.1"),
|
|
466
|
-
port=int(os.getenv("ROBOPARK_MOTOR_PORT", "8001")),
|
|
467
|
-
)
|
|
463
|
+
uvicorn.run(
|
|
464
|
+
app,
|
|
465
|
+
host=os.getenv("ROBOPARK_MOTOR_HOST", "127.0.0.1"),
|
|
466
|
+
port=int(os.getenv("ROBOPARK_MOTOR_PORT", "8001")),
|
|
467
|
+
)
|