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