foodforthought-cli 0.2.8__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 (116) 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 +12 -0
  7. ate/behaviors/approach.py +399 -0
  8. ate/cli.py +855 -4551
  9. ate/client.py +90 -0
  10. ate/commands/__init__.py +168 -0
  11. ate/commands/auth.py +389 -0
  12. ate/commands/bridge.py +448 -0
  13. ate/commands/data.py +185 -0
  14. ate/commands/deps.py +111 -0
  15. ate/commands/generate.py +384 -0
  16. ate/commands/memory.py +907 -0
  17. ate/commands/parts.py +166 -0
  18. ate/commands/primitive.py +399 -0
  19. ate/commands/protocol.py +288 -0
  20. ate/commands/recording.py +524 -0
  21. ate/commands/repo.py +154 -0
  22. ate/commands/simulation.py +291 -0
  23. ate/commands/skill.py +303 -0
  24. ate/commands/skills.py +487 -0
  25. ate/commands/team.py +147 -0
  26. ate/commands/workflow.py +271 -0
  27. ate/detection/__init__.py +38 -0
  28. ate/detection/base.py +142 -0
  29. ate/detection/color_detector.py +399 -0
  30. ate/detection/trash_detector.py +322 -0
  31. ate/drivers/__init__.py +18 -6
  32. ate/drivers/ble_transport.py +405 -0
  33. ate/drivers/mechdog.py +360 -24
  34. ate/drivers/wifi_camera.py +477 -0
  35. ate/interfaces/__init__.py +16 -0
  36. ate/interfaces/base.py +2 -0
  37. ate/interfaces/sensors.py +247 -0
  38. ate/llm_proxy.py +239 -0
  39. ate/memory/__init__.py +35 -0
  40. ate/memory/cloud.py +244 -0
  41. ate/memory/context.py +269 -0
  42. ate/memory/embeddings.py +184 -0
  43. ate/memory/export.py +26 -0
  44. ate/memory/merge.py +146 -0
  45. ate/memory/migrate/__init__.py +34 -0
  46. ate/memory/migrate/base.py +89 -0
  47. ate/memory/migrate/pipeline.py +189 -0
  48. ate/memory/migrate/sources/__init__.py +13 -0
  49. ate/memory/migrate/sources/chroma.py +170 -0
  50. ate/memory/migrate/sources/pinecone.py +120 -0
  51. ate/memory/migrate/sources/qdrant.py +110 -0
  52. ate/memory/migrate/sources/weaviate.py +160 -0
  53. ate/memory/reranker.py +353 -0
  54. ate/memory/search.py +26 -0
  55. ate/memory/store.py +548 -0
  56. ate/recording/__init__.py +42 -3
  57. ate/recording/session.py +12 -2
  58. ate/recording/visual.py +416 -0
  59. ate/robot/__init__.py +142 -0
  60. ate/robot/agentic_servo.py +856 -0
  61. ate/robot/behaviors.py +493 -0
  62. ate/robot/ble_capture.py +1000 -0
  63. ate/robot/ble_enumerate.py +506 -0
  64. ate/robot/calibration.py +88 -3
  65. ate/robot/calibration_state.py +388 -0
  66. ate/robot/commands.py +143 -11
  67. ate/robot/direction_calibration.py +554 -0
  68. ate/robot/discovery.py +104 -2
  69. ate/robot/llm_system_id.py +654 -0
  70. ate/robot/locomotion_calibration.py +508 -0
  71. ate/robot/marker_generator.py +611 -0
  72. ate/robot/perception.py +502 -0
  73. ate/robot/primitives.py +614 -0
  74. ate/robot/profiles.py +6 -0
  75. ate/robot/registry.py +5 -2
  76. ate/robot/servo_mapper.py +1153 -0
  77. ate/robot/skill_upload.py +285 -3
  78. ate/robot/target_calibration.py +500 -0
  79. ate/robot/teach.py +515 -0
  80. ate/robot/types.py +242 -0
  81. ate/robot/visual_labeler.py +9 -0
  82. ate/robot/visual_servo_loop.py +494 -0
  83. ate/robot/visual_servoing.py +570 -0
  84. ate/robot/visual_system_id.py +906 -0
  85. ate/transports/__init__.py +121 -0
  86. ate/transports/base.py +394 -0
  87. ate/transports/ble.py +405 -0
  88. ate/transports/hybrid.py +444 -0
  89. ate/transports/serial.py +345 -0
  90. ate/urdf/__init__.py +30 -0
  91. ate/urdf/capture.py +582 -0
  92. ate/urdf/cloud.py +491 -0
  93. ate/urdf/collision.py +271 -0
  94. ate/urdf/commands.py +708 -0
  95. ate/urdf/depth.py +360 -0
  96. ate/urdf/inertial.py +312 -0
  97. ate/urdf/kinematics.py +330 -0
  98. ate/urdf/lifting.py +415 -0
  99. ate/urdf/meshing.py +300 -0
  100. ate/urdf/models/__init__.py +110 -0
  101. ate/urdf/models/depth_anything.py +253 -0
  102. ate/urdf/models/sam2.py +324 -0
  103. ate/urdf/motion_analysis.py +396 -0
  104. ate/urdf/pipeline.py +468 -0
  105. ate/urdf/scale.py +256 -0
  106. ate/urdf/scan_session.py +411 -0
  107. ate/urdf/segmentation.py +299 -0
  108. ate/urdf/synthesis.py +319 -0
  109. ate/urdf/topology.py +336 -0
  110. ate/urdf/validation.py +371 -0
  111. {foodforthought_cli-0.2.8.dist-info → foodforthought_cli-0.3.0.dist-info}/METADATA +1 -1
  112. foodforthought_cli-0.3.0.dist-info/RECORD +166 -0
  113. {foodforthought_cli-0.2.8.dist-info → foodforthought_cli-0.3.0.dist-info}/WHEEL +1 -1
  114. foodforthought_cli-0.2.8.dist-info/RECORD +0 -73
  115. {foodforthought_cli-0.2.8.dist-info → foodforthought_cli-0.3.0.dist-info}/entry_points.txt +0 -0
  116. {foodforthought_cli-0.2.8.dist-info → foodforthought_cli-0.3.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,388 @@
1
+ """
2
+ Calibration State Machine with Enforced Prerequisites.
3
+
4
+ This module ensures calibration steps are completed in the correct order,
5
+ making it IMPOSSIBLE to skip critical steps like direction calibration.
6
+
7
+ The Problem This Solves:
8
+ -----------------------
9
+ Previously, a user could run:
10
+ ate robot calibrate start # Get servo ranges
11
+ ate robot behavior pickup # CRASH! Wrong direction!
12
+
13
+ The behavior would fail catastrophically because we knew WHICH servos
14
+ existed but not WHETHER positive commands moved TOWARD or AWAY from targets.
15
+
16
+ The Solution:
17
+ ------------
18
+ Each calibration stage must be completed before the next can begin.
19
+ Commands check prerequisites and refuse to run if not met.
20
+
21
+ Stages:
22
+ 1. DEVICE_DISCOVERED - Robot found on USB/network
23
+ 2. SERVO_RANGES_KNOWN - Min/max/neutral for each servo
24
+ 3. STRUCTURE_IDENTIFIED - Which servo controls which joint
25
+ 4. DIRECTION_CALIBRATED - For each servo: does + move toward target?
26
+ 5. VALIDATED - Pickup test passed
27
+
28
+ Usage:
29
+ from ate.robot.calibration_state import CalibrationState, check_prerequisite
30
+
31
+ # Check if we can run a behavior
32
+ state = CalibrationState.load("my_robot")
33
+ if not state.direction_calibrated:
34
+ print("Run 'ate robot calibrate direction' first")
35
+ sys.exit(1)
36
+
37
+ # Or use the decorator
38
+ @check_prerequisite("direction_calibrated")
39
+ def robot_behavior_command(...):
40
+ ...
41
+ """
42
+
43
+ import json
44
+ import os
45
+ from dataclasses import dataclass, field, asdict
46
+ from datetime import datetime
47
+ from pathlib import Path
48
+ from typing import Optional, Dict, List, Any
49
+ from functools import wraps
50
+ import sys
51
+
52
+
53
+ # State storage directory
54
+ STATE_DIR = Path.home() / ".ate" / "calibration_state"
55
+
56
+
57
+ @dataclass
58
+ class DirectionMapping:
59
+ """Records whether positive servo values move toward or away from target."""
60
+ servo_id: int
61
+ toward_direction: str # "positive" or "negative"
62
+ confidence: float # 0.0 to 1.0
63
+ delta_pixels: float # measured movement in pixels
64
+ calibrated_at: str # ISO timestamp
65
+
66
+ @classmethod
67
+ def from_dict(cls, d: dict) -> "DirectionMapping":
68
+ return cls(**d)
69
+
70
+
71
+ @dataclass
72
+ class CalibrationState:
73
+ """
74
+ Tracks calibration progress for a robot.
75
+
76
+ Enforces that each stage is completed before the next can begin.
77
+ Persists to disk so progress is not lost between sessions.
78
+ """
79
+
80
+ robot_name: str
81
+
82
+ # Stage 1: Device discovered
83
+ device_discovered: bool = False
84
+ serial_port: Optional[str] = None
85
+ camera_ip: Optional[str] = None
86
+ discovered_at: Optional[str] = None
87
+
88
+ # Stage 2: Servo ranges known
89
+ servo_ranges_known: bool = False
90
+ servo_count: int = 0
91
+ servo_ranges: Dict[int, Dict[str, int]] = field(default_factory=dict)
92
+ ranges_calibrated_at: Optional[str] = None
93
+
94
+ # Stage 3: Kinematic structure identified
95
+ structure_identified: bool = False
96
+ joint_mappings: Dict[int, str] = field(default_factory=dict) # servo_id → joint_name
97
+ structure_identified_at: Optional[str] = None
98
+
99
+ # Stage 4: Direction calibrated (THE CRITICAL MISSING STEP)
100
+ direction_calibrated: bool = False
101
+ direction_mappings: Dict[int, DirectionMapping] = field(default_factory=dict)
102
+ direction_calibrated_at: Optional[str] = None
103
+
104
+ # Stage 5: Validated via task execution
105
+ validated: bool = False
106
+ validation_task: Optional[str] = None
107
+ validated_at: Optional[str] = None
108
+
109
+ # Metadata
110
+ created_at: str = field(default_factory=lambda: datetime.now().isoformat())
111
+ updated_at: str = field(default_factory=lambda: datetime.now().isoformat())
112
+
113
+ def save(self) -> bool:
114
+ """Persist state to disk."""
115
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
116
+ path = STATE_DIR / f"{self.robot_name}.json"
117
+
118
+ self.updated_at = datetime.now().isoformat()
119
+
120
+ # Convert to JSON-serializable dict
121
+ data = asdict(self)
122
+
123
+ # Convert DirectionMapping objects to dicts
124
+ if self.direction_mappings:
125
+ data["direction_mappings"] = {
126
+ str(k): asdict(v) if isinstance(v, DirectionMapping) else v
127
+ for k, v in self.direction_mappings.items()
128
+ }
129
+
130
+ try:
131
+ with open(path, "w") as f:
132
+ json.dump(data, f, indent=2)
133
+ return True
134
+ except Exception as e:
135
+ print(f"Error saving calibration state: {e}", file=sys.stderr)
136
+ return False
137
+
138
+ @classmethod
139
+ def load(cls, robot_name: str) -> "CalibrationState":
140
+ """Load state from disk, or create new if not exists."""
141
+ path = STATE_DIR / f"{robot_name}.json"
142
+
143
+ if not path.exists():
144
+ return cls(robot_name=robot_name)
145
+
146
+ try:
147
+ with open(path) as f:
148
+ data = json.load(f)
149
+
150
+ # Convert direction_mappings back to DirectionMapping objects
151
+ if "direction_mappings" in data and data["direction_mappings"]:
152
+ data["direction_mappings"] = {
153
+ int(k): DirectionMapping.from_dict(v)
154
+ for k, v in data["direction_mappings"].items()
155
+ }
156
+
157
+ return cls(**data)
158
+ except Exception as e:
159
+ print(f"Error loading calibration state: {e}", file=sys.stderr)
160
+ return cls(robot_name=robot_name)
161
+
162
+ @classmethod
163
+ def list_all(cls) -> List[str]:
164
+ """List all saved calibration states."""
165
+ if not STATE_DIR.exists():
166
+ return []
167
+ return [p.stem for p in STATE_DIR.glob("*.json")]
168
+
169
+ def get_current_stage(self) -> str:
170
+ """Get the name of the current (incomplete) stage."""
171
+ if not self.device_discovered:
172
+ return "DEVICE_DISCOVERY"
173
+ if not self.servo_ranges_known:
174
+ return "SERVO_RANGE_DISCOVERY"
175
+ if not self.structure_identified:
176
+ return "STRUCTURE_IDENTIFICATION"
177
+ if not self.direction_calibrated:
178
+ return "DIRECTION_CALIBRATION"
179
+ if not self.validated:
180
+ return "VALIDATION"
181
+ return "COMPLETE"
182
+
183
+ def get_next_command(self) -> str:
184
+ """Get the command to run for the next stage."""
185
+ stage = self.get_current_stage()
186
+ commands = {
187
+ "DEVICE_DISCOVERY": "ate robot discover",
188
+ "SERVO_RANGE_DISCOVERY": "ate robot calibrate start",
189
+ "STRUCTURE_IDENTIFICATION": "ate robot calibrate structure",
190
+ "DIRECTION_CALIBRATION": "ate robot calibrate direction",
191
+ "VALIDATION": "ate robot calibrate validate",
192
+ "COMPLETE": "(calibration complete)",
193
+ }
194
+ return commands.get(stage, "unknown")
195
+
196
+ def check_prerequisite(self, required_stage: str) -> tuple[bool, str]:
197
+ """
198
+ Check if a prerequisite stage is complete.
199
+
200
+ Returns:
201
+ (passed, message) - passed is True if OK, message explains what's needed
202
+ """
203
+ stages = {
204
+ "device_discovered": (self.device_discovered, "ate robot discover"),
205
+ "servo_ranges_known": (self.servo_ranges_known, "ate robot calibrate start"),
206
+ "structure_identified": (self.structure_identified, "ate robot calibrate structure"),
207
+ "direction_calibrated": (self.direction_calibrated, "ate robot calibrate direction"),
208
+ "validated": (self.validated, "ate robot calibrate validate"),
209
+ }
210
+
211
+ if required_stage not in stages:
212
+ return True, ""
213
+
214
+ passed, command = stages[required_stage]
215
+
216
+ if passed:
217
+ return True, ""
218
+
219
+ # Build error message showing what's needed
220
+ msg = f"\n{'='*60}\n"
221
+ msg += "PREREQUISITE NOT MET\n"
222
+ msg += f"{'='*60}\n\n"
223
+ msg += f"Required: {required_stage.replace('_', ' ').title()}\n\n"
224
+ msg += "You must complete calibration steps in order:\n\n"
225
+
226
+ checklist = [
227
+ ("device_discovered", "Device Discovery", "ate robot discover"),
228
+ ("servo_ranges_known", "Servo Ranges", "ate robot calibrate start"),
229
+ ("structure_identified", "Kinematic Structure", "ate robot calibrate structure"),
230
+ ("direction_calibrated", "Direction Mapping", "ate robot calibrate direction"),
231
+ ("validated", "Task Validation", "ate robot calibrate validate"),
232
+ ]
233
+
234
+ for stage_key, name, cmd in checklist:
235
+ status = "✓" if getattr(self, stage_key) else "○"
236
+ arrow = " ← NEXT" if stage_key == required_stage else ""
237
+ msg += f" {status} {name}{arrow}\n"
238
+ if stage_key == required_stage:
239
+ break
240
+
241
+ msg += f"\nRun: {command}\n"
242
+
243
+ return False, msg
244
+
245
+ # Stage completion methods
246
+
247
+ def complete_device_discovery(
248
+ self,
249
+ serial_port: Optional[str] = None,
250
+ camera_ip: Optional[str] = None,
251
+ ) -> None:
252
+ """Mark device discovery as complete."""
253
+ self.device_discovered = True
254
+ self.serial_port = serial_port
255
+ self.camera_ip = camera_ip
256
+ self.discovered_at = datetime.now().isoformat()
257
+ self.save()
258
+
259
+ def complete_servo_ranges(
260
+ self,
261
+ servo_count: int,
262
+ servo_ranges: Dict[int, Dict[str, int]],
263
+ ) -> None:
264
+ """Mark servo range discovery as complete."""
265
+ self.servo_ranges_known = True
266
+ self.servo_count = servo_count
267
+ self.servo_ranges = servo_ranges
268
+ self.ranges_calibrated_at = datetime.now().isoformat()
269
+ self.save()
270
+
271
+ def complete_structure_identification(
272
+ self,
273
+ joint_mappings: Dict[int, str],
274
+ ) -> None:
275
+ """Mark structure identification as complete."""
276
+ self.structure_identified = True
277
+ self.joint_mappings = joint_mappings
278
+ self.structure_identified_at = datetime.now().isoformat()
279
+ self.save()
280
+
281
+ def complete_direction_calibration(
282
+ self,
283
+ direction_mappings: Dict[int, DirectionMapping],
284
+ ) -> None:
285
+ """Mark direction calibration as complete."""
286
+ self.direction_calibrated = True
287
+ self.direction_mappings = direction_mappings
288
+ self.direction_calibrated_at = datetime.now().isoformat()
289
+ self.save()
290
+
291
+ def complete_validation(self, task_name: str) -> None:
292
+ """Mark validation as complete."""
293
+ self.validated = True
294
+ self.validation_task = task_name
295
+ self.validated_at = datetime.now().isoformat()
296
+ self.save()
297
+
298
+ def reset(self) -> None:
299
+ """Reset all calibration state."""
300
+ self.__init__(robot_name=self.robot_name)
301
+ self.save()
302
+
303
+ def print_status(self) -> None:
304
+ """Print current calibration status."""
305
+ print(f"\nCalibration Status: {self.robot_name}")
306
+ print("=" * 50)
307
+
308
+ stages = [
309
+ ("device_discovered", "Device Discovery", self.discovered_at),
310
+ ("servo_ranges_known", "Servo Ranges", self.ranges_calibrated_at),
311
+ ("structure_identified", "Kinematic Structure", self.structure_identified_at),
312
+ ("direction_calibrated", "Direction Mapping", self.direction_calibrated_at),
313
+ ("validated", "Task Validation", self.validated_at),
314
+ ]
315
+
316
+ for key, name, timestamp in stages:
317
+ status = "✓" if getattr(self, key) else "○"
318
+ time_str = f" ({timestamp[:10]})" if timestamp else ""
319
+ print(f" {status} {name}{time_str}")
320
+
321
+ print()
322
+
323
+ current = self.get_current_stage()
324
+ if current != "COMPLETE":
325
+ print(f"Next step: {self.get_next_command()}")
326
+ else:
327
+ print("Robot is fully calibrated and validated!")
328
+
329
+
330
+ def check_prerequisite(required_stage: str):
331
+ """
332
+ Decorator that checks calibration prerequisites before running a command.
333
+
334
+ Usage:
335
+ @check_prerequisite("direction_calibrated")
336
+ def robot_behavior_command(profile_name: str, ...):
337
+ # Will only run if direction_calibrated is True
338
+ ...
339
+ """
340
+ def decorator(func):
341
+ @wraps(func)
342
+ def wrapper(*args, **kwargs):
343
+ # Try to get robot name from various sources
344
+ robot_name = None
345
+
346
+ # Check kwargs
347
+ for key in ["profile_name", "name", "robot_name"]:
348
+ if key in kwargs and kwargs[key]:
349
+ robot_name = kwargs[key]
350
+ break
351
+
352
+ # Check positional args (common patterns)
353
+ if not robot_name and args:
354
+ robot_name = args[0] if isinstance(args[0], str) else None
355
+
356
+ if not robot_name:
357
+ # Can't check without robot name - let the function handle it
358
+ return func(*args, **kwargs)
359
+
360
+ # Load state and check prerequisite
361
+ state = CalibrationState.load(robot_name)
362
+ passed, message = state.check_prerequisite(required_stage)
363
+
364
+ if not passed:
365
+ print(message, file=sys.stderr)
366
+ sys.exit(1)
367
+
368
+ return func(*args, **kwargs)
369
+
370
+ return wrapper
371
+ return decorator
372
+
373
+
374
+ def require_direction_calibration(robot_name: str) -> bool:
375
+ """
376
+ Check if direction calibration is complete. Exit if not.
377
+
378
+ Use this in commands that need direction calibration.
379
+ Returns True if calibrated, exits with error if not.
380
+ """
381
+ state = CalibrationState.load(robot_name)
382
+ passed, message = state.check_prerequisite("direction_calibrated")
383
+
384
+ if not passed:
385
+ print(message, file=sys.stderr)
386
+ return False
387
+
388
+ return True
ate/robot/commands.py CHANGED
@@ -1742,6 +1742,7 @@ def robot_upload_command(
1742
1742
  name: str,
1743
1743
  project_id: Optional[str] = None,
1744
1744
  include_images: bool = True,
1745
+ dry_run: bool = False,
1745
1746
  ):
1746
1747
  """
1747
1748
  Upload skill library to FoodforThought.
@@ -1752,6 +1753,7 @@ def robot_upload_command(
1752
1753
  ate robot upload mechdog
1753
1754
  ate robot upload mechdog --project-id abc123
1754
1755
  ate robot upload mechdog --no-images
1756
+ ate robot upload mechdog --dry-run
1755
1757
  """
1756
1758
  # Check login status
1757
1759
  config_file = Path.home() / ".ate" / "config.json"
@@ -1775,16 +1777,67 @@ def robot_upload_command(
1775
1777
  print("Run 'ate robot label' first to create skills.")
1776
1778
  sys.exit(1)
1777
1779
 
1780
+ # Count images
1781
+ img_count = 0
1782
+ if include_images:
1783
+ img_dir = Path.home() / ".ate" / "skill_images" / name
1784
+ if img_dir.exists():
1785
+ img_count = len(list(img_dir.glob("*.jpg")))
1786
+
1787
+ # Dry run preview
1788
+ if dry_run:
1789
+ print("=" * 60)
1790
+ print("DRY RUN - No changes will be made")
1791
+ print("=" * 60)
1792
+ print()
1793
+ print(f"Would upload skill library: {name}")
1794
+ print(f" Robot model: {lib.robot_model}")
1795
+ print(f" Target project: {project_id or '(new project)'}")
1796
+ print()
1797
+ print("Artifacts that would be created:")
1798
+ print()
1799
+
1800
+ # Raw artifacts (images)
1801
+ if include_images and img_count > 0:
1802
+ print(f" [raw] {img_count} pose images")
1803
+ for img_path in list(img_dir.glob("*.jpg"))[:3]:
1804
+ print(f" - {img_path.name}")
1805
+ if img_count > 3:
1806
+ print(f" ... and {img_count - 3} more")
1807
+
1808
+ # Processed artifact (calibration)
1809
+ print(f" [processed] {name}_calibration")
1810
+ print(f" - {len(cal.servos)} servos configured")
1811
+
1812
+ # Labeled artifacts (poses)
1813
+ print(f" [labeled] {len(cal.poses)} poses:")
1814
+ for pose_name in list(cal.poses.keys())[:5]:
1815
+ print(f" - {pose_name}")
1816
+ if len(cal.poses) > 5:
1817
+ print(f" ... and {len(cal.poses) - 5} more")
1818
+
1819
+ # Skill artifacts
1820
+ print(f" [skill] {len(lib.actions)} skills:")
1821
+ for action_name in list(lib.actions.keys())[:5]:
1822
+ print(f" - {action_name}")
1823
+ if len(lib.actions) > 5:
1824
+ print(f" ... and {len(lib.actions) - 5} more")
1825
+
1826
+ print()
1827
+ print("Total artifacts: ", end="")
1828
+ total = img_count + 1 + len(cal.poses) + len(lib.actions)
1829
+ print(f"{total}")
1830
+ print()
1831
+ print("Run without --dry-run to perform the upload.")
1832
+ return
1833
+
1778
1834
  print(f"Uploading skill library: {name}")
1779
1835
  print(f" Robot model: {lib.robot_model}")
1780
1836
  print(f" Actions: {len(lib.actions)}")
1781
1837
  print(f" Poses: {len(cal.poses)}")
1782
1838
  print(f" Servos: {len(cal.servos)}")
1783
- if include_images:
1784
- img_dir = Path.home() / ".ate" / "skill_images" / name
1785
- if img_dir.exists():
1786
- img_count = len(list(img_dir.glob("*.jpg")))
1787
- print(f" Images: {img_count}")
1839
+ if include_images and img_count > 0:
1840
+ print(f" Images: {img_count}")
1788
1841
  print()
1789
1842
 
1790
1843
  try:
@@ -1894,12 +1947,14 @@ def robot_upload_calibration_command(
1894
1947
  robot_name: Optional[str] = None,
1895
1948
  method: str = "llm_vision",
1896
1949
  publish: bool = False,
1950
+ dry_run: bool = False,
1897
1951
  ):
1898
1952
  """
1899
1953
  Upload a calibration to the community registry.
1900
1954
 
1901
1955
  ate robot upload-calibration ./servo_mappings.json --robot-slug mechdog-mini
1902
1956
  ate robot upload-calibration ./calibration.json --publish
1957
+ ate robot upload-calibration ./calibration.json --dry-run
1903
1958
  """
1904
1959
  import json
1905
1960
  import requests
@@ -1913,6 +1968,38 @@ def robot_upload_calibration_command(
1913
1968
  with open(calibration_file, 'r') as f:
1914
1969
  calibration_data = json.load(f)
1915
1970
 
1971
+ # Dry run preview
1972
+ if dry_run:
1973
+ print("=" * 60)
1974
+ print("DRY RUN - No changes will be made")
1975
+ print("=" * 60)
1976
+ print()
1977
+ print(f"Would upload calibration from: {calibration_file}")
1978
+ print()
1979
+ print("Calibration details:")
1980
+ print(f" Name: {calibration_data.get('name', f'Calibration from {calibration_file}')}")
1981
+ print(f" Version: {calibration_data.get('version', '1.0.0')}")
1982
+ print(f" Method: {method}")
1983
+ print(f" Robot: {robot_slug or calibration_data.get('robot_slug', 'Not specified')}")
1984
+ print()
1985
+
1986
+ # Show what data would be uploaded
1987
+ if calibration_data.get("servos"):
1988
+ print(f" Servos: {len(calibration_data['servos'])}")
1989
+ if calibration_data.get("joint_mappings") or calibration_data.get("mappings"):
1990
+ mappings = calibration_data.get("joint_mappings") or calibration_data.get("mappings")
1991
+ print(f" Joint mappings: {len(mappings) if isinstance(mappings, (list, dict)) else 'present'}")
1992
+ if calibration_data.get("urdf"):
1993
+ print(f" URDF: included ({len(calibration_data['urdf'])} chars)")
1994
+ if calibration_data.get("python_code"):
1995
+ print(f" Python code: included ({len(calibration_data['python_code'])} chars)")
1996
+
1997
+ print()
1998
+ print(f" Publish immediately: {publish}")
1999
+ print()
2000
+ print("Run without --dry-run to perform the upload.")
2001
+ return
2002
+
1916
2003
  # Get API credentials
1917
2004
  try:
1918
2005
  uploader = SkillLibraryUploader()
@@ -1975,15 +2062,54 @@ def robot_upload_calibration_command(
1975
2062
  json=payload,
1976
2063
  headers=headers,
1977
2064
  )
1978
- response.raise_for_status()
2065
+
2066
+ if not response.ok:
2067
+ # Parse error and provide helpful suggestions
2068
+ try:
2069
+ error_body = response.json()
2070
+ except:
2071
+ error_body = {"raw": response.text}
2072
+
2073
+ print(f"Error uploading calibration: {response.status_code} {response.reason}")
2074
+
2075
+ error_msg = error_body.get("error") or error_body.get("message", "")
2076
+ if error_msg:
2077
+ print(f"Details: {error_msg}")
2078
+
2079
+ # Provide specific suggestions based on error
2080
+ print("\nSuggestions:")
2081
+ if response.status_code == 404 and "robot" in str(error_body).lower():
2082
+ print(" - Robot slug may not exist in the database")
2083
+ print(" - Search for robots: ate robot identify --search <partial-name>")
2084
+ print(" - Try without --robot-slug to search by robot name")
2085
+ # Try to find similar robots
2086
+ if robot_slug:
2087
+ print(f"\n Looking for robots similar to '{robot_slug}'...")
2088
+ try:
2089
+ search_resp = requests.get(
2090
+ f"{base_url}/api/robots?search={robot_slug.replace('-', ' ')}",
2091
+ headers=headers,
2092
+ )
2093
+ if search_resp.ok:
2094
+ robots = search_resp.json().get("robots", [])
2095
+ if robots:
2096
+ print(" Found similar robots:")
2097
+ for r in robots[:5]:
2098
+ print(f" - {r.get('slug')}: {r.get('name')}")
2099
+ except:
2100
+ pass
2101
+
2102
+ elif response.status_code == 400:
2103
+ print(" - Check calibration file contains required fields")
2104
+ print(" - Ensure robot_slug or robot_id is specified")
2105
+ elif response.status_code == 401:
2106
+ print(" - Session expired. Run: ate login")
2107
+
2108
+ sys.exit(1)
2109
+
1979
2110
  result = response.json()
1980
2111
  except requests.RequestException as e:
1981
2112
  print(f"Error uploading calibration: {e}")
1982
- if hasattr(e.response, 'json'):
1983
- try:
1984
- print(f"Details: {e.response.json()}")
1985
- except:
1986
- pass
1987
2113
  sys.exit(1)
1988
2114
 
1989
2115
  print("[OK] Calibration uploaded successfully!")
@@ -3095,6 +3221,8 @@ EXAMPLES:
3095
3221
  upload_parser.add_argument("--project-id", dest="project_id", help="Existing project ID (creates new if omitted)")
3096
3222
  upload_parser.add_argument("--no-images", action="store_true", dest="no_images",
3097
3223
  help="Skip uploading pose images")
3224
+ upload_parser.add_argument("--dry-run", action="store_true",
3225
+ help="Preview what would be uploaded without making changes")
3098
3226
 
3099
3227
  # robot teach - Fast keyboard-driven teaching mode
3100
3228
  teach_parser = robot_subparsers.add_parser("teach",
@@ -3257,6 +3385,8 @@ EXAMPLES:
3257
3385
  choices=["aruco_marker", "llm_vision", "manual", "urdf_import", "twitch_test", "direction_calibration"],
3258
3386
  default="llm_vision",
3259
3387
  help="Calibration method (default: llm_vision)")
3388
+ upload_cal_parser.add_argument("--dry-run", action="store_true",
3389
+ help="Preview what would be uploaded without making changes")
3260
3390
  upload_cal_parser.add_argument("--publish", action="store_true",
3261
3391
  help="Publish immediately (default: save as draft)")
3262
3392
 
@@ -3501,6 +3631,7 @@ def handle_robot_command(args):
3501
3631
  name=args.name,
3502
3632
  project_id=getattr(args, "project_id", None),
3503
3633
  include_images=not getattr(args, "no_images", False),
3634
+ dry_run=getattr(args, "dry_run", False),
3504
3635
  )
3505
3636
 
3506
3637
  elif args.robot_action == "teach":
@@ -3563,6 +3694,7 @@ def handle_robot_command(args):
3563
3694
  robot_name=getattr(args, "robot_name", None),
3564
3695
  method=getattr(args, "method", "llm_vision"),
3565
3696
  publish=getattr(args, "publish", False),
3697
+ dry_run=getattr(args, "dry_run", False),
3566
3698
  )
3567
3699
 
3568
3700
  elif args.robot_action == "urdf":