foodforthought-cli 0.2.7__py3-none-any.whl → 0.3.0__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.
Files changed (131) hide show
  1. ate/__init__.py +6 -0
  2. ate/__main__.py +16 -0
  3. ate/auth/__init__.py +1 -0
  4. ate/auth/device_flow.py +141 -0
  5. ate/auth/token_store.py +96 -0
  6. ate/behaviors/__init__.py +100 -0
  7. ate/behaviors/approach.py +399 -0
  8. ate/behaviors/common.py +686 -0
  9. ate/behaviors/tree.py +454 -0
  10. ate/cli.py +855 -3995
  11. ate/client.py +90 -0
  12. ate/commands/__init__.py +168 -0
  13. ate/commands/auth.py +389 -0
  14. ate/commands/bridge.py +448 -0
  15. ate/commands/data.py +185 -0
  16. ate/commands/deps.py +111 -0
  17. ate/commands/generate.py +384 -0
  18. ate/commands/memory.py +907 -0
  19. ate/commands/parts.py +166 -0
  20. ate/commands/primitive.py +399 -0
  21. ate/commands/protocol.py +288 -0
  22. ate/commands/recording.py +524 -0
  23. ate/commands/repo.py +154 -0
  24. ate/commands/simulation.py +291 -0
  25. ate/commands/skill.py +303 -0
  26. ate/commands/skills.py +487 -0
  27. ate/commands/team.py +147 -0
  28. ate/commands/workflow.py +271 -0
  29. ate/detection/__init__.py +38 -0
  30. ate/detection/base.py +142 -0
  31. ate/detection/color_detector.py +399 -0
  32. ate/detection/trash_detector.py +322 -0
  33. ate/drivers/__init__.py +39 -0
  34. ate/drivers/ble_transport.py +405 -0
  35. ate/drivers/mechdog.py +942 -0
  36. ate/drivers/wifi_camera.py +477 -0
  37. ate/interfaces/__init__.py +187 -0
  38. ate/interfaces/base.py +273 -0
  39. ate/interfaces/body.py +267 -0
  40. ate/interfaces/detection.py +282 -0
  41. ate/interfaces/locomotion.py +422 -0
  42. ate/interfaces/manipulation.py +408 -0
  43. ate/interfaces/navigation.py +389 -0
  44. ate/interfaces/perception.py +362 -0
  45. ate/interfaces/sensors.py +247 -0
  46. ate/interfaces/types.py +371 -0
  47. ate/llm_proxy.py +239 -0
  48. ate/mcp_server.py +387 -0
  49. ate/memory/__init__.py +35 -0
  50. ate/memory/cloud.py +244 -0
  51. ate/memory/context.py +269 -0
  52. ate/memory/embeddings.py +184 -0
  53. ate/memory/export.py +26 -0
  54. ate/memory/merge.py +146 -0
  55. ate/memory/migrate/__init__.py +34 -0
  56. ate/memory/migrate/base.py +89 -0
  57. ate/memory/migrate/pipeline.py +189 -0
  58. ate/memory/migrate/sources/__init__.py +13 -0
  59. ate/memory/migrate/sources/chroma.py +170 -0
  60. ate/memory/migrate/sources/pinecone.py +120 -0
  61. ate/memory/migrate/sources/qdrant.py +110 -0
  62. ate/memory/migrate/sources/weaviate.py +160 -0
  63. ate/memory/reranker.py +353 -0
  64. ate/memory/search.py +26 -0
  65. ate/memory/store.py +548 -0
  66. ate/recording/__init__.py +83 -0
  67. ate/recording/demonstration.py +378 -0
  68. ate/recording/session.py +415 -0
  69. ate/recording/upload.py +304 -0
  70. ate/recording/visual.py +416 -0
  71. ate/recording/wrapper.py +95 -0
  72. ate/robot/__init__.py +221 -0
  73. ate/robot/agentic_servo.py +856 -0
  74. ate/robot/behaviors.py +493 -0
  75. ate/robot/ble_capture.py +1000 -0
  76. ate/robot/ble_enumerate.py +506 -0
  77. ate/robot/calibration.py +668 -0
  78. ate/robot/calibration_state.py +388 -0
  79. ate/robot/commands.py +3735 -0
  80. ate/robot/direction_calibration.py +554 -0
  81. ate/robot/discovery.py +441 -0
  82. ate/robot/introspection.py +330 -0
  83. ate/robot/llm_system_id.py +654 -0
  84. ate/robot/locomotion_calibration.py +508 -0
  85. ate/robot/manager.py +270 -0
  86. ate/robot/marker_generator.py +611 -0
  87. ate/robot/perception.py +502 -0
  88. ate/robot/primitives.py +614 -0
  89. ate/robot/profiles.py +281 -0
  90. ate/robot/registry.py +322 -0
  91. ate/robot/servo_mapper.py +1153 -0
  92. ate/robot/skill_upload.py +675 -0
  93. ate/robot/target_calibration.py +500 -0
  94. ate/robot/teach.py +515 -0
  95. ate/robot/types.py +242 -0
  96. ate/robot/visual_labeler.py +1048 -0
  97. ate/robot/visual_servo_loop.py +494 -0
  98. ate/robot/visual_servoing.py +570 -0
  99. ate/robot/visual_system_id.py +906 -0
  100. ate/transports/__init__.py +121 -0
  101. ate/transports/base.py +394 -0
  102. ate/transports/ble.py +405 -0
  103. ate/transports/hybrid.py +444 -0
  104. ate/transports/serial.py +345 -0
  105. ate/urdf/__init__.py +30 -0
  106. ate/urdf/capture.py +582 -0
  107. ate/urdf/cloud.py +491 -0
  108. ate/urdf/collision.py +271 -0
  109. ate/urdf/commands.py +708 -0
  110. ate/urdf/depth.py +360 -0
  111. ate/urdf/inertial.py +312 -0
  112. ate/urdf/kinematics.py +330 -0
  113. ate/urdf/lifting.py +415 -0
  114. ate/urdf/meshing.py +300 -0
  115. ate/urdf/models/__init__.py +110 -0
  116. ate/urdf/models/depth_anything.py +253 -0
  117. ate/urdf/models/sam2.py +324 -0
  118. ate/urdf/motion_analysis.py +396 -0
  119. ate/urdf/pipeline.py +468 -0
  120. ate/urdf/scale.py +256 -0
  121. ate/urdf/scan_session.py +411 -0
  122. ate/urdf/segmentation.py +299 -0
  123. ate/urdf/synthesis.py +319 -0
  124. ate/urdf/topology.py +336 -0
  125. ate/urdf/validation.py +371 -0
  126. {foodforthought_cli-0.2.7.dist-info → foodforthought_cli-0.3.0.dist-info}/METADATA +9 -1
  127. foodforthought_cli-0.3.0.dist-info/RECORD +166 -0
  128. {foodforthought_cli-0.2.7.dist-info → foodforthought_cli-0.3.0.dist-info}/WHEEL +1 -1
  129. foodforthought_cli-0.2.7.dist-info/RECORD +0 -44
  130. {foodforthought_cli-0.2.7.dist-info → foodforthought_cli-0.3.0.dist-info}/entry_points.txt +0 -0
  131. {foodforthought_cli-0.2.7.dist-info → foodforthought_cli-0.3.0.dist-info}/top_level.txt +0 -0
ate/transports/ble.py ADDED
@@ -0,0 +1,405 @@
1
+ """
2
+ BLE Transport Implementation.
3
+
4
+ Uses bleak library for cross-platform Bluetooth Low Energy communication.
5
+ Tested with HiWonder MechDog but designed to be adaptable to other robots.
6
+
7
+ Key learnings from MechDog reverse engineering:
8
+ - Write commands to FFE1 characteristic
9
+ - Subscribe to FFE2 for responses (CRITICAL - many examples miss this!)
10
+ - Commands are ASCII strings in CMD|...|$ format
11
+ - Some commands work (locomotion), some don't (arm) - firmware dependent
12
+ """
13
+
14
+ import asyncio
15
+ from typing import Optional
16
+ from dataclasses import dataclass
17
+
18
+ try:
19
+ from bleak import BleakClient, BleakScanner
20
+ from bleak.backends.device import BLEDevice
21
+ BLEAK_AVAILABLE = True
22
+ except ImportError:
23
+ BLEAK_AVAILABLE = False
24
+ BleakClient = None
25
+ BleakScanner = None
26
+ BLEDevice = None
27
+
28
+ from .base import (
29
+ RobotTransport,
30
+ TransportState,
31
+ TransportCapability,
32
+ ConnectionInfo,
33
+ CommandResult,
34
+ HiWonderProtocol,
35
+ )
36
+
37
+
38
+ # Standard HiWonder GATT UUIDs
39
+ HIWONDER_SERVICE_UUID = "0000ffe0-0000-1000-8000-00805f9b34fb"
40
+ HIWONDER_WRITE_UUID = "0000ffe1-0000-1000-8000-00805f9b34fb" # Write commands here
41
+ HIWONDER_NOTIFY_UUID = "0000ffe2-0000-1000-8000-00805f9b34fb" # Subscribe for responses
42
+
43
+
44
+ @dataclass
45
+ class BLEConfig:
46
+ """Configuration for BLE transport."""
47
+ write_uuid: str = HIWONDER_WRITE_UUID
48
+ notify_uuid: str = HIWONDER_NOTIFY_UUID
49
+ response_timeout: float = 2.0
50
+ retry_count: int = 3
51
+ probe_on_connect: bool = True
52
+
53
+
54
+ class BLETransport(RobotTransport):
55
+ """
56
+ Bluetooth Low Energy transport for robot communication.
57
+
58
+ Example:
59
+ async with BLETransport() as transport:
60
+ if await transport.connect("FAF198B6-D9A4-CC27-7BBA-3159F63A61A9"):
61
+ result = await transport.send_command("CMD|6|$") # Battery query
62
+ print(f"Battery: {result.response}")
63
+ """
64
+
65
+ def __init__(self, config: Optional[BLEConfig] = None):
66
+ super().__init__()
67
+
68
+ if not BLEAK_AVAILABLE:
69
+ raise ImportError(
70
+ "bleak is required for BLE transport. "
71
+ "Install with: pip install bleak"
72
+ )
73
+
74
+ self.config = config or BLEConfig()
75
+ self._client: Optional[BleakClient] = None
76
+ self._protocol = HiWonderProtocol()
77
+ self._pending_responses: asyncio.Queue = asyncio.Queue()
78
+ self._response_buffer: list[bytes] = []
79
+
80
+ async def connect(self, address: str, **kwargs) -> bool:
81
+ """
82
+ Connect to a BLE robot.
83
+
84
+ Args:
85
+ address: MAC address or UUID of the device
86
+ **kwargs: Additional options (name hint, timeout)
87
+
88
+ Returns:
89
+ True if connected successfully
90
+ """
91
+ self._state = TransportState.CONNECTING
92
+
93
+ try:
94
+ # Scan to find the device
95
+ device = await self._find_device(address, kwargs.get("name"))
96
+ if not device:
97
+ self._state = TransportState.ERROR
98
+ return False
99
+
100
+ # Connect
101
+ self._client = BleakClient(device)
102
+ connected = await self._client.connect()
103
+
104
+ if not connected or not self._client.is_connected:
105
+ self._state = TransportState.ERROR
106
+ return False
107
+
108
+ # Start notification handler
109
+ await self._client.start_notify(
110
+ self.config.notify_uuid,
111
+ self._notification_handler
112
+ )
113
+
114
+ self._state = TransportState.CONNECTED
115
+ self._connection_info = ConnectionInfo(
116
+ transport_type="ble",
117
+ address=address,
118
+ name=device.name,
119
+ rssi=device.rssi if hasattr(device, 'rssi') else None,
120
+ )
121
+
122
+ # Probe capabilities if configured
123
+ if self.config.probe_on_connect:
124
+ self._capabilities = await self.probe_capabilities()
125
+
126
+ return True
127
+
128
+ except Exception as e:
129
+ print(f"BLE connect error: {e}")
130
+ self._state = TransportState.ERROR
131
+ return False
132
+
133
+ async def disconnect(self) -> None:
134
+ """Disconnect from the BLE device."""
135
+ if self._client and self._client.is_connected:
136
+ try:
137
+ await self._client.stop_notify(self.config.notify_uuid)
138
+ except Exception:
139
+ pass
140
+ try:
141
+ await self._client.disconnect()
142
+ except Exception:
143
+ pass
144
+
145
+ self._client = None
146
+ self._state = TransportState.DISCONNECTED
147
+ self._connection_info = None
148
+
149
+ async def send(self, data: bytes) -> CommandResult:
150
+ """Send raw bytes to the robot."""
151
+ if not self.is_connected or not self._client:
152
+ return CommandResult(success=False, error="Not connected")
153
+
154
+ import time
155
+ start = time.perf_counter()
156
+
157
+ try:
158
+ # Clear pending responses
159
+ while not self._pending_responses.empty():
160
+ try:
161
+ self._pending_responses.get_nowait()
162
+ except asyncio.QueueEmpty:
163
+ break
164
+
165
+ # Send command
166
+ await self._client.write_gatt_char(
167
+ self.config.write_uuid,
168
+ data
169
+ )
170
+
171
+ # Wait for response
172
+ try:
173
+ response = await asyncio.wait_for(
174
+ self._pending_responses.get(),
175
+ timeout=self.config.response_timeout
176
+ )
177
+ latency = (time.perf_counter() - start) * 1000
178
+
179
+ return CommandResult(
180
+ success=True,
181
+ response=response.decode('ascii', errors='replace'),
182
+ raw_data=response,
183
+ latency_ms=latency
184
+ )
185
+ except asyncio.TimeoutError:
186
+ # Command sent but no response (may still have worked)
187
+ latency = (time.perf_counter() - start) * 1000
188
+ return CommandResult(
189
+ success=True,
190
+ error="No response (command may have worked)",
191
+ latency_ms=latency
192
+ )
193
+
194
+ except Exception as e:
195
+ return CommandResult(success=False, error=str(e))
196
+
197
+ async def send_command(self, command: str) -> CommandResult:
198
+ """Send a command string (handles encoding)."""
199
+ if command.startswith("CMD|"):
200
+ # Already formatted
201
+ data = command.encode('ascii')
202
+ else:
203
+ # Try to encode as canonical command
204
+ try:
205
+ data = self._protocol.encode_command(command)
206
+ except ValueError:
207
+ # Unknown command, send as-is
208
+ data = command.encode('ascii')
209
+
210
+ return await self.send(data)
211
+
212
+ @classmethod
213
+ async def discover(cls, timeout: float = 5.0) -> list[ConnectionInfo]:
214
+ """Discover BLE robots."""
215
+ if not BLEAK_AVAILABLE:
216
+ return []
217
+
218
+ devices = await BleakScanner.discover(timeout=timeout)
219
+ results = []
220
+
221
+ for device in devices:
222
+ # Filter for likely robot devices
223
+ name = device.name or ""
224
+ if any(kw in name.lower() for kw in ["mechdog", "robot", "hiwonder", "unitree"]):
225
+ results.append(ConnectionInfo(
226
+ transport_type="ble",
227
+ address=device.address,
228
+ name=device.name,
229
+ rssi=device.rssi if hasattr(device, 'rssi') else None,
230
+ ))
231
+
232
+ return results
233
+
234
+ async def probe_capabilities(self) -> set[TransportCapability]:
235
+ """
236
+ Probe which capabilities work on this connection.
237
+
238
+ Sends test commands and checks for valid responses.
239
+ This handles the firmware fragmentation problem.
240
+ """
241
+ caps = set()
242
+
243
+ if not self.is_connected:
244
+ return caps
245
+
246
+ # Always try battery first - good connectivity test
247
+ result = await self.send(b"CMD|6|$")
248
+ if result.success and result.response and "CMD|6|" in result.response:
249
+ caps.add(TransportCapability.BATTERY)
250
+ caps.add(TransportCapability.BIDIRECTIONAL)
251
+
252
+ # Try ultrasonic
253
+ result = await self.send(b"CMD|4|1|$")
254
+ await asyncio.sleep(0.3)
255
+ if result.success and result.response and "CMD|4|" in result.response:
256
+ caps.add(TransportCapability.ULTRASONIC)
257
+
258
+ # We know locomotion works from testing
259
+ # Could verify with a small movement, but risky for capability probe
260
+ caps.add(TransportCapability.WALK)
261
+ caps.add(TransportCapability.POSTURE)
262
+ caps.add(TransportCapability.ACTION_GROUPS)
263
+
264
+ # UPDATE: CMD|7|... commands DO work for arm control!
265
+ # Tested 2026-01-04: gripper, shoulder, elbow all respond to CMD|7
266
+ caps.add(TransportCapability.ARM_POSITION)
267
+ caps.add(TransportCapability.GRIPPER)
268
+
269
+ return caps
270
+
271
+ async def _find_device(
272
+ self,
273
+ address: str,
274
+ name_hint: Optional[str] = None
275
+ ) -> Optional["BLEDevice"]:
276
+ """Find a BLE device by address or name."""
277
+ devices = await BleakScanner.discover(timeout=5.0)
278
+
279
+ for device in devices:
280
+ if device.address == address:
281
+ return device
282
+ if name_hint and device.name and name_hint.lower() in device.name.lower():
283
+ return device
284
+ if device.name and "mechdog" in device.name.lower():
285
+ return device
286
+
287
+ return None
288
+
289
+ def _notification_handler(self, sender, data: bytes) -> None:
290
+ """Handle incoming BLE notifications."""
291
+ # Store in buffer
292
+ self._response_buffer.append(data)
293
+
294
+ # Notify async handlers
295
+ asyncio.create_task(self._notify_handlers(data))
296
+
297
+ # Put in queue for synchronous waiting
298
+ try:
299
+ self._pending_responses.put_nowait(data)
300
+ except asyncio.QueueFull:
301
+ pass
302
+
303
+ # ========== High-Level Commands ==========
304
+ # These use the protocol adapter for clean API
305
+
306
+ async def walk_forward(self, speed: int = 100) -> CommandResult:
307
+ """Walk forward at given speed (0-200)."""
308
+ return await self.send(f"CMD|3|{speed}|0|0|$".encode())
309
+
310
+ async def walk_backward(self, speed: int = 100) -> CommandResult:
311
+ """Walk backward at given speed."""
312
+ return await self.send(f"CMD|3|{-speed}|0|0|$".encode())
313
+
314
+ async def stop(self) -> CommandResult:
315
+ """Stop all movement."""
316
+ return await self.send(b"CMD|3|0|0|0|$")
317
+
318
+ async def stand(self) -> CommandResult:
319
+ """Stand in normal posture."""
320
+ return await self.send(b"CMD|1|0|$")
321
+
322
+ async def sit(self) -> CommandResult:
323
+ """Sit down."""
324
+ return await self.send(b"CMD|1|7|$")
325
+
326
+ async def get_battery(self) -> Optional[float]:
327
+ """Get battery voltage."""
328
+ result = await self.send(b"CMD|6|$")
329
+ if result.response and "CMD|6|" in result.response:
330
+ try:
331
+ parts = result.response.split("|")
332
+ return float(parts[2])
333
+ except (IndexError, ValueError):
334
+ pass
335
+ return None
336
+
337
+ async def get_ultrasonic(self) -> Optional[int]:
338
+ """Get ultrasonic distance in mm."""
339
+ result = await self.send(b"CMD|4|1|$")
340
+ if result.response and "CMD|4|" in result.response:
341
+ try:
342
+ parts = result.response.split("|")
343
+ return int(parts[2])
344
+ except (IndexError, ValueError):
345
+ pass
346
+ return None
347
+
348
+ async def run_action_group(self, group_id: int) -> CommandResult:
349
+ """Run a pre-programmed action group."""
350
+ return await self.send(f"CMD|2|1|{group_id}|$".encode())
351
+
352
+ # ========== ARM Control (CMD|7|...) ==========
353
+ # Tested and confirmed working 2026-01-04
354
+
355
+ async def gripper_open(self) -> CommandResult:
356
+ """Open the gripper."""
357
+ return await self.send(b"CMD|7|6|$")
358
+
359
+ async def gripper_close(self) -> CommandResult:
360
+ """Close the gripper."""
361
+ return await self.send(b"CMD|7|7|$")
362
+
363
+ async def shoulder_up(self, repeats: int = 1) -> CommandResult:
364
+ """Move shoulder joint up (incremental)."""
365
+ result = None
366
+ for _ in range(repeats):
367
+ result = await self.send(b"CMD|7|3|1|$")
368
+ await asyncio.sleep(0.2)
369
+ return result
370
+
371
+ async def shoulder_down(self, repeats: int = 1) -> CommandResult:
372
+ """Move shoulder joint down (incremental)."""
373
+ result = None
374
+ for _ in range(repeats):
375
+ result = await self.send(b"CMD|7|3|-1|$")
376
+ await asyncio.sleep(0.2)
377
+ return result
378
+
379
+ async def elbow_extend(self, repeats: int = 1) -> CommandResult:
380
+ """Extend elbow joint (incremental)."""
381
+ result = None
382
+ for _ in range(repeats):
383
+ result = await self.send(b"CMD|7|4|1|$")
384
+ await asyncio.sleep(0.2)
385
+ return result
386
+
387
+ async def elbow_retract(self, repeats: int = 1) -> CommandResult:
388
+ """Retract elbow joint (incremental)."""
389
+ result = None
390
+ for _ in range(repeats):
391
+ result = await self.send(b"CMD|7|4|-1|$")
392
+ await asyncio.sleep(0.2)
393
+ return result
394
+
395
+ async def arm_to_pickup_position(self) -> None:
396
+ """Move arm to pickup position (lowered, extended)."""
397
+ await self.gripper_open()
398
+ await asyncio.sleep(0.3)
399
+ await self.shoulder_down(repeats=10)
400
+ await self.elbow_extend(repeats=8)
401
+
402
+ async def arm_to_carry_position(self) -> None:
403
+ """Move arm to carry position (raised, retracted)."""
404
+ await self.shoulder_up(repeats=10)
405
+ await self.elbow_retract(repeats=8)