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.
- ate/__init__.py +1 -1
- ate/behaviors/__init__.py +88 -0
- ate/behaviors/common.py +686 -0
- ate/behaviors/tree.py +454 -0
- ate/cli.py +610 -54
- ate/drivers/__init__.py +27 -0
- ate/drivers/mechdog.py +606 -0
- ate/interfaces/__init__.py +171 -0
- ate/interfaces/base.py +271 -0
- ate/interfaces/body.py +267 -0
- ate/interfaces/detection.py +282 -0
- ate/interfaces/locomotion.py +422 -0
- ate/interfaces/manipulation.py +408 -0
- ate/interfaces/navigation.py +389 -0
- ate/interfaces/perception.py +362 -0
- ate/interfaces/types.py +371 -0
- ate/mcp_server.py +387 -0
- ate/recording/__init__.py +44 -0
- ate/recording/demonstration.py +378 -0
- ate/recording/session.py +405 -0
- ate/recording/upload.py +304 -0
- ate/recording/wrapper.py +95 -0
- ate/robot/__init__.py +79 -0
- ate/robot/calibration.py +583 -0
- ate/robot/commands.py +3603 -0
- ate/robot/discovery.py +339 -0
- ate/robot/introspection.py +330 -0
- ate/robot/manager.py +270 -0
- ate/robot/profiles.py +275 -0
- ate/robot/registry.py +319 -0
- ate/robot/skill_upload.py +393 -0
- ate/robot/visual_labeler.py +1039 -0
- {foodforthought_cli-0.2.4.dist-info → foodforthought_cli-0.2.8.dist-info}/METADATA +9 -1
- {foodforthought_cli-0.2.4.dist-info → foodforthought_cli-0.2.8.dist-info}/RECORD +37 -8
- {foodforthought_cli-0.2.4.dist-info → foodforthought_cli-0.2.8.dist-info}/WHEEL +0 -0
- {foodforthought_cli-0.2.4.dist-info → foodforthought_cli-0.2.8.dist-info}/entry_points.txt +0 -0
- {foodforthought_cli-0.2.4.dist-info → foodforthought_cli-0.2.8.dist-info}/top_level.txt +0 -0
ate/recording/session.py
ADDED
|
@@ -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)
|
ate/recording/upload.py
ADDED
|
@@ -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)}")
|