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/cli.py
CHANGED
|
@@ -30,30 +30,37 @@ class ATEClient:
|
|
|
30
30
|
self.headers = {
|
|
31
31
|
"Content-Type": "application/json",
|
|
32
32
|
}
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
self._config = {}
|
|
34
|
+
self._device_id = None
|
|
35
|
+
|
|
36
|
+
# Try to load from config file first (device auth flow)
|
|
37
|
+
if CONFIG_FILE.exists():
|
|
38
|
+
try:
|
|
39
|
+
with open(CONFIG_FILE) as f:
|
|
40
|
+
self._config = json.load(f)
|
|
41
|
+
|
|
42
|
+
# Prefer access_token from device auth flow
|
|
43
|
+
access_token = self._config.get("access_token")
|
|
44
|
+
if access_token:
|
|
45
|
+
self.headers["Authorization"] = f"Bearer {access_token}"
|
|
46
|
+
self._device_id = self._config.get("device_id")
|
|
47
|
+
else:
|
|
48
|
+
# Fall back to legacy api_key
|
|
49
|
+
stored_key = self._config.get("api_key")
|
|
50
|
+
if stored_key:
|
|
51
|
+
self.headers["Authorization"] = f"Bearer {stored_key}"
|
|
52
|
+
except Exception:
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
# Override with explicit api_key or env var if provided
|
|
35
56
|
if api_key is None:
|
|
36
57
|
api_key = os.getenv("ATE_API_KEY", API_KEY)
|
|
37
|
-
|
|
58
|
+
|
|
38
59
|
if api_key:
|
|
39
|
-
# Ensure API key has correct format
|
|
40
|
-
if not api_key.startswith("ate_"):
|
|
41
|
-
print("Warning: API key should start with 'ate_'", file=sys.stderr)
|
|
42
60
|
self.headers["Authorization"] = f"Bearer {api_key}"
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
try:
|
|
47
|
-
with open(CONFIG_FILE) as f:
|
|
48
|
-
config = json.load(f)
|
|
49
|
-
stored_key = config.get("api_key")
|
|
50
|
-
if stored_key:
|
|
51
|
-
self.headers["Authorization"] = f"Bearer {stored_key}"
|
|
52
|
-
except Exception:
|
|
53
|
-
pass
|
|
54
|
-
|
|
55
|
-
if "Authorization" not in self.headers:
|
|
56
|
-
print("Warning: No API key found. Set ATE_API_KEY environment variable or run 'ate login'.", file=sys.stderr)
|
|
61
|
+
|
|
62
|
+
if "Authorization" not in self.headers:
|
|
63
|
+
print("Warning: Not logged in. Run 'ate login' to authenticate.", file=sys.stderr)
|
|
57
64
|
|
|
58
65
|
def _request(self, method: str, endpoint: str, **kwargs) -> Dict:
|
|
59
66
|
"""Make HTTP request to API"""
|
|
@@ -583,7 +590,259 @@ class ATEClient:
|
|
|
583
590
|
print(f"\n✗ Upload failed: {e}", file=sys.stderr)
|
|
584
591
|
sys.exit(1)
|
|
585
592
|
|
|
586
|
-
|
|
593
|
+
# ========================================================================
|
|
594
|
+
# Recording Methods (Data Flywheel)
|
|
595
|
+
# ========================================================================
|
|
596
|
+
|
|
597
|
+
def record_start(self, robot_id: str, skill_id: str, task_description: Optional[str] = None) -> None:
|
|
598
|
+
"""Start recording telemetry from a robot"""
|
|
599
|
+
import uuid
|
|
600
|
+
|
|
601
|
+
# Store recording state in a file
|
|
602
|
+
recording_file = CONFIG_DIR / "active_recording.json"
|
|
603
|
+
CONFIG_DIR.mkdir(exist_ok=True)
|
|
604
|
+
|
|
605
|
+
if recording_file.exists():
|
|
606
|
+
print("Error: Recording already in progress. Run 'ate record stop' first.", file=sys.stderr)
|
|
607
|
+
sys.exit(1)
|
|
608
|
+
|
|
609
|
+
recording_id = str(uuid.uuid4())
|
|
610
|
+
recording_state = {
|
|
611
|
+
"id": recording_id,
|
|
612
|
+
"robot_id": robot_id,
|
|
613
|
+
"skill_id": skill_id,
|
|
614
|
+
"task_description": task_description or "",
|
|
615
|
+
"start_time": time.time(),
|
|
616
|
+
"frames": [],
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
with open(recording_file, "w") as f:
|
|
620
|
+
json.dump(recording_state, f, indent=2)
|
|
621
|
+
|
|
622
|
+
print(f"Recording started!")
|
|
623
|
+
print(f" Recording ID: {recording_id}")
|
|
624
|
+
print(f" Robot: {robot_id}")
|
|
625
|
+
print(f" Skill: {skill_id}")
|
|
626
|
+
if task_description:
|
|
627
|
+
print(f" Task: {task_description}")
|
|
628
|
+
print(f"\nRun 'ate record stop' when finished.")
|
|
629
|
+
|
|
630
|
+
def record_stop(self, success: bool = True, notes: Optional[str] = None,
|
|
631
|
+
upload: bool = True, create_labeling_task: bool = False) -> None:
|
|
632
|
+
"""Stop recording and optionally upload to FoodforThought"""
|
|
633
|
+
from datetime import datetime
|
|
634
|
+
|
|
635
|
+
recording_file = CONFIG_DIR / "active_recording.json"
|
|
636
|
+
|
|
637
|
+
if not recording_file.exists():
|
|
638
|
+
print("Error: No active recording. Start one with 'ate record start'.", file=sys.stderr)
|
|
639
|
+
sys.exit(1)
|
|
640
|
+
|
|
641
|
+
with open(recording_file, "r") as f:
|
|
642
|
+
recording_state = json.load(f)
|
|
643
|
+
|
|
644
|
+
# Calculate duration
|
|
645
|
+
end_time = time.time()
|
|
646
|
+
duration = end_time - recording_state["start_time"]
|
|
647
|
+
frame_count = len(recording_state.get("frames", []))
|
|
648
|
+
|
|
649
|
+
print(f"Recording stopped!")
|
|
650
|
+
print(f" Recording ID: {recording_state['id']}")
|
|
651
|
+
print(f" Duration: {duration:.1f}s")
|
|
652
|
+
print(f" Frames: {frame_count}")
|
|
653
|
+
print(f" Success: {'Yes' if success else 'No'}")
|
|
654
|
+
|
|
655
|
+
if upload:
|
|
656
|
+
print(f"\nUploading to FoodforThought...")
|
|
657
|
+
|
|
658
|
+
try:
|
|
659
|
+
recording_data = {
|
|
660
|
+
"recording": {
|
|
661
|
+
"id": recording_state["id"],
|
|
662
|
+
"robotId": recording_state["robot_id"],
|
|
663
|
+
"skillId": recording_state["skill_id"],
|
|
664
|
+
"source": "hardware",
|
|
665
|
+
"startTime": datetime.fromtimestamp(recording_state["start_time"]).isoformat(),
|
|
666
|
+
"endTime": datetime.fromtimestamp(end_time).isoformat(),
|
|
667
|
+
"success": success,
|
|
668
|
+
"metadata": {
|
|
669
|
+
"duration": duration,
|
|
670
|
+
"frameRate": frame_count / duration if duration > 0 else 0,
|
|
671
|
+
"totalFrames": frame_count,
|
|
672
|
+
"tags": ["edge_recording", "cli"],
|
|
673
|
+
"notes": notes,
|
|
674
|
+
},
|
|
675
|
+
"frames": recording_state.get("frames", []),
|
|
676
|
+
"events": [],
|
|
677
|
+
},
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
if create_labeling_task:
|
|
681
|
+
recording_data["createLabelingTask"] = True
|
|
682
|
+
|
|
683
|
+
response = self._request("POST", "/telemetry/ingest", json=recording_data)
|
|
684
|
+
|
|
685
|
+
artifact_id = response.get("data", {}).get("artifactId", "")
|
|
686
|
+
print(f"\n✓ Uploaded successfully!")
|
|
687
|
+
print(f" Artifact ID: {artifact_id}")
|
|
688
|
+
print(f" View at: https://foodforthought.kindly.fyi/artifacts/{artifact_id}")
|
|
689
|
+
|
|
690
|
+
if create_labeling_task:
|
|
691
|
+
task_id = response.get("data", {}).get("taskId", "")
|
|
692
|
+
if task_id:
|
|
693
|
+
print(f" Labeling Task: https://foodforthought.kindly.fyi/labeling/{task_id}")
|
|
694
|
+
|
|
695
|
+
except Exception as e:
|
|
696
|
+
print(f"\n✗ Upload failed: {e}", file=sys.stderr)
|
|
697
|
+
print("Recording saved locally. You can upload later.", file=sys.stderr)
|
|
698
|
+
|
|
699
|
+
if notes:
|
|
700
|
+
print(f"\nNotes: {notes}")
|
|
701
|
+
|
|
702
|
+
# Remove recording state file
|
|
703
|
+
recording_file.unlink()
|
|
704
|
+
|
|
705
|
+
def record_status(self) -> None:
|
|
706
|
+
"""Get current recording status"""
|
|
707
|
+
recording_file = CONFIG_DIR / "active_recording.json"
|
|
708
|
+
|
|
709
|
+
if not recording_file.exists():
|
|
710
|
+
print("No active recording session.")
|
|
711
|
+
return
|
|
712
|
+
|
|
713
|
+
with open(recording_file, "r") as f:
|
|
714
|
+
recording_state = json.load(f)
|
|
715
|
+
|
|
716
|
+
elapsed = time.time() - recording_state["start_time"]
|
|
717
|
+
frame_count = len(recording_state.get("frames", []))
|
|
718
|
+
|
|
719
|
+
print(f"Recording in progress")
|
|
720
|
+
print(f" Recording ID: {recording_state['id']}")
|
|
721
|
+
print(f" Robot: {recording_state['robot_id']}")
|
|
722
|
+
print(f" Skill: {recording_state['skill_id']}")
|
|
723
|
+
print(f" Elapsed: {elapsed:.1f}s")
|
|
724
|
+
print(f" Frames: {frame_count}")
|
|
725
|
+
if recording_state.get("task_description"):
|
|
726
|
+
print(f" Task: {recording_state['task_description']}")
|
|
727
|
+
|
|
728
|
+
def record_demo(self, robot_id: str, skill_id: str, task_description: str,
|
|
729
|
+
duration_seconds: float = 30.0, create_labeling_task: bool = True) -> None:
|
|
730
|
+
"""Record a timed demonstration"""
|
|
731
|
+
import uuid
|
|
732
|
+
from datetime import datetime
|
|
733
|
+
|
|
734
|
+
recording_id = str(uuid.uuid4())
|
|
735
|
+
print(f"Recording demonstration...")
|
|
736
|
+
print(f" Recording ID: {recording_id}")
|
|
737
|
+
print(f" Robot: {robot_id}")
|
|
738
|
+
print(f" Skill: {skill_id}")
|
|
739
|
+
print(f" Task: {task_description}")
|
|
740
|
+
print(f" Duration: {duration_seconds}s")
|
|
741
|
+
print()
|
|
742
|
+
|
|
743
|
+
start_time = time.time()
|
|
744
|
+
|
|
745
|
+
# Show a countdown/progress indicator
|
|
746
|
+
elapsed = 0
|
|
747
|
+
while elapsed < duration_seconds:
|
|
748
|
+
remaining = duration_seconds - elapsed
|
|
749
|
+
print(f"\rRecording... {remaining:.0f}s remaining", end="", flush=True)
|
|
750
|
+
time.sleep(min(1.0, remaining))
|
|
751
|
+
elapsed = time.time() - start_time
|
|
752
|
+
|
|
753
|
+
end_time = time.time()
|
|
754
|
+
actual_duration = end_time - start_time
|
|
755
|
+
print(f"\rRecording complete!{' ' * 20}")
|
|
756
|
+
|
|
757
|
+
print(f"\nUploading to FoodforThought...")
|
|
758
|
+
|
|
759
|
+
try:
|
|
760
|
+
recording_data = {
|
|
761
|
+
"recording": {
|
|
762
|
+
"id": recording_id,
|
|
763
|
+
"robotId": robot_id,
|
|
764
|
+
"skillId": skill_id,
|
|
765
|
+
"source": "hardware",
|
|
766
|
+
"startTime": datetime.fromtimestamp(start_time).isoformat(),
|
|
767
|
+
"endTime": datetime.fromtimestamp(end_time).isoformat(),
|
|
768
|
+
"success": True,
|
|
769
|
+
"metadata": {
|
|
770
|
+
"duration": actual_duration,
|
|
771
|
+
"frameRate": 0,
|
|
772
|
+
"totalFrames": 0,
|
|
773
|
+
"tags": ["demonstration", "cli"],
|
|
774
|
+
"task_description": task_description,
|
|
775
|
+
},
|
|
776
|
+
"frames": [],
|
|
777
|
+
"events": [],
|
|
778
|
+
},
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
if create_labeling_task:
|
|
782
|
+
recording_data["createLabelingTask"] = True
|
|
783
|
+
|
|
784
|
+
response = self._request("POST", "/telemetry/ingest", json=recording_data)
|
|
785
|
+
|
|
786
|
+
artifact_id = response.get("data", {}).get("artifactId", "")
|
|
787
|
+
print(f"\n✓ Uploaded successfully!")
|
|
788
|
+
print(f" Artifact ID: {artifact_id}")
|
|
789
|
+
print(f" View at: https://foodforthought.kindly.fyi/artifacts/{artifact_id}")
|
|
790
|
+
|
|
791
|
+
if create_labeling_task:
|
|
792
|
+
task_id = response.get("data", {}).get("taskId", "")
|
|
793
|
+
if task_id:
|
|
794
|
+
print(f" Labeling Task: https://foodforthought.kindly.fyi/labeling/{task_id}")
|
|
795
|
+
|
|
796
|
+
except Exception as e:
|
|
797
|
+
print(f"\n✗ Upload failed: {e}", file=sys.stderr)
|
|
798
|
+
|
|
799
|
+
def record_list(self, robot_id: Optional[str] = None, skill_id: Optional[str] = None,
|
|
800
|
+
success_only: bool = False, limit: int = 20) -> None:
|
|
801
|
+
"""List telemetry recordings from FoodforThought"""
|
|
802
|
+
print("Fetching recordings...")
|
|
803
|
+
|
|
804
|
+
params = {
|
|
805
|
+
"type": "trajectory",
|
|
806
|
+
"limit": limit,
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
if robot_id:
|
|
810
|
+
params["robotModel"] = robot_id
|
|
811
|
+
if skill_id:
|
|
812
|
+
params["task"] = skill_id
|
|
813
|
+
|
|
814
|
+
try:
|
|
815
|
+
response = self._request("GET", "/artifacts", params=params)
|
|
816
|
+
artifacts = response.get("artifacts", [])
|
|
817
|
+
|
|
818
|
+
if not artifacts:
|
|
819
|
+
print("No recordings found.")
|
|
820
|
+
return
|
|
821
|
+
|
|
822
|
+
print(f"\nFound {len(artifacts)} recording(s):\n")
|
|
823
|
+
|
|
824
|
+
for artifact in artifacts:
|
|
825
|
+
metadata = artifact.get("metadata", {})
|
|
826
|
+
|
|
827
|
+
# Skip failed recordings if success_only
|
|
828
|
+
if success_only and not metadata.get("success", True):
|
|
829
|
+
continue
|
|
830
|
+
|
|
831
|
+
success_marker = "✓" if metadata.get("success", True) else "✗"
|
|
832
|
+
print(f"{success_marker} {artifact.get('name', 'Unnamed')}")
|
|
833
|
+
print(f" ID: {artifact.get('id')}")
|
|
834
|
+
print(f" Robot: {metadata.get('robotId', 'Unknown')}")
|
|
835
|
+
print(f" Skill: {metadata.get('skillId', 'Unknown')}")
|
|
836
|
+
print(f" Duration: {metadata.get('duration', 0):.1f}s")
|
|
837
|
+
print(f" Frames: {metadata.get('frameCount', 0)}")
|
|
838
|
+
print(f" Source: {metadata.get('source', 'Unknown')}")
|
|
839
|
+
print()
|
|
840
|
+
|
|
841
|
+
except Exception as e:
|
|
842
|
+
print(f"Error fetching recordings: {e}", file=sys.stderr)
|
|
843
|
+
sys.exit(1)
|
|
844
|
+
|
|
845
|
+
def check_transfer(self, skill: Optional[str], source: str, target: str,
|
|
587
846
|
min_score: float) -> None:
|
|
588
847
|
"""Check skill transfer compatibility between robots"""
|
|
589
848
|
print(f"Checking skill transfer compatibility...")
|
|
@@ -3106,33 +3365,151 @@ setup(
|
|
|
3106
3365
|
ATEClient.data_upload(self, path, skill, stage)
|
|
3107
3366
|
|
|
3108
3367
|
|
|
3368
|
+
def _generate_pkce():
|
|
3369
|
+
"""Generate PKCE code verifier and challenge."""
|
|
3370
|
+
import hashlib
|
|
3371
|
+
import base64
|
|
3372
|
+
import secrets
|
|
3373
|
+
|
|
3374
|
+
# Generate code verifier (43-128 chars, URL-safe)
|
|
3375
|
+
code_verifier = secrets.token_urlsafe(32)
|
|
3376
|
+
|
|
3377
|
+
# Generate code challenge (SHA256 hash of verifier, base64url encoded)
|
|
3378
|
+
digest = hashlib.sha256(code_verifier.encode()).digest()
|
|
3379
|
+
code_challenge = base64.urlsafe_b64encode(digest).rstrip(b'=').decode()
|
|
3380
|
+
|
|
3381
|
+
return code_verifier, code_challenge
|
|
3382
|
+
|
|
3383
|
+
|
|
3384
|
+
def _generate_device_id():
|
|
3385
|
+
"""Generate a unique device ID for this CLI installation."""
|
|
3386
|
+
import platform
|
|
3387
|
+
import hashlib
|
|
3388
|
+
|
|
3389
|
+
# Create a stable device ID based on machine info
|
|
3390
|
+
machine_info = f"{platform.node()}-{platform.system()}-{platform.machine()}"
|
|
3391
|
+
return f"cli-{hashlib.sha256(machine_info.encode()).hexdigest()[:16]}"
|
|
3392
|
+
|
|
3393
|
+
|
|
3109
3394
|
def login_command():
|
|
3110
|
-
"""Interactive login
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
print("
|
|
3114
|
-
print(
|
|
3115
|
-
|
|
3395
|
+
"""Interactive login via browser (GitHub-style device flow)"""
|
|
3396
|
+
import webbrowser
|
|
3397
|
+
|
|
3398
|
+
print("Authenticating with FoodforThought...")
|
|
3399
|
+
print()
|
|
3400
|
+
|
|
3401
|
+
# Generate PKCE
|
|
3402
|
+
code_verifier, code_challenge = _generate_pkce()
|
|
3403
|
+
device_id = _generate_device_id()
|
|
3404
|
+
|
|
3405
|
+
# Step 1: Initiate device auth
|
|
3116
3406
|
try:
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3407
|
+
response = requests.post(
|
|
3408
|
+
f"{BASE_URL}/device-auth/initiate",
|
|
3409
|
+
json={
|
|
3410
|
+
"codeChallenge": code_challenge,
|
|
3411
|
+
"deviceId": device_id,
|
|
3412
|
+
"deviceName": "FoodforThought CLI",
|
|
3413
|
+
},
|
|
3414
|
+
timeout=10,
|
|
3415
|
+
)
|
|
3416
|
+
response.raise_for_status()
|
|
3417
|
+
data = response.json()
|
|
3418
|
+
except requests.RequestException as e:
|
|
3419
|
+
print(f"Error: Failed to initiate login: {e}", file=sys.stderr)
|
|
3120
3420
|
sys.exit(1)
|
|
3121
|
-
|
|
3122
|
-
if not
|
|
3123
|
-
print("Error:
|
|
3421
|
+
|
|
3422
|
+
if not data.get("success"):
|
|
3423
|
+
print(f"Error: {data.get('error', 'Unknown error')}", file=sys.stderr)
|
|
3124
3424
|
sys.exit(1)
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3425
|
+
|
|
3426
|
+
auth_url = data["authUrl"]
|
|
3427
|
+
state = data["state"]
|
|
3428
|
+
|
|
3429
|
+
# Step 2: Open browser
|
|
3430
|
+
print("Opening browser for authentication...")
|
|
3431
|
+
print(f"If browser doesn't open, visit: {auth_url}")
|
|
3432
|
+
print()
|
|
3433
|
+
|
|
3434
|
+
try:
|
|
3435
|
+
webbrowser.open(auth_url)
|
|
3436
|
+
except Exception:
|
|
3437
|
+
pass # Browser open is best-effort
|
|
3438
|
+
|
|
3439
|
+
# Step 3: Poll for authorization
|
|
3440
|
+
print("Waiting for authorization...", end="", flush=True)
|
|
3441
|
+
|
|
3442
|
+
max_attempts = 120 # 2 minutes with 1s intervals
|
|
3443
|
+
poll_interval = 1.0
|
|
3444
|
+
|
|
3445
|
+
for attempt in range(max_attempts):
|
|
3446
|
+
time.sleep(poll_interval)
|
|
3447
|
+
|
|
3448
|
+
try:
|
|
3449
|
+
poll_response = requests.post(
|
|
3450
|
+
f"{BASE_URL}/device-auth/poll",
|
|
3451
|
+
json={"state": state, "deviceId": device_id},
|
|
3452
|
+
timeout=10,
|
|
3453
|
+
)
|
|
3454
|
+
poll_data = poll_response.json()
|
|
3455
|
+
except requests.RequestException:
|
|
3456
|
+
print(".", end="", flush=True)
|
|
3457
|
+
continue
|
|
3458
|
+
|
|
3459
|
+
status = poll_data.get("status")
|
|
3460
|
+
|
|
3461
|
+
if status == "pending":
|
|
3462
|
+
print(".", end="", flush=True)
|
|
3463
|
+
continue
|
|
3464
|
+
elif status == "authorized":
|
|
3465
|
+
print(" authorized!")
|
|
3466
|
+
callback_token = poll_data.get("token")
|
|
3467
|
+
break
|
|
3468
|
+
elif status == "expired":
|
|
3469
|
+
print("\nError: Authorization request expired. Please try again.", file=sys.stderr)
|
|
3129
3470
|
sys.exit(1)
|
|
3130
|
-
|
|
3131
|
-
|
|
3471
|
+
elif status == "exchanged":
|
|
3472
|
+
print("\nError: This authorization was already used. Please try again.", file=sys.stderr)
|
|
3473
|
+
sys.exit(1)
|
|
3474
|
+
else:
|
|
3475
|
+
print(".", end="", flush=True)
|
|
3476
|
+
else:
|
|
3477
|
+
print("\nError: Timeout waiting for authorization.", file=sys.stderr)
|
|
3478
|
+
sys.exit(1)
|
|
3479
|
+
|
|
3480
|
+
# Step 4: Exchange for access token
|
|
3481
|
+
print("Exchanging for access token...")
|
|
3482
|
+
|
|
3483
|
+
try:
|
|
3484
|
+
exchange_response = requests.post(
|
|
3485
|
+
f"{BASE_URL}/device-auth/exchange",
|
|
3486
|
+
json={
|
|
3487
|
+
"token": callback_token,
|
|
3488
|
+
"state": state,
|
|
3489
|
+
"codeVerifier": code_verifier,
|
|
3490
|
+
"deviceId": device_id,
|
|
3491
|
+
},
|
|
3492
|
+
timeout=10,
|
|
3493
|
+
)
|
|
3494
|
+
exchange_response.raise_for_status()
|
|
3495
|
+
exchange_data = exchange_response.json()
|
|
3496
|
+
except requests.RequestException as e:
|
|
3497
|
+
print(f"Error: Failed to exchange token: {e}", file=sys.stderr)
|
|
3498
|
+
sys.exit(1)
|
|
3499
|
+
|
|
3500
|
+
if not exchange_data.get("success"):
|
|
3501
|
+
print(f"Error: {exchange_data.get('error', 'Unknown error')}", file=sys.stderr)
|
|
3502
|
+
sys.exit(1)
|
|
3503
|
+
|
|
3504
|
+
access_token = exchange_data["accessToken"]
|
|
3505
|
+
refresh_token = exchange_data["refreshToken"]
|
|
3506
|
+
user = exchange_data.get("user", {})
|
|
3507
|
+
expires_at = exchange_data.get("expiresAt")
|
|
3508
|
+
|
|
3509
|
+
# Step 5: Save credentials
|
|
3132
3510
|
try:
|
|
3133
3511
|
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
3134
|
-
|
|
3135
|
-
# Load existing config if present
|
|
3512
|
+
|
|
3136
3513
|
config = {}
|
|
3137
3514
|
if CONFIG_FILE.exists():
|
|
3138
3515
|
try:
|
|
@@ -3140,26 +3517,114 @@ def login_command():
|
|
|
3140
3517
|
config = json.load(f)
|
|
3141
3518
|
except Exception:
|
|
3142
3519
|
pass
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
config["
|
|
3146
|
-
|
|
3147
|
-
|
|
3520
|
+
|
|
3521
|
+
config["access_token"] = access_token
|
|
3522
|
+
config["refresh_token"] = refresh_token
|
|
3523
|
+
config["device_id"] = device_id
|
|
3524
|
+
config["expires_at"] = expires_at
|
|
3525
|
+
config["user"] = {
|
|
3526
|
+
"id": user.get("id"),
|
|
3527
|
+
"email": user.get("email"),
|
|
3528
|
+
"name": user.get("name"),
|
|
3529
|
+
}
|
|
3530
|
+
|
|
3148
3531
|
with open(CONFIG_FILE, "w") as f:
|
|
3149
3532
|
json.dump(config, f, indent=2)
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3533
|
+
|
|
3534
|
+
# Set restrictive permissions
|
|
3535
|
+
CONFIG_FILE.chmod(0o600)
|
|
3536
|
+
|
|
3153
3537
|
except Exception as e:
|
|
3154
3538
|
print(f"Error saving credentials: {e}", file=sys.stderr)
|
|
3155
3539
|
sys.exit(1)
|
|
3156
3540
|
|
|
3541
|
+
print()
|
|
3542
|
+
print(f"✓ Logged in as {user.get('name') or user.get('email')}")
|
|
3543
|
+
print(f" Credentials saved to {CONFIG_FILE}")
|
|
3544
|
+
|
|
3545
|
+
|
|
3546
|
+
def logout_command():
|
|
3547
|
+
"""Log out and remove stored credentials."""
|
|
3548
|
+
if not CONFIG_FILE.exists():
|
|
3549
|
+
print("Not logged in.")
|
|
3550
|
+
return
|
|
3551
|
+
|
|
3552
|
+
try:
|
|
3553
|
+
# Load config to get device_id for revoking
|
|
3554
|
+
with open(CONFIG_FILE) as f:
|
|
3555
|
+
config = json.load(f)
|
|
3556
|
+
|
|
3557
|
+
access_token = config.get("access_token")
|
|
3558
|
+
device_id = config.get("device_id")
|
|
3559
|
+
|
|
3560
|
+
# Try to revoke the session on the server
|
|
3561
|
+
if access_token and device_id:
|
|
3562
|
+
try:
|
|
3563
|
+
requests.post(
|
|
3564
|
+
f"{BASE_URL}/device-auth/revoke",
|
|
3565
|
+
json={"accessToken": access_token, "deviceId": device_id},
|
|
3566
|
+
timeout=5,
|
|
3567
|
+
)
|
|
3568
|
+
except Exception:
|
|
3569
|
+
pass # Best effort
|
|
3570
|
+
|
|
3571
|
+
# Remove local credentials
|
|
3572
|
+
CONFIG_FILE.unlink()
|
|
3573
|
+
print("✓ Logged out successfully.")
|
|
3574
|
+
|
|
3575
|
+
except Exception as e:
|
|
3576
|
+
print(f"Error during logout: {e}", file=sys.stderr)
|
|
3577
|
+
# Still try to remove the file
|
|
3578
|
+
try:
|
|
3579
|
+
CONFIG_FILE.unlink()
|
|
3580
|
+
except Exception:
|
|
3581
|
+
pass
|
|
3582
|
+
|
|
3583
|
+
|
|
3584
|
+
def whoami_command():
|
|
3585
|
+
"""Show current logged-in user."""
|
|
3586
|
+
if not CONFIG_FILE.exists():
|
|
3587
|
+
print("Not logged in. Run 'ate login' to authenticate.")
|
|
3588
|
+
sys.exit(1)
|
|
3589
|
+
|
|
3590
|
+
try:
|
|
3591
|
+
with open(CONFIG_FILE) as f:
|
|
3592
|
+
config = json.load(f)
|
|
3593
|
+
|
|
3594
|
+
user = config.get("user", {})
|
|
3595
|
+
access_token = config.get("access_token")
|
|
3596
|
+
expires_at = config.get("expires_at")
|
|
3597
|
+
|
|
3598
|
+
if not access_token:
|
|
3599
|
+
# Legacy api_key mode
|
|
3600
|
+
if config.get("api_key"):
|
|
3601
|
+
print("Authenticated via API key (legacy mode)")
|
|
3602
|
+
print("Run 'ate login' to upgrade to device authentication.")
|
|
3603
|
+
return
|
|
3604
|
+
else:
|
|
3605
|
+
print("Not logged in. Run 'ate login' to authenticate.")
|
|
3606
|
+
sys.exit(1)
|
|
3607
|
+
|
|
3608
|
+
print(f"Logged in as: {user.get('name') or 'Unknown'}")
|
|
3609
|
+
print(f"Email: {user.get('email') or 'Unknown'}")
|
|
3610
|
+
if expires_at:
|
|
3611
|
+
print(f"Session expires: {expires_at}")
|
|
3612
|
+
|
|
3613
|
+
except Exception as e:
|
|
3614
|
+
print(f"Error reading credentials: {e}", file=sys.stderr)
|
|
3615
|
+
sys.exit(1)
|
|
3616
|
+
|
|
3157
3617
|
|
|
3158
3618
|
def main():
|
|
3159
3619
|
"""Main CLI entry point"""
|
|
3160
3620
|
parser = argparse.ArgumentParser(description="FoodforThought CLI")
|
|
3161
3621
|
subparsers = parser.add_subparsers(dest="command", help="Command to run")
|
|
3162
3622
|
|
|
3623
|
+
# Auth commands
|
|
3624
|
+
subparsers.add_parser("login", help="Authenticate with FoodforThought via browser")
|
|
3625
|
+
subparsers.add_parser("logout", help="Log out and remove stored credentials")
|
|
3626
|
+
subparsers.add_parser("whoami", help="Show current logged-in user")
|
|
3627
|
+
|
|
3163
3628
|
# init command
|
|
3164
3629
|
init_parser = subparsers.add_parser("init", help="Initialize a new repository")
|
|
3165
3630
|
init_parser.add_argument("name", help="Repository name")
|
|
@@ -3741,6 +4206,59 @@ EXAMPLES:
|
|
|
3741
4206
|
help="Check deployment status")
|
|
3742
4207
|
deploy_status_parser.add_argument("target", help="Target fleet or robot")
|
|
3743
4208
|
|
|
4209
|
+
# record command - Telemetry recording for Data Flywheel
|
|
4210
|
+
record_parser = subparsers.add_parser("record",
|
|
4211
|
+
help="Record robot telemetry for the Data Flywheel",
|
|
4212
|
+
description="""Record telemetry from robots for training data.
|
|
4213
|
+
|
|
4214
|
+
Examples:
|
|
4215
|
+
ate record start --robot my-robot --skill pick_and_place --task "Pick red cube"
|
|
4216
|
+
ate record stop --success
|
|
4217
|
+
ate record status
|
|
4218
|
+
ate record demo --robot my-robot --skill grasp --task "Grasp object" --duration 30
|
|
4219
|
+
ate record list --robot my-robot""")
|
|
4220
|
+
record_subparsers = record_parser.add_subparsers(dest="record_action", help="Record action")
|
|
4221
|
+
|
|
4222
|
+
# record start
|
|
4223
|
+
record_start_parser = record_subparsers.add_parser("start", help="Start recording telemetry")
|
|
4224
|
+
record_start_parser.add_argument("--robot", "-r", required=True, help="Robot ID to record from")
|
|
4225
|
+
record_start_parser.add_argument("--skill", "-s", required=True, help="Skill ID being executed")
|
|
4226
|
+
record_start_parser.add_argument("--task", "-t", help="Task description (optional)")
|
|
4227
|
+
|
|
4228
|
+
# record stop
|
|
4229
|
+
record_stop_parser = record_subparsers.add_parser("stop", help="Stop recording and upload")
|
|
4230
|
+
record_stop_parser.add_argument("--success", action="store_true", default=True,
|
|
4231
|
+
help="Mark recording as successful (default)")
|
|
4232
|
+
record_stop_parser.add_argument("--failure", action="store_true",
|
|
4233
|
+
help="Mark recording as failed")
|
|
4234
|
+
record_stop_parser.add_argument("--notes", "-n", help="Notes about the recording")
|
|
4235
|
+
record_stop_parser.add_argument("--no-upload", action="store_true",
|
|
4236
|
+
help="Don't upload to FoodforThought")
|
|
4237
|
+
record_stop_parser.add_argument("--create-task", action="store_true",
|
|
4238
|
+
help="Create a labeling task for community annotation")
|
|
4239
|
+
|
|
4240
|
+
# record status
|
|
4241
|
+
record_subparsers.add_parser("status", help="Get current recording status")
|
|
4242
|
+
|
|
4243
|
+
# record demo (timed demonstration)
|
|
4244
|
+
record_demo_parser = record_subparsers.add_parser("demo", help="Record a timed demonstration")
|
|
4245
|
+
record_demo_parser.add_argument("--robot", "-r", required=True, help="Robot ID")
|
|
4246
|
+
record_demo_parser.add_argument("--skill", "-s", required=True, help="Skill being demonstrated")
|
|
4247
|
+
record_demo_parser.add_argument("--task", "-t", required=True, help="Task description")
|
|
4248
|
+
record_demo_parser.add_argument("--duration", "-d", type=float, default=30.0,
|
|
4249
|
+
help="Recording duration in seconds (default: 30)")
|
|
4250
|
+
record_demo_parser.add_argument("--create-task", action="store_true", default=True,
|
|
4251
|
+
help="Create labeling task after upload (default)")
|
|
4252
|
+
|
|
4253
|
+
# record list
|
|
4254
|
+
record_list_parser = record_subparsers.add_parser("list", help="List telemetry recordings")
|
|
4255
|
+
record_list_parser.add_argument("--robot", "-r", help="Filter by robot ID")
|
|
4256
|
+
record_list_parser.add_argument("--skill", "-s", help="Filter by skill ID")
|
|
4257
|
+
record_list_parser.add_argument("--success-only", action="store_true",
|
|
4258
|
+
help="Only show successful recordings")
|
|
4259
|
+
record_list_parser.add_argument("--limit", "-l", type=int, default=20,
|
|
4260
|
+
help="Maximum number of results (default: 20)")
|
|
4261
|
+
|
|
3744
4262
|
# robot-setup command - Interactive wizard for robot discovery and primitive skill generation
|
|
3745
4263
|
robot_setup_parser = subparsers.add_parser("robot-setup",
|
|
3746
4264
|
help="Interactive wizard to discover robot and generate primitive skills")
|
|
@@ -3842,15 +4360,21 @@ EXAMPLES:
|
|
|
3842
4360
|
parser.print_help()
|
|
3843
4361
|
sys.exit(1)
|
|
3844
4362
|
|
|
3845
|
-
#
|
|
3846
|
-
subparsers.add_parser("login", help="Authenticate with FoodforThought")
|
|
3847
|
-
|
|
3848
|
-
client = ATEClient()
|
|
3849
|
-
|
|
4363
|
+
# Handle auth commands first (before creating client)
|
|
3850
4364
|
if args.command == "login":
|
|
3851
4365
|
login_command()
|
|
3852
4366
|
return
|
|
3853
4367
|
|
|
4368
|
+
if args.command == "logout":
|
|
4369
|
+
logout_command()
|
|
4370
|
+
return
|
|
4371
|
+
|
|
4372
|
+
if args.command == "whoami":
|
|
4373
|
+
whoami_command()
|
|
4374
|
+
return
|
|
4375
|
+
|
|
4376
|
+
client = ATEClient()
|
|
4377
|
+
|
|
3854
4378
|
if args.command == "init":
|
|
3855
4379
|
result = client.init(args.name, args.description, args.visibility)
|
|
3856
4380
|
print(f"Created repository: {result['repository']['id']}")
|
|
@@ -4056,6 +4580,38 @@ EXAMPLES:
|
|
|
4056
4580
|
else:
|
|
4057
4581
|
deploy_parser.print_help()
|
|
4058
4582
|
|
|
4583
|
+
elif args.command == "record":
|
|
4584
|
+
if args.record_action == "start":
|
|
4585
|
+
client.record_start(args.robot, args.skill, args.task)
|
|
4586
|
+
elif args.record_action == "stop":
|
|
4587
|
+
success = not args.failure if hasattr(args, 'failure') else args.success
|
|
4588
|
+
upload = not args.no_upload if hasattr(args, 'no_upload') else True
|
|
4589
|
+
client.record_stop(
|
|
4590
|
+
success=success,
|
|
4591
|
+
notes=args.notes if hasattr(args, 'notes') else None,
|
|
4592
|
+
upload=upload,
|
|
4593
|
+
create_labeling_task=args.create_task if hasattr(args, 'create_task') else False
|
|
4594
|
+
)
|
|
4595
|
+
elif args.record_action == "status":
|
|
4596
|
+
client.record_status()
|
|
4597
|
+
elif args.record_action == "demo":
|
|
4598
|
+
client.record_demo(
|
|
4599
|
+
args.robot,
|
|
4600
|
+
args.skill,
|
|
4601
|
+
args.task,
|
|
4602
|
+
args.duration,
|
|
4603
|
+
args.create_task if hasattr(args, 'create_task') else True
|
|
4604
|
+
)
|
|
4605
|
+
elif args.record_action == "list":
|
|
4606
|
+
client.record_list(
|
|
4607
|
+
robot_id=args.robot if hasattr(args, 'robot') else None,
|
|
4608
|
+
skill_id=args.skill if hasattr(args, 'skill') else None,
|
|
4609
|
+
success_only=args.success_only if hasattr(args, 'success_only') else False,
|
|
4610
|
+
limit=args.limit if hasattr(args, 'limit') else 20
|
|
4611
|
+
)
|
|
4612
|
+
else:
|
|
4613
|
+
record_parser.print_help()
|
|
4614
|
+
|
|
4059
4615
|
elif args.command == "robot-setup":
|
|
4060
4616
|
from ate.robot_setup import run_wizard, RobotSetupWizard
|
|
4061
4617
|
|