foodforthought-cli 0.2.4__py3-none-any.whl → 0.2.8__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 (37) hide show
  1. ate/__init__.py +1 -1
  2. ate/behaviors/__init__.py +88 -0
  3. ate/behaviors/common.py +686 -0
  4. ate/behaviors/tree.py +454 -0
  5. ate/cli.py +610 -54
  6. ate/drivers/__init__.py +27 -0
  7. ate/drivers/mechdog.py +606 -0
  8. ate/interfaces/__init__.py +171 -0
  9. ate/interfaces/base.py +271 -0
  10. ate/interfaces/body.py +267 -0
  11. ate/interfaces/detection.py +282 -0
  12. ate/interfaces/locomotion.py +422 -0
  13. ate/interfaces/manipulation.py +408 -0
  14. ate/interfaces/navigation.py +389 -0
  15. ate/interfaces/perception.py +362 -0
  16. ate/interfaces/types.py +371 -0
  17. ate/mcp_server.py +387 -0
  18. ate/recording/__init__.py +44 -0
  19. ate/recording/demonstration.py +378 -0
  20. ate/recording/session.py +405 -0
  21. ate/recording/upload.py +304 -0
  22. ate/recording/wrapper.py +95 -0
  23. ate/robot/__init__.py +79 -0
  24. ate/robot/calibration.py +583 -0
  25. ate/robot/commands.py +3603 -0
  26. ate/robot/discovery.py +339 -0
  27. ate/robot/introspection.py +330 -0
  28. ate/robot/manager.py +270 -0
  29. ate/robot/profiles.py +275 -0
  30. ate/robot/registry.py +319 -0
  31. ate/robot/skill_upload.py +393 -0
  32. ate/robot/visual_labeler.py +1039 -0
  33. {foodforthought_cli-0.2.4.dist-info → foodforthought_cli-0.2.8.dist-info}/METADATA +9 -1
  34. {foodforthought_cli-0.2.4.dist-info → foodforthought_cli-0.2.8.dist-info}/RECORD +37 -8
  35. {foodforthought_cli-0.2.4.dist-info → foodforthought_cli-0.2.8.dist-info}/WHEEL +0 -0
  36. {foodforthought_cli-0.2.4.dist-info → foodforthought_cli-0.2.8.dist-info}/entry_points.txt +0 -0
  37. {foodforthought_cli-0.2.4.dist-info → foodforthought_cli-0.2.8.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,27 @@
1
+ """
2
+ Hardware drivers for specific robots.
3
+
4
+ Each driver implements the appropriate interfaces from ate.interfaces.
5
+
6
+ Example:
7
+ from ate.drivers import MechDogDriver
8
+
9
+ # Connect to MechDog
10
+ dog = MechDogDriver(port="/dev/cu.usbserial-10")
11
+ dog.connect()
12
+
13
+ # Use through abstract interface
14
+ dog.stand()
15
+ dog.walk(Vector3.forward(), speed=0.3)
16
+ dog.set_body_height(0.15)
17
+ dog.stop()
18
+
19
+ # Disconnect
20
+ dog.disconnect()
21
+ """
22
+
23
+ from .mechdog import MechDogDriver
24
+
25
+ __all__ = [
26
+ "MechDogDriver",
27
+ ]
ate/drivers/mechdog.py ADDED
@@ -0,0 +1,606 @@
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
+ )
40
+ from ..interfaces.base import (
41
+ RobotInterface,
42
+ SafetyInterface,
43
+ RobotInfo,
44
+ Capability,
45
+ )
46
+ from ..interfaces.locomotion import QuadrupedLocomotion
47
+ from ..interfaces.body import BodyPoseInterface
48
+
49
+
50
+ # =============================================================================
51
+ # MechDog-specific constants
52
+ # =============================================================================
53
+
54
+ # Servo value ranges (raw values from MechDog)
55
+ SERVO_MIN = 0
56
+ SERVO_MAX = 4096
57
+ SERVO_CENTER = 2048
58
+
59
+ # Body dimensions (meters)
60
+ BODY_LENGTH = 0.20 # Front to back
61
+ BODY_WIDTH = 0.10 # Side to side
62
+ LEG_LENGTH = 0.12 # Total leg length
63
+
64
+ # Height limits (meters)
65
+ HEIGHT_MIN = 0.05
66
+ HEIGHT_MAX = 0.18
67
+ HEIGHT_DEFAULT = 0.12
68
+
69
+ # Speed limits
70
+ MAX_STRIDE_MM = 100 # Maximum stride in mm
71
+ MAX_TURN_DEG = 30 # Maximum turn rate in degrees
72
+
73
+
74
+ @dataclass
75
+ class MechDogConfig:
76
+ """Configuration for MechDog connection."""
77
+ port: str = "/dev/cu.usbserial-10"
78
+ baud_rate: int = 115200
79
+ timeout: float = 2.0
80
+ has_arm: bool = False
81
+ has_camera: bool = False
82
+
83
+
84
+ class MechDogDriver(QuadrupedLocomotion, BodyPoseInterface, SafetyInterface, RobotInterface):
85
+ """
86
+ Driver for HiWonder MechDog quadruped robot.
87
+
88
+ Implements:
89
+ - QuadrupedLocomotion: walk, turn, stand, sit, etc.
90
+ - BodyPoseInterface: height, roll, pitch, yaw control
91
+ - SafetyInterface: emergency stop, battery monitoring
92
+ - RobotInterface: connection lifecycle, status
93
+
94
+ Example:
95
+ dog = MechDogDriver(port="/dev/cu.usbserial-10")
96
+ dog.connect()
97
+
98
+ dog.stand()
99
+ dog.walk(Vector3.forward(), speed=0.3)
100
+ dog.set_body_height(0.10)
101
+
102
+ dog.disconnect()
103
+ """
104
+
105
+ def __init__(self, port: str = "/dev/cu.usbserial-10", config: Optional[MechDogConfig] = None):
106
+ """
107
+ Initialize MechDog driver.
108
+
109
+ Args:
110
+ port: Serial port for MechDog connection
111
+ config: Optional configuration
112
+ """
113
+ self.config = config or MechDogConfig(port=port)
114
+ self._serial: Optional[serial.Serial] = None
115
+ self._connected = False
116
+ self._estopped = False
117
+ self._current_gait = GaitType.WALK
118
+
119
+ # Cached state
120
+ self._current_height = HEIGHT_DEFAULT
121
+ self._current_orientation = (0.0, 0.0, 0.0) # roll, pitch, yaw
122
+ self._is_moving = False
123
+
124
+ # Odometry (basic dead reckoning)
125
+ self._pose = Pose.identity()
126
+ self._velocity = Twist.zero()
127
+
128
+ # =========================================================================
129
+ # RobotInterface implementation
130
+ # =========================================================================
131
+
132
+ def get_info(self) -> RobotInfo:
133
+ """Get MechDog information."""
134
+ capabilities = {
135
+ Capability.QUADRUPED,
136
+ Capability.BODY_POSE,
137
+ Capability.IMU,
138
+ }
139
+
140
+ if self.config.has_arm:
141
+ capabilities.add(Capability.ARM)
142
+ capabilities.add(Capability.GRIPPER)
143
+
144
+ if self.config.has_camera:
145
+ capabilities.add(Capability.CAMERA)
146
+
147
+ return RobotInfo(
148
+ name="MechDog",
149
+ model="hiwonder_mechdog",
150
+ manufacturer="HiWonder",
151
+ archetype="quadruped",
152
+ capabilities=capabilities,
153
+ mass=1.5, # kg
154
+ dimensions=(BODY_LENGTH, BODY_WIDTH, HEIGHT_DEFAULT),
155
+ workspace_min=(-2.0, -2.0, 0.0),
156
+ workspace_max=(2.0, 2.0, 0.5),
157
+ max_speed=0.3, # m/s
158
+ description="HiWonder MechDog quadruped robot with 12 DOF",
159
+ )
160
+
161
+ def connect(self) -> ActionResult:
162
+ """Connect to MechDog via serial."""
163
+ if not HAS_SERIAL:
164
+ return ActionResult.error("pyserial not installed. Run: pip install pyserial")
165
+
166
+ try:
167
+ self._serial = serial.Serial(
168
+ self.config.port,
169
+ self.config.baud_rate,
170
+ timeout=self.config.timeout
171
+ )
172
+ time.sleep(0.5)
173
+
174
+ # Enter friendly REPL mode
175
+ self._serial.write(b'\x03') # Ctrl+C to interrupt
176
+ time.sleep(0.3)
177
+ self._serial.write(b'\x02') # Ctrl+B for friendly REPL
178
+ time.sleep(0.5)
179
+ self._serial.reset_input_buffer()
180
+
181
+ # Initialize MechDog library
182
+ result = self._send_command("from HW_MechDog import MechDog")
183
+ if "Error" in result or "Traceback" in result:
184
+ return ActionResult.error(f"Failed to import HW_MechDog: {result}")
185
+
186
+ result = self._send_command("dog = MechDog()")
187
+ if "Error" in result or "Traceback" in result:
188
+ return ActionResult.error(f"Failed to create MechDog: {result}")
189
+
190
+ self._connected = True
191
+ return ActionResult.ok("Connected to MechDog")
192
+
193
+ except Exception as e:
194
+ return ActionResult.error(f"Connection failed: {e}")
195
+
196
+ def disconnect(self) -> ActionResult:
197
+ """Disconnect from MechDog."""
198
+ if self._serial:
199
+ try:
200
+ # Stop any movement first
201
+ self.stop()
202
+ self._serial.close()
203
+ except Exception:
204
+ pass
205
+ finally:
206
+ self._serial = None
207
+ self._connected = False
208
+
209
+ return ActionResult.ok("Disconnected")
210
+
211
+ def is_connected(self) -> bool:
212
+ """Check connection status."""
213
+ return self._connected and self._serial is not None and self._serial.is_open
214
+
215
+ def get_status(self) -> RobotStatus:
216
+ """Get robot status."""
217
+ mode = RobotMode.IDLE
218
+ if self._estopped:
219
+ mode = RobotMode.ESTOPPED
220
+ elif self._is_moving:
221
+ mode = RobotMode.MOVING
222
+ elif self._connected:
223
+ mode = RobotMode.READY
224
+
225
+ return RobotStatus(
226
+ mode=mode,
227
+ is_ready=self._connected and not self._estopped,
228
+ is_moving=self._is_moving,
229
+ errors=[],
230
+ battery=self.get_battery_state(),
231
+ )
232
+
233
+ # =========================================================================
234
+ # SafetyInterface implementation
235
+ # =========================================================================
236
+
237
+ def emergency_stop(self) -> ActionResult:
238
+ """Emergency stop - immediately stop all motion."""
239
+ self._estopped = True
240
+ self._is_moving = False
241
+
242
+ if self._serial:
243
+ # Send stop command
244
+ self._send_command("dog.move(0, 0)")
245
+
246
+ return ActionResult.ok("Emergency stop activated")
247
+
248
+ def reset_emergency_stop(self) -> ActionResult:
249
+ """Clear emergency stop."""
250
+ self._estopped = False
251
+ return ActionResult.ok("Emergency stop cleared")
252
+
253
+ def is_estopped(self) -> bool:
254
+ """Check if estopped."""
255
+ return self._estopped
256
+
257
+ def get_battery_state(self) -> Optional[BatteryState]:
258
+ """Get battery state (not supported on basic MechDog)."""
259
+ # MechDog doesn't report battery state via REPL
260
+ return None
261
+
262
+ # =========================================================================
263
+ # QuadrupedLocomotion implementation
264
+ # =========================================================================
265
+
266
+ def get_pose(self) -> Pose:
267
+ """Get current pose (from dead reckoning)."""
268
+ return self._pose
269
+
270
+ def get_velocity(self) -> Twist:
271
+ """Get current velocity."""
272
+ return self._velocity
273
+
274
+ def stop(self) -> ActionResult:
275
+ """Stop all movement."""
276
+ if not self._check_connection():
277
+ return ActionResult.error("Not connected")
278
+
279
+ self._send_command("dog.move(0, 0)")
280
+ self._is_moving = False
281
+ self._velocity = Twist.zero()
282
+
283
+ return ActionResult.ok("Stopped")
284
+
285
+ def is_moving(self) -> bool:
286
+ """Check if moving."""
287
+ return self._is_moving
288
+
289
+ def walk(self, direction: Vector3, speed: float = 0.5) -> ActionResult:
290
+ """Walk in direction at speed."""
291
+ if not self._check_connection():
292
+ return ActionResult.error("Not connected")
293
+
294
+ if self._estopped:
295
+ return ActionResult.error("Emergency stop active")
296
+
297
+ # Convert direction and speed to MechDog stride/angle
298
+ # MechDog uses: move(stride_mm, angle_deg)
299
+ # stride: -100 to +100 (negative = backward)
300
+ # angle: -30 to +30 (negative = turn right)
301
+
302
+ # Calculate stride from speed (simple mapping)
303
+ stride = int(min(MAX_STRIDE_MM, max(-MAX_STRIDE_MM, speed * 200)))
304
+
305
+ # Apply direction
306
+ if direction.x < 0: # Backward
307
+ stride = -abs(stride)
308
+
309
+ # Calculate turn from y component
310
+ angle = int(direction.y * MAX_TURN_DEG)
311
+ angle = max(-MAX_TURN_DEG, min(MAX_TURN_DEG, angle))
312
+
313
+ self._send_command(f"dog.move({stride}, {angle})")
314
+ self._is_moving = True
315
+
316
+ # Update velocity estimate
317
+ self._velocity = Twist(
318
+ linear=Vector3(speed * direction.x, speed * direction.y, 0),
319
+ angular=Vector3.zero()
320
+ )
321
+
322
+ return ActionResult.ok(f"Walking: stride={stride}, angle={angle}")
323
+
324
+ def walk_to(self, target: Vector3, speed: float = 0.5) -> ActionResult:
325
+ """Walk to target position (blocking)."""
326
+ if not self._check_connection():
327
+ return ActionResult.error("Not connected")
328
+
329
+ # Simple implementation: walk toward target
330
+ # In a real implementation, this would use feedback control
331
+
332
+ current = self.get_pose().position
333
+ delta = target - current
334
+ distance = delta.magnitude()
335
+
336
+ if distance < 0.05: # Already at target
337
+ return ActionResult.ok("Already at target")
338
+
339
+ # Calculate direction
340
+ direction = delta.normalized()
341
+
342
+ # Walk toward target
343
+ self.walk(direction, speed)
344
+
345
+ # Wait for approximate time to reach target
346
+ # (Very rough estimate - real implementation needs odometry)
347
+ travel_time = distance / speed
348
+ time.sleep(min(travel_time, 10.0)) # Cap at 10 seconds
349
+
350
+ self.stop()
351
+
352
+ return ActionResult.ok(f"Walked to target")
353
+
354
+ def turn(self, angle: float, speed: float = 0.5) -> ActionResult:
355
+ """Turn by angle (blocking)."""
356
+ if not self._check_connection():
357
+ return ActionResult.error("Not connected")
358
+
359
+ # Convert radians to degrees
360
+ angle_deg = math.degrees(angle)
361
+
362
+ # MechDog turn is via move(0, angle)
363
+ # Positive angle = turn left
364
+ turn_deg = max(-MAX_TURN_DEG, min(MAX_TURN_DEG, angle_deg))
365
+
366
+ # Estimate time based on turn rate
367
+ turn_time = abs(angle_deg) / 30.0 # Rough estimate
368
+
369
+ self._send_command(f"dog.move(0, {int(turn_deg)})")
370
+ self._is_moving = True
371
+
372
+ time.sleep(turn_time)
373
+
374
+ self.stop()
375
+
376
+ # Update pose estimate
377
+ roll, pitch, yaw = self._pose.orientation.to_euler()
378
+ yaw += angle
379
+ self._pose = Pose(
380
+ self._pose.position,
381
+ Quaternion.from_euler(roll, pitch, yaw)
382
+ )
383
+
384
+ return ActionResult.ok(f"Turned {angle_deg:.1f} degrees")
385
+
386
+ def turn_continuous(self, angular_velocity: float) -> ActionResult:
387
+ """Turn continuously."""
388
+ if not self._check_connection():
389
+ return ActionResult.error("Not connected")
390
+
391
+ # Map angular velocity to turn angle
392
+ angle = int(angular_velocity * 20) # Rough mapping
393
+ angle = max(-MAX_TURN_DEG, min(MAX_TURN_DEG, angle))
394
+
395
+ self._send_command(f"dog.move(0, {angle})")
396
+ self._is_moving = True
397
+
398
+ self._velocity = Twist(
399
+ linear=Vector3.zero(),
400
+ angular=Vector3(0, 0, angular_velocity)
401
+ )
402
+
403
+ return ActionResult.ok(f"Turning at {angular_velocity:.2f} rad/s")
404
+
405
+ def stand(self) -> ActionResult:
406
+ """Stand up."""
407
+ if not self._check_connection():
408
+ return ActionResult.error("Not connected")
409
+
410
+ self._send_command("dog.set_default_pose()")
411
+ self._is_moving = False
412
+ self._current_height = HEIGHT_DEFAULT
413
+
414
+ return ActionResult.ok("Standing")
415
+
416
+ def sit(self) -> ActionResult:
417
+ """Sit down."""
418
+ if not self._check_connection():
419
+ return ActionResult.error("Not connected")
420
+
421
+ # Lower body to minimum height
422
+ return self.set_body_height(HEIGHT_MIN)
423
+
424
+ def lie_down(self) -> ActionResult:
425
+ """Lie down (same as sit for MechDog)."""
426
+ return self.sit()
427
+
428
+ def set_gait(self, gait: GaitType) -> ActionResult:
429
+ """Set gait (MechDog has limited gait support)."""
430
+ self._current_gait = gait
431
+ return ActionResult.ok(f"Gait set to {gait.name}")
432
+
433
+ def get_gait(self) -> GaitType:
434
+ """Get current gait."""
435
+ return self._current_gait
436
+
437
+ def get_foot_positions(self) -> List[Vector3]:
438
+ """Get foot positions (estimated from body height)."""
439
+ # Simplified: assume feet are at corners of body rectangle
440
+ h = self._current_height
441
+ return [
442
+ Vector3(BODY_LENGTH/2, BODY_WIDTH/2, -h), # Front left
443
+ Vector3(BODY_LENGTH/2, -BODY_WIDTH/2, -h), # Front right
444
+ Vector3(-BODY_LENGTH/2, BODY_WIDTH/2, -h), # Back left
445
+ Vector3(-BODY_LENGTH/2, -BODY_WIDTH/2, -h), # Back right
446
+ ]
447
+
448
+ def get_joint_state(self) -> JointState:
449
+ """Get all joint positions."""
450
+ if not self._check_connection():
451
+ return JointState()
452
+
453
+ result = self._send_command("print(dog.read_all_servo())")
454
+
455
+ # Parse result like "[2096, 1621, 2170, ...]"
456
+ try:
457
+ # Extract list from response
458
+ import re
459
+ match = re.search(r'\[([^\]]+)\]', result)
460
+ if match:
461
+ values = [int(x.strip()) for x in match.group(1).split(',')]
462
+ # Convert to radians (rough conversion)
463
+ positions = tuple(
464
+ (v - SERVO_CENTER) / SERVO_MAX * math.pi
465
+ for v in values
466
+ )
467
+ return JointState(positions=positions)
468
+ except Exception:
469
+ pass
470
+
471
+ return JointState()
472
+
473
+ # =========================================================================
474
+ # BodyPoseInterface implementation
475
+ # =========================================================================
476
+
477
+ def set_body_height(self, height: float) -> ActionResult:
478
+ """Set body height."""
479
+ if not self._check_connection():
480
+ return ActionResult.error("Not connected")
481
+
482
+ # Clamp to limits
483
+ height = max(HEIGHT_MIN, min(HEIGHT_MAX, height))
484
+
485
+ # MechDog uses set_pose() for body position
486
+ # Height is controlled via leg extension
487
+ # This is a simplified implementation
488
+
489
+ self._current_height = height
490
+ return ActionResult.ok(f"Height set to {height:.3f}m")
491
+
492
+ def get_body_height(self) -> float:
493
+ """Get body height."""
494
+ return self._current_height
495
+
496
+ def get_height_limits(self) -> Tuple[float, float]:
497
+ """Get height limits."""
498
+ return (HEIGHT_MIN, HEIGHT_MAX)
499
+
500
+ def set_body_orientation(
501
+ self,
502
+ roll: float = 0.0,
503
+ pitch: float = 0.0,
504
+ yaw: float = 0.0
505
+ ) -> ActionResult:
506
+ """Set body orientation."""
507
+ if not self._check_connection():
508
+ return ActionResult.error("Not connected")
509
+
510
+ # MechDog can tilt body via set_pose or transform
511
+ # Convert to degrees for MechDog
512
+ roll_deg = math.degrees(roll)
513
+ pitch_deg = math.degrees(pitch)
514
+ yaw_deg = math.degrees(yaw)
515
+
516
+ # MechDog's transform() takes (x, y, z, roll, pitch, yaw)
517
+ self._send_command(f"dog.transform(0, 0, 0, {roll_deg}, {pitch_deg}, {yaw_deg})")
518
+
519
+ self._current_orientation = (roll, pitch, yaw)
520
+ return ActionResult.ok(f"Orientation set to R={roll_deg:.1f} P={pitch_deg:.1f} Y={yaw_deg:.1f}")
521
+
522
+ def get_body_orientation(self) -> Tuple[float, float, float]:
523
+ """Get body orientation."""
524
+ return self._current_orientation
525
+
526
+ def set_body_pose(
527
+ self,
528
+ height: Optional[float] = None,
529
+ roll: Optional[float] = None,
530
+ pitch: Optional[float] = None,
531
+ yaw: Optional[float] = None,
532
+ x_offset: Optional[float] = None,
533
+ y_offset: Optional[float] = None
534
+ ) -> ActionResult:
535
+ """Set combined body pose."""
536
+ if height is not None:
537
+ self.set_body_height(height)
538
+
539
+ # Get current values for any not specified
540
+ curr_r, curr_p, curr_y = self._current_orientation
541
+ if roll is None:
542
+ roll = curr_r
543
+ if pitch is None:
544
+ pitch = curr_p
545
+ if yaw is None:
546
+ yaw = curr_y
547
+
548
+ return self.set_body_orientation(roll, pitch, yaw)
549
+
550
+ # =========================================================================
551
+ # Internal methods
552
+ # =========================================================================
553
+
554
+ def _check_connection(self) -> bool:
555
+ """Check if connected and ready."""
556
+ return self._connected and self._serial is not None and not self._estopped
557
+
558
+ def _send_command(self, cmd: str, wait: float = 0.5) -> str:
559
+ """Send command to MicroPython REPL and get response."""
560
+ if not self._serial:
561
+ return ""
562
+
563
+ try:
564
+ self._serial.reset_input_buffer()
565
+ self._serial.write(f"{cmd}\r\n".encode())
566
+ time.sleep(wait)
567
+ response = self._serial.read(4000).decode('utf-8', errors='ignore')
568
+ return response
569
+ except Exception as e:
570
+ return f"Error: {e}"
571
+
572
+ # =========================================================================
573
+ # MechDog-specific methods (not in interface)
574
+ # =========================================================================
575
+
576
+ def read_servo(self, servo_id: int) -> Optional[int]:
577
+ """Read single servo position (raw value)."""
578
+ if not self._check_connection():
579
+ return None
580
+
581
+ result = self._send_command(f"print(dog.read_servo({servo_id}))")
582
+ try:
583
+ # Parse integer from response
584
+ import re
585
+ match = re.search(r'(\d+)', result.split('\n')[-2])
586
+ if match:
587
+ return int(match.group(1))
588
+ except Exception:
589
+ pass
590
+ return None
591
+
592
+ def set_servo(self, servo_id: int, position: int, time_ms: int = 500) -> ActionResult:
593
+ """Set single servo position (raw value)."""
594
+ if not self._check_connection():
595
+ return ActionResult.error("Not connected")
596
+
597
+ self._send_command(f"dog.set_servo({servo_id}, {position}, {time_ms})")
598
+ return ActionResult.ok(f"Servo {servo_id} set to {position}")
599
+
600
+ def run_action(self, action_name: str) -> ActionResult:
601
+ """Run a pre-programmed action."""
602
+ if not self._check_connection():
603
+ return ActionResult.error("Not connected")
604
+
605
+ self._send_command(f"dog.action_run('{action_name}')")
606
+ return ActionResult.ok(f"Running action: {action_name}")