foodforthought-cli 0.2.1__py3-none-any.whl → 0.2.3__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 (46) hide show
  1. ate/__init__.py +1 -1
  2. ate/bridge_server.py +622 -0
  3. ate/cli.py +2625 -242
  4. ate/compatibility.py +580 -0
  5. ate/generators/__init__.py +19 -0
  6. ate/generators/docker_generator.py +461 -0
  7. ate/generators/hardware_config.py +469 -0
  8. ate/generators/ros2_generator.py +617 -0
  9. ate/generators/skill_generator.py +783 -0
  10. ate/marketplace.py +524 -0
  11. ate/mcp_server.py +1341 -107
  12. ate/primitives.py +1016 -0
  13. ate/robot_setup.py +2222 -0
  14. ate/skill_schema.py +537 -0
  15. ate/telemetry/__init__.py +33 -0
  16. ate/telemetry/cli.py +455 -0
  17. ate/telemetry/collector.py +444 -0
  18. ate/telemetry/context.py +318 -0
  19. ate/telemetry/fleet_agent.py +419 -0
  20. ate/telemetry/formats/__init__.py +18 -0
  21. ate/telemetry/formats/hdf5_serializer.py +503 -0
  22. ate/telemetry/formats/mcap_serializer.py +457 -0
  23. ate/telemetry/types.py +334 -0
  24. foodforthought_cli-0.2.3.dist-info/METADATA +300 -0
  25. foodforthought_cli-0.2.3.dist-info/RECORD +44 -0
  26. foodforthought_cli-0.2.3.dist-info/top_level.txt +6 -0
  27. mechdog_labeled/__init__.py +3 -0
  28. mechdog_labeled/primitives.py +113 -0
  29. mechdog_labeled/servo_map.py +209 -0
  30. mechdog_output/__init__.py +3 -0
  31. mechdog_output/primitives.py +59 -0
  32. mechdog_output/servo_map.py +203 -0
  33. test_autodetect/__init__.py +3 -0
  34. test_autodetect/primitives.py +113 -0
  35. test_autodetect/servo_map.py +209 -0
  36. test_full_auto/__init__.py +3 -0
  37. test_full_auto/primitives.py +113 -0
  38. test_full_auto/servo_map.py +209 -0
  39. test_smart_detect/__init__.py +3 -0
  40. test_smart_detect/primitives.py +113 -0
  41. test_smart_detect/servo_map.py +209 -0
  42. foodforthought_cli-0.2.1.dist-info/METADATA +0 -151
  43. foodforthought_cli-0.2.1.dist-info/RECORD +0 -9
  44. foodforthought_cli-0.2.1.dist-info/top_level.txt +0 -1
  45. {foodforthought_cli-0.2.1.dist-info → foodforthought_cli-0.2.3.dist-info}/WHEEL +0 -0
  46. {foodforthought_cli-0.2.1.dist-info → foodforthought_cli-0.2.3.dist-info}/entry_points.txt +0 -0
ate/robot_setup.py ADDED
@@ -0,0 +1,2222 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Robot Setup Wizard - Automated discovery and primitive skill generation for any robot.
4
+
5
+ This wizard:
6
+ 1. Discovers connected USB/serial devices
7
+ 2. Enumerates servos/motors on each device
8
+ 3. Characterizes each component's parameters
9
+ 4. Guides interactive labeling with AI assistance
10
+ 5. Generates primitive skills and protocol definitions
11
+
12
+ Usage:
13
+ ate robot-setup # Interactive wizard
14
+ ate robot-setup --port /dev/tty... # Skip device selection
15
+ ate robot-setup --output ./robot # Specify output directory
16
+ """
17
+
18
+ import json
19
+ import os
20
+ import sys
21
+ import time
22
+ import struct
23
+ from dataclasses import dataclass, field, asdict
24
+ from enum import Enum
25
+ from pathlib import Path
26
+ from typing import List, Dict, Optional, Any, Tuple, Callable
27
+ from datetime import datetime
28
+
29
+ # Optional imports with fallbacks
30
+ try:
31
+ import serial
32
+ import serial.tools.list_ports
33
+ HAS_SERIAL = True
34
+ except ImportError:
35
+ HAS_SERIAL = False
36
+
37
+ try:
38
+ import anthropic
39
+ HAS_ANTHROPIC = True
40
+ except ImportError:
41
+ HAS_ANTHROPIC = False
42
+
43
+
44
+ class DeviceType(str, Enum):
45
+ """Types of robot communication devices."""
46
+ SERIAL = "serial"
47
+ USB_HID = "usb_hid"
48
+ BLUETOOTH = "bluetooth"
49
+ WIFI = "wifi"
50
+ UNKNOWN = "unknown"
51
+
52
+
53
+ class ActuatorType(str, Enum):
54
+ """Types of actuators."""
55
+ SERIAL_BUS_SERVO = "serial_bus_servo"
56
+ PWM_SERVO = "pwm_servo"
57
+ STEPPER_MOTOR = "stepper_motor"
58
+ DC_MOTOR = "dc_motor"
59
+ LINEAR_ACTUATOR = "linear_actuator"
60
+ UNKNOWN = "unknown"
61
+
62
+
63
+ class ProtocolType(str, Enum):
64
+ """Known servo protocols."""
65
+ HIWONDER = "hiwonder" # HiWonder/LewanSoul serial bus servos
66
+ DYNAMIXEL = "dynamixel" # Dynamixel servos (Robotis)
67
+ FEETECH = "feetech" # Feetech/SCS servos
68
+ WAVESHARE = "waveshare" # Waveshare serial servos
69
+ CUSTOM = "custom"
70
+ UNKNOWN = "unknown"
71
+
72
+
73
+ @dataclass
74
+ class DiscoveredDevice:
75
+ """A discovered communication device."""
76
+ port: str
77
+ device_type: DeviceType
78
+ description: str = ""
79
+ vid: Optional[int] = None
80
+ pid: Optional[int] = None
81
+ serial_number: Optional[str] = None
82
+ manufacturer: Optional[str] = None
83
+ detected_protocol: Optional[ProtocolType] = None
84
+ baud_rate: int = 115200
85
+
86
+
87
+ @dataclass
88
+ class ActuatorCharacteristics:
89
+ """Characteristics of an actuator determined through testing."""
90
+ position_min: int = 0
91
+ position_max: int = 1000
92
+ position_center: int = 500
93
+ speed_min: int = 0
94
+ speed_max: int = 2000
95
+ supports_torque_control: bool = False
96
+ supports_position_read: bool = True
97
+ supports_voltage_read: bool = False
98
+ supports_temperature_read: bool = False
99
+ response_time_ms: float = 0.0
100
+
101
+
102
+ @dataclass
103
+ class DiscoveredActuator:
104
+ """A discovered actuator (servo, motor, etc.)."""
105
+ id: int
106
+ actuator_type: ActuatorType
107
+ protocol: ProtocolType
108
+ characteristics: ActuatorCharacteristics = field(default_factory=ActuatorCharacteristics)
109
+
110
+ # User-assigned labels
111
+ label: str = "" # e.g., "front_left_hip", "arm_shoulder"
112
+ group: str = "" # e.g., "left_front_leg", "arm"
113
+ role: str = "" # e.g., "hip", "knee", "ankle", "shoulder"
114
+
115
+ # Test results
116
+ verified_working: bool = False
117
+ notes: str = ""
118
+
119
+
120
+ @dataclass
121
+ class RobotProfile:
122
+ """Complete profile of a discovered robot."""
123
+ name: str = "unnamed_robot"
124
+ robot_type: str = "" # e.g., "quadruped", "humanoid", "arm"
125
+ manufacturer: str = ""
126
+ model: str = ""
127
+
128
+ # Communication
129
+ device: Optional[DiscoveredDevice] = None
130
+
131
+ # Actuators
132
+ actuators: List[DiscoveredActuator] = field(default_factory=list)
133
+
134
+ # Groups (legs, arms, etc.)
135
+ groups: Dict[str, List[int]] = field(default_factory=dict) # group_name -> [actuator_ids]
136
+
137
+ # Metadata
138
+ created_at: str = field(default_factory=lambda: datetime.now().isoformat())
139
+ wizard_version: str = "1.0.0"
140
+
141
+
142
+ class WizardUI:
143
+ """Console UI helpers for the wizard."""
144
+
145
+ COLORS = {
146
+ 'reset': '\033[0m',
147
+ 'bold': '\033[1m',
148
+ 'red': '\033[91m',
149
+ 'green': '\033[92m',
150
+ 'yellow': '\033[93m',
151
+ 'blue': '\033[94m',
152
+ 'magenta': '\033[95m',
153
+ 'cyan': '\033[96m',
154
+ }
155
+
156
+ @classmethod
157
+ def print_header(cls, text: str):
158
+ """Print a section header."""
159
+ print(f"\n{cls.COLORS['bold']}{cls.COLORS['cyan']}{'='*60}{cls.COLORS['reset']}")
160
+ print(f"{cls.COLORS['bold']}{cls.COLORS['cyan']} {text}{cls.COLORS['reset']}")
161
+ print(f"{cls.COLORS['bold']}{cls.COLORS['cyan']}{'='*60}{cls.COLORS['reset']}\n")
162
+
163
+ @classmethod
164
+ def print_step(cls, step: int, total: int, text: str):
165
+ """Print a wizard step indicator."""
166
+ print(f"\n{cls.COLORS['magenta']}[Step {step}/{total}]{cls.COLORS['reset']} {cls.COLORS['bold']}{text}{cls.COLORS['reset']}")
167
+
168
+ @classmethod
169
+ def print_success(cls, text: str):
170
+ """Print success message."""
171
+ print(f"{cls.COLORS['green']}✓ {text}{cls.COLORS['reset']}")
172
+
173
+ @classmethod
174
+ def print_warning(cls, text: str):
175
+ """Print warning message."""
176
+ print(f"{cls.COLORS['yellow']}⚠ {text}{cls.COLORS['reset']}")
177
+
178
+ @classmethod
179
+ def print_error(cls, text: str):
180
+ """Print error message."""
181
+ print(f"{cls.COLORS['red']}✗ {text}{cls.COLORS['reset']}")
182
+
183
+ @classmethod
184
+ def print_info(cls, text: str):
185
+ """Print info message."""
186
+ print(f"{cls.COLORS['blue']}ℹ {text}{cls.COLORS['reset']}")
187
+
188
+ @classmethod
189
+ def prompt(cls, text: str, default: str = "") -> str:
190
+ """Prompt user for input."""
191
+ if default:
192
+ result = input(f"{text} [{default}]: ").strip()
193
+ return result if result else default
194
+ return input(f"{text}: ").strip()
195
+
196
+ @classmethod
197
+ def confirm(cls, text: str, default: bool = True) -> bool:
198
+ """Prompt for yes/no confirmation."""
199
+ suffix = "[Y/n]" if default else "[y/N]"
200
+ result = input(f"{text} {suffix}: ").strip().lower()
201
+ if not result:
202
+ return default
203
+ return result in ('y', 'yes')
204
+
205
+ @classmethod
206
+ def select(cls, text: str, options: List[str], allow_multiple: bool = False) -> List[int]:
207
+ """Prompt user to select from options."""
208
+ print(f"\n{text}")
209
+ for i, opt in enumerate(options, 1):
210
+ print(f" {i}. {opt}")
211
+
212
+ if allow_multiple:
213
+ print(f"\n Enter numbers separated by commas (e.g., 1,3,5) or 'all':")
214
+
215
+ while True:
216
+ selection = input(" Selection: ").strip()
217
+
218
+ if allow_multiple and selection.lower() == 'all':
219
+ return list(range(len(options)))
220
+
221
+ try:
222
+ if allow_multiple:
223
+ indices = [int(x.strip()) - 1 for x in selection.split(',')]
224
+ else:
225
+ indices = [int(selection) - 1]
226
+
227
+ if all(0 <= i < len(options) for i in indices):
228
+ return indices
229
+ except ValueError:
230
+ pass
231
+
232
+ print(f" {cls.COLORS['red']}Invalid selection. Please try again.{cls.COLORS['reset']}")
233
+
234
+
235
+ class ServoProtocolHandler:
236
+ """Handles communication with serial bus servos."""
237
+
238
+ def __init__(self, port: str, baud_rate: int = 115200, protocol: ProtocolType = ProtocolType.HIWONDER):
239
+ self.port = port
240
+ self.baud_rate = baud_rate
241
+ self.protocol = protocol
242
+ self.serial: Optional[serial.Serial] = None
243
+
244
+ def connect(self) -> bool:
245
+ """Open serial connection."""
246
+ if not HAS_SERIAL:
247
+ WizardUI.print_error("pyserial not installed. Run: pip install pyserial")
248
+ return False
249
+
250
+ try:
251
+ self.serial = serial.Serial(self.port, self.baud_rate, timeout=0.1)
252
+ time.sleep(0.1) # Let it stabilize
253
+ return True
254
+ except Exception as e:
255
+ WizardUI.print_error(f"Failed to open {self.port}: {e}")
256
+ return False
257
+
258
+ def disconnect(self):
259
+ """Close serial connection."""
260
+ if self.serial:
261
+ self.serial.close()
262
+ self.serial = None
263
+
264
+ def _send_command(self, servo_id: int, cmd: int, params: bytes = b'') -> Optional[bytes]:
265
+ """Send a command and receive response (HiWonder protocol)."""
266
+ if not self.serial:
267
+ return None
268
+
269
+ # HiWonder packet: 0x55 0x55 ID LEN CMD [PARAMS...] CHECKSUM
270
+ length = len(params) + 3 # cmd + params + checksum
271
+ packet = bytes([0x55, 0x55, servo_id, length, cmd]) + params
272
+ checksum = (~sum(packet[2:]) & 0xFF)
273
+ packet += bytes([checksum])
274
+
275
+ try:
276
+ self.serial.reset_input_buffer()
277
+ self.serial.write(packet)
278
+ self.serial.flush()
279
+
280
+ # Read response header
281
+ time.sleep(0.01)
282
+ header = self.serial.read(5)
283
+ if len(header) < 5:
284
+ return None
285
+
286
+ if header[0:2] != b'\x55\x55':
287
+ return None
288
+
289
+ resp_len = header[3]
290
+ remaining = self.serial.read(resp_len - 2) # -2 because length includes cmd+checksum
291
+
292
+ return header + remaining
293
+ except Exception:
294
+ return None
295
+
296
+ def ping(self, servo_id: int) -> bool:
297
+ """Check if a servo responds at given ID."""
298
+ if self.protocol == ProtocolType.HIWONDER:
299
+ # Read position command (14)
300
+ response = self._send_command(servo_id, 14)
301
+ return response is not None and len(response) >= 7
302
+ return False
303
+
304
+ def read_position(self, servo_id: int) -> Optional[int]:
305
+ """Read current position of servo."""
306
+ if self.protocol == ProtocolType.HIWONDER:
307
+ response = self._send_command(servo_id, 28) # Read position
308
+ if response and len(response) >= 8:
309
+ pos_low = response[5]
310
+ pos_high = response[6]
311
+ return pos_low | (pos_high << 8)
312
+ return None
313
+
314
+ def move_servo(self, servo_id: int, position: int, time_ms: int = 500):
315
+ """Move servo to position."""
316
+ if self.protocol == ProtocolType.HIWONDER:
317
+ # Move command (1): ID, time_low, time_high, pos_low, pos_high
318
+ params = struct.pack('<HH', time_ms, position)
319
+ self._send_command(servo_id, 1, params)
320
+
321
+ def scan_servos(self, id_range: range = range(0, 32), progress_callback: Optional[Callable[[int, int], None]] = None) -> List[int]:
322
+ """Scan for servos in ID range."""
323
+ found = []
324
+ total = len(id_range)
325
+
326
+ for i, servo_id in enumerate(id_range):
327
+ if progress_callback:
328
+ progress_callback(i + 1, total)
329
+
330
+ if self.ping(servo_id):
331
+ found.append(servo_id)
332
+
333
+ return found
334
+
335
+ def characterize_servo(self, servo_id: int) -> ActuatorCharacteristics:
336
+ """Determine servo characteristics through safe testing."""
337
+ chars = ActuatorCharacteristics()
338
+
339
+ # Try to read current position
340
+ pos = self.read_position(servo_id)
341
+ if pos is not None:
342
+ chars.supports_position_read = True
343
+ chars.position_center = pos
344
+
345
+ # HiWonder servos typically have 0-1000 range
346
+ if self.protocol == ProtocolType.HIWONDER:
347
+ chars.position_min = 0
348
+ chars.position_max = 1000
349
+ chars.position_center = 500
350
+ chars.speed_min = 0
351
+ chars.speed_max = 30000 # time in ms (lower = faster)
352
+
353
+ return chars
354
+
355
+
356
+ class AILabelingAssistant:
357
+ """AI-powered assistant for labeling robot components."""
358
+
359
+ def __init__(self, api_key: Optional[str] = None):
360
+ self.api_key = api_key or os.getenv("ANTHROPIC_API_KEY")
361
+ self.client = None
362
+
363
+ if HAS_ANTHROPIC and self.api_key:
364
+ self.client = anthropic.Anthropic(api_key=self.api_key)
365
+
366
+ def suggest_robot_type(self, num_servos: int, servo_ids: List[int]) -> str:
367
+ """Suggest robot type based on servo count and ID distribution.
368
+
369
+ Args:
370
+ num_servos: Total number of servos found
371
+ servo_ids: List of servo IDs (used to detect core vs peripheral servos)
372
+ """
373
+ # Analyze servo ID distribution to find "core" actuators
374
+ # Many robots have main actuators in low IDs (0-15) and peripherals in higher IDs
375
+ core_servo_count = self._count_core_servos(servo_ids)
376
+
377
+ # Use core count for detection if it differs significantly from total
378
+ effective_count = core_servo_count if core_servo_count < num_servos * 0.7 else num_servos
379
+
380
+ if not self.client:
381
+ # Rule-based detection
382
+ return self._detect_robot_type_by_count(effective_count, num_servos)
383
+
384
+ try:
385
+ response = self.client.messages.create(
386
+ model="claude-3-haiku-20240307",
387
+ max_tokens=150,
388
+ messages=[{
389
+ "role": "user",
390
+ "content": f"""Detected {num_servos} servos total, but analysis suggests {effective_count} are main actuators.
391
+ Servo IDs found: {sorted(servo_ids)[:20]}{'...' if len(servo_ids) > 20 else ''}
392
+
393
+ What type of robot is this likely to be?
394
+
395
+ Common patterns:
396
+ - 12 servos: quadruped (4 legs × 3 joints)
397
+ - 13-15 servos: quadruped_with_arm (4 legs + arm)
398
+ - 18 servos: hexapod (6 legs × 3 joints)
399
+ - 6 servos: 6dof_arm
400
+ - 16-17 servos: humanoid_basic (arms + legs only)
401
+ - 18-20 servos: humanoid (arms + legs + some torso)
402
+ - 21-24 servos: humanoid_advanced (full body)
403
+ - 25+ servos: humanoid_full (with dexterous hands)
404
+
405
+ Reply with just the robot type from the list above."""
406
+ }]
407
+ )
408
+ return response.content[0].text.strip().lower()
409
+ except Exception:
410
+ return self._detect_robot_type_by_count(effective_count, num_servos)
411
+
412
+ def _count_core_servos(self, servo_ids: List[int]) -> int:
413
+ """Count core servos by analyzing ID distribution.
414
+
415
+ Heuristics:
416
+ - IDs 0-15 are typically main actuators
417
+ - Look for gaps in ID sequence as boundaries
418
+ - Contiguous low IDs are more likely to be core actuators
419
+ """
420
+ if not servo_ids:
421
+ return 0
422
+
423
+ sorted_ids = sorted(servo_ids)
424
+
425
+ # Count servos in the "core" range (0-15)
426
+ core_range_count = sum(1 for id in sorted_ids if id <= 15)
427
+
428
+ # Also look for the first significant gap (gap > 1)
429
+ last_id = -1
430
+ contiguous_count = 0
431
+ for id in sorted_ids:
432
+ if last_id >= 0 and id - last_id > 1:
433
+ # Found a gap - servos before this are likely core
434
+ break
435
+ contiguous_count += 1
436
+ last_id = id
437
+
438
+ # Use the more conservative estimate
439
+ return min(core_range_count, contiguous_count) if contiguous_count > 0 else core_range_count
440
+
441
+ def _detect_robot_type_by_count(self, effective_count: int, total_count: int) -> str:
442
+ """Detect robot type based on servo count."""
443
+ # Show both counts in suggestions if they differ
444
+ count = effective_count
445
+
446
+ if count == 12:
447
+ return "quadruped"
448
+ elif count in (13, 14, 15):
449
+ return "quadruped_with_arm" # 12 leg + 1-3 arm servos
450
+ elif count == 6:
451
+ return "6dof_arm"
452
+ elif count == 18:
453
+ return "hexapod"
454
+ elif count in (16, 17):
455
+ return "humanoid_basic"
456
+ elif count in (18, 19, 20):
457
+ return "humanoid"
458
+ elif count in (21, 22, 23, 24):
459
+ return "humanoid_advanced"
460
+ elif count >= 25:
461
+ return "humanoid_full"
462
+ elif count <= 4:
463
+ return "robotic_arm"
464
+ return "custom"
465
+
466
+ def suggest_labels(self, robot_type: str, servo_ids: List[int]) -> Dict[int, Dict[str, str]]:
467
+ """Suggest labels for servos based on robot type."""
468
+ labels = {}
469
+
470
+ if robot_type == "quadruped":
471
+ # Standard quadruped mapping
472
+ leg_names = ["front_right", "front_left", "back_right", "back_left"]
473
+ joint_names = ["hip", "upper", "lower"]
474
+
475
+ for i, servo_id in enumerate(sorted(servo_ids)):
476
+ leg_idx = i // 3
477
+ joint_idx = i % 3
478
+
479
+ if leg_idx < len(leg_names) and joint_idx < len(joint_names):
480
+ labels[servo_id] = {
481
+ "label": f"{leg_names[leg_idx]}_{joint_names[joint_idx]}",
482
+ "group": leg_names[leg_idx] + "_leg",
483
+ "role": joint_names[joint_idx]
484
+ }
485
+ else:
486
+ labels[servo_id] = {
487
+ "label": f"servo_{servo_id}",
488
+ "group": "other",
489
+ "role": "unknown"
490
+ }
491
+
492
+ elif robot_type == "hexapod":
493
+ leg_names = ["front_right", "middle_right", "back_right",
494
+ "back_left", "middle_left", "front_left"]
495
+ joint_names = ["coxa", "femur", "tibia"]
496
+
497
+ for i, servo_id in enumerate(sorted(servo_ids)):
498
+ leg_idx = i // 3
499
+ joint_idx = i % 3
500
+
501
+ if leg_idx < len(leg_names) and joint_idx < len(joint_names):
502
+ labels[servo_id] = {
503
+ "label": f"{leg_names[leg_idx]}_{joint_names[joint_idx]}",
504
+ "group": leg_names[leg_idx] + "_leg",
505
+ "role": joint_names[joint_idx]
506
+ }
507
+ else:
508
+ labels[servo_id] = {
509
+ "label": f"servo_{servo_id}",
510
+ "group": "other",
511
+ "role": "unknown"
512
+ }
513
+
514
+ elif robot_type == "quadruped_with_arm":
515
+ # 12 leg servos + up to 3 arm servos
516
+ leg_names = ["front_right", "front_left", "back_right", "back_left"]
517
+ joint_names = ["hip", "upper", "lower"]
518
+ arm_joints = ["shoulder", "elbow", "gripper"]
519
+
520
+ sorted_ids = sorted(servo_ids)
521
+ for i, servo_id in enumerate(sorted_ids):
522
+ if i < 12:
523
+ # Leg servos
524
+ leg_idx = i // 3
525
+ joint_idx = i % 3
526
+ labels[servo_id] = {
527
+ "label": f"{leg_names[leg_idx]}_{joint_names[joint_idx]}",
528
+ "group": leg_names[leg_idx] + "_leg",
529
+ "role": joint_names[joint_idx]
530
+ }
531
+ elif i < 12 + len(arm_joints):
532
+ # Arm servos
533
+ arm_idx = i - 12
534
+ labels[servo_id] = {
535
+ "label": f"arm_{arm_joints[arm_idx]}",
536
+ "group": "arm",
537
+ "role": arm_joints[arm_idx]
538
+ }
539
+ else:
540
+ labels[servo_id] = {
541
+ "label": f"servo_{servo_id}",
542
+ "group": "other",
543
+ "role": "unknown"
544
+ }
545
+
546
+ elif robot_type in ("humanoid_basic", "humanoid", "humanoid_advanced", "humanoid_full"):
547
+ # Humanoid labeling - organized by body part
548
+ # Standard humanoid servo layout (varies by manufacturer):
549
+ # - Right arm: shoulder_pitch, shoulder_roll, elbow
550
+ # - Left arm: shoulder_pitch, shoulder_roll, elbow
551
+ # - Right leg: hip_yaw, hip_roll, hip_pitch, knee, ankle_pitch, ankle_roll
552
+ # - Left leg: hip_yaw, hip_roll, hip_pitch, knee, ankle_pitch, ankle_roll
553
+ # - Torso: torso_yaw (optional)
554
+ # - Head: head_pan, head_tilt (optional)
555
+
556
+ sorted_ids = sorted(servo_ids)
557
+ num_servos = len(sorted_ids)
558
+
559
+ # Define humanoid body parts based on servo count
560
+ if robot_type == "humanoid_basic":
561
+ # 16-17 servos: basic arms (3 each) + legs (5 each)
562
+ body_map = [
563
+ # Right arm (3)
564
+ ("right_shoulder_pitch", "right_arm", "shoulder_pitch"),
565
+ ("right_shoulder_roll", "right_arm", "shoulder_roll"),
566
+ ("right_elbow", "right_arm", "elbow"),
567
+ # Left arm (3)
568
+ ("left_shoulder_pitch", "left_arm", "shoulder_pitch"),
569
+ ("left_shoulder_roll", "left_arm", "shoulder_roll"),
570
+ ("left_elbow", "left_arm", "elbow"),
571
+ # Right leg (5)
572
+ ("right_hip_roll", "right_leg", "hip_roll"),
573
+ ("right_hip_pitch", "right_leg", "hip_pitch"),
574
+ ("right_knee", "right_leg", "knee"),
575
+ ("right_ankle_pitch", "right_leg", "ankle_pitch"),
576
+ ("right_ankle_roll", "right_leg", "ankle_roll"),
577
+ # Left leg (5)
578
+ ("left_hip_roll", "left_leg", "hip_roll"),
579
+ ("left_hip_pitch", "left_leg", "hip_pitch"),
580
+ ("left_knee", "left_leg", "knee"),
581
+ ("left_ankle_pitch", "left_leg", "ankle_pitch"),
582
+ ("left_ankle_roll", "left_leg", "ankle_roll"),
583
+ # Extra (1)
584
+ ("torso_yaw", "torso", "yaw"),
585
+ ]
586
+ elif robot_type == "humanoid":
587
+ # 18-20 servos: arms (3 each) + legs (6 each) + torso
588
+ body_map = [
589
+ # Right arm (3)
590
+ ("right_shoulder_pitch", "right_arm", "shoulder_pitch"),
591
+ ("right_shoulder_roll", "right_arm", "shoulder_roll"),
592
+ ("right_elbow", "right_arm", "elbow"),
593
+ # Left arm (3)
594
+ ("left_shoulder_pitch", "left_arm", "shoulder_pitch"),
595
+ ("left_shoulder_roll", "left_arm", "shoulder_roll"),
596
+ ("left_elbow", "left_arm", "elbow"),
597
+ # Right leg (6)
598
+ ("right_hip_yaw", "right_leg", "hip_yaw"),
599
+ ("right_hip_roll", "right_leg", "hip_roll"),
600
+ ("right_hip_pitch", "right_leg", "hip_pitch"),
601
+ ("right_knee", "right_leg", "knee"),
602
+ ("right_ankle_pitch", "right_leg", "ankle_pitch"),
603
+ ("right_ankle_roll", "right_leg", "ankle_roll"),
604
+ # Left leg (6)
605
+ ("left_hip_yaw", "left_leg", "hip_yaw"),
606
+ ("left_hip_roll", "left_leg", "hip_roll"),
607
+ ("left_hip_pitch", "left_leg", "hip_pitch"),
608
+ ("left_knee", "left_leg", "knee"),
609
+ ("left_ankle_pitch", "left_leg", "ankle_pitch"),
610
+ ("left_ankle_roll", "left_leg", "ankle_roll"),
611
+ # Torso (2)
612
+ ("torso_yaw", "torso", "yaw"),
613
+ ("torso_pitch", "torso", "pitch"),
614
+ ]
615
+ elif robot_type == "humanoid_advanced":
616
+ # 21-24 servos: full arms (4 each) + legs (6 each) + torso + head
617
+ body_map = [
618
+ # Right arm (4)
619
+ ("right_shoulder_pitch", "right_arm", "shoulder_pitch"),
620
+ ("right_shoulder_roll", "right_arm", "shoulder_roll"),
621
+ ("right_shoulder_yaw", "right_arm", "shoulder_yaw"),
622
+ ("right_elbow", "right_arm", "elbow"),
623
+ # Left arm (4)
624
+ ("left_shoulder_pitch", "left_arm", "shoulder_pitch"),
625
+ ("left_shoulder_roll", "left_arm", "shoulder_roll"),
626
+ ("left_shoulder_yaw", "left_arm", "shoulder_yaw"),
627
+ ("left_elbow", "left_arm", "elbow"),
628
+ # Right leg (6)
629
+ ("right_hip_yaw", "right_leg", "hip_yaw"),
630
+ ("right_hip_roll", "right_leg", "hip_roll"),
631
+ ("right_hip_pitch", "right_leg", "hip_pitch"),
632
+ ("right_knee", "right_leg", "knee"),
633
+ ("right_ankle_pitch", "right_leg", "ankle_pitch"),
634
+ ("right_ankle_roll", "right_leg", "ankle_roll"),
635
+ # Left leg (6)
636
+ ("left_hip_yaw", "left_leg", "hip_yaw"),
637
+ ("left_hip_roll", "left_leg", "hip_roll"),
638
+ ("left_hip_pitch", "left_leg", "hip_pitch"),
639
+ ("left_knee", "left_leg", "knee"),
640
+ ("left_ankle_pitch", "left_leg", "ankle_pitch"),
641
+ ("left_ankle_roll", "left_leg", "ankle_roll"),
642
+ # Head (2)
643
+ ("head_pan", "head", "pan"),
644
+ ("head_tilt", "head", "tilt"),
645
+ # Torso (2)
646
+ ("torso_yaw", "torso", "yaw"),
647
+ ("torso_pitch", "torso", "pitch"),
648
+ ]
649
+ else: # humanoid_full
650
+ # 25+ servos: full body with wrists/grippers
651
+ body_map = [
652
+ # Right arm (6)
653
+ ("right_shoulder_pitch", "right_arm", "shoulder_pitch"),
654
+ ("right_shoulder_roll", "right_arm", "shoulder_roll"),
655
+ ("right_shoulder_yaw", "right_arm", "shoulder_yaw"),
656
+ ("right_elbow", "right_arm", "elbow"),
657
+ ("right_wrist_yaw", "right_arm", "wrist_yaw"),
658
+ ("right_gripper", "right_arm", "gripper"),
659
+ # Left arm (6)
660
+ ("left_shoulder_pitch", "left_arm", "shoulder_pitch"),
661
+ ("left_shoulder_roll", "left_arm", "shoulder_roll"),
662
+ ("left_shoulder_yaw", "left_arm", "shoulder_yaw"),
663
+ ("left_elbow", "left_arm", "elbow"),
664
+ ("left_wrist_yaw", "left_arm", "wrist_yaw"),
665
+ ("left_gripper", "left_arm", "gripper"),
666
+ # Right leg (6)
667
+ ("right_hip_yaw", "right_leg", "hip_yaw"),
668
+ ("right_hip_roll", "right_leg", "hip_roll"),
669
+ ("right_hip_pitch", "right_leg", "hip_pitch"),
670
+ ("right_knee", "right_leg", "knee"),
671
+ ("right_ankle_pitch", "right_leg", "ankle_pitch"),
672
+ ("right_ankle_roll", "right_leg", "ankle_roll"),
673
+ # Left leg (6)
674
+ ("left_hip_yaw", "left_leg", "hip_yaw"),
675
+ ("left_hip_roll", "left_leg", "hip_roll"),
676
+ ("left_hip_pitch", "left_leg", "hip_pitch"),
677
+ ("left_knee", "left_leg", "knee"),
678
+ ("left_ankle_pitch", "left_leg", "ankle_pitch"),
679
+ ("left_ankle_roll", "left_leg", "ankle_roll"),
680
+ # Head (2)
681
+ ("head_pan", "head", "pan"),
682
+ ("head_tilt", "head", "tilt"),
683
+ # Torso (2)
684
+ ("torso_yaw", "torso", "yaw"),
685
+ ("torso_pitch", "torso", "pitch"),
686
+ # Extra slots for variations
687
+ ("head_roll", "head", "roll"),
688
+ ("waist_yaw", "torso", "waist_yaw"),
689
+ ]
690
+
691
+ # Apply labels
692
+ for i, servo_id in enumerate(sorted_ids):
693
+ if i < len(body_map):
694
+ label, group, role = body_map[i]
695
+ labels[servo_id] = {
696
+ "label": label,
697
+ "group": group,
698
+ "role": role
699
+ }
700
+ else:
701
+ labels[servo_id] = {
702
+ "label": f"extra_{servo_id}",
703
+ "group": "extra",
704
+ "role": "unknown"
705
+ }
706
+
707
+ elif robot_type == "6dof_arm":
708
+ # 6-DOF robotic arm
709
+ arm_joints = [
710
+ ("base_rotation", "arm", "base"),
711
+ ("shoulder_pitch", "arm", "shoulder"),
712
+ ("elbow_pitch", "arm", "elbow"),
713
+ ("wrist_pitch", "arm", "wrist_pitch"),
714
+ ("wrist_roll", "arm", "wrist_roll"),
715
+ ("gripper", "arm", "gripper"),
716
+ ]
717
+ for i, servo_id in enumerate(sorted(servo_ids)):
718
+ if i < len(arm_joints):
719
+ label, group, role = arm_joints[i]
720
+ labels[servo_id] = {"label": label, "group": group, "role": role}
721
+ else:
722
+ labels[servo_id] = {"label": f"servo_{servo_id}", "group": "extra", "role": "unknown"}
723
+
724
+ else:
725
+ # Generic numbering for custom/unknown types
726
+ for servo_id in servo_ids:
727
+ labels[servo_id] = {
728
+ "label": f"servo_{servo_id}",
729
+ "group": "main",
730
+ "role": "actuator"
731
+ }
732
+
733
+ return labels
734
+
735
+ def interactive_label_session(self,
736
+ robot_profile: RobotProfile,
737
+ protocol_handler: ServoProtocolHandler) -> RobotProfile:
738
+ """Run interactive labeling session with AI assistance."""
739
+
740
+ WizardUI.print_header("AI-Assisted Component Labeling")
741
+
742
+ # Suggest robot type based on servo count and ID distribution
743
+ servo_ids = [a.id for a in robot_profile.actuators]
744
+ suggested_type = self.suggest_robot_type(len(servo_ids), servo_ids)
745
+
746
+ # Show detection info
747
+ core_count = self._count_core_servos(servo_ids)
748
+ if core_count < len(servo_ids):
749
+ WizardUI.print_info(f"Found {len(servo_ids)} servos total, {core_count} appear to be main actuators")
750
+ WizardUI.print_info(f"Detected robot type: {suggested_type}")
751
+ robot_type = WizardUI.prompt("Robot type", suggested_type)
752
+ robot_profile.robot_type = robot_type
753
+
754
+ # Get suggested labels
755
+ suggested_labels = self.suggest_labels(robot_type, servo_ids)
756
+
757
+ # Separate core and peripheral servos for display
758
+ core_servo_ids = [id for id in servo_ids if id <= 15 or suggested_labels.get(id, {}).get('group') != 'other']
759
+ peripheral_servo_ids = [id for id in servo_ids if id not in core_servo_ids]
760
+
761
+ print("\nSuggested servo labels (main actuators):")
762
+ print("-" * 50)
763
+
764
+ for servo_id in sorted(core_servo_ids):
765
+ if servo_id in suggested_labels:
766
+ s = suggested_labels[servo_id]
767
+ print(f" Servo {servo_id:2d}: {s['label']} ({s['group']}/{s['role']})")
768
+
769
+ if peripheral_servo_ids:
770
+ print(f"\n + {len(peripheral_servo_ids)} peripheral servos (IDs: {min(peripheral_servo_ids)}-{max(peripheral_servo_ids)})")
771
+
772
+ # Offer three options
773
+ print("\nHow would you like to proceed?")
774
+ options = [
775
+ "Accept suggested labels",
776
+ "🔍 Verify interactively (wiggle each servo to confirm)",
777
+ "Label manually from scratch"
778
+ ]
779
+ choice = WizardUI.select("Choose an option:", options)
780
+
781
+ if not choice:
782
+ choice = [0] # Default to accept
783
+
784
+ if choice[0] == 0:
785
+ # Accept suggested labels
786
+ for actuator in robot_profile.actuators:
787
+ if actuator.id in suggested_labels:
788
+ actuator.label = suggested_labels[actuator.id]['label']
789
+ actuator.group = suggested_labels[actuator.id]['group']
790
+ actuator.role = suggested_labels[actuator.id]['role']
791
+
792
+ elif choice[0] == 1:
793
+ # Interactive verification - wiggle each core servo
794
+ WizardUI.print_info("Interactive verification mode")
795
+ WizardUI.print_info("I'll wiggle each servo - press Enter to confirm or type a new label\n")
796
+
797
+ # Group actuators by suggested group for organized verification
798
+ groups_order = ['front_right_leg', 'front_left_leg', 'back_right_leg', 'back_left_leg', 'arm', 'other']
799
+ actuators_by_group = {}
800
+ for actuator in robot_profile.actuators:
801
+ group = suggested_labels.get(actuator.id, {}).get('group', 'other')
802
+ if group not in actuators_by_group:
803
+ actuators_by_group[group] = []
804
+ actuators_by_group[group].append(actuator)
805
+
806
+ for group in groups_order:
807
+ if group not in actuators_by_group:
808
+ continue
809
+
810
+ actuators = actuators_by_group[group]
811
+ if group == 'other' and len(actuators) > 5:
812
+ # Skip bulk peripheral servos
813
+ WizardUI.print_info(f"\nSkipping {len(actuators)} peripheral servos in '{group}' group")
814
+ for actuator in actuators:
815
+ actuator.label = f"servo_{actuator.id}"
816
+ actuator.group = "other"
817
+ actuator.role = "unknown"
818
+ continue
819
+
820
+ print(f"\n--- {group.replace('_', ' ').title()} ---")
821
+
822
+ for actuator in actuators:
823
+ suggestion = suggested_labels.get(actuator.id, {})
824
+ default_label = suggestion.get('label', f'servo_{actuator.id}')
825
+
826
+ # Wiggle the servo
827
+ print(f"\n Servo {actuator.id}: Wiggling... ", end='', flush=True)
828
+ try:
829
+ current_pos = protocol_handler.read_position(actuator.id) or 500
830
+ protocol_handler.move_servo(actuator.id, current_pos + 60, 150)
831
+ time.sleep(0.2)
832
+ protocol_handler.move_servo(actuator.id, current_pos - 60, 150)
833
+ time.sleep(0.2)
834
+ protocol_handler.move_servo(actuator.id, current_pos, 150)
835
+ time.sleep(0.2)
836
+ print("done")
837
+ except Exception as e:
838
+ print(f"(error: {e})")
839
+
840
+ # Ask for confirmation
841
+ user_label = WizardUI.prompt(f" Label [{default_label}]", "")
842
+ actuator.label = user_label if user_label else default_label
843
+ actuator.group = suggestion.get('group', 'main')
844
+ actuator.role = suggestion.get('role', 'actuator')
845
+
846
+ else:
847
+ # Full manual labeling
848
+ WizardUI.print_info("Manual labeling mode")
849
+ WizardUI.print_info("I'll wiggle each servo so you can identify and label it.\n")
850
+
851
+ for actuator in robot_profile.actuators:
852
+ print(f"\n--- Servo ID {actuator.id} ---")
853
+
854
+ if WizardUI.confirm("Wiggle this servo?", default=True):
855
+ try:
856
+ current_pos = protocol_handler.read_position(actuator.id) or 500
857
+ protocol_handler.move_servo(actuator.id, current_pos + 50, 200)
858
+ time.sleep(0.3)
859
+ protocol_handler.move_servo(actuator.id, current_pos - 50, 200)
860
+ time.sleep(0.3)
861
+ protocol_handler.move_servo(actuator.id, current_pos, 200)
862
+ time.sleep(0.3)
863
+ except Exception:
864
+ pass
865
+
866
+ default_label = suggested_labels.get(actuator.id, {}).get('label', f'servo_{actuator.id}')
867
+ actuator.label = WizardUI.prompt("Label", default_label)
868
+ actuator.group = WizardUI.prompt("Group (e.g., left_leg, arm)",
869
+ suggested_labels.get(actuator.id, {}).get('group', 'main'))
870
+ actuator.role = WizardUI.prompt("Role (e.g., hip, knee, shoulder)",
871
+ suggested_labels.get(actuator.id, {}).get('role', 'actuator'))
872
+
873
+ # Build groups
874
+ robot_profile.groups = {}
875
+ for actuator in robot_profile.actuators:
876
+ if actuator.group not in robot_profile.groups:
877
+ robot_profile.groups[actuator.group] = []
878
+ robot_profile.groups[actuator.group].append(actuator.id)
879
+
880
+ return robot_profile
881
+
882
+
883
+ class PrimitiveSkillGenerator:
884
+ """Generates Python code and JSON definitions for primitive skills."""
885
+
886
+ def __init__(self, robot_profile: RobotProfile):
887
+ self.profile = robot_profile
888
+ self.generated_primitives: List[Dict[str, Any]] = [] # Track for batch push
889
+
890
+ def generate_protocol_definition(self) -> Dict[str, Any]:
891
+ """Generate protocol.json definition."""
892
+ return {
893
+ "version": "1.0",
894
+ "robot_model": self.profile.name,
895
+ "robot_type": self.profile.robot_type,
896
+ "manufacturer": self.profile.manufacturer,
897
+ "model": self.profile.model,
898
+ "transport": {
899
+ "type": "serial",
900
+ "port": self.profile.device.port if self.profile.device else "",
901
+ "baud_rate": self.profile.device.baud_rate if self.profile.device else 115200,
902
+ "protocol": self.profile.device.detected_protocol.value if self.profile.device and self.profile.device.detected_protocol else "hiwonder"
903
+ },
904
+ "actuators": [
905
+ {
906
+ "id": a.id,
907
+ "label": a.label,
908
+ "group": a.group,
909
+ "role": a.role,
910
+ "type": a.actuator_type.value,
911
+ "characteristics": asdict(a.characteristics)
912
+ }
913
+ for a in self.profile.actuators
914
+ ],
915
+ "groups": self.profile.groups,
916
+ "created_at": self.profile.created_at,
917
+ "wizard_version": self.profile.wizard_version
918
+ }
919
+
920
+ def generate_servo_map(self) -> str:
921
+ """Generate Python servo map code."""
922
+ lines = [
923
+ '"""',
924
+ f'Servo map for {self.profile.name}',
925
+ f'Generated by ate robot-setup wizard',
926
+ f'Robot type: {self.profile.robot_type}',
927
+ '"""',
928
+ '',
929
+ 'from enum import IntEnum',
930
+ '',
931
+ '',
932
+ 'class ServoID(IntEnum):',
933
+ ' """Servo IDs mapped to descriptive names."""',
934
+ ]
935
+
936
+ for actuator in sorted(self.profile.actuators, key=lambda a: a.id):
937
+ name = actuator.label.upper().replace(' ', '_').replace('-', '_')
938
+ lines.append(f' {name} = {actuator.id}')
939
+
940
+ lines.extend([
941
+ '',
942
+ '',
943
+ '# Servo groups',
944
+ ])
945
+
946
+ for group_name, servo_ids in self.profile.groups.items():
947
+ var_name = group_name.upper().replace(' ', '_').replace('-', '_')
948
+ servo_names = [f'ServoID.{self.profile.actuators[i].label.upper().replace(" ", "_").replace("-", "_")}'
949
+ for i, a in enumerate(self.profile.actuators) if a.id in servo_ids]
950
+ lines.append(f'{var_name} = [{", ".join(servo_names)}]')
951
+
952
+ lines.extend([
953
+ '',
954
+ '',
955
+ '# All servos',
956
+ f'ALL_SERVOS = list(ServoID)',
957
+ '',
958
+ '',
959
+ '# Servo characteristics',
960
+ 'SERVO_LIMITS = {',
961
+ ])
962
+
963
+ for actuator in self.profile.actuators:
964
+ chars = actuator.characteristics
965
+ lines.append(f' ServoID.{actuator.label.upper().replace(" ", "_").replace("-", "_")}: {{')
966
+ lines.append(f' "min": {chars.position_min},')
967
+ lines.append(f' "max": {chars.position_max},')
968
+ lines.append(f' "center": {chars.position_center},')
969
+ lines.append(f' }},')
970
+
971
+ lines.append('}')
972
+
973
+ return '\n'.join(lines)
974
+
975
+ def generate_primitives(self) -> str:
976
+ """Generate primitive skill code."""
977
+ lines = [
978
+ '"""',
979
+ f'Primitive skills for {self.profile.name}',
980
+ f'Generated by ate robot-setup wizard',
981
+ '"""',
982
+ '',
983
+ 'import time',
984
+ 'from typing import List, Dict, Optional',
985
+ 'from dataclasses import dataclass',
986
+ '',
987
+ 'from .servo_map import ServoID, SERVO_LIMITS, ALL_SERVOS',
988
+ '',
989
+ '',
990
+ '@dataclass',
991
+ 'class ServoCommand:',
992
+ ' """Command for a single servo."""',
993
+ ' servo_id: int',
994
+ ' position: int',
995
+ ' time_ms: int = 500',
996
+ '',
997
+ '',
998
+ 'class PrimitiveSkills:',
999
+ ' """Low-level primitive skills for the robot."""',
1000
+ '',
1001
+ ' def __init__(self, robot_controller):',
1002
+ ' """',
1003
+ ' Args:',
1004
+ ' robot_controller: Controller with move_servo(), read_position() methods',
1005
+ ' """',
1006
+ ' self.controller = robot_controller',
1007
+ '',
1008
+ ' def move_servo(self, servo_id: ServoID, position: int, time_ms: int = 500):',
1009
+ ' """Move a single servo to position."""',
1010
+ ' limits = SERVO_LIMITS.get(servo_id, {"min": 0, "max": 1000})',
1011
+ ' position = max(limits["min"], min(limits["max"], position))',
1012
+ ' self.controller.move_servo(servo_id, position, time_ms)',
1013
+ '',
1014
+ ' def move_servos(self, commands: List[ServoCommand]):',
1015
+ ' """Move multiple servos simultaneously."""',
1016
+ ' for cmd in commands:',
1017
+ ' limits = SERVO_LIMITS.get(cmd.servo_id, {"min": 0, "max": 1000})',
1018
+ ' pos = max(limits["min"], min(limits["max"], cmd.position))',
1019
+ ' self.controller.move_servo(cmd.servo_id, pos, cmd.time_ms)',
1020
+ '',
1021
+ ' def home(self):',
1022
+ ' """Move all servos to center/home position."""',
1023
+ ' commands = [',
1024
+ ' ServoCommand(servo_id, SERVO_LIMITS[servo_id]["center"])',
1025
+ ' for servo_id in ALL_SERVOS',
1026
+ ' ]',
1027
+ ' self.move_servos(commands)',
1028
+ '',
1029
+ ' def read_positions(self) -> Dict[ServoID, int]:',
1030
+ ' """Read all servo positions."""',
1031
+ ' positions = {}',
1032
+ ' for servo_id in ALL_SERVOS:',
1033
+ ' pos = self.controller.read_position(servo_id)',
1034
+ ' if pos is not None:',
1035
+ ' positions[servo_id] = pos',
1036
+ ' return positions',
1037
+ '',
1038
+ ]
1039
+
1040
+ # Add group-specific methods
1041
+ for group_name, servo_ids in self.profile.groups.items():
1042
+ method_name = group_name.lower().replace(' ', '_').replace('-', '_')
1043
+ group_var = group_name.upper().replace(' ', '_').replace('-', '_')
1044
+
1045
+ lines.extend([
1046
+ f' def move_{method_name}(self, positions: Dict[ServoID, int], time_ms: int = 500):',
1047
+ f' """Move {group_name} servos."""',
1048
+ f' commands = [',
1049
+ f' ServoCommand(servo_id, pos, time_ms)',
1050
+ f' for servo_id, pos in positions.items()',
1051
+ f' if servo_id in {group_var}',
1052
+ f' ]',
1053
+ f' self.move_servos(commands)',
1054
+ '',
1055
+ ])
1056
+
1057
+ return '\n'.join(lines)
1058
+
1059
+ def generate_primitive_jsons(self, output_dir: Path) -> List[str]:
1060
+ """Generate .primitive.json files for CLI push workflow.
1061
+
1062
+ This is the PostHog-style auto-generation - from discovered hardware
1063
+ we automatically create a comprehensive library of primitive skills.
1064
+ """
1065
+ primitives_dir = output_dir / "primitives"
1066
+ primitives_dir.mkdir(parents=True, exist_ok=True)
1067
+
1068
+ generated_files = []
1069
+ self.generated_primitives = []
1070
+
1071
+ device = self.profile.device
1072
+ protocol_type = device.detected_protocol.value if device and device.detected_protocol else "hiwonder"
1073
+ baud_rate = device.baud_rate if device else 115200
1074
+
1075
+ # ====================================================================
1076
+ # 1. Per-Servo Primitives - Direct control of each labeled actuator
1077
+ # ====================================================================
1078
+ for actuator in self.profile.actuators:
1079
+ chars = actuator.characteristics
1080
+
1081
+ # move_<label>(position, speed) - Move servo to absolute position
1082
+ primitive = {
1083
+ "name": f"move_{actuator.label}",
1084
+ "description": f"Move {actuator.label.replace('_', ' ')} servo to specified position",
1085
+ "category": "motion",
1086
+ "commandType": "single",
1087
+ "commandTemplate": self._build_move_command(actuator.id, protocol_type),
1088
+ "parameters": {
1089
+ "position": {
1090
+ "type": "integer",
1091
+ "min": chars.position_min,
1092
+ "max": chars.position_max,
1093
+ "default": chars.position_center,
1094
+ "description": f"Target position ({chars.position_min}-{chars.position_max})"
1095
+ },
1096
+ "time_ms": {
1097
+ "type": "integer",
1098
+ "min": 0,
1099
+ "max": 5000,
1100
+ "default": 500,
1101
+ "description": "Movement duration in milliseconds"
1102
+ }
1103
+ },
1104
+ "servoId": actuator.id,
1105
+ "group": actuator.group,
1106
+ "role": actuator.role,
1107
+ "protocol": {
1108
+ "type": protocol_type,
1109
+ "baudRate": baud_rate
1110
+ },
1111
+ "robotModel": self.profile.name,
1112
+ "robotType": self.profile.robot_type,
1113
+ "executionTimeMs": 500,
1114
+ "settleTimeMs": 100,
1115
+ "safetyNotes": f"Ensure {actuator.label} has clearance before moving"
1116
+ }
1117
+
1118
+ filename = f"move_{actuator.label}.primitive.json"
1119
+ filepath = primitives_dir / filename
1120
+ with open(filepath, 'w') as f:
1121
+ json.dump(primitive, f, indent=2)
1122
+ generated_files.append(str(filepath))
1123
+ self.generated_primitives.append(primitive)
1124
+
1125
+ # center_<label>() - Move to center/home position
1126
+ center_primitive = {
1127
+ "name": f"center_{actuator.label}",
1128
+ "description": f"Move {actuator.label.replace('_', ' ')} to center position",
1129
+ "category": "motion",
1130
+ "commandType": "single",
1131
+ "commandTemplate": self._build_move_command(actuator.id, protocol_type, chars.position_center),
1132
+ "parameters": {
1133
+ "time_ms": {
1134
+ "type": "integer",
1135
+ "min": 0,
1136
+ "max": 5000,
1137
+ "default": 500,
1138
+ "description": "Movement duration in milliseconds"
1139
+ }
1140
+ },
1141
+ "servoId": actuator.id,
1142
+ "protocol": {"type": protocol_type, "baudRate": baud_rate},
1143
+ "robotModel": self.profile.name,
1144
+ "robotType": self.profile.robot_type
1145
+ }
1146
+
1147
+ filename = f"center_{actuator.label}.primitive.json"
1148
+ filepath = primitives_dir / filename
1149
+ with open(filepath, 'w') as f:
1150
+ json.dump(center_primitive, f, indent=2)
1151
+ generated_files.append(str(filepath))
1152
+ self.generated_primitives.append(center_primitive)
1153
+
1154
+ # ====================================================================
1155
+ # 2. Group Primitives - Coordinated control of actuator groups
1156
+ # ====================================================================
1157
+ for group_name, servo_ids in self.profile.groups.items():
1158
+ if group_name in ('other', 'extra'):
1159
+ continue # Skip misc groups
1160
+
1161
+ group_actuators = [a for a in self.profile.actuators if a.id in servo_ids]
1162
+
1163
+ # move_<group>(positions) - Move all servos in group
1164
+ group_primitive = {
1165
+ "name": f"move_{group_name}",
1166
+ "description": f"Move all servos in {group_name.replace('_', ' ')} group",
1167
+ "category": "motion",
1168
+ "commandType": "sequence",
1169
+ "commandTemplate": json.dumps([
1170
+ {"servoId": a.id, "position": "${" + a.role + "_position}", "time_ms": "${time_ms}"}
1171
+ for a in group_actuators
1172
+ ]),
1173
+ "parameters": {
1174
+ **{
1175
+ f"{a.role}_position": {
1176
+ "type": "integer",
1177
+ "min": a.characteristics.position_min,
1178
+ "max": a.characteristics.position_max,
1179
+ "default": a.characteristics.position_center,
1180
+ "description": f"{a.role} servo position"
1181
+ }
1182
+ for a in group_actuators
1183
+ },
1184
+ "time_ms": {
1185
+ "type": "integer",
1186
+ "min": 0,
1187
+ "max": 5000,
1188
+ "default": 500,
1189
+ "description": "Movement duration"
1190
+ }
1191
+ },
1192
+ "servoIds": servo_ids,
1193
+ "group": group_name,
1194
+ "protocol": {"type": protocol_type, "baudRate": baud_rate},
1195
+ "robotModel": self.profile.name,
1196
+ "robotType": self.profile.robot_type,
1197
+ "executionTimeMs": 500,
1198
+ "settleTimeMs": 200
1199
+ }
1200
+
1201
+ filename = f"move_{group_name}.primitive.json"
1202
+ filepath = primitives_dir / filename
1203
+ with open(filepath, 'w') as f:
1204
+ json.dump(group_primitive, f, indent=2)
1205
+ generated_files.append(str(filepath))
1206
+ self.generated_primitives.append(group_primitive)
1207
+
1208
+ # home_<group>() - Move all servos in group to center
1209
+ home_group = {
1210
+ "name": f"home_{group_name}",
1211
+ "description": f"Move all {group_name.replace('_', ' ')} servos to home position",
1212
+ "category": "motion",
1213
+ "commandType": "sequence",
1214
+ "commandTemplate": json.dumps([
1215
+ {"servoId": a.id, "position": a.characteristics.position_center, "time_ms": 500}
1216
+ for a in group_actuators
1217
+ ]),
1218
+ "parameters": {},
1219
+ "servoIds": servo_ids,
1220
+ "group": group_name,
1221
+ "protocol": {"type": protocol_type, "baudRate": baud_rate},
1222
+ "robotModel": self.profile.name,
1223
+ "robotType": self.profile.robot_type
1224
+ }
1225
+
1226
+ filename = f"home_{group_name}.primitive.json"
1227
+ filepath = primitives_dir / filename
1228
+ with open(filepath, 'w') as f:
1229
+ json.dump(home_group, f, indent=2)
1230
+ generated_files.append(str(filepath))
1231
+ self.generated_primitives.append(home_group)
1232
+
1233
+ # ====================================================================
1234
+ # 3. Utility Primitives - System-wide control
1235
+ # ====================================================================
1236
+ all_servo_ids = [a.id for a in self.profile.actuators]
1237
+
1238
+ # home_all() - All servos to center
1239
+ home_all = {
1240
+ "name": "home_all",
1241
+ "description": "Move all servos to their center/home positions",
1242
+ "category": "motion",
1243
+ "commandType": "sequence",
1244
+ "commandTemplate": json.dumps([
1245
+ {"servoId": a.id, "position": a.characteristics.position_center, "time_ms": 800}
1246
+ for a in self.profile.actuators
1247
+ ]),
1248
+ "parameters": {},
1249
+ "servoIds": all_servo_ids,
1250
+ "protocol": {"type": protocol_type, "baudRate": baud_rate},
1251
+ "robotModel": self.profile.name,
1252
+ "robotType": self.profile.robot_type,
1253
+ "executionTimeMs": 1000,
1254
+ "settleTimeMs": 500,
1255
+ "safetyNotes": "Ensure robot has clearance. May cause significant movement."
1256
+ }
1257
+
1258
+ filepath = primitives_dir / "home_all.primitive.json"
1259
+ with open(filepath, 'w') as f:
1260
+ json.dump(home_all, f, indent=2)
1261
+ generated_files.append(str(filepath))
1262
+ self.generated_primitives.append(home_all)
1263
+
1264
+ # disable_torque() - Release all servos
1265
+ disable_torque = {
1266
+ "name": "disable_torque",
1267
+ "description": "Disable torque on all servos (robot goes limp)",
1268
+ "category": "safety",
1269
+ "commandType": "sequence",
1270
+ "commandTemplate": json.dumps([
1271
+ {"servoId": a.id, "command": "disable_torque"}
1272
+ for a in self.profile.actuators
1273
+ ]),
1274
+ "parameters": {},
1275
+ "servoIds": all_servo_ids,
1276
+ "protocol": {"type": protocol_type, "baudRate": baud_rate},
1277
+ "robotModel": self.profile.name,
1278
+ "robotType": self.profile.robot_type,
1279
+ "safetyNotes": "Robot will go limp. Ensure it is supported."
1280
+ }
1281
+
1282
+ filepath = primitives_dir / "disable_torque.primitive.json"
1283
+ with open(filepath, 'w') as f:
1284
+ json.dump(disable_torque, f, indent=2)
1285
+ generated_files.append(str(filepath))
1286
+ self.generated_primitives.append(disable_torque)
1287
+
1288
+ # enable_torque() - Enable all servos
1289
+ enable_torque = {
1290
+ "name": "enable_torque",
1291
+ "description": "Enable torque on all servos",
1292
+ "category": "safety",
1293
+ "commandType": "sequence",
1294
+ "commandTemplate": json.dumps([
1295
+ {"servoId": a.id, "command": "enable_torque"}
1296
+ for a in self.profile.actuators
1297
+ ]),
1298
+ "parameters": {},
1299
+ "servoIds": all_servo_ids,
1300
+ "protocol": {"type": protocol_type, "baudRate": baud_rate},
1301
+ "robotModel": self.profile.name,
1302
+ "robotType": self.profile.robot_type
1303
+ }
1304
+
1305
+ filepath = primitives_dir / "enable_torque.primitive.json"
1306
+ with open(filepath, 'w') as f:
1307
+ json.dump(enable_torque, f, indent=2)
1308
+ generated_files.append(str(filepath))
1309
+ self.generated_primitives.append(enable_torque)
1310
+
1311
+ # read_all_positions() - Read state of all servos
1312
+ read_positions = {
1313
+ "name": "read_all_positions",
1314
+ "description": "Read current positions of all servos",
1315
+ "category": "sensing",
1316
+ "commandType": "sequence",
1317
+ "commandTemplate": json.dumps([
1318
+ {"servoId": a.id, "command": "read_position"}
1319
+ for a in self.profile.actuators
1320
+ ]),
1321
+ "parameters": {},
1322
+ "servoIds": all_servo_ids,
1323
+ "protocol": {"type": protocol_type, "baudRate": baud_rate},
1324
+ "robotModel": self.profile.name,
1325
+ "robotType": self.profile.robot_type
1326
+ }
1327
+
1328
+ filepath = primitives_dir / "read_all_positions.primitive.json"
1329
+ with open(filepath, 'w') as f:
1330
+ json.dump(read_positions, f, indent=2)
1331
+ generated_files.append(str(filepath))
1332
+ self.generated_primitives.append(read_positions)
1333
+
1334
+ # ====================================================================
1335
+ # 4. Robot-Type Specific Poses
1336
+ # ====================================================================
1337
+ generated_files.extend(self._generate_pose_primitives(primitives_dir, protocol_type, baud_rate))
1338
+
1339
+ return generated_files
1340
+
1341
+ def _build_move_command(self, servo_id: int, protocol: str, fixed_position: Optional[int] = None) -> str:
1342
+ """Build command template for moving a servo."""
1343
+ if protocol == "hiwonder":
1344
+ if fixed_position is not None:
1345
+ return json.dumps({
1346
+ "servoId": servo_id,
1347
+ "position": fixed_position,
1348
+ "time_ms": "${time_ms}"
1349
+ })
1350
+ return json.dumps({
1351
+ "servoId": servo_id,
1352
+ "position": "${position}",
1353
+ "time_ms": "${time_ms}"
1354
+ })
1355
+ # Generic fallback
1356
+ if fixed_position is not None:
1357
+ return f"MOVE {servo_id} {fixed_position} ${{time_ms}}"
1358
+ return f"MOVE {servo_id} ${{position}} ${{time_ms}}"
1359
+
1360
+ def _generate_pose_primitives(self, output_dir: Path, protocol: str, baud_rate: int) -> List[str]:
1361
+ """Generate robot-type specific pose primitives."""
1362
+ generated_files = []
1363
+ robot_type = self.profile.robot_type
1364
+
1365
+ if robot_type == "quadruped":
1366
+ poses = self._generate_quadruped_poses()
1367
+ elif robot_type in ("humanoid", "humanoid_basic", "humanoid_advanced", "humanoid_full"):
1368
+ poses = self._generate_humanoid_poses()
1369
+ elif robot_type == "6dof_arm":
1370
+ poses = self._generate_arm_poses()
1371
+ else:
1372
+ poses = []
1373
+
1374
+ for pose in poses:
1375
+ pose["protocol"] = {"type": protocol, "baudRate": baud_rate}
1376
+ pose["robotModel"] = self.profile.name
1377
+ pose["robotType"] = self.profile.robot_type
1378
+
1379
+ filename = f"{pose['name']}.primitive.json"
1380
+ filepath = output_dir / filename
1381
+ with open(filepath, 'w') as f:
1382
+ json.dump(pose, f, indent=2)
1383
+ generated_files.append(str(filepath))
1384
+ self.generated_primitives.append(pose)
1385
+
1386
+ return generated_files
1387
+
1388
+ def _generate_quadruped_poses(self) -> List[Dict[str, Any]]:
1389
+ """Generate poses for quadruped robots."""
1390
+ poses = []
1391
+
1392
+ # Find leg groups
1393
+ leg_groups = [g for g in self.profile.groups.keys() if 'leg' in g.lower()]
1394
+
1395
+ if not leg_groups:
1396
+ return poses
1397
+
1398
+ # stand - All legs at standing position
1399
+ stand_commands = []
1400
+ for actuator in self.profile.actuators:
1401
+ if 'leg' in actuator.group.lower():
1402
+ # For standing: hips at center, upper legs forward, lower legs back
1403
+ if 'hip' in actuator.role:
1404
+ pos = actuator.characteristics.position_center
1405
+ elif 'upper' in actuator.role:
1406
+ pos = actuator.characteristics.position_center + 100
1407
+ elif 'lower' in actuator.role:
1408
+ pos = actuator.characteristics.position_center - 100
1409
+ else:
1410
+ pos = actuator.characteristics.position_center
1411
+ stand_commands.append({"servoId": actuator.id, "position": pos, "time_ms": 800})
1412
+
1413
+ poses.append({
1414
+ "name": "stand",
1415
+ "description": "Move to standing pose with all legs extended",
1416
+ "category": "pose",
1417
+ "commandType": "sequence",
1418
+ "commandTemplate": json.dumps(stand_commands),
1419
+ "parameters": {},
1420
+ "servoIds": [a.id for a in self.profile.actuators if 'leg' in a.group.lower()],
1421
+ "executionTimeMs": 1000,
1422
+ "settleTimeMs": 500
1423
+ })
1424
+
1425
+ # crouch - Low stance
1426
+ crouch_commands = []
1427
+ for actuator in self.profile.actuators:
1428
+ if 'leg' in actuator.group.lower():
1429
+ if 'hip' in actuator.role:
1430
+ pos = actuator.characteristics.position_center
1431
+ elif 'upper' in actuator.role:
1432
+ pos = actuator.characteristics.position_center + 200
1433
+ elif 'lower' in actuator.role:
1434
+ pos = actuator.characteristics.position_center - 200
1435
+ else:
1436
+ pos = actuator.characteristics.position_center
1437
+ crouch_commands.append({"servoId": actuator.id, "position": pos, "time_ms": 600})
1438
+
1439
+ poses.append({
1440
+ "name": "crouch",
1441
+ "description": "Move to crouched/low stance",
1442
+ "category": "pose",
1443
+ "commandType": "sequence",
1444
+ "commandTemplate": json.dumps(crouch_commands),
1445
+ "parameters": {},
1446
+ "servoIds": [a.id for a in self.profile.actuators if 'leg' in a.group.lower()],
1447
+ "executionTimeMs": 800,
1448
+ "settleTimeMs": 300
1449
+ })
1450
+
1451
+ # sit - Sitting pose (back legs tucked)
1452
+ sit_commands = []
1453
+ for actuator in self.profile.actuators:
1454
+ if 'back' in actuator.group.lower() and 'leg' in actuator.group.lower():
1455
+ # Back legs tucked under
1456
+ if 'upper' in actuator.role:
1457
+ pos = actuator.characteristics.position_center + 300
1458
+ elif 'lower' in actuator.role:
1459
+ pos = actuator.characteristics.position_center - 300
1460
+ else:
1461
+ pos = actuator.characteristics.position_center
1462
+ elif 'front' in actuator.group.lower() and 'leg' in actuator.group.lower():
1463
+ # Front legs extended
1464
+ if 'upper' in actuator.role:
1465
+ pos = actuator.characteristics.position_center
1466
+ else:
1467
+ pos = actuator.characteristics.position_center
1468
+ else:
1469
+ continue
1470
+ sit_commands.append({"servoId": actuator.id, "position": pos, "time_ms": 800})
1471
+
1472
+ if sit_commands:
1473
+ poses.append({
1474
+ "name": "sit",
1475
+ "description": "Move to sitting pose with back legs tucked",
1476
+ "category": "pose",
1477
+ "commandType": "sequence",
1478
+ "commandTemplate": json.dumps(sit_commands),
1479
+ "parameters": {},
1480
+ "servoIds": [c["servoId"] for c in sit_commands],
1481
+ "executionTimeMs": 1000,
1482
+ "settleTimeMs": 500
1483
+ })
1484
+
1485
+ return poses
1486
+
1487
+ def _generate_humanoid_poses(self) -> List[Dict[str, Any]]:
1488
+ """Generate poses for humanoid robots."""
1489
+ poses = []
1490
+
1491
+ # t_pose - Arms out, standing straight
1492
+ t_pose_commands = []
1493
+ for actuator in self.profile.actuators:
1494
+ if 'arm' in actuator.group.lower():
1495
+ # Arms out horizontal
1496
+ if 'shoulder_pitch' in actuator.role:
1497
+ pos = actuator.characteristics.position_center
1498
+ elif 'shoulder_roll' in actuator.role:
1499
+ pos = actuator.characteristics.position_max - 100 # Arms out
1500
+ elif 'elbow' in actuator.role:
1501
+ pos = actuator.characteristics.position_center # Straight
1502
+ else:
1503
+ pos = actuator.characteristics.position_center
1504
+ elif 'leg' in actuator.group.lower():
1505
+ pos = actuator.characteristics.position_center # Standing straight
1506
+ else:
1507
+ pos = actuator.characteristics.position_center
1508
+ t_pose_commands.append({"servoId": actuator.id, "position": pos, "time_ms": 1000})
1509
+
1510
+ poses.append({
1511
+ "name": "t_pose",
1512
+ "description": "T-pose: arms out horizontal, standing straight",
1513
+ "category": "pose",
1514
+ "commandType": "sequence",
1515
+ "commandTemplate": json.dumps(t_pose_commands),
1516
+ "parameters": {},
1517
+ "servoIds": [a.id for a in self.profile.actuators],
1518
+ "executionTimeMs": 1200,
1519
+ "settleTimeMs": 500,
1520
+ "safetyNotes": "Ensure clearance for arm movement"
1521
+ })
1522
+
1523
+ # arms_down - Relaxed standing
1524
+ arms_down_commands = []
1525
+ for actuator in self.profile.actuators:
1526
+ if 'arm' in actuator.group.lower():
1527
+ if 'shoulder_roll' in actuator.role:
1528
+ pos = actuator.characteristics.position_center # Arms at sides
1529
+ elif 'elbow' in actuator.role:
1530
+ pos = actuator.characteristics.position_center + 50 # Slightly bent
1531
+ else:
1532
+ pos = actuator.characteristics.position_center
1533
+ else:
1534
+ pos = actuator.characteristics.position_center
1535
+ arms_down_commands.append({"servoId": actuator.id, "position": pos, "time_ms": 800})
1536
+
1537
+ poses.append({
1538
+ "name": "arms_down",
1539
+ "description": "Relaxed stance with arms at sides",
1540
+ "category": "pose",
1541
+ "commandType": "sequence",
1542
+ "commandTemplate": json.dumps(arms_down_commands),
1543
+ "parameters": {},
1544
+ "servoIds": [a.id for a in self.profile.actuators],
1545
+ "executionTimeMs": 1000,
1546
+ "settleTimeMs": 300
1547
+ })
1548
+
1549
+ # wave_ready - One arm up, ready to wave
1550
+ wave_commands = []
1551
+ for actuator in self.profile.actuators:
1552
+ if 'right_arm' in actuator.group.lower():
1553
+ if 'shoulder_pitch' in actuator.role:
1554
+ pos = actuator.characteristics.position_max - 200 # Arm up
1555
+ elif 'shoulder_roll' in actuator.role:
1556
+ pos = actuator.characteristics.position_center + 100
1557
+ elif 'elbow' in actuator.role:
1558
+ pos = actuator.characteristics.position_center - 100 # Bent
1559
+ else:
1560
+ pos = actuator.characteristics.position_center
1561
+ wave_commands.append({"servoId": actuator.id, "position": pos, "time_ms": 600})
1562
+
1563
+ if wave_commands:
1564
+ poses.append({
1565
+ "name": "wave_ready",
1566
+ "description": "Right arm raised, ready to wave",
1567
+ "category": "pose",
1568
+ "commandType": "sequence",
1569
+ "commandTemplate": json.dumps(wave_commands),
1570
+ "parameters": {},
1571
+ "servoIds": [c["servoId"] for c in wave_commands],
1572
+ "executionTimeMs": 800,
1573
+ "settleTimeMs": 200
1574
+ })
1575
+
1576
+ return poses
1577
+
1578
+ def _generate_arm_poses(self) -> List[Dict[str, Any]]:
1579
+ """Generate poses for robotic arms."""
1580
+ poses = []
1581
+
1582
+ # home_arm - Safe home position
1583
+ home_commands = [
1584
+ {"servoId": a.id, "position": a.characteristics.position_center, "time_ms": 1000}
1585
+ for a in self.profile.actuators
1586
+ ]
1587
+
1588
+ poses.append({
1589
+ "name": "home_arm",
1590
+ "description": "Move arm to safe home position",
1591
+ "category": "pose",
1592
+ "commandType": "sequence",
1593
+ "commandTemplate": json.dumps(home_commands),
1594
+ "parameters": {},
1595
+ "servoIds": [a.id for a in self.profile.actuators],
1596
+ "executionTimeMs": 1200,
1597
+ "settleTimeMs": 500
1598
+ })
1599
+
1600
+ # gripper_open
1601
+ gripper = next((a for a in self.profile.actuators if 'gripper' in a.label.lower()), None)
1602
+ if gripper:
1603
+ poses.append({
1604
+ "name": "gripper_open",
1605
+ "description": "Open the gripper fully",
1606
+ "category": "gripper",
1607
+ "commandType": "single",
1608
+ "commandTemplate": json.dumps({
1609
+ "servoId": gripper.id,
1610
+ "position": gripper.characteristics.position_max,
1611
+ "time_ms": 300
1612
+ }),
1613
+ "parameters": {},
1614
+ "servoIds": [gripper.id],
1615
+ "executionTimeMs": 400,
1616
+ "settleTimeMs": 100
1617
+ })
1618
+
1619
+ poses.append({
1620
+ "name": "gripper_close",
1621
+ "description": "Close the gripper fully",
1622
+ "category": "gripper",
1623
+ "commandType": "single",
1624
+ "commandTemplate": json.dumps({
1625
+ "servoId": gripper.id,
1626
+ "position": gripper.characteristics.position_min,
1627
+ "time_ms": 300
1628
+ }),
1629
+ "parameters": {},
1630
+ "servoIds": [gripper.id],
1631
+ "executionTimeMs": 400,
1632
+ "settleTimeMs": 100
1633
+ })
1634
+
1635
+ # reach_forward - Extend arm forward
1636
+ reach_commands = []
1637
+ for actuator in self.profile.actuators:
1638
+ if 'base' in actuator.role:
1639
+ pos = actuator.characteristics.position_center
1640
+ elif 'shoulder' in actuator.role:
1641
+ pos = actuator.characteristics.position_center + 150
1642
+ elif 'elbow' in actuator.role:
1643
+ pos = actuator.characteristics.position_center - 100
1644
+ elif 'wrist' in actuator.role:
1645
+ pos = actuator.characteristics.position_center
1646
+ else:
1647
+ pos = actuator.characteristics.position_center
1648
+ reach_commands.append({"servoId": actuator.id, "position": pos, "time_ms": 800})
1649
+
1650
+ poses.append({
1651
+ "name": "reach_forward",
1652
+ "description": "Extend arm forward for picking",
1653
+ "category": "pose",
1654
+ "commandType": "sequence",
1655
+ "commandTemplate": json.dumps(reach_commands),
1656
+ "parameters": {},
1657
+ "servoIds": [a.id for a in self.profile.actuators],
1658
+ "executionTimeMs": 1000,
1659
+ "settleTimeMs": 300
1660
+ })
1661
+
1662
+ return poses
1663
+
1664
+ def generate_all(self, output_dir: Path) -> List[str]:
1665
+ """Generate all files to output directory."""
1666
+ output_dir.mkdir(parents=True, exist_ok=True)
1667
+
1668
+ generated_files = []
1669
+
1670
+ # protocol.json
1671
+ protocol_path = output_dir / "protocol.json"
1672
+ with open(protocol_path, 'w') as f:
1673
+ json.dump(self.generate_protocol_definition(), f, indent=2)
1674
+ generated_files.append(str(protocol_path))
1675
+
1676
+ # servo_map.py
1677
+ servo_map_path = output_dir / "servo_map.py"
1678
+ with open(servo_map_path, 'w') as f:
1679
+ f.write(self.generate_servo_map())
1680
+ generated_files.append(str(servo_map_path))
1681
+
1682
+ # primitives.py
1683
+ primitives_path = output_dir / "primitives.py"
1684
+ with open(primitives_path, 'w') as f:
1685
+ f.write(self.generate_primitives())
1686
+ generated_files.append(str(primitives_path))
1687
+
1688
+ # __init__.py
1689
+ init_path = output_dir / "__init__.py"
1690
+ with open(init_path, 'w') as f:
1691
+ f.write(f'"""Generated robot package for {self.profile.name}."""\n')
1692
+ f.write('from .servo_map import ServoID, SERVO_LIMITS, ALL_SERVOS\n')
1693
+ f.write('from .primitives import PrimitiveSkills, ServoCommand\n')
1694
+ generated_files.append(str(init_path))
1695
+
1696
+ # Generate .primitive.json files for CLI workflow
1697
+ # This is the PostHog-style auto-generation from discovered hardware
1698
+ primitive_json_files = self.generate_primitive_jsons(output_dir)
1699
+ generated_files.extend(primitive_json_files)
1700
+
1701
+ return generated_files
1702
+
1703
+
1704
+ class RobotSetupWizard:
1705
+ """Main wizard orchestrator."""
1706
+
1707
+ TOTAL_STEPS = 5
1708
+
1709
+ def __init__(self, output_dir: str = "./robot"):
1710
+ self.output_dir = Path(output_dir)
1711
+ self.profile = RobotProfile()
1712
+ self.protocol_handler: Optional[ServoProtocolHandler] = None
1713
+ self.ai_assistant = AILabelingAssistant()
1714
+
1715
+ def run(self,
1716
+ port: Optional[str] = None,
1717
+ skip_labeling: bool = False,
1718
+ robot_type: Optional[str] = None,
1719
+ non_interactive: bool = False,
1720
+ push: bool = False,
1721
+ api_url: Optional[str] = None) -> bool:
1722
+ """Run the complete setup wizard.
1723
+
1724
+ Args:
1725
+ port: Serial port to use (skip device selection)
1726
+ skip_labeling: Skip labeling (use generic names)
1727
+ robot_type: Force robot type (uses AI-suggested labels for that type)
1728
+ non_interactive: Run without user prompts
1729
+ push: Auto-push primitives to FoodforThought (skip confirmation)
1730
+ api_url: FoodforThought API URL (defaults to production)
1731
+ """
1732
+
1733
+ WizardUI.print_header("Robot Setup Wizard")
1734
+ print("This wizard will help you set up primitive skills for your robot.\n")
1735
+
1736
+ if not non_interactive:
1737
+ print("Requirements:")
1738
+ print(" - Robot connected via USB/serial")
1739
+ print(" - Robot powered on")
1740
+ print(" - Servos in safe position (not bearing load)\n")
1741
+
1742
+ if not WizardUI.confirm("Ready to begin?"):
1743
+ return False
1744
+
1745
+ try:
1746
+ # Step 1: Device Discovery
1747
+ WizardUI.print_step(1, self.TOTAL_STEPS, "Device Discovery")
1748
+
1749
+ if port:
1750
+ device = DiscoveredDevice(port=port, device_type=DeviceType.SERIAL)
1751
+ else:
1752
+ device = self._discover_device(non_interactive=non_interactive)
1753
+
1754
+ if not device:
1755
+ WizardUI.print_error("No device selected. Aborting.")
1756
+ return False
1757
+
1758
+ self.profile.device = device
1759
+ WizardUI.print_success(f"Using device: {device.port}")
1760
+
1761
+ # Step 2: Protocol Detection & Connection
1762
+ WizardUI.print_step(2, self.TOTAL_STEPS, "Protocol Detection")
1763
+
1764
+ if not self._connect_and_detect_protocol(device, non_interactive=non_interactive):
1765
+ return False
1766
+
1767
+ # Step 3: Servo Enumeration
1768
+ WizardUI.print_step(3, self.TOTAL_STEPS, "Servo Enumeration")
1769
+
1770
+ if not self._enumerate_servos():
1771
+ return False
1772
+
1773
+ # Step 4: Component Labeling
1774
+ WizardUI.print_step(4, self.TOTAL_STEPS, "Component Labeling")
1775
+
1776
+ if skip_labeling:
1777
+ # Apply generic labels
1778
+ for actuator in self.profile.actuators:
1779
+ actuator.label = f"servo_{actuator.id}"
1780
+ actuator.group = "main"
1781
+ actuator.role = "actuator"
1782
+ elif robot_type or non_interactive:
1783
+ # Apply AI-suggested labels for specified or detected robot type
1784
+ self._auto_label_components(robot_type)
1785
+ else:
1786
+ # Interactive labeling
1787
+ self._label_components()
1788
+
1789
+ # Step 5: Generate Primitive Skills
1790
+ WizardUI.print_step(5, self.TOTAL_STEPS, "Primitive Skill Generation")
1791
+
1792
+ self._generate_output(auto_push=push, api_url=api_url)
1793
+
1794
+ WizardUI.print_header("Setup Complete!")
1795
+ WizardUI.print_success(f"Generated files in: {self.output_dir}")
1796
+
1797
+ return True
1798
+
1799
+ except KeyboardInterrupt:
1800
+ print("\n")
1801
+ WizardUI.print_warning("Setup cancelled by user.")
1802
+ return False
1803
+ except Exception as e:
1804
+ WizardUI.print_error(f"Setup failed: {e}")
1805
+ import traceback
1806
+ traceback.print_exc()
1807
+ return False
1808
+ finally:
1809
+ if self.protocol_handler:
1810
+ self.protocol_handler.disconnect()
1811
+
1812
+ def _discover_device(self, non_interactive: bool = False) -> Optional[DiscoveredDevice]:
1813
+ """Discover and select communication device."""
1814
+ if not HAS_SERIAL:
1815
+ WizardUI.print_error("pyserial not installed. Run: pip install pyserial")
1816
+ return None
1817
+
1818
+ WizardUI.print_info("Scanning for serial ports...")
1819
+
1820
+ ports = list(serial.tools.list_ports.comports())
1821
+
1822
+ if not ports:
1823
+ WizardUI.print_warning("No serial ports found.")
1824
+ if non_interactive:
1825
+ return None
1826
+ manual_port = WizardUI.prompt("Enter port manually (or press Enter to abort)")
1827
+ if manual_port:
1828
+ return DiscoveredDevice(port=manual_port, device_type=DeviceType.SERIAL)
1829
+ return None
1830
+
1831
+ # Build device list
1832
+ devices = []
1833
+ for port in ports:
1834
+ device = DiscoveredDevice(
1835
+ port=port.device,
1836
+ device_type=DeviceType.SERIAL,
1837
+ description=port.description,
1838
+ vid=port.vid,
1839
+ pid=port.pid,
1840
+ serial_number=port.serial_number,
1841
+ manufacturer=port.manufacturer
1842
+ )
1843
+ devices.append(device)
1844
+
1845
+ # In non-interactive mode, always auto-detect
1846
+ if non_interactive:
1847
+ return self._auto_detect_robot(devices)
1848
+
1849
+ # Build options list with auto-detect first
1850
+ options = ["🔍 Auto-detect robot (recommended)"]
1851
+ for device in devices:
1852
+ desc = f"{device.port}"
1853
+ if device.description:
1854
+ desc += f" - {device.description}"
1855
+ if device.manufacturer:
1856
+ desc += f" ({device.manufacturer})"
1857
+ options.append(desc)
1858
+
1859
+ selected = WizardUI.select("Select the robot's serial port:", options)
1860
+ if not selected:
1861
+ return None
1862
+
1863
+ selection_idx = selected[0]
1864
+
1865
+ # Handle auto-detect option (index 0)
1866
+ if selection_idx == 0:
1867
+ return self._auto_detect_robot(devices)
1868
+
1869
+ # Otherwise return the selected device (subtract 1 for auto-detect option)
1870
+ return devices[selection_idx - 1]
1871
+
1872
+ def _auto_detect_robot(self, devices: List[DiscoveredDevice]) -> Optional[DiscoveredDevice]:
1873
+ """Try each port to find one with a robot (servos responding)."""
1874
+ WizardUI.print_info("Auto-detecting robot... (this may take a moment)")
1875
+
1876
+ # Filter out known system ports
1877
+ system_ports = ['debug-console', 'Bluetooth', 'MALS', 'SOC']
1878
+ candidate_devices = [
1879
+ d for d in devices
1880
+ if not any(sp.lower() in d.port.lower() for sp in system_ports)
1881
+ ]
1882
+
1883
+ # If no candidates after filtering, try all
1884
+ if not candidate_devices:
1885
+ candidate_devices = devices
1886
+
1887
+ # Try each port
1888
+ for device in candidate_devices:
1889
+ WizardUI.print_info(f" Trying {device.port}...")
1890
+
1891
+ # Try common baud rates
1892
+ for baud in [115200, 1000000, 500000]:
1893
+ try:
1894
+ handler = ServoProtocolHandler(device.port, baud, ProtocolType.HIWONDER)
1895
+ if handler.connect():
1896
+ # Try to ping a few servo IDs
1897
+ for test_id in [1, 0, 2, 3, 5, 10]:
1898
+ if handler.ping(test_id):
1899
+ WizardUI.print_success(f"Found robot on {device.port} at {baud} baud!")
1900
+ device.baud_rate = baud
1901
+ device.detected_protocol = ProtocolType.HIWONDER
1902
+ handler.disconnect()
1903
+ return device
1904
+ handler.disconnect()
1905
+ except Exception:
1906
+ pass
1907
+
1908
+ WizardUI.print_warning("Could not auto-detect robot on any port.")
1909
+ WizardUI.print_info("Please select a port manually or check connections.")
1910
+
1911
+ # Fall back to manual selection (without auto-detect option)
1912
+ options = [f"{d.port} - {d.description or 'n/a'}" for d in devices]
1913
+ selected = WizardUI.select("Select port manually:", options)
1914
+ if selected:
1915
+ return devices[selected[0]]
1916
+ return None
1917
+
1918
+ def _connect_and_detect_protocol(self, device: DiscoveredDevice, non_interactive: bool = False) -> bool:
1919
+ """Connect to device and detect servo protocol."""
1920
+
1921
+ # Try common baud rates
1922
+ baud_rates = [115200, 1000000, 500000, 57600, 9600]
1923
+
1924
+ WizardUI.print_info(f"Connecting to {device.port}...")
1925
+
1926
+ for baud in baud_rates:
1927
+ WizardUI.print_info(f" Trying {baud} baud...")
1928
+
1929
+ handler = ServoProtocolHandler(device.port, baud, ProtocolType.HIWONDER)
1930
+
1931
+ if handler.connect():
1932
+ # Try to find any servo
1933
+ for test_id in [1, 0, 2, 3]:
1934
+ if handler.ping(test_id):
1935
+ WizardUI.print_success(f"Connected at {baud} baud (HiWonder protocol)")
1936
+ device.baud_rate = baud
1937
+ device.detected_protocol = ProtocolType.HIWONDER
1938
+ self.protocol_handler = handler
1939
+ return True
1940
+
1941
+ handler.disconnect()
1942
+
1943
+ # Fallback: let user specify or use default
1944
+ WizardUI.print_warning("Could not auto-detect protocol.")
1945
+
1946
+ if non_interactive:
1947
+ device.baud_rate = 115200
1948
+ else:
1949
+ baud = WizardUI.prompt("Enter baud rate", "115200")
1950
+ try:
1951
+ device.baud_rate = int(baud)
1952
+ except ValueError:
1953
+ device.baud_rate = 115200
1954
+
1955
+ self.protocol_handler = ServoProtocolHandler(device.port, device.baud_rate)
1956
+ if self.protocol_handler.connect():
1957
+ device.detected_protocol = ProtocolType.HIWONDER
1958
+ return True
1959
+
1960
+ return False
1961
+
1962
+ def _auto_label_components(self, forced_robot_type: Optional[str] = None):
1963
+ """Apply AI-suggested labels automatically (non-interactive)."""
1964
+ servo_ids = [a.id for a in self.profile.actuators]
1965
+
1966
+ # Get robot type
1967
+ if forced_robot_type:
1968
+ robot_type = forced_robot_type
1969
+ else:
1970
+ robot_type = self.ai_assistant.suggest_robot_type(len(servo_ids), servo_ids)
1971
+
1972
+ # Show detection info
1973
+ core_count = self.ai_assistant._count_core_servos(servo_ids)
1974
+ if core_count < len(servo_ids):
1975
+ WizardUI.print_info(f"Found {len(servo_ids)} servos total, {core_count} appear to be main actuators")
1976
+ WizardUI.print_info(f"Detected robot type: {robot_type}")
1977
+ self.profile.robot_type = robot_type
1978
+
1979
+ # Get suggested labels
1980
+ suggested_labels = self.ai_assistant.suggest_labels(robot_type, servo_ids)
1981
+
1982
+ print("\nApplying suggested labels:")
1983
+ print("-" * 50)
1984
+
1985
+ for actuator in self.profile.actuators:
1986
+ if actuator.id in suggested_labels:
1987
+ actuator.label = suggested_labels[actuator.id]['label']
1988
+ actuator.group = suggested_labels[actuator.id]['group']
1989
+ actuator.role = suggested_labels[actuator.id]['role']
1990
+ print(f" Servo {actuator.id}: {actuator.label} ({actuator.group}/{actuator.role})")
1991
+ else:
1992
+ actuator.label = f"servo_{actuator.id}"
1993
+ actuator.group = "extra"
1994
+ actuator.role = "unknown"
1995
+ print(f" Servo {actuator.id}: {actuator.label} (extra/unknown)")
1996
+
1997
+ # Build groups
1998
+ self.profile.groups = {}
1999
+ for actuator in self.profile.actuators:
2000
+ if actuator.group not in self.profile.groups:
2001
+ self.profile.groups[actuator.group] = []
2002
+ self.profile.groups[actuator.group].append(actuator.id)
2003
+
2004
+ def _enumerate_servos(self) -> bool:
2005
+ """Scan for and enumerate all servos."""
2006
+
2007
+ WizardUI.print_info("Scanning for servos (this may take a moment)...")
2008
+
2009
+ id_range = range(0, 32) # Typical range for small robots
2010
+
2011
+ def progress(current, total):
2012
+ bar_len = 30
2013
+ filled = int(bar_len * current / total)
2014
+ bar = '█' * filled + '░' * (bar_len - filled)
2015
+ print(f"\r Scanning: [{bar}] {current}/{total}", end='', flush=True)
2016
+
2017
+ found_ids = self.protocol_handler.scan_servos(id_range, progress)
2018
+ print() # New line after progress bar
2019
+
2020
+ if not found_ids:
2021
+ WizardUI.print_warning("No servos found in ID range 0-31.")
2022
+
2023
+ if WizardUI.confirm("Scan extended range (0-253)?"):
2024
+ found_ids = self.protocol_handler.scan_servos(range(0, 254), progress)
2025
+ print()
2026
+
2027
+ if not found_ids:
2028
+ WizardUI.print_error("No servos found. Check connections and power.")
2029
+ return False
2030
+
2031
+ WizardUI.print_success(f"Found {len(found_ids)} servos: {found_ids}")
2032
+
2033
+ # Create actuator entries
2034
+ for servo_id in found_ids:
2035
+ chars = self.protocol_handler.characterize_servo(servo_id)
2036
+
2037
+ actuator = DiscoveredActuator(
2038
+ id=servo_id,
2039
+ actuator_type=ActuatorType.SERIAL_BUS_SERVO,
2040
+ protocol=self.profile.device.detected_protocol or ProtocolType.HIWONDER,
2041
+ characteristics=chars,
2042
+ verified_working=True
2043
+ )
2044
+ self.profile.actuators.append(actuator)
2045
+
2046
+ return True
2047
+
2048
+ def _label_components(self):
2049
+ """Run interactive labeling session."""
2050
+
2051
+ # Get robot name
2052
+ self.profile.name = WizardUI.prompt("Robot name", "my_robot")
2053
+ self.profile.manufacturer = WizardUI.prompt("Manufacturer (optional)", "")
2054
+ self.profile.model = WizardUI.prompt("Model (optional)", "")
2055
+
2056
+ # AI-assisted labeling
2057
+ self.ai_assistant.interactive_label_session(self.profile, self.protocol_handler)
2058
+
2059
+ def _generate_output(self, auto_push: bool = False, api_url: Optional[str] = None):
2060
+ """Generate all output files and optionally push to platform.
2061
+
2062
+ Args:
2063
+ auto_push: If True, push primitives to FoodforThought without prompting
2064
+ api_url: FoodforThought API URL (defaults to production)
2065
+ """
2066
+ generator = PrimitiveSkillGenerator(self.profile)
2067
+ generated_files = generator.generate_all(self.output_dir)
2068
+
2069
+ # Separate by type
2070
+ json_files = [f for f in generated_files if f.endswith('.primitive.json')]
2071
+ other_files = [f for f in generated_files if not f.endswith('.primitive.json')]
2072
+
2073
+ print("\nGenerated files:")
2074
+ for f in other_files:
2075
+ print(f" - {f}")
2076
+
2077
+ if json_files:
2078
+ print(f"\n{WizardUI.COLORS['cyan']}Generated {len(json_files)} primitive skills:{WizardUI.COLORS['reset']}")
2079
+ primitives_dir = self.output_dir / "primitives"
2080
+ print(f" - {primitives_dir}/*.primitive.json")
2081
+
2082
+ # Show breakdown by category
2083
+ categories = {}
2084
+ for p in generator.generated_primitives:
2085
+ cat = p.get('category', 'other')
2086
+ categories[cat] = categories.get(cat, 0) + 1
2087
+ print("\n Breakdown:")
2088
+ for cat, count in sorted(categories.items()):
2089
+ print(f" - {cat}: {count}")
2090
+
2091
+ # Push flow
2092
+ self.generator = generator # Store for access to primitives
2093
+
2094
+ if generator.generated_primitives:
2095
+ if auto_push:
2096
+ self._push_primitives(api_url)
2097
+ else:
2098
+ print(f"\n{WizardUI.COLORS['bold']}Ready to share with the community?{WizardUI.COLORS['reset']}")
2099
+ if WizardUI.confirm(f"Push {len(generator.generated_primitives)} primitives to FoodforThought?"):
2100
+ self._push_primitives(api_url)
2101
+ else:
2102
+ print("\nYou can push later with:")
2103
+ print(f" ate primitive push {self.output_dir / 'primitives'}/*.primitive.json")
2104
+
2105
+ def _push_primitives(self, api_url: Optional[str] = None):
2106
+ """Push generated primitives to FoodforThought platform."""
2107
+ import requests
2108
+
2109
+ base_url = api_url or os.getenv("FOODFORTHOUGHT_API_URL", "https://kindlyrobotics.com")
2110
+ api_endpoint = f"{base_url}/api/primitives"
2111
+
2112
+ # Check for auth token
2113
+ token = os.getenv("FOODFORTHOUGHT_TOKEN") or os.getenv("ATE_TOKEN")
2114
+ if not token:
2115
+ WizardUI.print_warning("No API token found. Set FOODFORTHOUGHT_TOKEN or ATE_TOKEN")
2116
+ WizardUI.print_info("Get your token at: https://kindlyrobotics.com/foodforthought/settings")
2117
+ return
2118
+
2119
+ headers = {
2120
+ "Authorization": f"Bearer {token}",
2121
+ "Content-Type": "application/json"
2122
+ }
2123
+
2124
+ print(f"\n{WizardUI.COLORS['cyan']}Pushing primitives to FoodforThought...{WizardUI.COLORS['reset']}")
2125
+
2126
+ success_count = 0
2127
+ fail_count = 0
2128
+
2129
+ for primitive in self.generator.generated_primitives:
2130
+ try:
2131
+ # Transform to API format
2132
+ payload = {
2133
+ "name": primitive["name"],
2134
+ "description": primitive.get("description", ""),
2135
+ "category": primitive.get("category", "motion"),
2136
+ "commandTemplate": primitive.get("commandTemplate", ""),
2137
+ "commandType": primitive.get("commandType", "single"),
2138
+ "parameters": primitive.get("parameters", {}),
2139
+ "executionTimeMs": primitive.get("executionTimeMs"),
2140
+ "settleTimeMs": primitive.get("settleTimeMs"),
2141
+ "cooldownMs": primitive.get("cooldownMs"),
2142
+ "safetyNotes": primitive.get("safetyNotes"),
2143
+ "robotModel": primitive.get("robotModel"),
2144
+ "robotType": primitive.get("robotType"),
2145
+ }
2146
+
2147
+ # Add protocol reference if available
2148
+ if primitive.get("protocol"):
2149
+ payload["protocol"] = primitive["protocol"]
2150
+
2151
+ response = requests.post(api_endpoint, json=payload, headers=headers, timeout=10)
2152
+
2153
+ if response.status_code in (200, 201):
2154
+ success_count += 1
2155
+ else:
2156
+ fail_count += 1
2157
+ if fail_count == 1:
2158
+ # Show first error
2159
+ WizardUI.print_warning(f"Failed to push {primitive['name']}: {response.text[:100]}")
2160
+
2161
+ except requests.exceptions.RequestException as e:
2162
+ fail_count += 1
2163
+ if fail_count == 1:
2164
+ WizardUI.print_error(f"Network error: {e}")
2165
+ if "kindlyrobotics.com" in str(api_endpoint):
2166
+ WizardUI.print_info("Make sure you're connected to the internet")
2167
+
2168
+ # Summary
2169
+ if success_count > 0:
2170
+ WizardUI.print_success(f"Pushed {success_count} primitives to FoodforThought!")
2171
+ print(f" View at: {base_url}/foodforthought/robots")
2172
+ if fail_count > 0:
2173
+ WizardUI.print_warning(f"{fail_count} primitives failed to push")
2174
+ print(f" Retry with: ate primitive push {self.output_dir / 'primitives'}/*.primitive.json")
2175
+
2176
+
2177
+ def run_wizard(port: Optional[str] = None,
2178
+ output: str = "./robot",
2179
+ skip_labeling: bool = False,
2180
+ robot_type: Optional[str] = None,
2181
+ non_interactive: bool = False,
2182
+ push: bool = False,
2183
+ api_url: Optional[str] = None) -> bool:
2184
+ """Entry point for CLI.
2185
+
2186
+ Args:
2187
+ port: Serial port to use
2188
+ output: Output directory for generated files
2189
+ skip_labeling: Skip labeling entirely (generic names)
2190
+ robot_type: Force robot type (e.g., 'quadruped', 'humanoid_full')
2191
+ non_interactive: Run without user prompts (use defaults)
2192
+ push: Auto-push primitives to FoodforThought platform
2193
+ api_url: FoodforThought API URL (defaults to production)
2194
+ """
2195
+ wizard = RobotSetupWizard(output_dir=output)
2196
+ return wizard.run(
2197
+ port=port,
2198
+ skip_labeling=skip_labeling,
2199
+ robot_type=robot_type,
2200
+ non_interactive=non_interactive,
2201
+ push=push,
2202
+ api_url=api_url
2203
+ )
2204
+
2205
+
2206
+ if __name__ == "__main__":
2207
+ # Quick test
2208
+ import argparse
2209
+ parser = argparse.ArgumentParser(description="Robot Setup Wizard")
2210
+ parser.add_argument("--port", "-p", help="Serial port (skip device selection)")
2211
+ parser.add_argument("--output", "-o", default="./robot", help="Output directory")
2212
+ parser.add_argument("--skip-labeling", action="store_true", help="Skip interactive labeling")
2213
+
2214
+ args = parser.parse_args()
2215
+
2216
+ success = run_wizard(
2217
+ port=args.port,
2218
+ output=args.output,
2219
+ skip_labeling=args.skip_labeling
2220
+ )
2221
+
2222
+ sys.exit(0 if success else 1)