foodforthought-cli 0.2.7__py3-none-any.whl → 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ate/__init__.py +6 -0
- ate/__main__.py +16 -0
- ate/auth/__init__.py +1 -0
- ate/auth/device_flow.py +141 -0
- ate/auth/token_store.py +96 -0
- ate/behaviors/__init__.py +100 -0
- ate/behaviors/approach.py +399 -0
- ate/behaviors/common.py +686 -0
- ate/behaviors/tree.py +454 -0
- ate/cli.py +855 -3995
- ate/client.py +90 -0
- ate/commands/__init__.py +168 -0
- ate/commands/auth.py +389 -0
- ate/commands/bridge.py +448 -0
- ate/commands/data.py +185 -0
- ate/commands/deps.py +111 -0
- ate/commands/generate.py +384 -0
- ate/commands/memory.py +907 -0
- ate/commands/parts.py +166 -0
- ate/commands/primitive.py +399 -0
- ate/commands/protocol.py +288 -0
- ate/commands/recording.py +524 -0
- ate/commands/repo.py +154 -0
- ate/commands/simulation.py +291 -0
- ate/commands/skill.py +303 -0
- ate/commands/skills.py +487 -0
- ate/commands/team.py +147 -0
- ate/commands/workflow.py +271 -0
- ate/detection/__init__.py +38 -0
- ate/detection/base.py +142 -0
- ate/detection/color_detector.py +399 -0
- ate/detection/trash_detector.py +322 -0
- ate/drivers/__init__.py +39 -0
- ate/drivers/ble_transport.py +405 -0
- ate/drivers/mechdog.py +942 -0
- ate/drivers/wifi_camera.py +477 -0
- ate/interfaces/__init__.py +187 -0
- ate/interfaces/base.py +273 -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/sensors.py +247 -0
- ate/interfaces/types.py +371 -0
- ate/llm_proxy.py +239 -0
- ate/mcp_server.py +387 -0
- ate/memory/__init__.py +35 -0
- ate/memory/cloud.py +244 -0
- ate/memory/context.py +269 -0
- ate/memory/embeddings.py +184 -0
- ate/memory/export.py +26 -0
- ate/memory/merge.py +146 -0
- ate/memory/migrate/__init__.py +34 -0
- ate/memory/migrate/base.py +89 -0
- ate/memory/migrate/pipeline.py +189 -0
- ate/memory/migrate/sources/__init__.py +13 -0
- ate/memory/migrate/sources/chroma.py +170 -0
- ate/memory/migrate/sources/pinecone.py +120 -0
- ate/memory/migrate/sources/qdrant.py +110 -0
- ate/memory/migrate/sources/weaviate.py +160 -0
- ate/memory/reranker.py +353 -0
- ate/memory/search.py +26 -0
- ate/memory/store.py +548 -0
- ate/recording/__init__.py +83 -0
- ate/recording/demonstration.py +378 -0
- ate/recording/session.py +415 -0
- ate/recording/upload.py +304 -0
- ate/recording/visual.py +416 -0
- ate/recording/wrapper.py +95 -0
- ate/robot/__init__.py +221 -0
- ate/robot/agentic_servo.py +856 -0
- ate/robot/behaviors.py +493 -0
- ate/robot/ble_capture.py +1000 -0
- ate/robot/ble_enumerate.py +506 -0
- ate/robot/calibration.py +668 -0
- ate/robot/calibration_state.py +388 -0
- ate/robot/commands.py +3735 -0
- ate/robot/direction_calibration.py +554 -0
- ate/robot/discovery.py +441 -0
- ate/robot/introspection.py +330 -0
- ate/robot/llm_system_id.py +654 -0
- ate/robot/locomotion_calibration.py +508 -0
- ate/robot/manager.py +270 -0
- ate/robot/marker_generator.py +611 -0
- ate/robot/perception.py +502 -0
- ate/robot/primitives.py +614 -0
- ate/robot/profiles.py +281 -0
- ate/robot/registry.py +322 -0
- ate/robot/servo_mapper.py +1153 -0
- ate/robot/skill_upload.py +675 -0
- ate/robot/target_calibration.py +500 -0
- ate/robot/teach.py +515 -0
- ate/robot/types.py +242 -0
- ate/robot/visual_labeler.py +1048 -0
- ate/robot/visual_servo_loop.py +494 -0
- ate/robot/visual_servoing.py +570 -0
- ate/robot/visual_system_id.py +906 -0
- ate/transports/__init__.py +121 -0
- ate/transports/base.py +394 -0
- ate/transports/ble.py +405 -0
- ate/transports/hybrid.py +444 -0
- ate/transports/serial.py +345 -0
- ate/urdf/__init__.py +30 -0
- ate/urdf/capture.py +582 -0
- ate/urdf/cloud.py +491 -0
- ate/urdf/collision.py +271 -0
- ate/urdf/commands.py +708 -0
- ate/urdf/depth.py +360 -0
- ate/urdf/inertial.py +312 -0
- ate/urdf/kinematics.py +330 -0
- ate/urdf/lifting.py +415 -0
- ate/urdf/meshing.py +300 -0
- ate/urdf/models/__init__.py +110 -0
- ate/urdf/models/depth_anything.py +253 -0
- ate/urdf/models/sam2.py +324 -0
- ate/urdf/motion_analysis.py +396 -0
- ate/urdf/pipeline.py +468 -0
- ate/urdf/scale.py +256 -0
- ate/urdf/scan_session.py +411 -0
- ate/urdf/segmentation.py +299 -0
- ate/urdf/synthesis.py +319 -0
- ate/urdf/topology.py +336 -0
- ate/urdf/validation.py +371 -0
- {foodforthought_cli-0.2.7.dist-info → foodforthought_cli-0.3.0.dist-info}/METADATA +9 -1
- foodforthought_cli-0.3.0.dist-info/RECORD +166 -0
- {foodforthought_cli-0.2.7.dist-info → foodforthought_cli-0.3.0.dist-info}/WHEEL +1 -1
- foodforthought_cli-0.2.7.dist-info/RECORD +0 -44
- {foodforthought_cli-0.2.7.dist-info → foodforthought_cli-0.3.0.dist-info}/entry_points.txt +0 -0
- {foodforthought_cli-0.2.7.dist-info → foodforthought_cli-0.3.0.dist-info}/top_level.txt +0 -0
ate/recording/session.py
ADDED
|
@@ -0,0 +1,415 @@
|
|
|
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, bytes):
|
|
58
|
+
# Don't include large binary data in JSON - just note its size
|
|
59
|
+
if len(value) > 1000:
|
|
60
|
+
return {"__type__": "bytes", "__size__": len(value), "__truncated__": True}
|
|
61
|
+
import base64
|
|
62
|
+
return {"__type__": "bytes", "__b64__": base64.b64encode(value).decode('ascii')}
|
|
63
|
+
if isinstance(value, (list, tuple)):
|
|
64
|
+
return [self._serialize_value(v) for v in value]
|
|
65
|
+
if isinstance(value, dict):
|
|
66
|
+
return {k: self._serialize_value(v) for k, v in value.items()}
|
|
67
|
+
|
|
68
|
+
# Handle our interface types (serialize fields individually to handle bytes)
|
|
69
|
+
if hasattr(value, '__dataclass_fields__'):
|
|
70
|
+
result = {"__type__": type(value).__name__}
|
|
71
|
+
for field_name in value.__dataclass_fields__:
|
|
72
|
+
field_value = getattr(value, field_name)
|
|
73
|
+
result[field_name] = self._serialize_value(field_value)
|
|
74
|
+
return result
|
|
75
|
+
if hasattr(value, 'to_dict'):
|
|
76
|
+
return {"__type__": type(value).__name__, **value.to_dict()}
|
|
77
|
+
|
|
78
|
+
# Handle ActionResult
|
|
79
|
+
if isinstance(value, ActionResult):
|
|
80
|
+
return {
|
|
81
|
+
"__type__": "ActionResult",
|
|
82
|
+
"success": value.success,
|
|
83
|
+
"message": value.message,
|
|
84
|
+
"error": value.error,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
# Fallback: convert to string
|
|
88
|
+
return {"__type__": type(value).__name__, "__str__": str(value)}
|
|
89
|
+
|
|
90
|
+
def _serialize_result(self, result: Any) -> Any:
|
|
91
|
+
"""Serialize return value."""
|
|
92
|
+
return self._serialize_value(result)
|
|
93
|
+
|
|
94
|
+
@classmethod
|
|
95
|
+
def from_dict(cls, data: dict) -> "RecordedCall":
|
|
96
|
+
"""Create from dictionary."""
|
|
97
|
+
return cls(
|
|
98
|
+
timestamp=data["timestamp"],
|
|
99
|
+
relative_time=data["relative_time"],
|
|
100
|
+
interface=data["interface"],
|
|
101
|
+
method=data["method"],
|
|
102
|
+
args=tuple(data["args"]),
|
|
103
|
+
kwargs=data["kwargs"],
|
|
104
|
+
result=data["result"],
|
|
105
|
+
success=data["success"],
|
|
106
|
+
error=data.get("error"),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass
|
|
111
|
+
class RecordingMetadata:
|
|
112
|
+
"""Metadata for a recording session."""
|
|
113
|
+
|
|
114
|
+
id: str # Unique recording ID
|
|
115
|
+
name: str # Human-readable name
|
|
116
|
+
robot_name: str # Robot name from driver
|
|
117
|
+
robot_model: str # Robot model from driver
|
|
118
|
+
robot_archetype: str # Robot archetype (quadruped, etc.)
|
|
119
|
+
capabilities: List[str] # Robot capabilities
|
|
120
|
+
start_time: float # Unix timestamp
|
|
121
|
+
end_time: Optional[float] = None # Unix timestamp
|
|
122
|
+
duration: Optional[float] = None # Seconds
|
|
123
|
+
description: Optional[str] = None
|
|
124
|
+
tags: List[str] = field(default_factory=list)
|
|
125
|
+
|
|
126
|
+
def to_dict(self) -> dict:
|
|
127
|
+
return asdict(self)
|
|
128
|
+
|
|
129
|
+
@classmethod
|
|
130
|
+
def from_dict(cls, data: dict) -> "RecordingMetadata":
|
|
131
|
+
return cls(**data)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class RecordingSession:
|
|
135
|
+
"""
|
|
136
|
+
Context manager for recording robot demonstrations.
|
|
137
|
+
|
|
138
|
+
Wraps a robot driver to intercept and record all interface method calls.
|
|
139
|
+
|
|
140
|
+
Usage:
|
|
141
|
+
with RecordingSession(driver, name="my_demo") as session:
|
|
142
|
+
driver.stand()
|
|
143
|
+
driver.walk(Vector3.forward(), speed=0.3)
|
|
144
|
+
|
|
145
|
+
session.save("my_demo.demonstration")
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
def __init__(
|
|
149
|
+
self,
|
|
150
|
+
driver: RobotInterface,
|
|
151
|
+
name: str = "recording",
|
|
152
|
+
description: Optional[str] = None,
|
|
153
|
+
tags: Optional[List[str]] = None,
|
|
154
|
+
auto_save: bool = False,
|
|
155
|
+
save_path: Optional[str] = None,
|
|
156
|
+
):
|
|
157
|
+
"""
|
|
158
|
+
Initialize a recording session.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
driver: Robot driver to record
|
|
162
|
+
name: Human-readable name for the recording
|
|
163
|
+
description: Optional description
|
|
164
|
+
tags: Optional tags for categorization
|
|
165
|
+
auto_save: If True, save automatically on exit
|
|
166
|
+
save_path: Path for auto-save (uses name if not provided)
|
|
167
|
+
"""
|
|
168
|
+
self._driver = driver
|
|
169
|
+
self._name = name
|
|
170
|
+
self._description = description
|
|
171
|
+
self._tags = tags or []
|
|
172
|
+
self._auto_save = auto_save
|
|
173
|
+
self._save_path = save_path
|
|
174
|
+
|
|
175
|
+
self._calls: List[RecordedCall] = []
|
|
176
|
+
self._start_time: Optional[float] = None
|
|
177
|
+
self._end_time: Optional[float] = None
|
|
178
|
+
self._original_methods: Dict[str, Callable] = {}
|
|
179
|
+
self._recording = False
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def calls(self) -> List[RecordedCall]:
|
|
183
|
+
"""Get all recorded calls."""
|
|
184
|
+
return self._calls.copy()
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def duration(self) -> Optional[float]:
|
|
188
|
+
"""Get recording duration in seconds."""
|
|
189
|
+
if self._start_time is None:
|
|
190
|
+
return None
|
|
191
|
+
end = self._end_time or time.time()
|
|
192
|
+
return end - self._start_time
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def is_recording(self) -> bool:
|
|
196
|
+
"""Check if currently recording."""
|
|
197
|
+
return self._recording
|
|
198
|
+
|
|
199
|
+
def __enter__(self) -> "RecordingSession":
|
|
200
|
+
"""Start recording."""
|
|
201
|
+
self.start()
|
|
202
|
+
return self
|
|
203
|
+
|
|
204
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
205
|
+
"""Stop recording."""
|
|
206
|
+
self.stop()
|
|
207
|
+
if self._auto_save:
|
|
208
|
+
path = self._save_path or f"{self._name}.demonstration"
|
|
209
|
+
self.save(path)
|
|
210
|
+
return False # Don't suppress exceptions
|
|
211
|
+
|
|
212
|
+
def start(self) -> None:
|
|
213
|
+
"""Start recording interface calls."""
|
|
214
|
+
if self._recording:
|
|
215
|
+
return
|
|
216
|
+
|
|
217
|
+
self._start_time = time.time()
|
|
218
|
+
self._recording = True
|
|
219
|
+
self._wrap_driver_methods()
|
|
220
|
+
|
|
221
|
+
def stop(self) -> None:
|
|
222
|
+
"""Stop recording interface calls."""
|
|
223
|
+
if not self._recording:
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
self._end_time = time.time()
|
|
227
|
+
self._recording = False
|
|
228
|
+
self._unwrap_driver_methods()
|
|
229
|
+
|
|
230
|
+
def _wrap_driver_methods(self) -> None:
|
|
231
|
+
"""Wrap all driver methods to intercept calls."""
|
|
232
|
+
# Get all interfaces the driver implements
|
|
233
|
+
interfaces = self._get_driver_interfaces()
|
|
234
|
+
|
|
235
|
+
for interface_name, interface_cls in interfaces.items():
|
|
236
|
+
# Get methods defined in this interface
|
|
237
|
+
for method_name in self._get_interface_methods(interface_cls):
|
|
238
|
+
if hasattr(self._driver, method_name):
|
|
239
|
+
original = getattr(self._driver, method_name)
|
|
240
|
+
if callable(original) and not method_name.startswith('_'):
|
|
241
|
+
# Store original
|
|
242
|
+
key = f"{interface_name}.{method_name}"
|
|
243
|
+
self._original_methods[key] = original
|
|
244
|
+
|
|
245
|
+
# Create wrapper
|
|
246
|
+
wrapper = self._create_wrapper(interface_name, method_name, original)
|
|
247
|
+
setattr(self._driver, method_name, wrapper)
|
|
248
|
+
|
|
249
|
+
def _unwrap_driver_methods(self) -> None:
|
|
250
|
+
"""Restore original driver methods."""
|
|
251
|
+
for key, original in self._original_methods.items():
|
|
252
|
+
_, method_name = key.split(".", 1)
|
|
253
|
+
setattr(self._driver, method_name, original)
|
|
254
|
+
self._original_methods.clear()
|
|
255
|
+
|
|
256
|
+
def _get_driver_interfaces(self) -> Dict[str, type]:
|
|
257
|
+
"""Get all interface classes the driver implements."""
|
|
258
|
+
from ate.interfaces import (
|
|
259
|
+
RobotInterface, SafetyInterface,
|
|
260
|
+
QuadrupedLocomotion, BipedLocomotion, WheeledLocomotion, AerialLocomotion,
|
|
261
|
+
ArmInterface, GripperInterface, DualArmInterface,
|
|
262
|
+
CameraInterface, DepthCameraInterface, LidarInterface, IMUInterface, ForceTorqueInterface,
|
|
263
|
+
BodyPoseInterface,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
all_interfaces = {
|
|
267
|
+
"RobotInterface": RobotInterface,
|
|
268
|
+
"SafetyInterface": SafetyInterface,
|
|
269
|
+
"QuadrupedLocomotion": QuadrupedLocomotion,
|
|
270
|
+
"BipedLocomotion": BipedLocomotion,
|
|
271
|
+
"WheeledLocomotion": WheeledLocomotion,
|
|
272
|
+
"AerialLocomotion": AerialLocomotion,
|
|
273
|
+
"ArmInterface": ArmInterface,
|
|
274
|
+
"GripperInterface": GripperInterface,
|
|
275
|
+
"DualArmInterface": DualArmInterface,
|
|
276
|
+
"CameraInterface": CameraInterface,
|
|
277
|
+
"DepthCameraInterface": DepthCameraInterface,
|
|
278
|
+
"LidarInterface": LidarInterface,
|
|
279
|
+
"IMUInterface": IMUInterface,
|
|
280
|
+
"ForceTorqueInterface": ForceTorqueInterface,
|
|
281
|
+
"BodyPoseInterface": BodyPoseInterface,
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
name: cls for name, cls in all_interfaces.items()
|
|
286
|
+
if isinstance(self._driver, cls)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
def _get_interface_methods(self, interface_cls: type) -> List[str]:
|
|
290
|
+
"""Get public methods defined in an interface class."""
|
|
291
|
+
methods = []
|
|
292
|
+
for name in dir(interface_cls):
|
|
293
|
+
if not name.startswith('_'):
|
|
294
|
+
attr = getattr(interface_cls, name, None)
|
|
295
|
+
if callable(attr):
|
|
296
|
+
methods.append(name)
|
|
297
|
+
return methods
|
|
298
|
+
|
|
299
|
+
def _create_wrapper(
|
|
300
|
+
self,
|
|
301
|
+
interface_name: str,
|
|
302
|
+
method_name: str,
|
|
303
|
+
original: Callable
|
|
304
|
+
) -> Callable:
|
|
305
|
+
"""Create a wrapper function that records the call."""
|
|
306
|
+
|
|
307
|
+
@wraps(original)
|
|
308
|
+
def wrapper(*args, **kwargs):
|
|
309
|
+
timestamp = time.time()
|
|
310
|
+
relative_time = timestamp - self._start_time
|
|
311
|
+
|
|
312
|
+
try:
|
|
313
|
+
result = original(*args, **kwargs)
|
|
314
|
+
success = True
|
|
315
|
+
error = None
|
|
316
|
+
|
|
317
|
+
# Check ActionResult for failure
|
|
318
|
+
if isinstance(result, ActionResult) and not result.success:
|
|
319
|
+
error = result.error
|
|
320
|
+
|
|
321
|
+
except Exception as e:
|
|
322
|
+
result = None
|
|
323
|
+
success = False
|
|
324
|
+
error = str(e)
|
|
325
|
+
raise
|
|
326
|
+
finally:
|
|
327
|
+
# Record the call
|
|
328
|
+
call = RecordedCall(
|
|
329
|
+
timestamp=timestamp,
|
|
330
|
+
relative_time=relative_time,
|
|
331
|
+
interface=interface_name,
|
|
332
|
+
method=method_name,
|
|
333
|
+
args=args,
|
|
334
|
+
kwargs=kwargs,
|
|
335
|
+
result=result,
|
|
336
|
+
success=success,
|
|
337
|
+
error=error,
|
|
338
|
+
)
|
|
339
|
+
self._calls.append(call)
|
|
340
|
+
|
|
341
|
+
return result
|
|
342
|
+
|
|
343
|
+
return wrapper
|
|
344
|
+
|
|
345
|
+
def get_metadata(self) -> RecordingMetadata:
|
|
346
|
+
"""Get recording metadata."""
|
|
347
|
+
info = self._driver.get_info()
|
|
348
|
+
return RecordingMetadata(
|
|
349
|
+
id=str(uuid.uuid4()),
|
|
350
|
+
name=self._name,
|
|
351
|
+
robot_name=info.name,
|
|
352
|
+
robot_model=info.model,
|
|
353
|
+
robot_archetype=info.archetype,
|
|
354
|
+
capabilities=[c.name for c in info.capabilities],
|
|
355
|
+
start_time=self._start_time or 0,
|
|
356
|
+
end_time=self._end_time,
|
|
357
|
+
duration=self.duration,
|
|
358
|
+
description=self._description,
|
|
359
|
+
tags=self._tags,
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
def save(self, path: str) -> None:
|
|
363
|
+
"""
|
|
364
|
+
Save recording to a file.
|
|
365
|
+
|
|
366
|
+
Args:
|
|
367
|
+
path: File path (should end with .demonstration)
|
|
368
|
+
"""
|
|
369
|
+
data = {
|
|
370
|
+
"version": "1.0",
|
|
371
|
+
"metadata": self.get_metadata().to_dict(),
|
|
372
|
+
"calls": [call.to_dict() for call in self._calls],
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
with open(path, 'w') as f:
|
|
376
|
+
json.dump(data, f, indent=2)
|
|
377
|
+
|
|
378
|
+
def to_dict(self) -> dict:
|
|
379
|
+
"""Convert to dictionary for API upload."""
|
|
380
|
+
return {
|
|
381
|
+
"version": "1.0",
|
|
382
|
+
"metadata": self.get_metadata().to_dict(),
|
|
383
|
+
"calls": [call.to_dict() for call in self._calls],
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
def summary(self) -> str:
|
|
387
|
+
"""Get a human-readable summary of the recording."""
|
|
388
|
+
if not self._calls:
|
|
389
|
+
return "No calls recorded"
|
|
390
|
+
|
|
391
|
+
# Group by interface
|
|
392
|
+
by_interface: Dict[str, List[RecordedCall]] = {}
|
|
393
|
+
for call in self._calls:
|
|
394
|
+
if call.interface not in by_interface:
|
|
395
|
+
by_interface[call.interface] = []
|
|
396
|
+
by_interface[call.interface].append(call)
|
|
397
|
+
|
|
398
|
+
lines = [
|
|
399
|
+
f"Recording: {self._name}",
|
|
400
|
+
f"Duration: {self.duration:.2f}s" if self.duration else "Duration: N/A",
|
|
401
|
+
f"Total calls: {len(self._calls)}",
|
|
402
|
+
"",
|
|
403
|
+
"By interface:",
|
|
404
|
+
]
|
|
405
|
+
|
|
406
|
+
for interface, calls in by_interface.items():
|
|
407
|
+
lines.append(f" {interface}: {len(calls)} calls")
|
|
408
|
+
# Count by method
|
|
409
|
+
by_method: Dict[str, int] = {}
|
|
410
|
+
for call in calls:
|
|
411
|
+
by_method[call.method] = by_method.get(call.method, 0) + 1
|
|
412
|
+
for method, count in sorted(by_method.items()):
|
|
413
|
+
lines.append(f" - {method}: {count}")
|
|
414
|
+
|
|
415
|
+
return "\n".join(lines)
|