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
@@ -0,0 +1,668 @@
1
+ """
2
+ Visual calibration system for robot servo mapping.
3
+
4
+ Provides:
5
+ - ate robot calibrate - Interactive servo discovery and range mapping
6
+ - Visual feedback using webcam/robot camera
7
+ - Pose library management for semantic skill development
8
+
9
+ The goal is to eliminate manual servo value guessing by:
10
+ 1. Auto-discovering servo IDs and their ranges
11
+ 2. Using camera to visually confirm poses
12
+ 3. Creating semantic mappings (e.g., "gripper_open" -> servo 11 @ 2500)
13
+ """
14
+
15
+ import json
16
+ import time
17
+ import os
18
+ from dataclasses import dataclass, field, asdict
19
+ from typing import Dict, List, Optional, Tuple, Any, Callable
20
+ from pathlib import Path
21
+ from enum import Enum
22
+
23
+ # Import unified types - use JointRole for semantic joint naming
24
+ from ate.robot.types import JointRole, JointType as MechanicalJointType, format_valid_options
25
+
26
+
27
+ class JointType(Enum):
28
+ """
29
+ Types of joints/servos - semantic role-based naming.
30
+
31
+ DEPRECATED: This enum combines mechanical types with semantic roles.
32
+ New code should use:
33
+ - ate.robot.types.JointType for mechanical types (revolute, prismatic, etc.)
34
+ - ate.robot.types.JointRole for semantic roles (shoulder_lift, gripper, etc.)
35
+
36
+ This enum is kept for backwards compatibility and accepts both.
37
+ """
38
+ UNKNOWN = "unknown"
39
+
40
+ # Standard URDF mechanical types (for backwards compatibility)
41
+ REVOLUTE = "revolute"
42
+ PRISMATIC = "prismatic"
43
+ CONTINUOUS = "continuous"
44
+ FIXED = "fixed"
45
+
46
+ # Locomotion
47
+ HIP_ROLL = "hip_roll"
48
+ HIP_PITCH = "hip_pitch"
49
+ KNEE = "knee"
50
+ ANKLE = "ankle"
51
+
52
+ # Arm
53
+ SHOULDER_PAN = "shoulder_pan"
54
+ SHOULDER_LIFT = "shoulder_lift"
55
+ ELBOW = "elbow"
56
+ WRIST_ROLL = "wrist_roll"
57
+ WRIST_PITCH = "wrist_pitch"
58
+
59
+ # Gripper
60
+ GRIPPER = "gripper"
61
+
62
+ # Body
63
+ HEAD_PAN = "head_pan"
64
+ HEAD_TILT = "head_tilt"
65
+ BODY_PITCH = "body_pitch"
66
+ BODY_ROLL = "body_roll"
67
+
68
+ @classmethod
69
+ def from_string(cls, value: str) -> "JointType":
70
+ """
71
+ Parse joint type from string with intelligent fallbacks.
72
+
73
+ Accepts both mechanical types (revolute, prismatic) and
74
+ semantic roles (shoulder_lift, gripper). Provides helpful
75
+ suggestions on unrecognized values.
76
+ """
77
+ if not value:
78
+ return cls.UNKNOWN
79
+
80
+ normalized = value.lower().strip().replace("-", "_").replace(" ", "_")
81
+
82
+ # Direct match
83
+ for member in cls:
84
+ if member.value == normalized:
85
+ return member
86
+
87
+ # Common aliases for mechanical types
88
+ aliases = {
89
+ "rotary": cls.REVOLUTE,
90
+ "rotation": cls.REVOLUTE,
91
+ "linear": cls.PRISMATIC,
92
+ "sliding": cls.PRISMATIC,
93
+ "wheel": cls.CONTINUOUS,
94
+ "static": cls.FIXED,
95
+ "rigid": cls.FIXED,
96
+ # Arm shortcuts
97
+ "shoulder": cls.SHOULDER_LIFT,
98
+ "arm_shoulder": cls.SHOULDER_LIFT,
99
+ "arm_elbow": cls.ELBOW,
100
+ # Gripper variations
101
+ "claw": cls.GRIPPER,
102
+ "hand": cls.GRIPPER,
103
+ }
104
+
105
+ if normalized in aliases:
106
+ return aliases[normalized]
107
+
108
+ # Fuzzy match
109
+ for member in cls:
110
+ if member.value in normalized or normalized in member.value:
111
+ return member
112
+
113
+ # Log warning but don't crash
114
+ import sys
115
+ valid_vals = [m.value for m in cls if m != cls.UNKNOWN]
116
+ print(f"Warning: Unknown joint type '{value}'. Using 'unknown'. "
117
+ f"Valid options: {', '.join(valid_vals[:8])}...",
118
+ file=sys.stderr)
119
+
120
+ return cls.UNKNOWN
121
+
122
+ @classmethod
123
+ def valid_values(cls) -> List[str]:
124
+ """Return list of valid values for error messages."""
125
+ return [m.value for m in cls if m != cls.UNKNOWN]
126
+
127
+
128
+ @dataclass
129
+ class ServoCalibration:
130
+ """Calibration data for a single servo."""
131
+ servo_id: int
132
+ name: str # Human-readable name
133
+ joint_type: JointType = JointType.UNKNOWN
134
+
135
+ # Range discovered during calibration
136
+ min_value: int = 0
137
+ max_value: int = 4096
138
+ center_value: int = 2048
139
+
140
+ # Semantic positions (name -> value)
141
+ positions: Dict[str, int] = field(default_factory=dict)
142
+
143
+ # Physical properties
144
+ inverted: bool = False # True if positive = clockwise
145
+ speed_limit: int = 1000 # Max speed in ms per movement
146
+
147
+ # Visual confirmation images (base64 or paths)
148
+ position_images: Dict[str, str] = field(default_factory=dict)
149
+
150
+ def get_position(self, name: str) -> Optional[int]:
151
+ """Get servo value for a named position."""
152
+ return self.positions.get(name)
153
+
154
+ def set_position(self, name: str, value: int, image_path: Optional[str] = None):
155
+ """Set a named position for this servo."""
156
+ self.positions[name] = value
157
+ if image_path:
158
+ self.position_images[name] = image_path
159
+
160
+
161
+ @dataclass
162
+ class Pose:
163
+ """A named pose consisting of multiple servo positions."""
164
+ name: str
165
+ description: str = ""
166
+ servo_positions: Dict[int, int] = field(default_factory=dict) # servo_id -> value
167
+ transition_time_ms: int = 500
168
+ image_path: Optional[str] = None
169
+
170
+ def to_dict(self) -> dict:
171
+ return {
172
+ "name": self.name,
173
+ "description": self.description,
174
+ "servo_positions": self.servo_positions,
175
+ "transition_time_ms": self.transition_time_ms,
176
+ "image_path": self.image_path,
177
+ }
178
+
179
+ @classmethod
180
+ def from_dict(cls, data: dict) -> "Pose":
181
+ return cls(
182
+ name=data["name"],
183
+ description=data.get("description", ""),
184
+ servo_positions={int(k): v for k, v in data["servo_positions"].items()},
185
+ transition_time_ms=data.get("transition_time_ms", 500),
186
+ image_path=data.get("image_path"),
187
+ )
188
+
189
+
190
+ @dataclass
191
+ class RobotCalibration:
192
+ """Complete calibration for a robot."""
193
+ robot_model: str
194
+ robot_name: str
195
+
196
+ # Servo calibrations by ID
197
+ servos: Dict[int, ServoCalibration] = field(default_factory=dict)
198
+
199
+ # Named poses
200
+ poses: Dict[str, Pose] = field(default_factory=dict)
201
+
202
+ # Camera configuration
203
+ camera_url: Optional[str] = None
204
+ camera_type: str = "wifi" # wifi, usb, none
205
+
206
+ # Connection info
207
+ serial_port: Optional[str] = None
208
+ baud_rate: int = 115200
209
+
210
+ # Metadata
211
+ calibrated_at: Optional[str] = None
212
+ notes: str = ""
213
+
214
+ def get_servo(self, servo_id: int) -> Optional[ServoCalibration]:
215
+ return self.servos.get(servo_id)
216
+
217
+ def get_pose(self, name: str) -> Optional[Pose]:
218
+ return self.poses.get(name)
219
+
220
+ def add_pose(self, pose: Pose):
221
+ self.poses[pose.name] = pose
222
+
223
+ def save(self, path: Path):
224
+ """Save calibration to JSON file."""
225
+ data = {
226
+ "robot_model": self.robot_model,
227
+ "robot_name": self.robot_name,
228
+ "servos": {
229
+ str(sid): {
230
+ "servo_id": s.servo_id,
231
+ "name": s.name,
232
+ "joint_type": s.joint_type.value,
233
+ "min_value": s.min_value,
234
+ "max_value": s.max_value,
235
+ "center_value": s.center_value,
236
+ "positions": s.positions,
237
+ "inverted": s.inverted,
238
+ "speed_limit": s.speed_limit,
239
+ }
240
+ for sid, s in self.servos.items()
241
+ },
242
+ "poses": {name: pose.to_dict() for name, pose in self.poses.items()},
243
+ "camera_url": self.camera_url,
244
+ "camera_type": self.camera_type,
245
+ "serial_port": self.serial_port,
246
+ "baud_rate": self.baud_rate,
247
+ "calibrated_at": self.calibrated_at,
248
+ "notes": self.notes,
249
+ }
250
+
251
+ path.parent.mkdir(parents=True, exist_ok=True)
252
+ with open(path, "w") as f:
253
+ json.dump(data, f, indent=2)
254
+
255
+ @classmethod
256
+ def load(cls, path: Path) -> "RobotCalibration":
257
+ """Load calibration from JSON file."""
258
+ with open(path) as f:
259
+ data = json.load(f)
260
+
261
+ cal = cls(
262
+ robot_model=data["robot_model"],
263
+ robot_name=data["robot_name"],
264
+ camera_url=data.get("camera_url"),
265
+ camera_type=data.get("camera_type", "wifi"),
266
+ serial_port=data.get("serial_port"),
267
+ baud_rate=data.get("baud_rate", 115200),
268
+ calibrated_at=data.get("calibrated_at"),
269
+ notes=data.get("notes", ""),
270
+ )
271
+
272
+ # Load servos with forgiving joint type parsing
273
+ for sid, sdata in data.get("servos", {}).items():
274
+ # Use from_string for flexible joint type parsing
275
+ joint_type_str = sdata.get("joint_type", "unknown")
276
+ joint_type = JointType.from_string(joint_type_str)
277
+
278
+ cal.servos[int(sid)] = ServoCalibration(
279
+ servo_id=sdata["servo_id"],
280
+ name=sdata["name"],
281
+ joint_type=joint_type,
282
+ min_value=sdata.get("min_value", 0),
283
+ max_value=sdata.get("max_value", 4096),
284
+ center_value=sdata.get("center_value", 2048),
285
+ positions=sdata.get("positions", {}),
286
+ inverted=sdata.get("inverted", False),
287
+ speed_limit=sdata.get("speed_limit", 1000),
288
+ )
289
+
290
+ # Load poses
291
+ for name, pdata in data.get("poses", {}).items():
292
+ cal.poses[name] = Pose.from_dict(pdata)
293
+
294
+ return cal
295
+
296
+
297
+ class VisualCalibrator:
298
+ """
299
+ Interactive calibration wizard with visual feedback.
300
+
301
+ Usage:
302
+ calibrator = VisualCalibrator(serial_port="/dev/cu.usbserial-10")
303
+ calibrator.set_camera("http://192.168.50.98:80/capture")
304
+ calibration = calibrator.run_interactive()
305
+ calibration.save(Path("~/.ate/calibrations/mechdog.json"))
306
+ """
307
+
308
+ def __init__(
309
+ self,
310
+ serial_port: str,
311
+ baud_rate: int = 115200,
312
+ robot_model: str = "unknown",
313
+ robot_name: str = "robot",
314
+ ):
315
+ self.serial_port = serial_port
316
+ self.baud_rate = baud_rate
317
+ self.robot_model = robot_model
318
+ self.robot_name = robot_name
319
+
320
+ self._serial = None
321
+ self._camera_url = None
322
+ self._camera_capture = None # Function to capture image
323
+
324
+ self.calibration = RobotCalibration(
325
+ robot_model=robot_model,
326
+ robot_name=robot_name,
327
+ serial_port=serial_port,
328
+ baud_rate=baud_rate,
329
+ )
330
+
331
+ def set_camera(self, camera_url: str):
332
+ """Set camera URL for visual feedback."""
333
+ self._camera_url = camera_url
334
+ self.calibration.camera_url = camera_url
335
+
336
+ def set_camera_capture(self, capture_fn: Callable[[], bytes]):
337
+ """Set custom camera capture function."""
338
+ self._camera_capture = capture_fn
339
+
340
+ def connect(self) -> bool:
341
+ """Connect to robot via serial."""
342
+ try:
343
+ import serial
344
+ self._serial = serial.Serial(self.serial_port, self.baud_rate, timeout=2.0)
345
+ time.sleep(0.5)
346
+ self._serial.reset_input_buffer()
347
+
348
+ # Get to REPL
349
+ self._serial.write(b'\x03')
350
+ time.sleep(0.3)
351
+ self._serial.write(b'\x02')
352
+ time.sleep(0.3)
353
+ self._serial.read(self._serial.in_waiting or 1)
354
+
355
+ return True
356
+ except Exception as e:
357
+ print(f"Failed to connect: {e}")
358
+ return False
359
+
360
+ def disconnect(self):
361
+ """Disconnect from robot."""
362
+ if self._serial and self._serial.is_open:
363
+ self._serial.close()
364
+
365
+ def send_command(self, cmd: str, wait: float = 0.3) -> str:
366
+ """Send command and return response."""
367
+ if not self._serial:
368
+ raise RuntimeError("Not connected")
369
+
370
+ self._serial.write(f"{cmd}\r\n".encode())
371
+ time.sleep(wait)
372
+ response = self._serial.read(self._serial.in_waiting or 1)
373
+ return response.decode("utf-8", errors="replace")
374
+
375
+ def capture_image(self, save_path: Optional[str] = None) -> Optional[bytes]:
376
+ """Capture image from camera."""
377
+ if self._camera_capture:
378
+ img_data = self._camera_capture()
379
+ elif self._camera_url:
380
+ import requests
381
+ try:
382
+ response = requests.get(self._camera_url, timeout=5)
383
+ if response.status_code == 200:
384
+ img_data = response.content
385
+ else:
386
+ return None
387
+ except Exception:
388
+ return None
389
+ else:
390
+ return None
391
+
392
+ if save_path and img_data:
393
+ with open(save_path, "wb") as f:
394
+ f.write(img_data)
395
+
396
+ return img_data
397
+
398
+ def read_servo(self, servo_id: int) -> Optional[int]:
399
+ """Read current servo position."""
400
+ response = self.send_command(f"dog.read_servo({servo_id})")
401
+ # Parse response for integer value
402
+ for line in response.split("\n"):
403
+ line = line.strip()
404
+ if line.isdigit():
405
+ return int(line)
406
+ try:
407
+ return int(line)
408
+ except ValueError:
409
+ continue
410
+ return None
411
+
412
+ def set_servo(self, servo_id: int, value: int, time_ms: int = 500) -> bool:
413
+ """Set servo to position."""
414
+ response = self.send_command(
415
+ f"dog.set_servo({servo_id}, {value}, {time_ms})",
416
+ wait=time_ms / 1000 + 0.3
417
+ )
418
+ return "True" in response
419
+
420
+ def discover_servos(self, max_id: int = 16) -> List[int]:
421
+ """Discover which servo IDs are active."""
422
+ active = []
423
+ print(f"Scanning servos 1-{max_id}...")
424
+
425
+ for servo_id in range(1, max_id + 1):
426
+ pos = self.read_servo(servo_id)
427
+ if pos is not None:
428
+ active.append(servo_id)
429
+ print(f" Found servo {servo_id} at position {pos}")
430
+
431
+ return active
432
+
433
+ def calibrate_servo_range(
434
+ self,
435
+ servo_id: int,
436
+ name: str = "",
437
+ test_values: List[int] = None,
438
+ ) -> ServoCalibration:
439
+ """
440
+ Interactively calibrate a single servo.
441
+
442
+ Moves servo through range while user confirms safe limits.
443
+ """
444
+ if test_values is None:
445
+ test_values = [500, 1000, 1500, 2000, 2500]
446
+
447
+ print(f"\n=== Calibrating Servo {servo_id} ===")
448
+ if not name:
449
+ name = input(f"Name for servo {servo_id} (e.g., 'gripper', 'arm_elbow'): ").strip()
450
+ if not name:
451
+ name = f"servo_{servo_id}"
452
+
453
+ cal = ServoCalibration(servo_id=servo_id, name=name)
454
+
455
+ # Test range
456
+ print(f"\nTesting positions: {test_values}")
457
+ print("Watch the servo and note safe range...")
458
+
459
+ working_values = []
460
+ for val in test_values:
461
+ print(f" Moving to {val}...", end=" ", flush=True)
462
+ success = self.set_servo(servo_id, val)
463
+ if success:
464
+ print("OK")
465
+ working_values.append(val)
466
+ else:
467
+ print("FAILED")
468
+ time.sleep(0.5)
469
+
470
+ if working_values:
471
+ cal.min_value = min(working_values)
472
+ cal.max_value = max(working_values)
473
+ cal.center_value = working_values[len(working_values) // 2]
474
+ print(f"\nRange: {cal.min_value} - {cal.max_value}")
475
+
476
+ return cal
477
+
478
+ def record_pose(self, name: str, description: str = "") -> Pose:
479
+ """
480
+ Record current robot position as a named pose.
481
+
482
+ Reads all known servos and saves their positions.
483
+ """
484
+ pose = Pose(name=name, description=description)
485
+
486
+ for servo_id in self.calibration.servos:
487
+ pos = self.read_servo(servo_id)
488
+ if pos is not None:
489
+ pose.servo_positions[servo_id] = pos
490
+
491
+ # Capture image if camera available
492
+ if self._camera_url:
493
+ img_dir = Path.home() / ".ate" / "calibrations" / "images"
494
+ img_dir.mkdir(parents=True, exist_ok=True)
495
+ img_path = img_dir / f"{self.robot_name}_{name}.jpg"
496
+ self.capture_image(str(img_path))
497
+ pose.image_path = str(img_path)
498
+
499
+ self.calibration.add_pose(pose)
500
+ return pose
501
+
502
+ def apply_pose(self, pose: Pose, sequential: bool = True) -> bool:
503
+ """
504
+ Apply a saved pose to the robot.
505
+
506
+ Args:
507
+ pose: The pose to apply
508
+ sequential: If True, move servos one at a time with waits
509
+ """
510
+ success = True
511
+
512
+ for servo_id, value in pose.servo_positions.items():
513
+ result = self.set_servo(servo_id, value, pose.transition_time_ms)
514
+ if not result:
515
+ success = False
516
+
517
+ if sequential:
518
+ time.sleep(pose.transition_time_ms / 1000 + 0.2)
519
+
520
+ return success
521
+
522
+ def run_interactive(self) -> RobotCalibration:
523
+ """
524
+ Run full interactive calibration wizard.
525
+
526
+ Returns:
527
+ Complete RobotCalibration object
528
+ """
529
+ from datetime import datetime
530
+
531
+ print("\n" + "=" * 50)
532
+ print(" FoodforThought Robot Calibration Wizard")
533
+ print("=" * 50)
534
+
535
+ # Connect
536
+ print("\nConnecting to robot...")
537
+ if not self.connect():
538
+ raise RuntimeError("Failed to connect to robot")
539
+ print("Connected!")
540
+
541
+ # Initialize robot
542
+ print("\nInitializing robot...")
543
+ self.send_command("from HW_MechDog import MechDog; dog = MechDog()", wait=1.5)
544
+
545
+ # Discover servos
546
+ print("\n--- Servo Discovery ---")
547
+ active_servos = self.discover_servos()
548
+ print(f"\nFound {len(active_servos)} active servos: {active_servos}")
549
+
550
+ # Calibrate each servo
551
+ print("\n--- Servo Calibration ---")
552
+ print("For each servo, we'll test its range.")
553
+ print("Press Enter to continue or 'skip' to skip a servo.\n")
554
+
555
+ for servo_id in active_servos:
556
+ skip = input(f"Calibrate servo {servo_id}? [Enter/skip]: ").strip().lower()
557
+ if skip == "skip":
558
+ continue
559
+
560
+ cal = self.calibrate_servo_range(servo_id)
561
+ self.calibration.servos[servo_id] = cal
562
+
563
+ # Record key poses
564
+ print("\n--- Pose Recording ---")
565
+ print("Now let's record some key poses.")
566
+ print("Manually position the robot and type a pose name to save it.")
567
+ print("Type 'done' when finished.\n")
568
+
569
+ while True:
570
+ pose_name = input("Pose name (or 'done'): ").strip()
571
+ if pose_name.lower() == "done":
572
+ break
573
+ if not pose_name:
574
+ continue
575
+
576
+ desc = input("Description: ").strip()
577
+ pose = self.record_pose(pose_name, desc)
578
+ print(f"Recorded pose '{pose_name}' with {len(pose.servo_positions)} servos")
579
+
580
+ # Finalize
581
+ self.calibration.calibrated_at = datetime.now().isoformat()
582
+ self.disconnect()
583
+
584
+ print("\n" + "=" * 50)
585
+ print(" Calibration Complete!")
586
+ print("=" * 50)
587
+ print(f"Servos: {len(self.calibration.servos)}")
588
+ print(f"Poses: {len(self.calibration.poses)}")
589
+
590
+ return self.calibration
591
+
592
+
593
+ # =============================================================================
594
+ # Quick calibration functions for common scenarios
595
+ # =============================================================================
596
+
597
+ def quick_gripper_calibration(
598
+ serial_port: str,
599
+ gripper_servo_id: int = 11,
600
+ test_open: int = 2500,
601
+ test_closed: int = 200,
602
+ ) -> ServoCalibration:
603
+ """
604
+ Quick calibration for just a gripper servo.
605
+
606
+ Usage:
607
+ gripper = quick_gripper_calibration("/dev/cu.usbserial-10")
608
+ print(f"Open: {gripper.positions['open']}, Closed: {gripper.positions['closed']}")
609
+ """
610
+ calibrator = VisualCalibrator(serial_port)
611
+
612
+ if not calibrator.connect():
613
+ raise RuntimeError("Failed to connect")
614
+
615
+ try:
616
+ calibrator.send_command("from HW_MechDog import MechDog; dog = MechDog()", wait=1.5)
617
+
618
+ gripper = ServoCalibration(
619
+ servo_id=gripper_servo_id,
620
+ name="gripper",
621
+ joint_type=JointType.GRIPPER,
622
+ )
623
+
624
+ # Test open
625
+ print("Opening gripper...")
626
+ calibrator.set_servo(gripper_servo_id, test_open)
627
+ time.sleep(1.5)
628
+ actual_open = calibrator.read_servo(gripper_servo_id)
629
+ gripper.positions["open"] = actual_open or test_open
630
+ gripper.max_value = gripper.positions["open"]
631
+
632
+ # Test closed
633
+ print("Closing gripper...")
634
+ calibrator.set_servo(gripper_servo_id, test_closed)
635
+ time.sleep(1.5)
636
+ actual_closed = calibrator.read_servo(gripper_servo_id)
637
+ gripper.positions["closed"] = actual_closed or test_closed
638
+ gripper.min_value = gripper.positions["closed"]
639
+
640
+ # Center
641
+ gripper.center_value = (gripper.min_value + gripper.max_value) // 2
642
+
643
+ return gripper
644
+ finally:
645
+ calibrator.disconnect()
646
+
647
+
648
+ def load_calibration(robot_name: str) -> Optional[RobotCalibration]:
649
+ """Load calibration from default location."""
650
+ path = Path.home() / ".ate" / "calibrations" / f"{robot_name}.json"
651
+ if path.exists():
652
+ return RobotCalibration.load(path)
653
+ return None
654
+
655
+
656
+ def save_calibration(calibration: RobotCalibration):
657
+ """Save calibration to default location."""
658
+ path = Path.home() / ".ate" / "calibrations" / f"{calibration.robot_name}.json"
659
+ calibration.save(path)
660
+ print(f"Saved calibration to: {path}")
661
+
662
+
663
+ def list_calibrations() -> List[str]:
664
+ """List available calibrations."""
665
+ cal_dir = Path.home() / ".ate" / "calibrations"
666
+ if not cal_dir.exists():
667
+ return []
668
+ return [p.stem for p in cal_dir.glob("*.json")]