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/drivers/mechdog.py ADDED
@@ -0,0 +1,942 @@
1
+ """
2
+ MechDog driver implementation.
3
+
4
+ Implements the FoodforThought interfaces for HiWonder MechDog.
5
+
6
+ The MechDog runs MicroPython with the HW_MechDog library.
7
+ Communication is via serial REPL commands.
8
+
9
+ Hardware specs:
10
+ - 12 servos (3 per leg: hip, thigh, calf)
11
+ - Optional arm attachment (3 servos)
12
+ - ESP32-based controller
13
+ - LX-16A serial bus servos
14
+ """
15
+
16
+ import time
17
+ import math
18
+ from typing import Optional, List, Set, Tuple
19
+ from dataclasses import dataclass
20
+
21
+ try:
22
+ import serial
23
+ HAS_SERIAL = True
24
+ except ImportError:
25
+ HAS_SERIAL = False
26
+
27
+ from ..interfaces.types import (
28
+ Vector3,
29
+ Quaternion,
30
+ Pose,
31
+ Twist,
32
+ JointState,
33
+ GaitType,
34
+ ActionResult,
35
+ RobotStatus,
36
+ RobotMode,
37
+ BatteryState,
38
+ IMUReading,
39
+ Image,
40
+ )
41
+ from ..interfaces.base import (
42
+ RobotInterface,
43
+ SafetyInterface,
44
+ RobotInfo,
45
+ Capability,
46
+ )
47
+ from ..interfaces.locomotion import QuadrupedLocomotion
48
+ from ..interfaces.body import BodyPoseInterface
49
+ from ..interfaces.perception import CameraInterface
50
+ from ..interfaces.sensors import DistanceSensorInterface, DistanceReading, DistanceSensorType
51
+ from .wifi_camera import WiFiCamera, WiFiCameraConfig
52
+
53
+
54
+ # =============================================================================
55
+ # MechDog-specific constants
56
+ # =============================================================================
57
+
58
+ # Servo value ranges (raw values from MechDog)
59
+ SERVO_MIN = 0
60
+ SERVO_MAX = 4096
61
+ SERVO_CENTER = 2048
62
+
63
+ # Body dimensions (meters)
64
+ BODY_LENGTH = 0.20 # Front to back
65
+ BODY_WIDTH = 0.10 # Side to side
66
+ LEG_LENGTH = 0.12 # Total leg length
67
+
68
+ # Height limits (meters)
69
+ HEIGHT_MIN = 0.05
70
+ HEIGHT_MAX = 0.18
71
+ HEIGHT_DEFAULT = 0.12
72
+
73
+ # Speed limits
74
+ MAX_STRIDE_MM = 100 # Maximum stride in mm
75
+ MAX_TURN_DEG = 30 # Maximum turn rate in degrees
76
+
77
+ # Arm servo IDs (for models with arm attachment)
78
+ # CORRECTED 2026-01-03: Discovered via vision-based servo identification
79
+ # Vision diff analysis showed:
80
+ # - Servos 1,2,4,5,6,7 are LEG servos (large movement, body tilt)
81
+ # - Servos 8,9 are ARM servos (medium movement, arm only)
82
+ # - Servo 11 is GRIPPER (small, localized movement at tip)
83
+ SERVO_GRIPPER = 11 # Gripper servo (open/close)
84
+ SERVO_SHOULDER = 8 # Shoulder/arm raise-lower
85
+ SERVO_ELBOW = 9 # Elbow extend/retract
86
+
87
+ # Calibrated arm servo positions (PWM microseconds: 500-2500)
88
+ # Discovered empirically 2026-01-03 via systematic testing
89
+ GRIPPER_OPEN = 500 # Gripper jaws spread wide
90
+ GRIPPER_CLOSED = 2400 # Gripper jaws clamped
91
+ GRIPPER_NEUTRAL = 1500 # Gripper partially open
92
+
93
+ SHOULDER_UP = 2200 # Arm raised up (servo 4 high)
94
+ SHOULDER_DOWN = 800 # Arm lowered for pickup (servo 4 low)
95
+ SHOULDER_NEUTRAL = 1500 # Arm at middle height
96
+
97
+ ELBOW_RETRACTED = 2000 # Arm pulled in toward body (servo 5 high)
98
+ ELBOW_EXTENDED = 800 # Arm reaching forward (servo 5 low)
99
+ ELBOW_NEUTRAL = 1500 # Arm at middle extension
100
+
101
+
102
+ @dataclass
103
+ class MechDogConfig:
104
+ """Configuration for MechDog connection."""
105
+ port: str = "/dev/cu.usbserial-10"
106
+ baud_rate: int = 115200
107
+ timeout: float = 2.0
108
+ has_arm: bool = False
109
+ # Camera configuration
110
+ has_camera: bool = False
111
+ camera_ip: str = "" # IP of visual module (e.g., "192.168.1.100")
112
+ camera_port: int = 80 # HTTP port
113
+ camera_stream_port: int = 81 # MJPEG stream port
114
+ # Ultrasonic sensor (HC-SR04 style on ESP32)
115
+ has_ultrasonic: bool = True # MechDog has built-in ultrasonic
116
+ # BLE configuration (alternative to USB serial)
117
+ use_ble: bool = False # Use BLE instead of serial
118
+ ble_address: str = "" # BLE device address (MAC on Windows/Linux, UUID on macOS)
119
+ ble_service_uuid: str = "0000ffe0-0000-1000-8000-00805f9b34fb" # HM-10/ESP32 BLE serial
120
+ ble_char_uuid: str = "0000ffe1-0000-1000-8000-00805f9b34fb" # RX/TX characteristic
121
+
122
+
123
+ class MechDogDriver(QuadrupedLocomotion, BodyPoseInterface, SafetyInterface, CameraInterface, DistanceSensorInterface, RobotInterface):
124
+ """
125
+ Driver for HiWonder MechDog quadruped robot.
126
+
127
+ Implements:
128
+ - QuadrupedLocomotion: walk, turn, stand, sit, etc.
129
+ - BodyPoseInterface: height, roll, pitch, yaw control
130
+ - SafetyInterface: emergency stop, battery monitoring
131
+ - CameraInterface: image capture from visual module (optional)
132
+ - DistanceSensorInterface: ultrasonic distance sensing
133
+ - RobotInterface: connection lifecycle, status
134
+
135
+ Example:
136
+ dog = MechDogDriver(port="/dev/cu.usbserial-10")
137
+ dog.connect()
138
+
139
+ dog.stand()
140
+ dog.walk(Vector3.forward(), speed=0.3)
141
+ dog.set_body_height(0.10)
142
+
143
+ # If camera is configured:
144
+ image = dog.get_image()
145
+
146
+ dog.disconnect()
147
+ """
148
+
149
+ def __init__(self, port: str = "/dev/cu.usbserial-10", config: Optional[MechDogConfig] = None):
150
+ """
151
+ Initialize MechDog driver.
152
+
153
+ Args:
154
+ port: Serial port or BLE address for MechDog connection
155
+ config: Optional configuration
156
+ """
157
+ self.config = config or MechDogConfig(port=port)
158
+ self._serial: Optional[serial.Serial] = None
159
+ self._ble_transport = None # BLE transport when use_ble=True
160
+ self._connected = False
161
+ self._estopped = False
162
+ self._current_gait = GaitType.WALK
163
+
164
+ # Cached state
165
+ self._current_height = HEIGHT_DEFAULT
166
+ self._current_orientation = (0.0, 0.0, 0.0) # roll, pitch, yaw
167
+ self._is_moving = False
168
+
169
+ # Odometry (basic dead reckoning)
170
+ self._pose = Pose.identity()
171
+ self._velocity = Twist.zero()
172
+
173
+ # Camera (visual module)
174
+ self._camera: Optional[WiFiCamera] = None
175
+ if self.config.has_camera and self.config.camera_ip:
176
+ camera_config = WiFiCameraConfig(
177
+ ip=self.config.camera_ip,
178
+ port=self.config.camera_port,
179
+ stream_port=self.config.camera_stream_port,
180
+ )
181
+ self._camera = WiFiCamera(camera_config)
182
+
183
+ @property
184
+ def _transport(self):
185
+ """Get the active transport (serial or BLE)."""
186
+ if self.config.use_ble and self._ble_transport:
187
+ return self._ble_transport
188
+ return self._serial
189
+
190
+ # =========================================================================
191
+ # RobotInterface implementation
192
+ # =========================================================================
193
+
194
+ def get_info(self) -> RobotInfo:
195
+ """Get MechDog information."""
196
+ capabilities = {
197
+ Capability.QUADRUPED,
198
+ Capability.BODY_POSE,
199
+ Capability.IMU,
200
+ }
201
+
202
+ if self.config.has_arm:
203
+ capabilities.add(Capability.ARM)
204
+ capabilities.add(Capability.GRIPPER)
205
+
206
+ if self.config.has_camera:
207
+ capabilities.add(Capability.CAMERA)
208
+
209
+ if self.config.has_ultrasonic:
210
+ capabilities.add(Capability.DISTANCE_SENSOR)
211
+
212
+ return RobotInfo(
213
+ name="MechDog",
214
+ model="hiwonder_mechdog",
215
+ manufacturer="HiWonder",
216
+ archetype="quadruped",
217
+ capabilities=capabilities,
218
+ mass=1.5, # kg
219
+ dimensions=(BODY_LENGTH, BODY_WIDTH, HEIGHT_DEFAULT),
220
+ workspace_min=(-2.0, -2.0, 0.0),
221
+ workspace_max=(2.0, 2.0, 0.5),
222
+ max_speed=0.3, # m/s
223
+ description="HiWonder MechDog quadruped robot with 12 DOF",
224
+ )
225
+
226
+ def connect(self) -> ActionResult:
227
+ """Connect to MechDog via serial or BLE."""
228
+ if self.config.use_ble:
229
+ return self._connect_ble()
230
+ return self._connect_serial()
231
+
232
+ def _connect_ble(self) -> ActionResult:
233
+ """Connect to MechDog via Bluetooth Low Energy."""
234
+ try:
235
+ from .ble_transport import BLETransport
236
+ except ImportError:
237
+ return ActionResult.error("BLE transport requires bleak. Run: pip install bleak")
238
+
239
+ if not self.config.ble_address:
240
+ return ActionResult.error("BLE address not configured. Set config.ble_address")
241
+
242
+ try:
243
+ self._ble_transport = BLETransport(
244
+ address=self.config.ble_address,
245
+ service_uuid=self.config.ble_service_uuid,
246
+ char_uuid=self.config.ble_char_uuid,
247
+ timeout=self.config.timeout,
248
+ )
249
+
250
+ if not self._ble_transport.connect():
251
+ return ActionResult.error("Failed to connect to BLE device")
252
+
253
+ time.sleep(0.5)
254
+
255
+ # Enter friendly REPL mode
256
+ self._ble_transport.write(b'\x03') # Ctrl+C to interrupt
257
+ time.sleep(0.3)
258
+ self._ble_transport.write(b'\x02') # Ctrl+B for friendly REPL
259
+ time.sleep(0.5)
260
+ self._ble_transport.reset_input_buffer()
261
+
262
+ # Initialize MechDog library
263
+ result = self._send_command("from HW_MechDog import MechDog")
264
+ if "Error" in result or "Traceback" in result:
265
+ return ActionResult.error(f"Failed to import HW_MechDog: {result}")
266
+
267
+ result = self._send_command("dog = MechDog()")
268
+ if "Error" in result or "Traceback" in result:
269
+ return ActionResult.error(f"Failed to create MechDog: {result}")
270
+
271
+ self._connected = True
272
+ return ActionResult.ok(f"Connected to MechDog via BLE ({self.config.ble_address})")
273
+
274
+ except Exception as e:
275
+ return ActionResult.error(f"BLE connection failed: {e}")
276
+
277
+ def _connect_serial(self) -> ActionResult:
278
+ """Connect to MechDog via USB serial."""
279
+ if not HAS_SERIAL:
280
+ return ActionResult.error("pyserial not installed. Run: pip install pyserial")
281
+
282
+ try:
283
+ self._serial = serial.Serial(
284
+ self.config.port,
285
+ self.config.baud_rate,
286
+ timeout=self.config.timeout
287
+ )
288
+ time.sleep(0.5)
289
+
290
+ # Enter friendly REPL mode
291
+ self._serial.write(b'\x03') # Ctrl+C to interrupt
292
+ time.sleep(0.3)
293
+ self._serial.write(b'\x02') # Ctrl+B for friendly REPL
294
+ time.sleep(0.5)
295
+ self._serial.reset_input_buffer()
296
+
297
+ # Initialize MechDog library
298
+ result = self._send_command("from HW_MechDog import MechDog")
299
+ if "Error" in result or "Traceback" in result:
300
+ return ActionResult.error(f"Failed to import HW_MechDog: {result}")
301
+
302
+ result = self._send_command("dog = MechDog()")
303
+ if "Error" in result or "Traceback" in result:
304
+ return ActionResult.error(f"Failed to create MechDog: {result}")
305
+
306
+ self._connected = True
307
+ return ActionResult.ok("Connected to MechDog")
308
+
309
+ except Exception as e:
310
+ return ActionResult.error(f"Connection failed: {e}")
311
+
312
+ def disconnect(self) -> ActionResult:
313
+ """Disconnect from MechDog."""
314
+ try:
315
+ # Stop any movement first
316
+ self.stop()
317
+ except Exception:
318
+ pass
319
+
320
+ # Close BLE transport if active
321
+ if self._ble_transport:
322
+ try:
323
+ self._ble_transport.close()
324
+ except Exception:
325
+ pass
326
+ finally:
327
+ self._ble_transport = None
328
+
329
+ # Close serial if active
330
+ if self._serial:
331
+ try:
332
+ self._serial.close()
333
+ except Exception:
334
+ pass
335
+ finally:
336
+ self._serial = None
337
+
338
+ self._connected = False
339
+ return ActionResult.ok("Disconnected")
340
+
341
+ def is_connected(self) -> bool:
342
+ """Check connection status."""
343
+ if not self._connected:
344
+ return False
345
+
346
+ if self.config.use_ble:
347
+ return self._ble_transport is not None and self._ble_transport.is_open
348
+
349
+ return self._serial is not None and self._serial.is_open
350
+
351
+ def get_status(self) -> RobotStatus:
352
+ """Get robot status."""
353
+ mode = RobotMode.IDLE
354
+ if self._estopped:
355
+ mode = RobotMode.ESTOPPED
356
+ elif self._is_moving:
357
+ mode = RobotMode.MOVING
358
+ elif self._connected:
359
+ mode = RobotMode.READY
360
+
361
+ return RobotStatus(
362
+ mode=mode,
363
+ is_ready=self._connected and not self._estopped,
364
+ is_moving=self._is_moving,
365
+ errors=[],
366
+ battery=self.get_battery_state(),
367
+ )
368
+
369
+ # =========================================================================
370
+ # SafetyInterface implementation
371
+ # =========================================================================
372
+
373
+ def emergency_stop(self) -> ActionResult:
374
+ """Emergency stop - immediately stop all motion."""
375
+ self._estopped = True
376
+ self._is_moving = False
377
+
378
+ if self._transport:
379
+ # Send stop command
380
+ self._send_command("dog.move(0, 0)")
381
+
382
+ return ActionResult.ok("Emergency stop activated")
383
+
384
+ def reset_emergency_stop(self) -> ActionResult:
385
+ """Clear emergency stop."""
386
+ self._estopped = False
387
+ return ActionResult.ok("Emergency stop cleared")
388
+
389
+ def is_estopped(self) -> bool:
390
+ """Check if estopped."""
391
+ return self._estopped
392
+
393
+ def get_battery_state(self) -> Optional[BatteryState]:
394
+ """Get battery state (not supported on basic MechDog)."""
395
+ # MechDog doesn't report battery state via REPL
396
+ return None
397
+
398
+ # =========================================================================
399
+ # CameraInterface implementation
400
+ # =========================================================================
401
+
402
+ def get_image(self) -> Image:
403
+ """
404
+ Capture image from visual module.
405
+
406
+ Returns:
407
+ Image with RGB data, or empty Image if camera not configured
408
+ """
409
+ if not self._camera:
410
+ return Image(
411
+ data=b"",
412
+ width=0,
413
+ height=0,
414
+ format="error",
415
+ encoding="none",
416
+ timestamp=time.time(),
417
+ metadata={"error": "Camera not configured"},
418
+ )
419
+
420
+ return self._camera.get_image()
421
+
422
+ def get_resolution(self) -> Tuple[int, int]:
423
+ """Get camera resolution."""
424
+ if not self._camera:
425
+ return (0, 0)
426
+ return self._camera.get_resolution()
427
+
428
+ def get_intrinsics(self) -> dict:
429
+ """Get camera intrinsic parameters."""
430
+ if not self._camera:
431
+ return {}
432
+ return self._camera.get_intrinsics()
433
+
434
+ def get_frame_id(self) -> str:
435
+ """Get coordinate frame ID for camera."""
436
+ return "mechdog_camera_link"
437
+
438
+ def has_camera(self) -> bool:
439
+ """Check if camera is available."""
440
+ return self._camera is not None
441
+
442
+ def is_camera_connected(self) -> bool:
443
+ """Check if camera is reachable."""
444
+ if not self._camera:
445
+ return False
446
+ return self._camera.is_connected()
447
+
448
+ # =========================================================================
449
+ # DistanceSensorInterface implementation
450
+ # =========================================================================
451
+
452
+ def get_distance(self) -> DistanceReading:
453
+ """
454
+ Read distance from ultrasonic sensor.
455
+
456
+ MechDog's ultrasonic sensor is mounted on the front.
457
+ Returns distance in meters.
458
+ """
459
+ if not self._check_connection():
460
+ return DistanceReading.invalid()
461
+
462
+ if not self.config.has_ultrasonic:
463
+ return DistanceReading.invalid()
464
+
465
+ try:
466
+ # MechDog ultrasonic reading via HW_MechDog library
467
+ # The sensor returns distance in centimeters
468
+ result = self._send_command("print(dog.read_ultrasonic())")
469
+
470
+ # Parse result - typically returns just a number
471
+ import re
472
+ match = re.search(r'(\d+\.?\d*)', result.split('\n')[-2] if '\n' in result else result)
473
+ if match:
474
+ distance_cm = float(match.group(1))
475
+ distance_m = distance_cm / 100.0
476
+
477
+ # Validate reading is in range
478
+ if distance_m < self.get_min_range() or distance_m > self.get_max_range():
479
+ return DistanceReading(
480
+ distance=distance_m,
481
+ valid=False,
482
+ sensor_type=DistanceSensorType.ULTRASONIC,
483
+ timestamp=time.time(),
484
+ confidence=0.5,
485
+ )
486
+
487
+ return DistanceReading(
488
+ distance=distance_m,
489
+ valid=True,
490
+ sensor_type=DistanceSensorType.ULTRASONIC,
491
+ timestamp=time.time(),
492
+ confidence=0.9,
493
+ )
494
+ except Exception:
495
+ pass
496
+
497
+ return DistanceReading.invalid()
498
+
499
+ def get_min_range(self) -> float:
500
+ """Get minimum measurable range (2cm for HC-SR04)."""
501
+ return 0.02 # 2cm
502
+
503
+ def get_max_range(self) -> float:
504
+ """Get maximum measurable range (4m for HC-SR04)."""
505
+ return 4.0 # 4m
506
+
507
+ def get_sensor_type(self) -> DistanceSensorType:
508
+ """Get the type of distance sensor."""
509
+ return DistanceSensorType.ULTRASONIC
510
+
511
+ # =========================================================================
512
+ # QuadrupedLocomotion implementation
513
+ # =========================================================================
514
+
515
+ def get_pose(self) -> Pose:
516
+ """Get current pose (from dead reckoning)."""
517
+ return self._pose
518
+
519
+ def get_velocity(self) -> Twist:
520
+ """Get current velocity."""
521
+ return self._velocity
522
+
523
+ def stop(self) -> ActionResult:
524
+ """Stop all movement."""
525
+ if not self._check_connection():
526
+ return ActionResult.error("Not connected")
527
+
528
+ self._send_command("dog.move(0, 0)")
529
+ self._is_moving = False
530
+ self._velocity = Twist.zero()
531
+
532
+ return ActionResult.ok("Stopped")
533
+
534
+ def is_moving(self) -> bool:
535
+ """Check if moving."""
536
+ return self._is_moving
537
+
538
+ def walk(self, direction: Vector3, speed: float = 0.5) -> ActionResult:
539
+ """Walk in direction at speed."""
540
+ if not self._check_connection():
541
+ return ActionResult.error("Not connected")
542
+
543
+ if self._estopped:
544
+ return ActionResult.error("Emergency stop active")
545
+
546
+ # Convert direction and speed to MechDog velocity control
547
+ # MechDog uses: move(forward_vel, turn_vel)
548
+ # forward_vel: -100 to +100 (negative = backward)
549
+ # turn_vel: -100 to +100 (negative = turn left, positive = turn right)
550
+ # Note: This is velocity-based, NOT distance-based!
551
+
552
+ # Calculate forward velocity from speed (simple mapping)
553
+ stride = int(min(MAX_STRIDE_MM, max(-MAX_STRIDE_MM, speed * 200)))
554
+
555
+ # Apply direction
556
+ if direction.x < 0: # Backward
557
+ stride = -abs(stride)
558
+
559
+ # Calculate turn from y component
560
+ angle = int(direction.y * MAX_TURN_DEG)
561
+ angle = max(-MAX_TURN_DEG, min(MAX_TURN_DEG, angle))
562
+
563
+ self._send_command(f"dog.move({stride}, {angle})")
564
+ self._is_moving = True
565
+
566
+ # Update velocity estimate
567
+ self._velocity = Twist(
568
+ linear=Vector3(speed * direction.x, speed * direction.y, 0),
569
+ angular=Vector3.zero()
570
+ )
571
+
572
+ return ActionResult.ok(f"Walking: stride={stride}, angle={angle}")
573
+
574
+ def walk_to(self, target: Vector3, speed: float = 0.5) -> ActionResult:
575
+ """Walk to target position (blocking)."""
576
+ if not self._check_connection():
577
+ return ActionResult.error("Not connected")
578
+
579
+ # Simple implementation: walk toward target
580
+ # In a real implementation, this would use feedback control
581
+
582
+ current = self.get_pose().position
583
+ delta = target - current
584
+ distance = delta.magnitude()
585
+
586
+ if distance < 0.05: # Already at target
587
+ return ActionResult.ok("Already at target")
588
+
589
+ # Calculate direction
590
+ direction = delta.normalized()
591
+
592
+ # Walk toward target
593
+ self.walk(direction, speed)
594
+
595
+ # Wait for approximate time to reach target
596
+ # (Very rough estimate - real implementation needs odometry)
597
+ travel_time = distance / speed
598
+ time.sleep(min(travel_time, 10.0)) # Cap at 10 seconds
599
+
600
+ self.stop()
601
+
602
+ return ActionResult.ok(f"Walked to target")
603
+
604
+ def turn(self, angle: float, speed: float = 0.5) -> ActionResult:
605
+ """Turn by angle (blocking)."""
606
+ if not self._check_connection():
607
+ return ActionResult.error("Not connected")
608
+
609
+ # Convert radians to degrees
610
+ angle_deg = math.degrees(angle)
611
+
612
+ # MechDog turn is via move(0, angle)
613
+ # Positive angle = turn left
614
+ turn_deg = max(-MAX_TURN_DEG, min(MAX_TURN_DEG, angle_deg))
615
+
616
+ # Estimate time based on turn rate
617
+ turn_time = abs(angle_deg) / 30.0 # Rough estimate
618
+
619
+ self._send_command(f"dog.move(0, {int(turn_deg)})")
620
+ self._is_moving = True
621
+
622
+ time.sleep(turn_time)
623
+
624
+ self.stop()
625
+
626
+ # Update pose estimate
627
+ roll, pitch, yaw = self._pose.orientation.to_euler()
628
+ yaw += angle
629
+ self._pose = Pose(
630
+ self._pose.position,
631
+ Quaternion.from_euler(roll, pitch, yaw)
632
+ )
633
+
634
+ return ActionResult.ok(f"Turned {angle_deg:.1f} degrees")
635
+
636
+ def turn_continuous(self, angular_velocity: float) -> ActionResult:
637
+ """Turn continuously."""
638
+ if not self._check_connection():
639
+ return ActionResult.error("Not connected")
640
+
641
+ # Map angular velocity to turn angle
642
+ angle = int(angular_velocity * 20) # Rough mapping
643
+ angle = max(-MAX_TURN_DEG, min(MAX_TURN_DEG, angle))
644
+
645
+ self._send_command(f"dog.move(0, {angle})")
646
+ self._is_moving = True
647
+
648
+ self._velocity = Twist(
649
+ linear=Vector3.zero(),
650
+ angular=Vector3(0, 0, angular_velocity)
651
+ )
652
+
653
+ return ActionResult.ok(f"Turning at {angular_velocity:.2f} rad/s")
654
+
655
+ def stand(self) -> ActionResult:
656
+ """Stand up."""
657
+ if not self._check_connection():
658
+ return ActionResult.error("Not connected")
659
+
660
+ self._send_command("dog.set_default_pose()")
661
+ self._is_moving = False
662
+ self._current_height = HEIGHT_DEFAULT
663
+
664
+ return ActionResult.ok("Standing")
665
+
666
+ def sit(self) -> ActionResult:
667
+ """Sit down."""
668
+ if not self._check_connection():
669
+ return ActionResult.error("Not connected")
670
+
671
+ # Lower body to minimum height
672
+ return self.set_body_height(HEIGHT_MIN)
673
+
674
+ def lie_down(self) -> ActionResult:
675
+ """Lie down (same as sit for MechDog)."""
676
+ return self.sit()
677
+
678
+ def set_gait(self, gait: GaitType) -> ActionResult:
679
+ """Set gait (MechDog has limited gait support)."""
680
+ self._current_gait = gait
681
+ return ActionResult.ok(f"Gait set to {gait.name}")
682
+
683
+ def get_gait(self) -> GaitType:
684
+ """Get current gait."""
685
+ return self._current_gait
686
+
687
+ def get_foot_positions(self) -> List[Vector3]:
688
+ """Get foot positions (estimated from body height)."""
689
+ # Simplified: assume feet are at corners of body rectangle
690
+ h = self._current_height
691
+ return [
692
+ Vector3(BODY_LENGTH/2, BODY_WIDTH/2, -h), # Front left
693
+ Vector3(BODY_LENGTH/2, -BODY_WIDTH/2, -h), # Front right
694
+ Vector3(-BODY_LENGTH/2, BODY_WIDTH/2, -h), # Back left
695
+ Vector3(-BODY_LENGTH/2, -BODY_WIDTH/2, -h), # Back right
696
+ ]
697
+
698
+ def get_joint_state(self) -> JointState:
699
+ """Get all joint positions."""
700
+ if not self._check_connection():
701
+ return JointState()
702
+
703
+ result = self._send_command("print(dog.read_all_servo())")
704
+
705
+ # Parse result like "[2096, 1621, 2170, ...]"
706
+ try:
707
+ # Extract list from response
708
+ import re
709
+ match = re.search(r'\[([^\]]+)\]', result)
710
+ if match:
711
+ values = [int(x.strip()) for x in match.group(1).split(',')]
712
+ # Convert to radians (rough conversion)
713
+ positions = tuple(
714
+ (v - SERVO_CENTER) / SERVO_MAX * math.pi
715
+ for v in values
716
+ )
717
+ return JointState(positions=positions)
718
+ except Exception:
719
+ pass
720
+
721
+ return JointState()
722
+
723
+ # =========================================================================
724
+ # BodyPoseInterface implementation
725
+ # =========================================================================
726
+
727
+ def set_body_height(self, height: float) -> ActionResult:
728
+ """Set body height."""
729
+ if not self._check_connection():
730
+ return ActionResult.error("Not connected")
731
+
732
+ # Clamp to limits
733
+ height = max(HEIGHT_MIN, min(HEIGHT_MAX, height))
734
+
735
+ # MechDog uses set_pose() for body position
736
+ # Height is controlled via leg extension
737
+ # This is a simplified implementation
738
+
739
+ self._current_height = height
740
+ return ActionResult.ok(f"Height set to {height:.3f}m")
741
+
742
+ def get_body_height(self) -> float:
743
+ """Get body height."""
744
+ return self._current_height
745
+
746
+ def get_height_limits(self) -> Tuple[float, float]:
747
+ """Get height limits."""
748
+ return (HEIGHT_MIN, HEIGHT_MAX)
749
+
750
+ def set_body_orientation(
751
+ self,
752
+ roll: float = 0.0,
753
+ pitch: float = 0.0,
754
+ yaw: float = 0.0
755
+ ) -> ActionResult:
756
+ """Set body orientation."""
757
+ if not self._check_connection():
758
+ return ActionResult.error("Not connected")
759
+
760
+ # MechDog can tilt body via set_pose or transform
761
+ # Convert to degrees for MechDog
762
+ roll_deg = math.degrees(roll)
763
+ pitch_deg = math.degrees(pitch)
764
+ yaw_deg = math.degrees(yaw)
765
+
766
+ # MechDog's transform() takes (x, y, z, roll, pitch, yaw)
767
+ self._send_command(f"dog.transform(0, 0, 0, {roll_deg}, {pitch_deg}, {yaw_deg})")
768
+
769
+ self._current_orientation = (roll, pitch, yaw)
770
+ return ActionResult.ok(f"Orientation set to R={roll_deg:.1f} P={pitch_deg:.1f} Y={yaw_deg:.1f}")
771
+
772
+ def get_body_orientation(self) -> Tuple[float, float, float]:
773
+ """Get body orientation."""
774
+ return self._current_orientation
775
+
776
+ def set_body_pose(
777
+ self,
778
+ height: Optional[float] = None,
779
+ roll: Optional[float] = None,
780
+ pitch: Optional[float] = None,
781
+ yaw: Optional[float] = None,
782
+ x_offset: Optional[float] = None,
783
+ y_offset: Optional[float] = None
784
+ ) -> ActionResult:
785
+ """Set combined body pose."""
786
+ if height is not None:
787
+ self.set_body_height(height)
788
+
789
+ # Get current values for any not specified
790
+ curr_r, curr_p, curr_y = self._current_orientation
791
+ if roll is None:
792
+ roll = curr_r
793
+ if pitch is None:
794
+ pitch = curr_p
795
+ if yaw is None:
796
+ yaw = curr_y
797
+
798
+ return self.set_body_orientation(roll, pitch, yaw)
799
+
800
+ # =========================================================================
801
+ # Internal methods
802
+ # =========================================================================
803
+
804
+ def _check_connection(self) -> bool:
805
+ """Check if connected and ready."""
806
+ if self._estopped:
807
+ return False
808
+ return self._connected and self._transport is not None
809
+
810
+ def _send_command(self, cmd: str, wait: float = 0.5) -> str:
811
+ """Send command to MicroPython REPL and get response."""
812
+ transport = self._transport
813
+ if not transport:
814
+ return ""
815
+
816
+ try:
817
+ transport.reset_input_buffer()
818
+ transport.write(f"{cmd}\r\n".encode())
819
+ time.sleep(wait)
820
+ response = transport.read(4000).decode('utf-8', errors='ignore')
821
+ return response
822
+ except Exception as e:
823
+ return f"Error: {e}"
824
+
825
+ # =========================================================================
826
+ # MechDog-specific methods (not in interface)
827
+ # =========================================================================
828
+
829
+ def read_servo(self, servo_id: int) -> Optional[int]:
830
+ """Read single servo position (raw value)."""
831
+ if not self._check_connection():
832
+ return None
833
+
834
+ result = self._send_command(f"print(dog.read_servo({servo_id}))")
835
+ try:
836
+ # Parse integer from response
837
+ import re
838
+ match = re.search(r'(\d+)', result.split('\n')[-2])
839
+ if match:
840
+ return int(match.group(1))
841
+ except Exception:
842
+ pass
843
+ return None
844
+
845
+ def set_servo(self, servo_id: int, position: int, time_ms: int = 500) -> ActionResult:
846
+ """Set single servo position (raw value)."""
847
+ if not self._check_connection():
848
+ return ActionResult.error("Not connected")
849
+
850
+ self._send_command(f"dog.set_servo({servo_id}, {position}, {time_ms})")
851
+ return ActionResult.ok(f"Servo {servo_id} set to {position}")
852
+
853
+ def run_action(self, action_id: int) -> ActionResult:
854
+ """
855
+ Run a pre-programmed action by ID.
856
+
857
+ Args:
858
+ action_id: Integer ID of the action to run
859
+
860
+ Note: Action IDs must be discovered experimentally as
861
+ the MechDog firmware doesn't document available actions.
862
+ Common IDs found: 0-15 (varies by firmware version)
863
+ """
864
+ if not self._check_connection():
865
+ return ActionResult.error("Not connected")
866
+
867
+ self._send_command(f"dog.action_run({action_id})")
868
+ return ActionResult.ok(f"Running action ID: {action_id}")
869
+
870
+ def wait_action(self) -> ActionResult:
871
+ """Wait for current action to complete."""
872
+ if not self._check_connection():
873
+ return ActionResult.error("Not connected")
874
+
875
+ self._send_command("dog.wait_action_over()")
876
+ return ActionResult.ok("Action complete")
877
+
878
+ def stop_action(self) -> ActionResult:
879
+ """Stop current action."""
880
+ if not self._check_connection():
881
+ return ActionResult.error("Not connected")
882
+
883
+ self._send_command("dog.stop_action()")
884
+ return ActionResult.ok("Action stopped")
885
+
886
+ # =========================================================================
887
+ # Arm control methods (for models with arm attachment)
888
+ # =========================================================================
889
+
890
+ def gripper_open(self, time_ms: int = 400) -> ActionResult:
891
+ """Open gripper to grab position."""
892
+ return self.set_servo(SERVO_GRIPPER, GRIPPER_OPEN, time_ms)
893
+
894
+ def gripper_close(self, time_ms: int = 400) -> ActionResult:
895
+ """Close gripper to grasp object."""
896
+ return self.set_servo(SERVO_GRIPPER, GRIPPER_CLOSED, time_ms)
897
+
898
+ def arm_up(self, time_ms: int = 600) -> ActionResult:
899
+ """Raise arm to carry position."""
900
+ self.set_servo(SERVO_ELBOW, ELBOW_RETRACTED, time_ms)
901
+ return self.set_servo(SERVO_SHOULDER, SHOULDER_UP, time_ms)
902
+
903
+ def arm_down(self, time_ms: int = 600) -> ActionResult:
904
+ """Lower arm to pickup position."""
905
+ self.set_servo(SERVO_SHOULDER, SHOULDER_DOWN, time_ms)
906
+ return self.set_servo(SERVO_ELBOW, ELBOW_EXTENDED, time_ms)
907
+
908
+ def arm_neutral(self, time_ms: int = 500) -> ActionResult:
909
+ """Move arm to neutral position."""
910
+ self.set_servo(SERVO_GRIPPER, GRIPPER_NEUTRAL, time_ms)
911
+ self.set_servo(SERVO_SHOULDER, SHOULDER_NEUTRAL, time_ms)
912
+ return self.set_servo(SERVO_ELBOW, ELBOW_NEUTRAL, time_ms)
913
+
914
+ def pickup_sequence(self, grab_delay: float = 0.3) -> ActionResult:
915
+ """
916
+ Execute pickup sequence: lower arm, close gripper, lift.
917
+
918
+ Args:
919
+ grab_delay: Seconds to wait between lowering and closing gripper
920
+
921
+ Returns:
922
+ ActionResult indicating success/failure
923
+ """
924
+ if not self.config.has_arm:
925
+ return ActionResult.error("Robot does not have arm attachment")
926
+
927
+ # Open gripper
928
+ self.gripper_open()
929
+ time.sleep(0.3)
930
+
931
+ # Lower arm
932
+ self.arm_down()
933
+ time.sleep(grab_delay)
934
+
935
+ # Close gripper
936
+ self.gripper_close()
937
+ time.sleep(0.3)
938
+
939
+ # Lift arm
940
+ self.arm_up()
941
+
942
+ return ActionResult.ok("Pickup sequence complete")