foodforthought-cli 0.2.4__py3-none-any.whl → 0.2.8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. ate/__init__.py +1 -1
  2. ate/behaviors/__init__.py +88 -0
  3. ate/behaviors/common.py +686 -0
  4. ate/behaviors/tree.py +454 -0
  5. ate/cli.py +610 -54
  6. ate/drivers/__init__.py +27 -0
  7. ate/drivers/mechdog.py +606 -0
  8. ate/interfaces/__init__.py +171 -0
  9. ate/interfaces/base.py +271 -0
  10. ate/interfaces/body.py +267 -0
  11. ate/interfaces/detection.py +282 -0
  12. ate/interfaces/locomotion.py +422 -0
  13. ate/interfaces/manipulation.py +408 -0
  14. ate/interfaces/navigation.py +389 -0
  15. ate/interfaces/perception.py +362 -0
  16. ate/interfaces/types.py +371 -0
  17. ate/mcp_server.py +387 -0
  18. ate/recording/__init__.py +44 -0
  19. ate/recording/demonstration.py +378 -0
  20. ate/recording/session.py +405 -0
  21. ate/recording/upload.py +304 -0
  22. ate/recording/wrapper.py +95 -0
  23. ate/robot/__init__.py +79 -0
  24. ate/robot/calibration.py +583 -0
  25. ate/robot/commands.py +3603 -0
  26. ate/robot/discovery.py +339 -0
  27. ate/robot/introspection.py +330 -0
  28. ate/robot/manager.py +270 -0
  29. ate/robot/profiles.py +275 -0
  30. ate/robot/registry.py +319 -0
  31. ate/robot/skill_upload.py +393 -0
  32. ate/robot/visual_labeler.py +1039 -0
  33. {foodforthought_cli-0.2.4.dist-info → foodforthought_cli-0.2.8.dist-info}/METADATA +9 -1
  34. {foodforthought_cli-0.2.4.dist-info → foodforthought_cli-0.2.8.dist-info}/RECORD +37 -8
  35. {foodforthought_cli-0.2.4.dist-info → foodforthought_cli-0.2.8.dist-info}/WHEEL +0 -0
  36. {foodforthought_cli-0.2.4.dist-info → foodforthought_cli-0.2.8.dist-info}/entry_points.txt +0 -0
  37. {foodforthought_cli-0.2.4.dist-info → foodforthought_cli-0.2.8.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1039 @@
1
+ """
2
+ Visual Labeling System for Robot Skill Development.
3
+
4
+ Uses dual cameras (external webcam + robot camera) to interactively:
5
+ 1. Discover and label servos by their physical effect
6
+ 2. Record named poses with visual confirmation
7
+ 3. Sequence poses into actions
8
+ 4. Generate reusable skill code
9
+
10
+ This creates a "bedrock" of basic skills that can be:
11
+ - Reused across similar robots
12
+ - Composed into complex behaviors
13
+ - Shared in the FoodforThought marketplace
14
+ """
15
+
16
+ import json
17
+ import time
18
+ import os
19
+ import sys
20
+ import threading
21
+ from dataclasses import dataclass, field
22
+ from typing import Dict, List, Optional, Tuple, Callable, Any
23
+ from pathlib import Path
24
+ from datetime import datetime
25
+ from enum import Enum
26
+
27
+ from .calibration import (
28
+ ServoCalibration,
29
+ Pose,
30
+ RobotCalibration,
31
+ JointType,
32
+ VisualCalibrator,
33
+ save_calibration,
34
+ load_calibration,
35
+ )
36
+
37
+
38
+ # =============================================================================
39
+ # Robot Preset Mappings - Pre-populated servo configurations
40
+ # =============================================================================
41
+
42
+ ROBOT_PRESETS = {
43
+ "hiwonder_mechdog": {
44
+ "name": "HiWonder MechDog",
45
+ "servos": {
46
+ # Front Right Leg
47
+ 1: {"name": "front_right_hip", "joint_type": "hip_roll", "min": 500, "max": 2500},
48
+ 2: {"name": "front_right_thigh", "joint_type": "hip_pitch", "min": 500, "max": 2500},
49
+ # Front Left Leg
50
+ 3: {"name": "front_left_hip", "joint_type": "hip_roll", "min": 500, "max": 2500},
51
+ 4: {"name": "front_left_thigh", "joint_type": "hip_pitch", "min": 500, "max": 2500},
52
+ # Rear Right Leg
53
+ 5: {"name": "rear_right_hip", "joint_type": "hip_roll", "min": 500, "max": 2500},
54
+ 6: {"name": "rear_right_thigh", "joint_type": "hip_pitch", "min": 500, "max": 2500},
55
+ # Rear Left Leg
56
+ 7: {"name": "rear_left_hip", "joint_type": "hip_roll", "min": 500, "max": 2500},
57
+ 8: {"name": "rear_left_thigh", "joint_type": "hip_pitch", "min": 500, "max": 2500},
58
+ # Arm (optional attachment)
59
+ 9: {"name": "arm_shoulder", "joint_type": "shoulder_lift", "min": 500, "max": 2500},
60
+ 10: {"name": "arm_elbow", "joint_type": "elbow", "min": 500, "max": 2500},
61
+ 11: {"name": "gripper", "joint_type": "gripper", "min": 200, "max": 2500},
62
+ },
63
+ "joint_groups": {
64
+ "front_right_leg": [1, 2],
65
+ "front_left_leg": [3, 4],
66
+ "rear_right_leg": [5, 6],
67
+ "rear_left_leg": [7, 8],
68
+ "arm": [9, 10, 11],
69
+ "all_legs": [1, 2, 3, 4, 5, 6, 7, 8],
70
+ },
71
+ "default_poses": {
72
+ "stand": {1: 2096, 2: 1621, 3: 2170, 4: 1611, 5: 904, 6: 1379, 7: 830, 8: 1389},
73
+ "gripper_open": {11: 2500},
74
+ "gripper_closed": {11: 200},
75
+ "arm_up": {9: 500, 10: 1500},
76
+ "arm_down": {9: 1800, 10: 1200},
77
+ },
78
+ },
79
+ }
80
+
81
+
82
+ def get_preset(robot_model: str) -> Optional[Dict]:
83
+ """Get preset mappings for a robot model."""
84
+ return ROBOT_PRESETS.get(robot_model)
85
+
86
+
87
+ def apply_preset_to_calibration(calibration: RobotCalibration, preset: Dict):
88
+ """Apply preset servo mappings to a calibration."""
89
+ for servo_id, info in preset.get("servos", {}).items():
90
+ if servo_id not in calibration.servos:
91
+ jtype = JointType(info["joint_type"]) if info.get("joint_type") else JointType.UNKNOWN
92
+ calibration.servos[servo_id] = ServoCalibration(
93
+ servo_id=servo_id,
94
+ name=info["name"],
95
+ joint_type=jtype,
96
+ min_value=info.get("min", 500),
97
+ max_value=info.get("max", 2500),
98
+ center_value=(info.get("min", 500) + info.get("max", 2500)) // 2,
99
+ )
100
+
101
+
102
+ class ActionType(Enum):
103
+ """Types of robot actions."""
104
+ LOCOMOTION = "locomotion" # Walking, turning
105
+ MANIPULATION = "manipulation" # Picking, placing
106
+ GESTURE = "gesture" # Waving, nodding
107
+ POSTURE = "posture" # Standing, sitting
108
+ COMPOSITE = "composite" # Multi-step actions
109
+
110
+
111
+ @dataclass
112
+ class ActionStep:
113
+ """A single step in an action sequence."""
114
+ pose_name: str
115
+ duration_ms: int = 500
116
+ wait_after_ms: int = 0
117
+ description: str = ""
118
+
119
+ def to_dict(self) -> dict:
120
+ return {
121
+ "pose_name": self.pose_name,
122
+ "duration_ms": self.duration_ms,
123
+ "wait_after_ms": self.wait_after_ms,
124
+ "description": self.description,
125
+ }
126
+
127
+ @classmethod
128
+ def from_dict(cls, data: dict) -> "ActionStep":
129
+ return cls(**data)
130
+
131
+
132
+ @dataclass
133
+ class Action:
134
+ """A named action consisting of pose sequences."""
135
+ name: str
136
+ action_type: ActionType = ActionType.COMPOSITE
137
+ description: str = ""
138
+ steps: List[ActionStep] = field(default_factory=list)
139
+
140
+ # Visual recordings
141
+ preview_image: Optional[str] = None # Path to preview image
142
+ video_path: Optional[str] = None # Path to recorded video
143
+
144
+ # Metadata
145
+ created_at: Optional[str] = None
146
+ tags: List[str] = field(default_factory=list)
147
+
148
+ def add_step(self, pose_name: str, duration_ms: int = 500, wait_after_ms: int = 0):
149
+ self.steps.append(ActionStep(pose_name, duration_ms, wait_after_ms))
150
+
151
+ def to_dict(self) -> dict:
152
+ return {
153
+ "name": self.name,
154
+ "action_type": self.action_type.value,
155
+ "description": self.description,
156
+ "steps": [s.to_dict() for s in self.steps],
157
+ "preview_image": self.preview_image,
158
+ "video_path": self.video_path,
159
+ "created_at": self.created_at,
160
+ "tags": self.tags,
161
+ }
162
+
163
+ @classmethod
164
+ def from_dict(cls, data: dict) -> "Action":
165
+ action = cls(
166
+ name=data["name"],
167
+ action_type=ActionType(data.get("action_type", "composite")),
168
+ description=data.get("description", ""),
169
+ preview_image=data.get("preview_image"),
170
+ video_path=data.get("video_path"),
171
+ created_at=data.get("created_at"),
172
+ tags=data.get("tags", []),
173
+ )
174
+ for step_data in data.get("steps", []):
175
+ action.steps.append(ActionStep.from_dict(step_data))
176
+ return action
177
+
178
+
179
+ @dataclass
180
+ class SkillLibrary:
181
+ """Collection of labeled servos, poses, and actions for a robot."""
182
+ robot_name: str
183
+ robot_model: str
184
+
185
+ # Core data
186
+ calibration: Optional[RobotCalibration] = None
187
+ actions: Dict[str, Action] = field(default_factory=dict)
188
+
189
+ # Semantic mappings
190
+ servo_labels: Dict[int, str] = field(default_factory=dict) # servo_id -> description
191
+ joint_groups: Dict[str, List[int]] = field(default_factory=dict) # group_name -> servo_ids
192
+
193
+ # Metadata
194
+ created_at: Optional[str] = None
195
+ updated_at: Optional[str] = None
196
+
197
+ def save(self, path: Path):
198
+ """Save skill library to JSON."""
199
+ data = {
200
+ "robot_name": self.robot_name,
201
+ "robot_model": self.robot_model,
202
+ "servo_labels": self.servo_labels,
203
+ "joint_groups": self.joint_groups,
204
+ "actions": {name: a.to_dict() for name, a in self.actions.items()},
205
+ "created_at": self.created_at,
206
+ "updated_at": datetime.now().isoformat(),
207
+ }
208
+
209
+ path.parent.mkdir(parents=True, exist_ok=True)
210
+ with open(path, "w") as f:
211
+ json.dump(data, f, indent=2)
212
+
213
+ @classmethod
214
+ def load(cls, path: Path, calibration: Optional[RobotCalibration] = None) -> "SkillLibrary":
215
+ """Load skill library from JSON."""
216
+ with open(path) as f:
217
+ data = json.load(f)
218
+
219
+ lib = cls(
220
+ robot_name=data["robot_name"],
221
+ robot_model=data["robot_model"],
222
+ calibration=calibration,
223
+ servo_labels=data.get("servo_labels", {}),
224
+ joint_groups=data.get("joint_groups", {}),
225
+ created_at=data.get("created_at"),
226
+ updated_at=data.get("updated_at"),
227
+ )
228
+
229
+ for name, action_data in data.get("actions", {}).items():
230
+ lib.actions[name] = Action.from_dict(action_data)
231
+
232
+ return lib
233
+
234
+
235
+ class WebcamCapture:
236
+ """Captures frames from local webcam using OpenCV."""
237
+
238
+ def __init__(self, device_id: int = 0):
239
+ self.device_id = device_id
240
+ self._cap = None
241
+ self._frame = None
242
+ self._running = False
243
+ self._thread = None
244
+
245
+ def start(self) -> bool:
246
+ """Start webcam capture."""
247
+ try:
248
+ import cv2
249
+ self._cap = cv2.VideoCapture(self.device_id)
250
+ if not self._cap.isOpened():
251
+ return False
252
+
253
+ self._running = True
254
+ self._thread = threading.Thread(target=self._capture_loop, daemon=True)
255
+ self._thread.start()
256
+ return True
257
+ except ImportError:
258
+ print("OpenCV not installed. Run: pip install opencv-python")
259
+ return False
260
+ except Exception as e:
261
+ print(f"Failed to start webcam: {e}")
262
+ return False
263
+
264
+ def _capture_loop(self):
265
+ """Background capture loop."""
266
+ import cv2
267
+ while self._running:
268
+ ret, frame = self._cap.read()
269
+ if ret:
270
+ self._frame = frame
271
+ time.sleep(0.033) # ~30fps
272
+
273
+ def stop(self):
274
+ """Stop webcam capture."""
275
+ self._running = False
276
+ if self._thread:
277
+ self._thread.join(timeout=1.0)
278
+ if self._cap:
279
+ self._cap.release()
280
+
281
+ def get_frame(self):
282
+ """Get latest frame as numpy array."""
283
+ return self._frame
284
+
285
+ def capture_image(self, save_path: Optional[str] = None) -> Optional[bytes]:
286
+ """Capture current frame as JPEG bytes."""
287
+ import cv2
288
+
289
+ if self._frame is None:
290
+ return None
291
+
292
+ _, jpeg = cv2.imencode('.jpg', self._frame)
293
+ img_bytes = jpeg.tobytes()
294
+
295
+ if save_path:
296
+ with open(save_path, 'wb') as f:
297
+ f.write(img_bytes)
298
+
299
+ return img_bytes
300
+
301
+ def show_preview(self, window_name: str = "Webcam"):
302
+ """Show live preview window."""
303
+ import cv2
304
+
305
+ if self._frame is not None:
306
+ cv2.imshow(window_name, self._frame)
307
+ cv2.waitKey(1)
308
+
309
+
310
+ class DualCameraLabeler:
311
+ """
312
+ Interactive labeling system using dual cameras.
313
+
314
+ - External webcam: See the robot's body from outside
315
+ - Robot camera: See what the robot sees (for manipulation)
316
+
317
+ Workflow:
318
+ 1. Discover servos and label by physical effect
319
+ 2. Record named poses with dual-camera snapshots
320
+ 3. Sequence poses into named actions
321
+ 4. Generate Python skill code
322
+ """
323
+
324
+ def __init__(
325
+ self,
326
+ serial_port: str,
327
+ robot_name: str = "robot",
328
+ robot_model: str = "unknown",
329
+ webcam_id: int = 0,
330
+ robot_camera_url: Optional[str] = None,
331
+ ):
332
+ self.serial_port = serial_port
333
+ self.robot_name = robot_name
334
+ self.robot_model = robot_model
335
+
336
+ # Cameras
337
+ self.webcam = WebcamCapture(webcam_id)
338
+ self.robot_camera_url = robot_camera_url
339
+
340
+ # Core components
341
+ self.calibrator = VisualCalibrator(
342
+ serial_port=serial_port,
343
+ robot_model=robot_model,
344
+ robot_name=robot_name,
345
+ )
346
+
347
+ if robot_camera_url:
348
+ self.calibrator.set_camera(robot_camera_url)
349
+
350
+ # Skill library
351
+ self.library = SkillLibrary(
352
+ robot_name=robot_name,
353
+ robot_model=robot_model,
354
+ created_at=datetime.now().isoformat(),
355
+ )
356
+
357
+ # State
358
+ self._connected = False
359
+ self._display_thread = None
360
+ self._display_running = False
361
+
362
+ def connect(self) -> bool:
363
+ """Connect to robot and start cameras."""
364
+ print("Connecting to robot...")
365
+ if not self.calibrator.connect():
366
+ return False
367
+
368
+ # Initialize robot
369
+ self.calibrator.send_command("from HW_MechDog import MechDog; dog = MechDog()", wait=1.5)
370
+
371
+ print("Starting webcam...")
372
+ if not self.webcam.start():
373
+ print("Warning: Webcam not available")
374
+
375
+ self._connected = True
376
+ return True
377
+
378
+ def disconnect(self):
379
+ """Disconnect and cleanup."""
380
+ self._display_running = False
381
+ self.webcam.stop()
382
+ self.calibrator.disconnect()
383
+ self._connected = False
384
+
385
+ try:
386
+ import cv2
387
+ cv2.destroyAllWindows()
388
+ except:
389
+ pass
390
+
391
+ def capture_dual_images(self, base_name: str) -> Tuple[Optional[str], Optional[str]]:
392
+ """Capture images from both cameras."""
393
+ img_dir = Path.home() / ".ate" / "skill_images" / self.robot_name
394
+ img_dir.mkdir(parents=True, exist_ok=True)
395
+
396
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
397
+
398
+ # Webcam (external view)
399
+ webcam_path = img_dir / f"{base_name}_external_{timestamp}.jpg"
400
+ webcam_img = self.webcam.capture_image(str(webcam_path))
401
+
402
+ # Robot camera (robot's view)
403
+ robot_path = img_dir / f"{base_name}_robot_{timestamp}.jpg"
404
+ robot_img = self.calibrator.capture_image(str(robot_path))
405
+
406
+ return (
407
+ str(webcam_path) if webcam_img else None,
408
+ str(robot_path) if robot_img else None,
409
+ )
410
+
411
+ def label_servo_interactive(self, servo_id: int) -> ServoCalibration:
412
+ """
413
+ Interactively label a servo by moving it and observing the effect.
414
+
415
+ Shows live webcam feed while user adjusts servo position.
416
+ """
417
+ print(f"\n=== Labeling Servo {servo_id} ===")
418
+ print("Watch the webcam window to see what this servo controls.")
419
+ print("Commands: +/- (adjust), min/max (extremes), name (set name), done (finish)")
420
+
421
+ # Start with center position
422
+ current_value = 1500
423
+ self.calibrator.set_servo(servo_id, current_value, 300)
424
+
425
+ cal = ServoCalibration(servo_id=servo_id, name=f"servo_{servo_id}")
426
+ tested_values = [current_value]
427
+
428
+ while True:
429
+ # Show webcam
430
+ self.webcam.show_preview(f"Servo {servo_id} - Webcam View")
431
+
432
+ cmd = input(f"Servo {servo_id} @ {current_value} > ").strip().lower()
433
+
434
+ if cmd in ('+', 'up', 'u'):
435
+ current_value = min(current_value + 200, 2500)
436
+ self.calibrator.set_servo(servo_id, current_value, 300)
437
+ tested_values.append(current_value)
438
+
439
+ elif cmd in ('-', 'down', 'd'):
440
+ current_value = max(current_value - 200, 200)
441
+ self.calibrator.set_servo(servo_id, current_value, 300)
442
+ tested_values.append(current_value)
443
+
444
+ elif cmd == 'min':
445
+ current_value = 200
446
+ self.calibrator.set_servo(servo_id, current_value, 500)
447
+ tested_values.append(current_value)
448
+
449
+ elif cmd == 'max':
450
+ current_value = 2500
451
+ self.calibrator.set_servo(servo_id, current_value, 500)
452
+ tested_values.append(current_value)
453
+
454
+ elif cmd.startswith('='):
455
+ try:
456
+ current_value = int(cmd[1:])
457
+ self.calibrator.set_servo(servo_id, current_value, 300)
458
+ tested_values.append(current_value)
459
+ except ValueError:
460
+ print("Invalid value. Use =1500 format.")
461
+
462
+ elif cmd == 'name' or cmd.startswith('name '):
463
+ name = cmd[5:].strip() if cmd.startswith('name ') else input("Name: ").strip()
464
+ if name:
465
+ cal.name = name
466
+ print(f"Named: {name}")
467
+
468
+ elif cmd == 'type':
469
+ print("Joint types:", [jt.value for jt in JointType])
470
+ jt = input("Joint type: ").strip()
471
+ try:
472
+ cal.joint_type = JointType(jt)
473
+ print(f"Set type: {cal.joint_type.value}")
474
+ except ValueError:
475
+ print("Invalid type")
476
+
477
+ elif cmd == 'snap':
478
+ ext_path, robot_path = self.capture_dual_images(f"servo_{servo_id}")
479
+ print(f"Captured: {ext_path}")
480
+
481
+ elif cmd == 'pos':
482
+ pos_name = input("Position name: ").strip()
483
+ if pos_name:
484
+ cal.positions[pos_name] = current_value
485
+ print(f"Saved position '{pos_name}' = {current_value}")
486
+
487
+ elif cmd in ('done', 'q', 'quit'):
488
+ break
489
+
490
+ elif cmd == 'help' or cmd == '?':
491
+ print("""
492
+ Commands:
493
+ +/- Adjust by 200
494
+ =VALUE Set to exact value (e.g., =1500)
495
+ min/max Go to extremes
496
+ name X Set servo name
497
+ type Set joint type
498
+ pos Save current as named position
499
+ snap Capture image
500
+ done Finish labeling
501
+ """)
502
+
503
+ # Calculate range from tested values
504
+ cal.min_value = min(tested_values)
505
+ cal.max_value = max(tested_values)
506
+ cal.center_value = (cal.min_value + cal.max_value) // 2
507
+
508
+ return cal
509
+
510
+ def record_pose_interactive(self) -> Optional[Pose]:
511
+ """
512
+ Record current robot position as a named pose.
513
+
514
+ Shows both camera views for confirmation.
515
+ """
516
+ print("\n=== Record Pose ===")
517
+ print("Position the robot manually or use servo commands first.")
518
+
519
+ # Show current state
520
+ self.webcam.show_preview("Current Pose - External")
521
+
522
+ name = input("Pose name: ").strip()
523
+ if not name:
524
+ print("Cancelled")
525
+ return None
526
+
527
+ desc = input("Description: ").strip()
528
+
529
+ # Create pose from current servo positions
530
+ pose = Pose(name=name, description=desc)
531
+
532
+ for servo_id in self.calibrator.calibration.servos:
533
+ pos = self.calibrator.read_servo(servo_id)
534
+ if pos is not None:
535
+ pose.servo_positions[servo_id] = pos
536
+
537
+ # Capture images
538
+ ext_path, robot_path = self.capture_dual_images(f"pose_{name}")
539
+ pose.image_path = ext_path
540
+
541
+ # Add to calibration
542
+ self.calibrator.calibration.add_pose(pose)
543
+
544
+ print(f"Recorded pose '{name}' with {len(pose.servo_positions)} servos")
545
+ return pose
546
+
547
+ def create_action_interactive(self) -> Optional[Action]:
548
+ """
549
+ Create an action by sequencing poses.
550
+
551
+ User selects poses and timing to create a multi-step action.
552
+ """
553
+ print("\n=== Create Action ===")
554
+
555
+ # Show available poses
556
+ poses = list(self.calibrator.calibration.poses.keys())
557
+ if not poses:
558
+ print("No poses recorded. Record some poses first.")
559
+ return None
560
+
561
+ print(f"Available poses: {poses}")
562
+
563
+ name = input("Action name: ").strip()
564
+ if not name:
565
+ print("Cancelled")
566
+ return None
567
+
568
+ desc = input("Description: ").strip()
569
+
570
+ # Select action type
571
+ print(f"Action types: {[at.value for at in ActionType]}")
572
+ action_type_str = input("Action type [composite]: ").strip() or "composite"
573
+ try:
574
+ action_type = ActionType(action_type_str)
575
+ except ValueError:
576
+ action_type = ActionType.COMPOSITE
577
+
578
+ action = Action(
579
+ name=name,
580
+ action_type=action_type,
581
+ description=desc,
582
+ created_at=datetime.now().isoformat(),
583
+ )
584
+
585
+ print("\nAdd steps (pose names). Type 'done' when finished.")
586
+ print("Format: pose_name [duration_ms] [wait_after_ms]")
587
+
588
+ while True:
589
+ step_input = input(f"Step {len(action.steps) + 1}: ").strip()
590
+
591
+ if step_input.lower() in ('done', 'q', ''):
592
+ break
593
+
594
+ parts = step_input.split()
595
+ pose_name = parts[0]
596
+
597
+ if pose_name not in poses:
598
+ print(f"Unknown pose: {pose_name}")
599
+ continue
600
+
601
+ duration = int(parts[1]) if len(parts) > 1 else 500
602
+ wait = int(parts[2]) if len(parts) > 2 else 0
603
+
604
+ action.add_step(pose_name, duration, wait)
605
+ print(f" Added: {pose_name} ({duration}ms, wait {wait}ms)")
606
+
607
+ if not action.steps:
608
+ print("No steps added, cancelled")
609
+ return None
610
+
611
+ # Preview action
612
+ preview = input("Preview action? [Y/n]: ").strip().lower()
613
+ if preview != 'n':
614
+ self.execute_action(action)
615
+
616
+ # Save
617
+ self.library.actions[name] = action
618
+ print(f"Created action '{name}' with {len(action.steps)} steps")
619
+
620
+ return action
621
+
622
+ def execute_action(self, action: Action):
623
+ """Execute an action by applying its pose sequence."""
624
+ print(f"\nExecuting action: {action.name}")
625
+
626
+ for i, step in enumerate(action.steps):
627
+ pose = self.calibrator.calibration.get_pose(step.pose_name)
628
+ if not pose:
629
+ print(f" Warning: Pose '{step.pose_name}' not found")
630
+ continue
631
+
632
+ print(f" Step {i+1}: {step.pose_name}")
633
+
634
+ # Apply pose
635
+ for servo_id, value in pose.servo_positions.items():
636
+ self.calibrator.set_servo(servo_id, value, step.duration_ms)
637
+
638
+ # Wait for movement to complete
639
+ time.sleep(step.duration_ms / 1000 + 0.1)
640
+
641
+ # Additional wait if specified
642
+ if step.wait_after_ms > 0:
643
+ time.sleep(step.wait_after_ms / 1000)
644
+
645
+ print("Action complete")
646
+
647
+ def generate_skill_code(self, action: Action) -> str:
648
+ """
649
+ Generate Python skill code from an action.
650
+
651
+ Creates a function that can be used as a reusable skill.
652
+ """
653
+ func_name = action.name.lower().replace(" ", "_").replace("-", "_")
654
+
655
+ code = f'''"""
656
+ Auto-generated skill: {action.name}
657
+ Type: {action.action_type.value}
658
+ Description: {action.description}
659
+ Generated: {datetime.now().isoformat()}
660
+ """
661
+
662
+ from typing import Optional
663
+ from ate.interfaces import RobotInterface
664
+
665
+
666
+ def {func_name}(robot: RobotInterface, speed_factor: float = 1.0) -> bool:
667
+ """
668
+ {action.description or action.name}
669
+
670
+ Args:
671
+ robot: Robot interface with set_servo method
672
+ speed_factor: Multiplier for movement speed (1.0 = normal)
673
+
674
+ Returns:
675
+ True if action completed successfully
676
+ """
677
+ import time
678
+
679
+ try:
680
+ '''
681
+
682
+ for i, step in enumerate(action.steps):
683
+ pose = self.calibrator.calibration.get_pose(step.pose_name)
684
+ if not pose:
685
+ continue
686
+
687
+ code += f'''
688
+ # Step {i+1}: {step.pose_name}
689
+ '''
690
+ for servo_id, value in pose.servo_positions.items():
691
+ servo_name = self.calibrator.calibration.servos.get(servo_id)
692
+ name_comment = f" # {servo_name.name}" if servo_name else ""
693
+ code += f''' robot.set_servo({servo_id}, {value}, int({step.duration_ms} / speed_factor)){name_comment}
694
+ '''
695
+
696
+ code += f''' time.sleep({step.duration_ms / 1000} / speed_factor)
697
+ '''
698
+
699
+ if step.wait_after_ms > 0:
700
+ code += f''' time.sleep({step.wait_after_ms / 1000})
701
+ '''
702
+
703
+ code += '''
704
+ return True
705
+
706
+ except Exception as e:
707
+ print(f"Skill failed: {e}")
708
+ return False
709
+ '''
710
+
711
+ return code
712
+
713
+ def run_interactive_session(self):
714
+ """
715
+ Run a full interactive labeling session.
716
+
717
+ Main workflow:
718
+ 1. Discover and label servos
719
+ 2. Define joint groups
720
+ 3. Record poses
721
+ 4. Create actions
722
+ 5. Generate skills
723
+ """
724
+ print("\n" + "=" * 60)
725
+ print(" FoodforThought Visual Skill Labeler")
726
+ print("=" * 60)
727
+ print(f"\nRobot: {self.robot_name} ({self.robot_model})")
728
+ print(f"Serial: {self.serial_port}")
729
+ print(f"Robot Camera: {self.robot_camera_url or 'None'}")
730
+ print(f"Webcam: Device {self.webcam.device_id}")
731
+
732
+ if not self.connect():
733
+ print("Failed to connect!")
734
+ return
735
+
736
+ try:
737
+ self._main_menu()
738
+ finally:
739
+ self.disconnect()
740
+
741
+ def _main_menu(self):
742
+ """Main interactive menu."""
743
+ while True:
744
+ print("\n" + "-" * 40)
745
+ print("Main Menu:")
746
+ print(" 1. Discover & Label Servos")
747
+ print(" 2. Define Joint Groups")
748
+ print(" 3. Record Poses")
749
+ print(" 4. Create Actions")
750
+ print(" 5. Generate Skills")
751
+ print(" 6. Execute Action")
752
+ print(" 7. Save Library")
753
+ print(" 8. Quick Servo Test")
754
+ print(" q. Quit")
755
+ print("-" * 40)
756
+
757
+ choice = input("Choice: ").strip().lower()
758
+
759
+ if choice == '1':
760
+ self._servo_discovery_flow()
761
+ elif choice == '2':
762
+ self._joint_groups_flow()
763
+ elif choice == '3':
764
+ self._pose_recording_flow()
765
+ elif choice == '4':
766
+ self._action_creation_flow()
767
+ elif choice == '5':
768
+ self._skill_generation_flow()
769
+ elif choice == '6':
770
+ self._execute_action_flow()
771
+ elif choice == '7':
772
+ self._save_library()
773
+ elif choice == '8':
774
+ self._quick_servo_test()
775
+ elif choice in ('q', 'quit', 'exit'):
776
+ break
777
+
778
+ def _servo_discovery_flow(self):
779
+ """Discover and label all servos."""
780
+ print("\n=== Servo Discovery ===")
781
+
782
+ # Auto-discover
783
+ active_servos = self.calibrator.discover_servos()
784
+
785
+ if not active_servos:
786
+ print("No servos found!")
787
+ return
788
+
789
+ print(f"\nFound {len(active_servos)} servos: {active_servos}")
790
+ print("\nLabel each servo by moving it and observing the effect.")
791
+
792
+ for servo_id in active_servos:
793
+ label = input(f"\nLabel servo {servo_id}? [y/n/skip all]: ").strip().lower()
794
+ if label == 'skip all':
795
+ break
796
+ if label != 'y':
797
+ continue
798
+
799
+ cal = self.label_servo_interactive(servo_id)
800
+ self.calibrator.calibration.servos[servo_id] = cal
801
+ self.library.servo_labels[servo_id] = cal.name
802
+
803
+ print(f"Servo {servo_id} labeled as '{cal.name}'")
804
+
805
+ def _joint_groups_flow(self):
806
+ """Define joint groups (e.g., left_leg, right_arm)."""
807
+ print("\n=== Joint Groups ===")
808
+ print("Group servos by function (e.g., 'left_front_leg': [1, 2, 3])")
809
+
810
+ servos = list(self.calibrator.calibration.servos.keys())
811
+ if not servos:
812
+ print("No servos discovered yet. Run servo discovery first.")
813
+ return
814
+
815
+ print(f"Available servos: {servos}")
816
+ for sid, cal in self.calibrator.calibration.servos.items():
817
+ print(f" {sid}: {cal.name}")
818
+
819
+ while True:
820
+ group_name = input("\nGroup name (or 'done'): ").strip()
821
+ if group_name.lower() == 'done':
822
+ break
823
+
824
+ servo_ids = input("Servo IDs (comma-separated): ").strip()
825
+ try:
826
+ ids = [int(s.strip()) for s in servo_ids.split(",")]
827
+ self.library.joint_groups[group_name] = ids
828
+ print(f"Created group '{group_name}': {ids}")
829
+ except ValueError:
830
+ print("Invalid input. Use: 1, 2, 3")
831
+
832
+ def _pose_recording_flow(self):
833
+ """Record multiple poses."""
834
+ print("\n=== Pose Recording ===")
835
+ print("Record named poses for the robot.")
836
+ print("Commands: record, list, delete, test, done")
837
+
838
+ while True:
839
+ cmd = input("\nPose> ").strip().lower()
840
+
841
+ if cmd == 'record' or cmd == 'r':
842
+ self.record_pose_interactive()
843
+
844
+ elif cmd == 'list' or cmd == 'l':
845
+ poses = self.calibrator.calibration.poses
846
+ if poses:
847
+ for name, pose in poses.items():
848
+ print(f" {name}: {len(pose.servo_positions)} servos")
849
+ else:
850
+ print("No poses recorded")
851
+
852
+ elif cmd.startswith('delete '):
853
+ name = cmd[7:].strip()
854
+ if name in self.calibrator.calibration.poses:
855
+ del self.calibrator.calibration.poses[name]
856
+ print(f"Deleted pose '{name}'")
857
+ else:
858
+ print(f"Pose not found: {name}")
859
+
860
+ elif cmd.startswith('test '):
861
+ name = cmd[5:].strip()
862
+ pose = self.calibrator.calibration.get_pose(name)
863
+ if pose:
864
+ print(f"Applying pose '{name}'...")
865
+ self.calibrator.apply_pose(pose)
866
+ else:
867
+ print(f"Pose not found: {name}")
868
+
869
+ elif cmd in ('done', 'q'):
870
+ break
871
+
872
+ def _action_creation_flow(self):
873
+ """Create actions from poses."""
874
+ print("\n=== Action Creation ===")
875
+ print("Create multi-step actions from recorded poses.")
876
+ print("Commands: create, list, preview, delete, done")
877
+
878
+ while True:
879
+ cmd = input("\nAction> ").strip().lower()
880
+
881
+ if cmd == 'create' or cmd == 'c':
882
+ self.create_action_interactive()
883
+
884
+ elif cmd == 'list' or cmd == 'l':
885
+ if self.library.actions:
886
+ for name, action in self.library.actions.items():
887
+ print(f" {name}: {len(action.steps)} steps ({action.action_type.value})")
888
+ else:
889
+ print("No actions created")
890
+
891
+ elif cmd.startswith('preview '):
892
+ name = cmd[8:].strip()
893
+ action = self.library.actions.get(name)
894
+ if action:
895
+ self.execute_action(action)
896
+ else:
897
+ print(f"Action not found: {name}")
898
+
899
+ elif cmd.startswith('delete '):
900
+ name = cmd[7:].strip()
901
+ if name in self.library.actions:
902
+ del self.library.actions[name]
903
+ print(f"Deleted action '{name}'")
904
+ else:
905
+ print(f"Action not found: {name}")
906
+
907
+ elif cmd in ('done', 'q'):
908
+ break
909
+
910
+ def _skill_generation_flow(self):
911
+ """Generate Python skill code."""
912
+ print("\n=== Skill Generation ===")
913
+
914
+ if not self.library.actions:
915
+ print("No actions to generate skills from.")
916
+ return
917
+
918
+ print("Available actions:")
919
+ for name in self.library.actions:
920
+ print(f" {name}")
921
+
922
+ action_name = input("\nAction to generate (or 'all'): ").strip()
923
+
924
+ skills_dir = Path.home() / ".ate" / "skills" / self.robot_name
925
+ skills_dir.mkdir(parents=True, exist_ok=True)
926
+
927
+ if action_name == 'all':
928
+ for name, action in self.library.actions.items():
929
+ code = self.generate_skill_code(action)
930
+ path = skills_dir / f"{name}.py"
931
+ with open(path, 'w') as f:
932
+ f.write(code)
933
+ print(f"Generated: {path}")
934
+ else:
935
+ action = self.library.actions.get(action_name)
936
+ if action:
937
+ code = self.generate_skill_code(action)
938
+ path = skills_dir / f"{action_name}.py"
939
+ with open(path, 'w') as f:
940
+ f.write(code)
941
+ print(f"Generated: {path}")
942
+ print("\n--- Generated Code ---")
943
+ print(code)
944
+ else:
945
+ print(f"Action not found: {action_name}")
946
+
947
+ def _execute_action_flow(self):
948
+ """Execute a saved action."""
949
+ if not self.library.actions:
950
+ print("No actions available.")
951
+ return
952
+
953
+ print("Available actions:")
954
+ for name in self.library.actions:
955
+ print(f" {name}")
956
+
957
+ name = input("Action to execute: ").strip()
958
+ action = self.library.actions.get(name)
959
+
960
+ if action:
961
+ self.execute_action(action)
962
+ else:
963
+ print(f"Action not found: {name}")
964
+
965
+ def _save_library(self):
966
+ """Save skill library and calibration."""
967
+ # Save calibration
968
+ save_calibration(self.calibrator.calibration)
969
+
970
+ # Save skill library
971
+ lib_path = Path.home() / ".ate" / "skill_libraries" / f"{self.robot_name}.json"
972
+ self.library.calibration = self.calibrator.calibration
973
+ self.library.save(lib_path)
974
+
975
+ print(f"Saved calibration: ~/.ate/calibrations/{self.robot_name}.json")
976
+ print(f"Saved skill library: {lib_path}")
977
+
978
+ def _quick_servo_test(self):
979
+ """Quick servo test without full labeling."""
980
+ print("\n=== Quick Servo Test ===")
981
+ print("Commands: <servo_id> <value> (e.g., '11 2500'), or 'done'")
982
+
983
+ while True:
984
+ cmd = input("Test> ").strip()
985
+ if cmd.lower() in ('done', 'q'):
986
+ break
987
+
988
+ try:
989
+ parts = cmd.split()
990
+ servo_id = int(parts[0])
991
+ value = int(parts[1]) if len(parts) > 1 else 1500
992
+
993
+ print(f"Moving servo {servo_id} to {value}...")
994
+ self.calibrator.set_servo(servo_id, value, 500)
995
+ self.webcam.show_preview("Servo Test")
996
+
997
+ except (ValueError, IndexError):
998
+ print("Format: <servo_id> <value> (e.g., '11 2500')")
999
+
1000
+
1001
+ # =============================================================================
1002
+ # CLI Integration
1003
+ # =============================================================================
1004
+
1005
+ def visual_label_command(
1006
+ port: str,
1007
+ name: str = "robot",
1008
+ robot_type: str = "unknown",
1009
+ webcam_id: int = 0,
1010
+ camera_url: Optional[str] = None,
1011
+ ):
1012
+ """Run the visual labeling session."""
1013
+ labeler = DualCameraLabeler(
1014
+ serial_port=port,
1015
+ robot_name=name,
1016
+ robot_model=robot_type,
1017
+ webcam_id=webcam_id,
1018
+ robot_camera_url=camera_url,
1019
+ )
1020
+
1021
+ labeler.run_interactive_session()
1022
+
1023
+
1024
+ def load_skill_library(robot_name: str) -> Optional[SkillLibrary]:
1025
+ """Load a saved skill library."""
1026
+ lib_path = Path.home() / ".ate" / "skill_libraries" / f"{robot_name}.json"
1027
+ cal = load_calibration(robot_name)
1028
+
1029
+ if lib_path.exists():
1030
+ return SkillLibrary.load(lib_path, cal)
1031
+ return None
1032
+
1033
+
1034
+ def list_skill_libraries() -> List[str]:
1035
+ """List available skill libraries."""
1036
+ lib_dir = Path.home() / ".ate" / "skill_libraries"
1037
+ if not lib_dir.exists():
1038
+ return []
1039
+ return [p.stem for p in lib_dir.glob("*.json")]