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,405 @@
1
+ """
2
+ Recording session for capturing robot demonstrations.
3
+ """
4
+
5
+ import time
6
+ import json
7
+ import uuid
8
+ from datetime import datetime
9
+ from dataclasses import dataclass, field, asdict
10
+ from typing import Any, List, Optional, Dict, Callable
11
+ from functools import wraps
12
+
13
+ from ate.interfaces import RobotInterface, ActionResult
14
+
15
+
16
+ @dataclass
17
+ class RecordedCall:
18
+ """A single recorded interface method call."""
19
+
20
+ timestamp: float # Unix timestamp (seconds)
21
+ relative_time: float # Time since recording started (seconds)
22
+ interface: str # Interface name (e.g., "QuadrupedLocomotion")
23
+ method: str # Method name (e.g., "walk")
24
+ args: tuple # Positional arguments (serialized)
25
+ kwargs: dict # Keyword arguments (serialized)
26
+ result: Any # Return value (serialized)
27
+ success: bool # Whether the call succeeded
28
+ error: Optional[str] = None # Error message if failed
29
+
30
+ def to_dict(self) -> dict:
31
+ """Convert to dictionary for serialization."""
32
+ return {
33
+ "timestamp": self.timestamp,
34
+ "relative_time": self.relative_time,
35
+ "interface": self.interface,
36
+ "method": self.method,
37
+ "args": self._serialize_args(self.args),
38
+ "kwargs": self._serialize_kwargs(self.kwargs),
39
+ "result": self._serialize_result(self.result),
40
+ "success": self.success,
41
+ "error": self.error,
42
+ }
43
+
44
+ def _serialize_args(self, args: tuple) -> list:
45
+ """Serialize arguments to JSON-compatible format."""
46
+ return [self._serialize_value(arg) for arg in args]
47
+
48
+ def _serialize_kwargs(self, kwargs: dict) -> dict:
49
+ """Serialize keyword arguments to JSON-compatible format."""
50
+ return {k: self._serialize_value(v) for k, v in kwargs.items()}
51
+
52
+ def _serialize_value(self, value: Any) -> Any:
53
+ """Serialize a single value."""
54
+ # Handle common types
55
+ if value is None or isinstance(value, (bool, int, float, str)):
56
+ return value
57
+ if isinstance(value, (list, tuple)):
58
+ return [self._serialize_value(v) for v in value]
59
+ if isinstance(value, dict):
60
+ return {k: self._serialize_value(v) for k, v in value.items()}
61
+
62
+ # Handle our interface types
63
+ if hasattr(value, '__dataclass_fields__'):
64
+ return {"__type__": type(value).__name__, **asdict(value)}
65
+ if hasattr(value, 'to_dict'):
66
+ return {"__type__": type(value).__name__, **value.to_dict()}
67
+
68
+ # Handle ActionResult
69
+ if isinstance(value, ActionResult):
70
+ return {
71
+ "__type__": "ActionResult",
72
+ "success": value.success,
73
+ "message": value.message,
74
+ "error": value.error,
75
+ }
76
+
77
+ # Fallback: convert to string
78
+ return {"__type__": type(value).__name__, "__str__": str(value)}
79
+
80
+ def _serialize_result(self, result: Any) -> Any:
81
+ """Serialize return value."""
82
+ return self._serialize_value(result)
83
+
84
+ @classmethod
85
+ def from_dict(cls, data: dict) -> "RecordedCall":
86
+ """Create from dictionary."""
87
+ return cls(
88
+ timestamp=data["timestamp"],
89
+ relative_time=data["relative_time"],
90
+ interface=data["interface"],
91
+ method=data["method"],
92
+ args=tuple(data["args"]),
93
+ kwargs=data["kwargs"],
94
+ result=data["result"],
95
+ success=data["success"],
96
+ error=data.get("error"),
97
+ )
98
+
99
+
100
+ @dataclass
101
+ class RecordingMetadata:
102
+ """Metadata for a recording session."""
103
+
104
+ id: str # Unique recording ID
105
+ name: str # Human-readable name
106
+ robot_name: str # Robot name from driver
107
+ robot_model: str # Robot model from driver
108
+ robot_archetype: str # Robot archetype (quadruped, etc.)
109
+ capabilities: List[str] # Robot capabilities
110
+ start_time: float # Unix timestamp
111
+ end_time: Optional[float] = None # Unix timestamp
112
+ duration: Optional[float] = None # Seconds
113
+ description: Optional[str] = None
114
+ tags: List[str] = field(default_factory=list)
115
+
116
+ def to_dict(self) -> dict:
117
+ return asdict(self)
118
+
119
+ @classmethod
120
+ def from_dict(cls, data: dict) -> "RecordingMetadata":
121
+ return cls(**data)
122
+
123
+
124
+ class RecordingSession:
125
+ """
126
+ Context manager for recording robot demonstrations.
127
+
128
+ Wraps a robot driver to intercept and record all interface method calls.
129
+
130
+ Usage:
131
+ with RecordingSession(driver, name="my_demo") as session:
132
+ driver.stand()
133
+ driver.walk(Vector3.forward(), speed=0.3)
134
+
135
+ session.save("my_demo.demonstration")
136
+ """
137
+
138
+ def __init__(
139
+ self,
140
+ driver: RobotInterface,
141
+ name: str = "recording",
142
+ description: Optional[str] = None,
143
+ tags: Optional[List[str]] = None,
144
+ auto_save: bool = False,
145
+ save_path: Optional[str] = None,
146
+ ):
147
+ """
148
+ Initialize a recording session.
149
+
150
+ Args:
151
+ driver: Robot driver to record
152
+ name: Human-readable name for the recording
153
+ description: Optional description
154
+ tags: Optional tags for categorization
155
+ auto_save: If True, save automatically on exit
156
+ save_path: Path for auto-save (uses name if not provided)
157
+ """
158
+ self._driver = driver
159
+ self._name = name
160
+ self._description = description
161
+ self._tags = tags or []
162
+ self._auto_save = auto_save
163
+ self._save_path = save_path
164
+
165
+ self._calls: List[RecordedCall] = []
166
+ self._start_time: Optional[float] = None
167
+ self._end_time: Optional[float] = None
168
+ self._original_methods: Dict[str, Callable] = {}
169
+ self._recording = False
170
+
171
+ @property
172
+ def calls(self) -> List[RecordedCall]:
173
+ """Get all recorded calls."""
174
+ return self._calls.copy()
175
+
176
+ @property
177
+ def duration(self) -> Optional[float]:
178
+ """Get recording duration in seconds."""
179
+ if self._start_time is None:
180
+ return None
181
+ end = self._end_time or time.time()
182
+ return end - self._start_time
183
+
184
+ @property
185
+ def is_recording(self) -> bool:
186
+ """Check if currently recording."""
187
+ return self._recording
188
+
189
+ def __enter__(self) -> "RecordingSession":
190
+ """Start recording."""
191
+ self.start()
192
+ return self
193
+
194
+ def __exit__(self, exc_type, exc_val, exc_tb):
195
+ """Stop recording."""
196
+ self.stop()
197
+ if self._auto_save:
198
+ path = self._save_path or f"{self._name}.demonstration"
199
+ self.save(path)
200
+ return False # Don't suppress exceptions
201
+
202
+ def start(self) -> None:
203
+ """Start recording interface calls."""
204
+ if self._recording:
205
+ return
206
+
207
+ self._start_time = time.time()
208
+ self._recording = True
209
+ self._wrap_driver_methods()
210
+
211
+ def stop(self) -> None:
212
+ """Stop recording interface calls."""
213
+ if not self._recording:
214
+ return
215
+
216
+ self._end_time = time.time()
217
+ self._recording = False
218
+ self._unwrap_driver_methods()
219
+
220
+ def _wrap_driver_methods(self) -> None:
221
+ """Wrap all driver methods to intercept calls."""
222
+ # Get all interfaces the driver implements
223
+ interfaces = self._get_driver_interfaces()
224
+
225
+ for interface_name, interface_cls in interfaces.items():
226
+ # Get methods defined in this interface
227
+ for method_name in self._get_interface_methods(interface_cls):
228
+ if hasattr(self._driver, method_name):
229
+ original = getattr(self._driver, method_name)
230
+ if callable(original) and not method_name.startswith('_'):
231
+ # Store original
232
+ key = f"{interface_name}.{method_name}"
233
+ self._original_methods[key] = original
234
+
235
+ # Create wrapper
236
+ wrapper = self._create_wrapper(interface_name, method_name, original)
237
+ setattr(self._driver, method_name, wrapper)
238
+
239
+ def _unwrap_driver_methods(self) -> None:
240
+ """Restore original driver methods."""
241
+ for key, original in self._original_methods.items():
242
+ _, method_name = key.split(".", 1)
243
+ setattr(self._driver, method_name, original)
244
+ self._original_methods.clear()
245
+
246
+ def _get_driver_interfaces(self) -> Dict[str, type]:
247
+ """Get all interface classes the driver implements."""
248
+ from ate.interfaces import (
249
+ RobotInterface, SafetyInterface,
250
+ QuadrupedLocomotion, BipedLocomotion, WheeledLocomotion, AerialLocomotion,
251
+ ArmInterface, GripperInterface, DualArmInterface,
252
+ CameraInterface, DepthCameraInterface, LidarInterface, IMUInterface, ForceTorqueInterface,
253
+ BodyPoseInterface,
254
+ )
255
+
256
+ all_interfaces = {
257
+ "RobotInterface": RobotInterface,
258
+ "SafetyInterface": SafetyInterface,
259
+ "QuadrupedLocomotion": QuadrupedLocomotion,
260
+ "BipedLocomotion": BipedLocomotion,
261
+ "WheeledLocomotion": WheeledLocomotion,
262
+ "AerialLocomotion": AerialLocomotion,
263
+ "ArmInterface": ArmInterface,
264
+ "GripperInterface": GripperInterface,
265
+ "DualArmInterface": DualArmInterface,
266
+ "CameraInterface": CameraInterface,
267
+ "DepthCameraInterface": DepthCameraInterface,
268
+ "LidarInterface": LidarInterface,
269
+ "IMUInterface": IMUInterface,
270
+ "ForceTorqueInterface": ForceTorqueInterface,
271
+ "BodyPoseInterface": BodyPoseInterface,
272
+ }
273
+
274
+ return {
275
+ name: cls for name, cls in all_interfaces.items()
276
+ if isinstance(self._driver, cls)
277
+ }
278
+
279
+ def _get_interface_methods(self, interface_cls: type) -> List[str]:
280
+ """Get public methods defined in an interface class."""
281
+ methods = []
282
+ for name in dir(interface_cls):
283
+ if not name.startswith('_'):
284
+ attr = getattr(interface_cls, name, None)
285
+ if callable(attr):
286
+ methods.append(name)
287
+ return methods
288
+
289
+ def _create_wrapper(
290
+ self,
291
+ interface_name: str,
292
+ method_name: str,
293
+ original: Callable
294
+ ) -> Callable:
295
+ """Create a wrapper function that records the call."""
296
+
297
+ @wraps(original)
298
+ def wrapper(*args, **kwargs):
299
+ timestamp = time.time()
300
+ relative_time = timestamp - self._start_time
301
+
302
+ try:
303
+ result = original(*args, **kwargs)
304
+ success = True
305
+ error = None
306
+
307
+ # Check ActionResult for failure
308
+ if isinstance(result, ActionResult) and not result.success:
309
+ error = result.error
310
+
311
+ except Exception as e:
312
+ result = None
313
+ success = False
314
+ error = str(e)
315
+ raise
316
+ finally:
317
+ # Record the call
318
+ call = RecordedCall(
319
+ timestamp=timestamp,
320
+ relative_time=relative_time,
321
+ interface=interface_name,
322
+ method=method_name,
323
+ args=args,
324
+ kwargs=kwargs,
325
+ result=result,
326
+ success=success,
327
+ error=error,
328
+ )
329
+ self._calls.append(call)
330
+
331
+ return result
332
+
333
+ return wrapper
334
+
335
+ def get_metadata(self) -> RecordingMetadata:
336
+ """Get recording metadata."""
337
+ info = self._driver.get_info()
338
+ return RecordingMetadata(
339
+ id=str(uuid.uuid4()),
340
+ name=self._name,
341
+ robot_name=info.name,
342
+ robot_model=info.model,
343
+ robot_archetype=info.archetype,
344
+ capabilities=[c.name for c in info.capabilities],
345
+ start_time=self._start_time or 0,
346
+ end_time=self._end_time,
347
+ duration=self.duration,
348
+ description=self._description,
349
+ tags=self._tags,
350
+ )
351
+
352
+ def save(self, path: str) -> None:
353
+ """
354
+ Save recording to a file.
355
+
356
+ Args:
357
+ path: File path (should end with .demonstration)
358
+ """
359
+ data = {
360
+ "version": "1.0",
361
+ "metadata": self.get_metadata().to_dict(),
362
+ "calls": [call.to_dict() for call in self._calls],
363
+ }
364
+
365
+ with open(path, 'w') as f:
366
+ json.dump(data, f, indent=2)
367
+
368
+ def to_dict(self) -> dict:
369
+ """Convert to dictionary for API upload."""
370
+ return {
371
+ "version": "1.0",
372
+ "metadata": self.get_metadata().to_dict(),
373
+ "calls": [call.to_dict() for call in self._calls],
374
+ }
375
+
376
+ def summary(self) -> str:
377
+ """Get a human-readable summary of the recording."""
378
+ if not self._calls:
379
+ return "No calls recorded"
380
+
381
+ # Group by interface
382
+ by_interface: Dict[str, List[RecordedCall]] = {}
383
+ for call in self._calls:
384
+ if call.interface not in by_interface:
385
+ by_interface[call.interface] = []
386
+ by_interface[call.interface].append(call)
387
+
388
+ lines = [
389
+ f"Recording: {self._name}",
390
+ f"Duration: {self.duration:.2f}s" if self.duration else "Duration: N/A",
391
+ f"Total calls: {len(self._calls)}",
392
+ "",
393
+ "By interface:",
394
+ ]
395
+
396
+ for interface, calls in by_interface.items():
397
+ lines.append(f" {interface}: {len(calls)} calls")
398
+ # Count by method
399
+ by_method: Dict[str, int] = {}
400
+ for call in calls:
401
+ by_method[call.method] = by_method.get(call.method, 0) + 1
402
+ for method, count in sorted(by_method.items()):
403
+ lines.append(f" - {method}: {count}")
404
+
405
+ return "\n".join(lines)
@@ -0,0 +1,304 @@
1
+ """
2
+ Upload demonstrations to FoodforThought.
3
+
4
+ Converts interface recordings to the FoodforThought telemetry format
5
+ and uploads them as artifacts for labeling and training.
6
+ """
7
+
8
+ import os
9
+ import json
10
+ import requests
11
+ from datetime import datetime
12
+ from typing import Optional, Dict, Any
13
+ from pathlib import Path
14
+
15
+ from .demonstration import Demonstration, load_demonstration
16
+ from .session import RecordingSession
17
+
18
+
19
+ # API configuration
20
+ BASE_URL = os.getenv("ATE_API_URL", "https://www.kindly.fyi/api")
21
+ CONFIG_FILE = Path.home() / ".ate" / "config.json"
22
+
23
+
24
+ class DemonstrationUploader:
25
+ """
26
+ Uploads demonstrations to FoodforThought.
27
+
28
+ Handles authentication and converts the interface-based recording
29
+ format to the FoodforThought telemetry ingest format.
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ base_url: str = BASE_URL,
35
+ api_key: Optional[str] = None,
36
+ ):
37
+ """
38
+ Initialize uploader.
39
+
40
+ Args:
41
+ base_url: FoodforThought API URL
42
+ api_key: API key (or set ATE_API_KEY env var)
43
+ """
44
+ self.base_url = base_url
45
+ self.headers = {
46
+ "Content-Type": "application/json",
47
+ }
48
+
49
+ token = None
50
+
51
+ # Try to load from config file first (device auth flow)
52
+ if CONFIG_FILE.exists():
53
+ try:
54
+ with open(CONFIG_FILE) as f:
55
+ config = json.load(f)
56
+ # Prefer access_token from device auth flow
57
+ token = config.get("access_token") or config.get("api_key")
58
+ except Exception:
59
+ pass
60
+
61
+ # Override with explicit api_key or env var
62
+ if api_key:
63
+ token = api_key
64
+ elif os.getenv("ATE_API_KEY"):
65
+ token = os.getenv("ATE_API_KEY")
66
+
67
+ if token:
68
+ self.headers["Authorization"] = f"Bearer {token}"
69
+ else:
70
+ raise ValueError(
71
+ "Not logged in. Run 'ate login' to authenticate."
72
+ )
73
+
74
+ def _request(self, method: str, endpoint: str, **kwargs) -> Dict:
75
+ """Make HTTP request to API."""
76
+ url = f"{self.base_url}{endpoint}"
77
+ response = requests.request(method, url, headers=self.headers, **kwargs)
78
+ response.raise_for_status()
79
+ return response.json()
80
+
81
+ def upload(
82
+ self,
83
+ demonstration: Demonstration,
84
+ project_id: Optional[str] = None,
85
+ skill_id: Optional[str] = None,
86
+ create_labeling_task: bool = False,
87
+ ) -> Dict[str, Any]:
88
+ """
89
+ Upload a demonstration to FoodforThought.
90
+
91
+ Args:
92
+ demonstration: Demonstration object to upload
93
+ project_id: Optional project ID to associate with
94
+ skill_id: Optional skill ID this demonstrates
95
+ create_labeling_task: Create a labeling task for annotation
96
+
97
+ Returns:
98
+ Response dict with artifactId and optional taskId
99
+ """
100
+ # Convert to telemetry ingest format
101
+ recording_data = self._convert_to_telemetry_format(
102
+ demonstration,
103
+ skill_id=skill_id,
104
+ )
105
+
106
+ if create_labeling_task:
107
+ recording_data["createLabelingTask"] = True
108
+
109
+ # Upload via telemetry ingest API
110
+ response = self._request("POST", "/telemetry/ingest", json=recording_data)
111
+
112
+ return {
113
+ "success": True,
114
+ "artifactId": response.get("data", {}).get("artifactId"),
115
+ "taskId": response.get("data", {}).get("taskId"),
116
+ "url": f"https://foodforthought.kindly.fyi/artifacts/{response.get('data', {}).get('artifactId', '')}",
117
+ }
118
+
119
+ def upload_file(
120
+ self,
121
+ path: str,
122
+ project_id: Optional[str] = None,
123
+ skill_id: Optional[str] = None,
124
+ create_labeling_task: bool = False,
125
+ ) -> Dict[str, Any]:
126
+ """
127
+ Upload a demonstration file to FoodforThought.
128
+
129
+ Args:
130
+ path: Path to .demonstration file
131
+ project_id: Optional project ID
132
+ skill_id: Optional skill ID
133
+ create_labeling_task: Create labeling task
134
+
135
+ Returns:
136
+ Response dict with artifactId
137
+ """
138
+ demonstration = load_demonstration(path)
139
+ return self.upload(
140
+ demonstration,
141
+ project_id=project_id,
142
+ skill_id=skill_id,
143
+ create_labeling_task=create_labeling_task,
144
+ )
145
+
146
+ def upload_session(
147
+ self,
148
+ session: RecordingSession,
149
+ project_id: Optional[str] = None,
150
+ skill_id: Optional[str] = None,
151
+ create_labeling_task: bool = False,
152
+ ) -> Dict[str, Any]:
153
+ """
154
+ Upload a recording session directly to FoodforThought.
155
+
156
+ Args:
157
+ session: RecordingSession to upload
158
+ project_id: Optional project ID
159
+ skill_id: Optional skill ID
160
+ create_labeling_task: Create labeling task
161
+
162
+ Returns:
163
+ Response dict with artifactId
164
+ """
165
+ # Convert session to demonstration
166
+ metadata = session.get_metadata()
167
+ demonstration = Demonstration(
168
+ metadata=metadata,
169
+ calls=session.calls,
170
+ segments=[],
171
+ )
172
+
173
+ return self.upload(
174
+ demonstration,
175
+ project_id=project_id,
176
+ skill_id=skill_id,
177
+ create_labeling_task=create_labeling_task,
178
+ )
179
+
180
+ def _convert_to_telemetry_format(
181
+ self,
182
+ demonstration: Demonstration,
183
+ skill_id: Optional[str] = None,
184
+ ) -> Dict[str, Any]:
185
+ """
186
+ Convert demonstration to FoodforThought telemetry ingest format.
187
+
188
+ The telemetry format is designed for time-series data from robots.
189
+ We map interface calls to this format while preserving the
190
+ abstract nature of the recording.
191
+ """
192
+ metadata = demonstration.metadata
193
+
194
+ # Convert calls to telemetry frames
195
+ frames = []
196
+ for call in demonstration.calls:
197
+ frame = {
198
+ "timestamp": call.timestamp,
199
+ "relativeTime": call.relative_time,
200
+ "type": "interface_call",
201
+ "data": {
202
+ "interface": call.interface,
203
+ "method": call.method,
204
+ "args": call.args,
205
+ "kwargs": call.kwargs,
206
+ "result": call.result,
207
+ "success": call.success,
208
+ },
209
+ }
210
+ if call.error:
211
+ frame["data"]["error"] = call.error
212
+ frames.append(frame)
213
+
214
+ # Convert segments to events
215
+ events = []
216
+ for segment in demonstration.segments:
217
+ events.append({
218
+ "type": "task_segment",
219
+ "startTime": segment.start_time,
220
+ "endTime": segment.end_time,
221
+ "label": segment.label,
222
+ "description": segment.description,
223
+ "confidence": segment.confidence,
224
+ })
225
+
226
+ # Build recording data
227
+ start_time = datetime.fromtimestamp(metadata.start_time).isoformat() if metadata.start_time else None
228
+ end_time = datetime.fromtimestamp(metadata.end_time).isoformat() if metadata.end_time else None
229
+
230
+ recording_data = {
231
+ "recording": {
232
+ "id": metadata.id,
233
+ "robotId": metadata.robot_model,
234
+ "skillId": skill_id or "demonstration",
235
+ "source": "interface_recording",
236
+ "startTime": start_time,
237
+ "endTime": end_time,
238
+ "success": all(c.success for c in demonstration.calls),
239
+ "metadata": {
240
+ "name": metadata.name,
241
+ "description": metadata.description,
242
+ "robotName": metadata.robot_name,
243
+ "robotModel": metadata.robot_model,
244
+ "robotArchetype": metadata.robot_archetype,
245
+ "capabilities": metadata.capabilities,
246
+ "duration": metadata.duration,
247
+ "callCount": len(demonstration.calls),
248
+ "segmentCount": len(demonstration.segments),
249
+ "interfacesUsed": demonstration.get_interfaces_used(),
250
+ "tags": metadata.tags + ["interface_recording"],
251
+ },
252
+ "frames": frames,
253
+ "events": events,
254
+ },
255
+ }
256
+
257
+ return recording_data
258
+
259
+
260
+ def upload_demonstration(
261
+ path_or_demonstration,
262
+ project_id: Optional[str] = None,
263
+ skill_id: Optional[str] = None,
264
+ create_labeling_task: bool = False,
265
+ api_key: Optional[str] = None,
266
+ ) -> Dict[str, Any]:
267
+ """
268
+ Convenience function to upload a demonstration.
269
+
270
+ Args:
271
+ path_or_demonstration: Path to .demonstration file or Demonstration object
272
+ project_id: Optional project ID
273
+ skill_id: Optional skill ID
274
+ create_labeling_task: Create labeling task
275
+ api_key: Optional API key
276
+
277
+ Returns:
278
+ Response dict with artifactId
279
+ """
280
+ uploader = DemonstrationUploader(api_key=api_key)
281
+
282
+ if isinstance(path_or_demonstration, str):
283
+ return uploader.upload_file(
284
+ path_or_demonstration,
285
+ project_id=project_id,
286
+ skill_id=skill_id,
287
+ create_labeling_task=create_labeling_task,
288
+ )
289
+ elif isinstance(path_or_demonstration, Demonstration):
290
+ return uploader.upload(
291
+ path_or_demonstration,
292
+ project_id=project_id,
293
+ skill_id=skill_id,
294
+ create_labeling_task=create_labeling_task,
295
+ )
296
+ elif isinstance(path_or_demonstration, RecordingSession):
297
+ return uploader.upload_session(
298
+ path_or_demonstration,
299
+ project_id=project_id,
300
+ skill_id=skill_id,
301
+ create_labeling_task=create_labeling_task,
302
+ )
303
+ else:
304
+ raise TypeError(f"Expected path, Demonstration, or RecordingSession, got {type(path_or_demonstration)}")