foodforthought-cli 0.2.1__py3-none-any.whl → 0.2.3__py3-none-any.whl
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.
- ate/__init__.py +1 -1
- ate/bridge_server.py +622 -0
- ate/cli.py +2625 -242
- ate/compatibility.py +580 -0
- ate/generators/__init__.py +19 -0
- ate/generators/docker_generator.py +461 -0
- ate/generators/hardware_config.py +469 -0
- ate/generators/ros2_generator.py +617 -0
- ate/generators/skill_generator.py +783 -0
- ate/marketplace.py +524 -0
- ate/mcp_server.py +1341 -107
- ate/primitives.py +1016 -0
- ate/robot_setup.py +2222 -0
- ate/skill_schema.py +537 -0
- ate/telemetry/__init__.py +33 -0
- ate/telemetry/cli.py +455 -0
- ate/telemetry/collector.py +444 -0
- ate/telemetry/context.py +318 -0
- ate/telemetry/fleet_agent.py +419 -0
- ate/telemetry/formats/__init__.py +18 -0
- ate/telemetry/formats/hdf5_serializer.py +503 -0
- ate/telemetry/formats/mcap_serializer.py +457 -0
- ate/telemetry/types.py +334 -0
- foodforthought_cli-0.2.3.dist-info/METADATA +300 -0
- foodforthought_cli-0.2.3.dist-info/RECORD +44 -0
- foodforthought_cli-0.2.3.dist-info/top_level.txt +6 -0
- mechdog_labeled/__init__.py +3 -0
- mechdog_labeled/primitives.py +113 -0
- mechdog_labeled/servo_map.py +209 -0
- mechdog_output/__init__.py +3 -0
- mechdog_output/primitives.py +59 -0
- mechdog_output/servo_map.py +203 -0
- test_autodetect/__init__.py +3 -0
- test_autodetect/primitives.py +113 -0
- test_autodetect/servo_map.py +209 -0
- test_full_auto/__init__.py +3 -0
- test_full_auto/primitives.py +113 -0
- test_full_auto/servo_map.py +209 -0
- test_smart_detect/__init__.py +3 -0
- test_smart_detect/primitives.py +113 -0
- test_smart_detect/servo_map.py +209 -0
- foodforthought_cli-0.2.1.dist-info/METADATA +0 -151
- foodforthought_cli-0.2.1.dist-info/RECORD +0 -9
- foodforthought_cli-0.2.1.dist-info/top_level.txt +0 -1
- {foodforthought_cli-0.2.1.dist-info → foodforthought_cli-0.2.3.dist-info}/WHEEL +0 -0
- {foodforthought_cli-0.2.1.dist-info → foodforthought_cli-0.2.3.dist-info}/entry_points.txt +0 -0
ate/__init__.py
CHANGED
ate/bridge_server.py
ADDED
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
ATE Bridge Server - WebSocket server for Artifex integration.
|
|
4
|
+
|
|
5
|
+
This module provides a WebSocket server that allows Artifex Desktop to:
|
|
6
|
+
1. Discover connected robots
|
|
7
|
+
2. Connect to robot hardware
|
|
8
|
+
3. Control servos and read states
|
|
9
|
+
4. Execute trajectories
|
|
10
|
+
5. Deploy and run skills
|
|
11
|
+
6. Sync URDF configurations
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
ate bridge # Start on default port 8765
|
|
15
|
+
ate bridge --port 9000 # Start on custom port
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import json
|
|
20
|
+
import time
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from typing import Dict, Any, Callable, Set, Optional, List
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
# Optional imports
|
|
26
|
+
try:
|
|
27
|
+
import websockets
|
|
28
|
+
from websockets.server import WebSocketServerProtocol
|
|
29
|
+
HAS_WEBSOCKETS = True
|
|
30
|
+
except ImportError:
|
|
31
|
+
HAS_WEBSOCKETS = False
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
import serial
|
|
35
|
+
import serial.tools.list_ports
|
|
36
|
+
HAS_SERIAL = True
|
|
37
|
+
except ImportError:
|
|
38
|
+
HAS_SERIAL = False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class ServoState:
|
|
43
|
+
"""Current state of a servo."""
|
|
44
|
+
id: int
|
|
45
|
+
position: float = 0.0 # radians
|
|
46
|
+
velocity: float = 0.0
|
|
47
|
+
temperature: float = 0.0
|
|
48
|
+
load: float = 0.0
|
|
49
|
+
voltage: float = 0.0
|
|
50
|
+
torque_enabled: bool = False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class RobotConnection:
|
|
55
|
+
"""Active robot connection."""
|
|
56
|
+
port: str
|
|
57
|
+
baud_rate: int
|
|
58
|
+
robot_type: str
|
|
59
|
+
servo_ids: List[int]
|
|
60
|
+
servo_states: Dict[int, ServoState] = field(default_factory=dict)
|
|
61
|
+
serial_conn: Any = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class PortInfo:
|
|
66
|
+
"""Information about a serial port."""
|
|
67
|
+
path: str
|
|
68
|
+
description: str
|
|
69
|
+
vid: Optional[int] = None
|
|
70
|
+
pid: Optional[int] = None
|
|
71
|
+
serial_number: Optional[str] = None
|
|
72
|
+
manufacturer: Optional[str] = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ATEBridgeServer:
|
|
76
|
+
"""
|
|
77
|
+
WebSocket server for Artifex-ATE integration.
|
|
78
|
+
|
|
79
|
+
Handles bidirectional communication between Artifex Desktop and
|
|
80
|
+
physical robot hardware through a WebSocket connection.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(self, port: int = 8765, verbose: bool = False):
|
|
84
|
+
self.port = port
|
|
85
|
+
self.verbose = verbose
|
|
86
|
+
self.connections: Set[WebSocketServerProtocol] = set()
|
|
87
|
+
self.robot: Optional[RobotConnection] = None
|
|
88
|
+
self.handlers: Dict[str, Callable] = {}
|
|
89
|
+
self._running = False
|
|
90
|
+
self._broadcast_task: Optional[asyncio.Task] = None
|
|
91
|
+
self._setup_handlers()
|
|
92
|
+
|
|
93
|
+
def _setup_handlers(self):
|
|
94
|
+
"""Register request handlers."""
|
|
95
|
+
self.handlers = {
|
|
96
|
+
# Connection management
|
|
97
|
+
"ping": self._handle_ping,
|
|
98
|
+
"discover_ports": self._handle_discover_ports,
|
|
99
|
+
"connect_robot": self._handle_connect_robot,
|
|
100
|
+
"disconnect_robot": self._handle_disconnect_robot,
|
|
101
|
+
|
|
102
|
+
# Servo control
|
|
103
|
+
"get_servo_state": self._handle_get_servo_state,
|
|
104
|
+
"set_servo_position": self._handle_set_servo_position,
|
|
105
|
+
"set_servo_torque": self._handle_set_servo_torque,
|
|
106
|
+
|
|
107
|
+
# Trajectory execution
|
|
108
|
+
"execute_trajectory": self._handle_execute_trajectory,
|
|
109
|
+
|
|
110
|
+
# Configuration sync
|
|
111
|
+
"get_robot_config": self._handle_get_robot_config,
|
|
112
|
+
"sync_urdf": self._handle_sync_urdf,
|
|
113
|
+
"sync_config": self._handle_sync_config,
|
|
114
|
+
|
|
115
|
+
# Skill management
|
|
116
|
+
"list_skills": self._handle_list_skills,
|
|
117
|
+
"deploy_skill": self._handle_deploy_skill,
|
|
118
|
+
"run_skill": self._handle_run_skill,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
def _log(self, message: str):
|
|
122
|
+
"""Log a message if verbose mode is enabled."""
|
|
123
|
+
if self.verbose:
|
|
124
|
+
print(f"[ATE Bridge] {message}")
|
|
125
|
+
|
|
126
|
+
async def handle_connection(self, websocket: WebSocketServerProtocol):
|
|
127
|
+
"""Handle a new WebSocket connection."""
|
|
128
|
+
self.connections.add(websocket)
|
|
129
|
+
client_addr = f"{websocket.remote_address[0]}:{websocket.remote_address[1]}"
|
|
130
|
+
self._log(f"Client connected: {client_addr}")
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
async for message in websocket:
|
|
134
|
+
try:
|
|
135
|
+
request = json.loads(message)
|
|
136
|
+
response = await self._handle_request(request)
|
|
137
|
+
await websocket.send(json.dumps(response))
|
|
138
|
+
except json.JSONDecodeError as e:
|
|
139
|
+
await websocket.send(json.dumps({
|
|
140
|
+
"error": f"Invalid JSON: {str(e)}"
|
|
141
|
+
}))
|
|
142
|
+
except websockets.exceptions.ConnectionClosed:
|
|
143
|
+
self._log(f"Client disconnected: {client_addr}")
|
|
144
|
+
finally:
|
|
145
|
+
self.connections.discard(websocket)
|
|
146
|
+
|
|
147
|
+
async def _handle_request(self, request: Dict[str, Any]) -> Dict[str, Any]:
|
|
148
|
+
"""Route request to appropriate handler."""
|
|
149
|
+
action = request.get("action")
|
|
150
|
+
params = request.get("params", {})
|
|
151
|
+
request_id = request.get("id")
|
|
152
|
+
|
|
153
|
+
self._log(f"Request: {action} (id={request_id})")
|
|
154
|
+
|
|
155
|
+
handler = self.handlers.get(action)
|
|
156
|
+
if not handler:
|
|
157
|
+
return {
|
|
158
|
+
"id": request_id,
|
|
159
|
+
"error": f"Unknown action: {action}"
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
result = await handler(params)
|
|
164
|
+
return {"id": request_id, "result": result}
|
|
165
|
+
except Exception as e:
|
|
166
|
+
self._log(f"Error handling {action}: {str(e)}")
|
|
167
|
+
return {"id": request_id, "error": str(e)}
|
|
168
|
+
|
|
169
|
+
# =========================================================================
|
|
170
|
+
# Connection Handlers
|
|
171
|
+
# =========================================================================
|
|
172
|
+
|
|
173
|
+
async def _handle_ping(self, params: Dict) -> Dict:
|
|
174
|
+
"""Simple ping for connection testing."""
|
|
175
|
+
return {"pong": True, "timestamp": time.time()}
|
|
176
|
+
|
|
177
|
+
async def _handle_discover_ports(self, params: Dict) -> Dict:
|
|
178
|
+
"""Discover available serial ports with robots."""
|
|
179
|
+
if not HAS_SERIAL:
|
|
180
|
+
return {"ports": [], "error": "pyserial not installed"}
|
|
181
|
+
|
|
182
|
+
ports = []
|
|
183
|
+
for port in serial.tools.list_ports.comports():
|
|
184
|
+
port_info = PortInfo(
|
|
185
|
+
path=port.device,
|
|
186
|
+
description=port.description or "",
|
|
187
|
+
vid=port.vid,
|
|
188
|
+
pid=port.pid,
|
|
189
|
+
serial_number=port.serial_number,
|
|
190
|
+
manufacturer=port.manufacturer
|
|
191
|
+
)
|
|
192
|
+
ports.append({
|
|
193
|
+
"path": port_info.path,
|
|
194
|
+
"description": port_info.description,
|
|
195
|
+
"vid": port_info.vid,
|
|
196
|
+
"pid": port_info.pid,
|
|
197
|
+
"serialNumber": port_info.serial_number,
|
|
198
|
+
"manufacturer": port_info.manufacturer
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
return {"ports": ports}
|
|
202
|
+
|
|
203
|
+
async def _handle_connect_robot(self, params: Dict) -> Dict:
|
|
204
|
+
"""Connect to robot on specified port."""
|
|
205
|
+
port = params.get("port")
|
|
206
|
+
robot_type = params.get("robot_type", "auto")
|
|
207
|
+
baud_rate = params.get("baud_rate", 115200)
|
|
208
|
+
|
|
209
|
+
if not port:
|
|
210
|
+
raise ValueError("Port is required")
|
|
211
|
+
|
|
212
|
+
if not HAS_SERIAL:
|
|
213
|
+
# Mock connection for testing without hardware
|
|
214
|
+
self._log(f"Mock connecting to {port}")
|
|
215
|
+
self.robot = RobotConnection(
|
|
216
|
+
port=port,
|
|
217
|
+
baud_rate=baud_rate,
|
|
218
|
+
robot_type=robot_type,
|
|
219
|
+
servo_ids=[1, 2, 3, 4, 5, 6], # Mock 6 servos
|
|
220
|
+
)
|
|
221
|
+
# Initialize mock servo states
|
|
222
|
+
for sid in self.robot.servo_ids:
|
|
223
|
+
self.robot.servo_states[sid] = ServoState(id=sid)
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
"connected": True,
|
|
227
|
+
"port": port,
|
|
228
|
+
"robotType": robot_type,
|
|
229
|
+
"servos": [
|
|
230
|
+
{"id": sid, "position": 0.0, "velocity": 0.0}
|
|
231
|
+
for sid in self.robot.servo_ids
|
|
232
|
+
]
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
# Real connection
|
|
236
|
+
try:
|
|
237
|
+
ser = serial.Serial(port, baud_rate, timeout=1)
|
|
238
|
+
|
|
239
|
+
# Scan for servos
|
|
240
|
+
servo_ids = await self._scan_servos(ser)
|
|
241
|
+
|
|
242
|
+
self.robot = RobotConnection(
|
|
243
|
+
port=port,
|
|
244
|
+
baud_rate=baud_rate,
|
|
245
|
+
robot_type=robot_type,
|
|
246
|
+
servo_ids=servo_ids,
|
|
247
|
+
serial_conn=ser
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
# Initialize servo states
|
|
251
|
+
for sid in servo_ids:
|
|
252
|
+
self.robot.servo_states[sid] = ServoState(id=sid)
|
|
253
|
+
|
|
254
|
+
self._log(f"Connected to {port}, found {len(servo_ids)} servos")
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
"connected": True,
|
|
258
|
+
"port": port,
|
|
259
|
+
"robotType": robot_type,
|
|
260
|
+
"servos": [
|
|
261
|
+
{"id": sid, "position": 0.0, "velocity": 0.0}
|
|
262
|
+
for sid in servo_ids
|
|
263
|
+
]
|
|
264
|
+
}
|
|
265
|
+
except serial.SerialException as e:
|
|
266
|
+
raise ValueError(f"Failed to connect: {str(e)}")
|
|
267
|
+
|
|
268
|
+
async def _scan_servos(self, ser: Any) -> List[int]:
|
|
269
|
+
"""Scan for connected servos."""
|
|
270
|
+
# This is a placeholder - actual implementation depends on protocol
|
|
271
|
+
# For HiWonder servos, we'd send a broadcast read and collect responses
|
|
272
|
+
found_ids = []
|
|
273
|
+
|
|
274
|
+
# Try scanning IDs 1-20
|
|
275
|
+
for servo_id in range(1, 21):
|
|
276
|
+
# Send ping command (protocol-specific)
|
|
277
|
+
# For now, return mock IDs
|
|
278
|
+
pass
|
|
279
|
+
|
|
280
|
+
# Return mock servo IDs for now
|
|
281
|
+
return [1, 2, 3, 4, 5, 6]
|
|
282
|
+
|
|
283
|
+
async def _handle_disconnect_robot(self, params: Dict) -> Dict:
|
|
284
|
+
"""Disconnect from robot."""
|
|
285
|
+
if self.robot:
|
|
286
|
+
if self.robot.serial_conn:
|
|
287
|
+
self.robot.serial_conn.close()
|
|
288
|
+
self.robot = None
|
|
289
|
+
self._log("Robot disconnected")
|
|
290
|
+
|
|
291
|
+
return {"disconnected": True}
|
|
292
|
+
|
|
293
|
+
# =========================================================================
|
|
294
|
+
# Servo Control Handlers
|
|
295
|
+
# =========================================================================
|
|
296
|
+
|
|
297
|
+
async def _handle_get_servo_state(self, params: Dict) -> Dict:
|
|
298
|
+
"""Get current state of all servos."""
|
|
299
|
+
if not self.robot:
|
|
300
|
+
raise ValueError("No robot connected")
|
|
301
|
+
|
|
302
|
+
# Read current servo states
|
|
303
|
+
servo_states = []
|
|
304
|
+
for sid, state in self.robot.servo_states.items():
|
|
305
|
+
servo_states.append({
|
|
306
|
+
"id": sid,
|
|
307
|
+
"position": state.position,
|
|
308
|
+
"velocity": state.velocity,
|
|
309
|
+
"temperature": state.temperature,
|
|
310
|
+
"load": state.load,
|
|
311
|
+
"voltage": state.voltage,
|
|
312
|
+
"torqueEnabled": state.torque_enabled
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
return {"servos": servo_states}
|
|
316
|
+
|
|
317
|
+
async def _handle_set_servo_position(self, params: Dict) -> Dict:
|
|
318
|
+
"""Set position of a single servo."""
|
|
319
|
+
if not self.robot:
|
|
320
|
+
raise ValueError("No robot connected")
|
|
321
|
+
|
|
322
|
+
servo_id = params.get("servo_id")
|
|
323
|
+
position = params.get("position") # radians
|
|
324
|
+
speed = params.get("speed", 1.0) # 0-1 normalized
|
|
325
|
+
|
|
326
|
+
if servo_id is None or position is None:
|
|
327
|
+
raise ValueError("servo_id and position are required")
|
|
328
|
+
|
|
329
|
+
if servo_id not in self.robot.servo_states:
|
|
330
|
+
raise ValueError(f"Unknown servo ID: {servo_id}")
|
|
331
|
+
|
|
332
|
+
# Update state (real implementation would send to hardware)
|
|
333
|
+
self.robot.servo_states[servo_id].position = position
|
|
334
|
+
self._log(f"Set servo {servo_id} to {position:.3f} rad")
|
|
335
|
+
|
|
336
|
+
return {"success": True, "servoId": servo_id, "position": position}
|
|
337
|
+
|
|
338
|
+
async def _handle_set_servo_torque(self, params: Dict) -> Dict:
|
|
339
|
+
"""Enable/disable torque on a servo or all servos."""
|
|
340
|
+
if not self.robot:
|
|
341
|
+
raise ValueError("No robot connected")
|
|
342
|
+
|
|
343
|
+
servo_id = params.get("servo_id") # None = all servos
|
|
344
|
+
enabled = params.get("enabled", True)
|
|
345
|
+
|
|
346
|
+
if servo_id is not None:
|
|
347
|
+
if servo_id not in self.robot.servo_states:
|
|
348
|
+
raise ValueError(f"Unknown servo ID: {servo_id}")
|
|
349
|
+
self.robot.servo_states[servo_id].torque_enabled = enabled
|
|
350
|
+
affected = [servo_id]
|
|
351
|
+
else:
|
|
352
|
+
for state in self.robot.servo_states.values():
|
|
353
|
+
state.torque_enabled = enabled
|
|
354
|
+
affected = list(self.robot.servo_states.keys())
|
|
355
|
+
|
|
356
|
+
return {"success": True, "servoIds": affected, "enabled": enabled}
|
|
357
|
+
|
|
358
|
+
# =========================================================================
|
|
359
|
+
# Trajectory Handlers
|
|
360
|
+
# =========================================================================
|
|
361
|
+
|
|
362
|
+
async def _handle_execute_trajectory(self, params: Dict) -> Dict:
|
|
363
|
+
"""Execute a trajectory on the robot."""
|
|
364
|
+
if not self.robot:
|
|
365
|
+
raise ValueError("No robot connected")
|
|
366
|
+
|
|
367
|
+
trajectory = params.get("trajectory", [])
|
|
368
|
+
if not trajectory:
|
|
369
|
+
raise ValueError("Empty trajectory")
|
|
370
|
+
|
|
371
|
+
self._log(f"Executing trajectory with {len(trajectory)} waypoints")
|
|
372
|
+
|
|
373
|
+
start_time = time.time()
|
|
374
|
+
|
|
375
|
+
for i, waypoint in enumerate(trajectory):
|
|
376
|
+
wp_time = waypoint.get("time", 0)
|
|
377
|
+
positions = waypoint.get("positions", {})
|
|
378
|
+
|
|
379
|
+
# Wait until waypoint time
|
|
380
|
+
elapsed = time.time() - start_time
|
|
381
|
+
if wp_time > elapsed:
|
|
382
|
+
await asyncio.sleep(wp_time - elapsed)
|
|
383
|
+
|
|
384
|
+
# Apply positions
|
|
385
|
+
for joint_name, position in positions.items():
|
|
386
|
+
# Map joint name to servo ID (simplified)
|
|
387
|
+
servo_id = int(joint_name.split("_")[-1]) if "_" in joint_name else 1
|
|
388
|
+
if servo_id in self.robot.servo_states:
|
|
389
|
+
self.robot.servo_states[servo_id].position = position
|
|
390
|
+
|
|
391
|
+
# Broadcast state update
|
|
392
|
+
await self._broadcast_servo_state()
|
|
393
|
+
|
|
394
|
+
duration = time.time() - start_time
|
|
395
|
+
self._log(f"Trajectory completed in {duration:.2f}s")
|
|
396
|
+
|
|
397
|
+
return {"success": True, "duration": duration}
|
|
398
|
+
|
|
399
|
+
# =========================================================================
|
|
400
|
+
# Configuration Handlers
|
|
401
|
+
# =========================================================================
|
|
402
|
+
|
|
403
|
+
async def _handle_get_robot_config(self, params: Dict) -> Dict:
|
|
404
|
+
"""Get current robot configuration."""
|
|
405
|
+
if not self.robot:
|
|
406
|
+
raise ValueError("No robot connected")
|
|
407
|
+
|
|
408
|
+
return {
|
|
409
|
+
"port": self.robot.port,
|
|
410
|
+
"baudRate": self.robot.baud_rate,
|
|
411
|
+
"robotType": self.robot.robot_type,
|
|
412
|
+
"servoIds": self.robot.servo_ids
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async def _handle_sync_urdf(self, params: Dict) -> Dict:
|
|
416
|
+
"""Sync URDF from Artifex to create joint mapping."""
|
|
417
|
+
urdf_content = params.get("urdf")
|
|
418
|
+
if not urdf_content:
|
|
419
|
+
raise ValueError("URDF content is required")
|
|
420
|
+
|
|
421
|
+
# Parse URDF to extract joint names
|
|
422
|
+
# This is a simplified parser - real implementation would use urdfpy
|
|
423
|
+
import re
|
|
424
|
+
joint_pattern = r'<joint\s+name="([^"]+)"'
|
|
425
|
+
joint_names = re.findall(joint_pattern, urdf_content)
|
|
426
|
+
|
|
427
|
+
# Create suggested mapping
|
|
428
|
+
mapping = []
|
|
429
|
+
for i, joint_name in enumerate(joint_names):
|
|
430
|
+
if self.robot and i < len(self.robot.servo_ids):
|
|
431
|
+
mapping.append({
|
|
432
|
+
"jointName": joint_name,
|
|
433
|
+
"servoId": self.robot.servo_ids[i],
|
|
434
|
+
"inverted": False,
|
|
435
|
+
"offset": 0.0
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
return {"mapping": mapping, "joints": joint_names}
|
|
439
|
+
|
|
440
|
+
async def _handle_sync_config(self, params: Dict) -> Dict:
|
|
441
|
+
"""Sync full robot configuration from Artifex."""
|
|
442
|
+
config = params.get("config", {})
|
|
443
|
+
|
|
444
|
+
# Store configuration
|
|
445
|
+
self._log(f"Received config for robot: {config.get('robot_name', 'unnamed')}")
|
|
446
|
+
|
|
447
|
+
# Apply joint mapping if provided
|
|
448
|
+
if "joints" in config:
|
|
449
|
+
for joint in config["joints"]:
|
|
450
|
+
servo_id = joint.get("servo_id")
|
|
451
|
+
if servo_id and self.robot and servo_id in self.robot.servo_states:
|
|
452
|
+
# Store any additional config per servo
|
|
453
|
+
pass
|
|
454
|
+
|
|
455
|
+
return {"success": True}
|
|
456
|
+
|
|
457
|
+
# =========================================================================
|
|
458
|
+
# Skill Handlers
|
|
459
|
+
# =========================================================================
|
|
460
|
+
|
|
461
|
+
async def _handle_list_skills(self, params: Dict) -> Dict:
|
|
462
|
+
"""List available skills."""
|
|
463
|
+
# Look for skills in the current directory
|
|
464
|
+
skills = []
|
|
465
|
+
skills_dir = Path("./skills")
|
|
466
|
+
|
|
467
|
+
if skills_dir.exists():
|
|
468
|
+
for skill_path in skills_dir.glob("*/skill.json"):
|
|
469
|
+
try:
|
|
470
|
+
with open(skill_path) as f:
|
|
471
|
+
skill_meta = json.load(f)
|
|
472
|
+
skills.append({
|
|
473
|
+
"name": skill_meta.get("name", skill_path.parent.name),
|
|
474
|
+
"version": skill_meta.get("version", "1.0.0"),
|
|
475
|
+
"description": skill_meta.get("description", ""),
|
|
476
|
+
"parameters": skill_meta.get("parameters", [])
|
|
477
|
+
})
|
|
478
|
+
except Exception:
|
|
479
|
+
pass
|
|
480
|
+
|
|
481
|
+
return {"skills": skills}
|
|
482
|
+
|
|
483
|
+
async def _handle_deploy_skill(self, params: Dict) -> Dict:
|
|
484
|
+
"""Deploy a skill package to the robot."""
|
|
485
|
+
skill_name = params.get("skill_name")
|
|
486
|
+
package = params.get("package") # Base64 encoded skill package
|
|
487
|
+
|
|
488
|
+
if not skill_name:
|
|
489
|
+
raise ValueError("skill_name is required")
|
|
490
|
+
|
|
491
|
+
self._log(f"Deploying skill: {skill_name}")
|
|
492
|
+
|
|
493
|
+
# In real implementation, this would:
|
|
494
|
+
# 1. Decode the package
|
|
495
|
+
# 2. Validate the skill
|
|
496
|
+
# 3. Save to robot's skill directory
|
|
497
|
+
# 4. Register with skill executor
|
|
498
|
+
|
|
499
|
+
return {"success": True, "skillName": skill_name}
|
|
500
|
+
|
|
501
|
+
async def _handle_run_skill(self, params: Dict) -> Dict:
|
|
502
|
+
"""Run a skill on the robot."""
|
|
503
|
+
skill_name = params.get("skill_name")
|
|
504
|
+
skill_params = params.get("params", {})
|
|
505
|
+
|
|
506
|
+
if not skill_name:
|
|
507
|
+
raise ValueError("skill_name is required")
|
|
508
|
+
|
|
509
|
+
self._log(f"Running skill: {skill_name} with params: {skill_params}")
|
|
510
|
+
|
|
511
|
+
# In real implementation, this would:
|
|
512
|
+
# 1. Load the skill
|
|
513
|
+
# 2. Execute with parameters
|
|
514
|
+
# 3. Return results
|
|
515
|
+
|
|
516
|
+
# Mock execution
|
|
517
|
+
await asyncio.sleep(1.0) # Simulate skill execution
|
|
518
|
+
|
|
519
|
+
return {
|
|
520
|
+
"success": True,
|
|
521
|
+
"skillName": skill_name,
|
|
522
|
+
"message": f"Skill {skill_name} completed",
|
|
523
|
+
"data": {}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
# =========================================================================
|
|
527
|
+
# Broadcasting
|
|
528
|
+
# =========================================================================
|
|
529
|
+
|
|
530
|
+
async def _broadcast_servo_state(self):
|
|
531
|
+
"""Broadcast current servo state to all connected clients."""
|
|
532
|
+
if not self.robot or not self.connections:
|
|
533
|
+
return
|
|
534
|
+
|
|
535
|
+
servo_states = []
|
|
536
|
+
for sid, state in self.robot.servo_states.items():
|
|
537
|
+
servo_states.append({
|
|
538
|
+
"id": sid,
|
|
539
|
+
"position": state.position,
|
|
540
|
+
"velocity": state.velocity,
|
|
541
|
+
"temperature": state.temperature,
|
|
542
|
+
"load": state.load
|
|
543
|
+
})
|
|
544
|
+
|
|
545
|
+
message = json.dumps({
|
|
546
|
+
"event": "servo_state",
|
|
547
|
+
"data": {"servos": servo_states}
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
await asyncio.gather(
|
|
551
|
+
*[ws.send(message) for ws in self.connections],
|
|
552
|
+
return_exceptions=True
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
async def _broadcast_loop(self):
|
|
556
|
+
"""Periodically broadcast servo state to all clients."""
|
|
557
|
+
while self._running:
|
|
558
|
+
try:
|
|
559
|
+
await self._broadcast_servo_state()
|
|
560
|
+
except Exception as e:
|
|
561
|
+
self._log(f"Broadcast error: {e}")
|
|
562
|
+
await asyncio.sleep(0.05) # 20Hz update rate
|
|
563
|
+
|
|
564
|
+
# =========================================================================
|
|
565
|
+
# Server Control
|
|
566
|
+
# =========================================================================
|
|
567
|
+
|
|
568
|
+
async def start(self):
|
|
569
|
+
"""Start the WebSocket server."""
|
|
570
|
+
if not HAS_WEBSOCKETS:
|
|
571
|
+
raise RuntimeError("websockets library not installed. Run: pip install websockets")
|
|
572
|
+
|
|
573
|
+
self._running = True
|
|
574
|
+
|
|
575
|
+
async with websockets.serve(
|
|
576
|
+
self.handle_connection,
|
|
577
|
+
"localhost",
|
|
578
|
+
self.port
|
|
579
|
+
) as server:
|
|
580
|
+
self._log(f"Server started on ws://localhost:{self.port}")
|
|
581
|
+
|
|
582
|
+
# Start broadcast loop
|
|
583
|
+
self._broadcast_task = asyncio.create_task(self._broadcast_loop())
|
|
584
|
+
|
|
585
|
+
# Wait forever
|
|
586
|
+
await asyncio.Future()
|
|
587
|
+
|
|
588
|
+
def stop(self):
|
|
589
|
+
"""Stop the server."""
|
|
590
|
+
self._running = False
|
|
591
|
+
if self._broadcast_task:
|
|
592
|
+
self._broadcast_task.cancel()
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def run_bridge_server(port: int = 8765, verbose: bool = False):
|
|
596
|
+
"""
|
|
597
|
+
Run the ATE bridge server.
|
|
598
|
+
|
|
599
|
+
Args:
|
|
600
|
+
port: WebSocket port to listen on
|
|
601
|
+
verbose: Enable verbose logging
|
|
602
|
+
"""
|
|
603
|
+
server = ATEBridgeServer(port=port, verbose=verbose)
|
|
604
|
+
|
|
605
|
+
print(f"\n{'='*50}")
|
|
606
|
+
print(f" ATE Bridge Server")
|
|
607
|
+
print(f"{'='*50}")
|
|
608
|
+
print(f" Port: {port}")
|
|
609
|
+
print(f" URL: ws://localhost:{port}")
|
|
610
|
+
print(f"{'='*50}")
|
|
611
|
+
print("\nWaiting for Artifex to connect...")
|
|
612
|
+
print("Press Ctrl+C to stop\n")
|
|
613
|
+
|
|
614
|
+
try:
|
|
615
|
+
asyncio.run(server.start())
|
|
616
|
+
except KeyboardInterrupt:
|
|
617
|
+
print("\nShutting down...")
|
|
618
|
+
server.stop()
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
if __name__ == "__main__":
|
|
622
|
+
run_bridge_server(verbose=True)
|