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.
- ate/__init__.py +6 -0
- ate/__main__.py +16 -0
- ate/auth/__init__.py +1 -0
- ate/auth/device_flow.py +141 -0
- ate/auth/token_store.py +96 -0
- ate/behaviors/__init__.py +100 -0
- ate/behaviors/approach.py +399 -0
- ate/behaviors/common.py +686 -0
- ate/behaviors/tree.py +454 -0
- ate/cli.py +855 -3995
- ate/client.py +90 -0
- ate/commands/__init__.py +168 -0
- ate/commands/auth.py +389 -0
- ate/commands/bridge.py +448 -0
- ate/commands/data.py +185 -0
- ate/commands/deps.py +111 -0
- ate/commands/generate.py +384 -0
- ate/commands/memory.py +907 -0
- ate/commands/parts.py +166 -0
- ate/commands/primitive.py +399 -0
- ate/commands/protocol.py +288 -0
- ate/commands/recording.py +524 -0
- ate/commands/repo.py +154 -0
- ate/commands/simulation.py +291 -0
- ate/commands/skill.py +303 -0
- ate/commands/skills.py +487 -0
- ate/commands/team.py +147 -0
- ate/commands/workflow.py +271 -0
- ate/detection/__init__.py +38 -0
- ate/detection/base.py +142 -0
- ate/detection/color_detector.py +399 -0
- ate/detection/trash_detector.py +322 -0
- ate/drivers/__init__.py +39 -0
- ate/drivers/ble_transport.py +405 -0
- ate/drivers/mechdog.py +942 -0
- ate/drivers/wifi_camera.py +477 -0
- ate/interfaces/__init__.py +187 -0
- ate/interfaces/base.py +273 -0
- ate/interfaces/body.py +267 -0
- ate/interfaces/detection.py +282 -0
- ate/interfaces/locomotion.py +422 -0
- ate/interfaces/manipulation.py +408 -0
- ate/interfaces/navigation.py +389 -0
- ate/interfaces/perception.py +362 -0
- ate/interfaces/sensors.py +247 -0
- ate/interfaces/types.py +371 -0
- ate/llm_proxy.py +239 -0
- ate/mcp_server.py +387 -0
- ate/memory/__init__.py +35 -0
- ate/memory/cloud.py +244 -0
- ate/memory/context.py +269 -0
- ate/memory/embeddings.py +184 -0
- ate/memory/export.py +26 -0
- ate/memory/merge.py +146 -0
- ate/memory/migrate/__init__.py +34 -0
- ate/memory/migrate/base.py +89 -0
- ate/memory/migrate/pipeline.py +189 -0
- ate/memory/migrate/sources/__init__.py +13 -0
- ate/memory/migrate/sources/chroma.py +170 -0
- ate/memory/migrate/sources/pinecone.py +120 -0
- ate/memory/migrate/sources/qdrant.py +110 -0
- ate/memory/migrate/sources/weaviate.py +160 -0
- ate/memory/reranker.py +353 -0
- ate/memory/search.py +26 -0
- ate/memory/store.py +548 -0
- ate/recording/__init__.py +83 -0
- ate/recording/demonstration.py +378 -0
- ate/recording/session.py +415 -0
- ate/recording/upload.py +304 -0
- ate/recording/visual.py +416 -0
- ate/recording/wrapper.py +95 -0
- ate/robot/__init__.py +221 -0
- ate/robot/agentic_servo.py +856 -0
- ate/robot/behaviors.py +493 -0
- ate/robot/ble_capture.py +1000 -0
- ate/robot/ble_enumerate.py +506 -0
- ate/robot/calibration.py +668 -0
- ate/robot/calibration_state.py +388 -0
- ate/robot/commands.py +3735 -0
- ate/robot/direction_calibration.py +554 -0
- ate/robot/discovery.py +441 -0
- ate/robot/introspection.py +330 -0
- ate/robot/llm_system_id.py +654 -0
- ate/robot/locomotion_calibration.py +508 -0
- ate/robot/manager.py +270 -0
- ate/robot/marker_generator.py +611 -0
- ate/robot/perception.py +502 -0
- ate/robot/primitives.py +614 -0
- ate/robot/profiles.py +281 -0
- ate/robot/registry.py +322 -0
- ate/robot/servo_mapper.py +1153 -0
- ate/robot/skill_upload.py +675 -0
- ate/robot/target_calibration.py +500 -0
- ate/robot/teach.py +515 -0
- ate/robot/types.py +242 -0
- ate/robot/visual_labeler.py +1048 -0
- ate/robot/visual_servo_loop.py +494 -0
- ate/robot/visual_servoing.py +570 -0
- ate/robot/visual_system_id.py +906 -0
- ate/transports/__init__.py +121 -0
- ate/transports/base.py +394 -0
- ate/transports/ble.py +405 -0
- ate/transports/hybrid.py +444 -0
- ate/transports/serial.py +345 -0
- ate/urdf/__init__.py +30 -0
- ate/urdf/capture.py +582 -0
- ate/urdf/cloud.py +491 -0
- ate/urdf/collision.py +271 -0
- ate/urdf/commands.py +708 -0
- ate/urdf/depth.py +360 -0
- ate/urdf/inertial.py +312 -0
- ate/urdf/kinematics.py +330 -0
- ate/urdf/lifting.py +415 -0
- ate/urdf/meshing.py +300 -0
- ate/urdf/models/__init__.py +110 -0
- ate/urdf/models/depth_anything.py +253 -0
- ate/urdf/models/sam2.py +324 -0
- ate/urdf/motion_analysis.py +396 -0
- ate/urdf/pipeline.py +468 -0
- ate/urdf/scale.py +256 -0
- ate/urdf/scan_session.py +411 -0
- ate/urdf/segmentation.py +299 -0
- ate/urdf/synthesis.py +319 -0
- ate/urdf/topology.py +336 -0
- ate/urdf/validation.py +371 -0
- {foodforthought_cli-0.2.7.dist-info → foodforthought_cli-0.3.0.dist-info}/METADATA +9 -1
- foodforthought_cli-0.3.0.dist-info/RECORD +166 -0
- {foodforthought_cli-0.2.7.dist-info → foodforthought_cli-0.3.0.dist-info}/WHEEL +1 -1
- foodforthought_cli-0.2.7.dist-info/RECORD +0 -44
- {foodforthought_cli-0.2.7.dist-info → foodforthought_cli-0.3.0.dist-info}/entry_points.txt +0 -0
- {foodforthought_cli-0.2.7.dist-info → foodforthought_cli-0.3.0.dist-info}/top_level.txt +0 -0
ate/robot/calibration.py
ADDED
|
@@ -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")]
|