cyberwave-cli 0.11.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.
- cyberwave_cli/__init__.py +1 -0
- cyberwave_cli/core.py +42 -0
- cyberwave_cli/plugins/__init__.py +1 -0
- cyberwave_cli/plugins/assets/__init__.py +1 -0
- cyberwave_cli/plugins/assets/app.py +87 -0
- cyberwave_cli/plugins/auth/__init__.py +1 -0
- cyberwave_cli/plugins/auth/app.py +340 -0
- cyberwave_cli/plugins/devices/__init__.py +1 -0
- cyberwave_cli/plugins/devices/app.py +49 -0
- cyberwave_cli/plugins/edge/app.py +149 -0
- cyberwave_cli/plugins/environments/app.py +75 -0
- cyberwave_cli/plugins/loader.py +43 -0
- cyberwave_cli/plugins/projects/__init__.py +1 -0
- cyberwave_cli/plugins/projects/app.py +55 -0
- cyberwave_cli/plugins/sensors/app.py +138 -0
- cyberwave_cli/plugins/sim/app.py +17 -0
- cyberwave_cli/plugins/telemetry/__init__.py +1 -0
- cyberwave_cli/plugins/telemetry/app.py +17 -0
- cyberwave_cli/plugins/twins/app.py +66 -0
- cyberwave_cli-0.11.0.dist-info/METADATA +17 -0
- cyberwave_cli-0.11.0.dist-info/RECORD +24 -0
- cyberwave_cli-0.11.0.dist-info/WHEEL +5 -0
- cyberwave_cli-0.11.0.dist-info/entry_points.txt +11 -0
- cyberwave_cli-0.11.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Init for cyberwave_cli package
|
cyberwave_cli/core.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from rich import print
|
|
3
|
+
from .plugins import loader
|
|
4
|
+
|
|
5
|
+
app = typer.Typer(rich_markup_mode="markdown")
|
|
6
|
+
|
|
7
|
+
def main():
|
|
8
|
+
# Discover and register plugins
|
|
9
|
+
loader.register_all(app)
|
|
10
|
+
# Run the app
|
|
11
|
+
app()
|
|
12
|
+
|
|
13
|
+
@app.callback()
|
|
14
|
+
def callback():
|
|
15
|
+
"""
|
|
16
|
+
CyberWave Command-Line Interface
|
|
17
|
+
"""
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
@app.command()
|
|
21
|
+
def version() -> None:
|
|
22
|
+
"""Show the installed CLI version."""
|
|
23
|
+
import importlib.metadata
|
|
24
|
+
|
|
25
|
+
cli_version = importlib.metadata.version("cyberwave-cli")
|
|
26
|
+
print(f"CyberWave CLI version: [bold green]{cli_version}[/bold green]")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@app.command()
|
|
30
|
+
def plugins_cmd() -> None:
|
|
31
|
+
"""List available CLI plugins."""
|
|
32
|
+
plugin_names = loader.discover_plugins()
|
|
33
|
+
if not plugin_names:
|
|
34
|
+
print("No plugins found")
|
|
35
|
+
else:
|
|
36
|
+
print("Loaded plugins:")
|
|
37
|
+
for name in plugin_names:
|
|
38
|
+
print(f"- {name}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
if __name__ == "__main__":
|
|
42
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Init for cyberwave_cli.plugins package
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Init for assets plugin
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import re
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from rich import print
|
|
8
|
+
|
|
9
|
+
from cyberwave import Client
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(help="Manage CyberWave assets (upload meshes to the catalog, list).")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _simple_slugify(text: str) -> str:
|
|
15
|
+
"""Very small helper to create URL-friendly slugs."""
|
|
16
|
+
slug = re.sub(r"[^a-zA-Z0-9-]+", "-", text.lower()).strip("-")
|
|
17
|
+
return slug
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def _upload_asset(
|
|
21
|
+
mesh_path: Path,
|
|
22
|
+
name: str,
|
|
23
|
+
workspace_uuid: str,
|
|
24
|
+
project_uuid: str,
|
|
25
|
+
description: Optional[str],
|
|
26
|
+
registry_id: Optional[str],
|
|
27
|
+
) -> None:
|
|
28
|
+
client = Client()
|
|
29
|
+
await client.login()
|
|
30
|
+
# Create a simple catalog asset and attach a GLB file
|
|
31
|
+
# Prefer high-level helpers if present (test stub API)
|
|
32
|
+
if hasattr(client, "create_asset_definition"):
|
|
33
|
+
created = await client.create_asset_definition(name=name)
|
|
34
|
+
await client.upload_mesh(created["id"], mesh_path)
|
|
35
|
+
await client.add_geometry_to_asset_definition(created["id"])
|
|
36
|
+
print(f":white_check_mark: Created asset '[bold]{name}[/bold]' (ID {created['id']}) and uploaded GLB")
|
|
37
|
+
return
|
|
38
|
+
# Fallback to raw request + upload the GLB
|
|
39
|
+
created = await client._request(
|
|
40
|
+
"POST",
|
|
41
|
+
"/assets",
|
|
42
|
+
json={
|
|
43
|
+
"name": name,
|
|
44
|
+
"description": description or "",
|
|
45
|
+
"public": False,
|
|
46
|
+
"registry_id": registry_id,
|
|
47
|
+
},
|
|
48
|
+
)
|
|
49
|
+
asset = created.json() if hasattr(created, "json") else created
|
|
50
|
+
await client.upload_asset_glb(asset.get("uuid") or asset.get("id"), mesh_path)
|
|
51
|
+
# If no thumbnail, attempt to auto-generate a simple one from the GLB path (placeholder icon)
|
|
52
|
+
# Backend may also generate thumbnails asynchronously; this is a best-effort client-side fallback.
|
|
53
|
+
try:
|
|
54
|
+
# For now, rely on backend pipeline; printing hint for the user.
|
|
55
|
+
print(":bulb: If the asset lacks a thumbnail, the backend will attempt to generate one.")
|
|
56
|
+
except Exception:
|
|
57
|
+
pass
|
|
58
|
+
print(f":white_check_mark: Created asset '[bold]{name}[/bold]' (UUID {asset['uuid']}) and uploaded GLB")
|
|
59
|
+
|
|
60
|
+
@app.command()
|
|
61
|
+
def upload(
|
|
62
|
+
mesh: Path = typer.Argument(..., exists=True, help="Path to a mesh file"),
|
|
63
|
+
name: str = typer.Option(..., "--name", help="Name for the catalog entry"),
|
|
64
|
+
workspace: str = typer.Option(..., "--workspace", "-w", help="Workspace UUID"),
|
|
65
|
+
project: str = typer.Option(..., "--project", "-p", help="Project UUID (for future)"),
|
|
66
|
+
registry_id: Optional[str] = typer.Option(None, "--registry-id", help="Catalog registry identifier (e.g., so/100)"),
|
|
67
|
+
description: Optional[str] = typer.Option(None, "--description", help="Description"),
|
|
68
|
+
):
|
|
69
|
+
"""Upload a mesh and create a catalog asset definition."""
|
|
70
|
+
|
|
71
|
+
asyncio.run(_upload_asset(mesh, name, workspace, project, description, registry_id))
|
|
72
|
+
|
|
73
|
+
@app.command("list") # Explicit command name
|
|
74
|
+
def list_assets():
|
|
75
|
+
"""List available assets in CyberWave storage."""
|
|
76
|
+
async def _run() -> None:
|
|
77
|
+
client = Client()
|
|
78
|
+
await client.login()
|
|
79
|
+
assets = await client.list_assets()
|
|
80
|
+
for a in assets:
|
|
81
|
+
rid = a.get("registry_id") or (a.get("metadata", {}) or {}).get("registry_id")
|
|
82
|
+
print(f"{a.get('uuid')} {a.get('name')} registry_id={rid}")
|
|
83
|
+
|
|
84
|
+
asyncio.run(_run())
|
|
85
|
+
|
|
86
|
+
if __name__ == "__main__":
|
|
87
|
+
app()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Auth plugin for web-based authentication
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import webbrowser
|
|
3
|
+
import time
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional, Dict, Any
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich import print
|
|
11
|
+
from rich.prompt import Confirm
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
console = Console()
|
|
17
|
+
app = typer.Typer(help="Authentication and configuration management")
|
|
18
|
+
|
|
19
|
+
# Configuration constants
|
|
20
|
+
CONFIG_DIR = Path.home() / ".cyberwave"
|
|
21
|
+
CONFIG_FILE = CONFIG_DIR / "config.toml"
|
|
22
|
+
DEFAULT_FRONTEND_URL = "http://localhost:3000"
|
|
23
|
+
DEFAULT_BACKEND_URL = "http://localhost:8000"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DeviceAuthFlow:
|
|
27
|
+
"""Handles device flow authentication with the CyberWave platform."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, backend_url: str, frontend_url: str):
|
|
30
|
+
self.backend_url = backend_url.rstrip('/')
|
|
31
|
+
self.frontend_url = frontend_url.rstrip('/')
|
|
32
|
+
self.client = httpx.AsyncClient(timeout=30.0)
|
|
33
|
+
|
|
34
|
+
async def initiate_device_flow(self) -> Dict[str, Any]:
|
|
35
|
+
"""
|
|
36
|
+
Initiate device flow authentication.
|
|
37
|
+
Returns device code, user code, and verification URLs.
|
|
38
|
+
"""
|
|
39
|
+
try:
|
|
40
|
+
response = await self.client.post(
|
|
41
|
+
f"{self.backend_url}/api/v1/auth/device/initiate",
|
|
42
|
+
json={"client_type": "cli"}
|
|
43
|
+
)
|
|
44
|
+
response.raise_for_status()
|
|
45
|
+
return response.json()
|
|
46
|
+
except httpx.HTTPError as e:
|
|
47
|
+
raise typer.Exit(f"Failed to initiate device flow: {e}")
|
|
48
|
+
|
|
49
|
+
async def poll_for_token(self, device_code: str, interval: int = 5) -> Optional[Dict[str, Any]]:
|
|
50
|
+
"""
|
|
51
|
+
Poll the backend for token completion.
|
|
52
|
+
Returns tokens when user completes authentication, None if still pending.
|
|
53
|
+
"""
|
|
54
|
+
try:
|
|
55
|
+
response = await self.client.post(
|
|
56
|
+
f"{self.backend_url}/api/v1/auth/device/token",
|
|
57
|
+
json={"device_code": device_code}
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
if response.status_code == 200:
|
|
61
|
+
return response.json()
|
|
62
|
+
elif response.status_code == 202:
|
|
63
|
+
# Still pending
|
|
64
|
+
return None
|
|
65
|
+
else:
|
|
66
|
+
response.raise_for_status()
|
|
67
|
+
except httpx.HTTPError:
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
async def close(self):
|
|
71
|
+
"""Close the HTTP client."""
|
|
72
|
+
await self.client.aclose()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def load_config() -> Dict[str, Any]:
|
|
76
|
+
"""Load CLI configuration from file."""
|
|
77
|
+
if not CONFIG_FILE.exists():
|
|
78
|
+
return {}
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
import tomllib
|
|
82
|
+
except ImportError:
|
|
83
|
+
try:
|
|
84
|
+
import tomli as tomllib
|
|
85
|
+
except ImportError:
|
|
86
|
+
print("[red]Error: Neither tomllib nor tomli is available. Please install tomli: pip install tomli[/red]")
|
|
87
|
+
raise typer.Exit(1)
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
with open(CONFIG_FILE, 'rb') as f:
|
|
91
|
+
return tomllib.load(f)
|
|
92
|
+
except Exception as e:
|
|
93
|
+
print(f"[red]Error reading config file: {e}[/red]")
|
|
94
|
+
return {}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def save_config(config: Dict[str, Any]) -> None:
|
|
98
|
+
"""Save CLI configuration to file."""
|
|
99
|
+
CONFIG_DIR.mkdir(exist_ok=True)
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
import tomli_w
|
|
103
|
+
except ImportError:
|
|
104
|
+
print("[red]Error: tomli_w is not available. Please install tomli-w: pip install tomli-w[/red]")
|
|
105
|
+
raise typer.Exit(1)
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
with open(CONFIG_FILE, 'wb') as f:
|
|
109
|
+
tomli_w.dump(config, f)
|
|
110
|
+
print(f"[green]Configuration saved to {CONFIG_FILE}[/green]")
|
|
111
|
+
except Exception as e:
|
|
112
|
+
print(f"[red]Error saving config: {e}[/red]")
|
|
113
|
+
raise typer.Exit(1)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@app.command()
|
|
117
|
+
def login(
|
|
118
|
+
backend_url: Optional[str] = typer.Option(None, "--backend-url", help="Backend URL"),
|
|
119
|
+
frontend_url: Optional[str] = typer.Option(None, "--frontend-url", help="Frontend URL"),
|
|
120
|
+
no_browser: bool = typer.Option(False, "--no-browser", help="Don't open browser automatically")
|
|
121
|
+
) -> None:
|
|
122
|
+
"""
|
|
123
|
+
Authenticate with CyberWave using web-based device flow.
|
|
124
|
+
|
|
125
|
+
This will open your web browser to complete authentication on the CyberWave platform.
|
|
126
|
+
"""
|
|
127
|
+
asyncio.run(_login_flow(backend_url, frontend_url, no_browser))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def _login_flow(backend_url: Optional[str], frontend_url: Optional[str], no_browser: bool) -> None:
|
|
131
|
+
"""Async implementation of login flow."""
|
|
132
|
+
config = load_config()
|
|
133
|
+
|
|
134
|
+
# Use provided URLs or fall back to config or defaults
|
|
135
|
+
backend_url = backend_url or config.get("backend_url", DEFAULT_BACKEND_URL)
|
|
136
|
+
frontend_url = frontend_url or config.get("frontend_url", DEFAULT_FRONTEND_URL)
|
|
137
|
+
|
|
138
|
+
print(f"[cyan]Initiating authentication with CyberWave...[/cyan]")
|
|
139
|
+
print(f"Backend: {backend_url}")
|
|
140
|
+
print(f"Frontend: {frontend_url}")
|
|
141
|
+
|
|
142
|
+
auth_flow = DeviceAuthFlow(backend_url, frontend_url)
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
# Step 1: Initiate device flow
|
|
146
|
+
device_data = await auth_flow.initiate_device_flow()
|
|
147
|
+
|
|
148
|
+
device_code = device_data["device_code"]
|
|
149
|
+
user_code = device_data["user_code"]
|
|
150
|
+
verification_url = device_data["verification_url"]
|
|
151
|
+
expires_in = device_data.get("expires_in", 300) # 5 minutes default
|
|
152
|
+
interval = device_data.get("interval", 5) # 5 seconds default
|
|
153
|
+
|
|
154
|
+
# Step 2: Display instructions to user
|
|
155
|
+
print(f"\n[bold yellow]Authentication Required[/bold yellow]")
|
|
156
|
+
print(f"Please visit: [bold blue]{verification_url}[/bold blue]")
|
|
157
|
+
print(f"And enter the code: [bold green]{user_code}[/bold green]")
|
|
158
|
+
print(f"\nCode expires in {expires_in} seconds")
|
|
159
|
+
|
|
160
|
+
# Step 3: Open browser (unless disabled)
|
|
161
|
+
if not no_browser:
|
|
162
|
+
try:
|
|
163
|
+
# Construct the full URL with the user code
|
|
164
|
+
full_url = f"{frontend_url}/auth/device?user_code={user_code}"
|
|
165
|
+
webbrowser.open(full_url)
|
|
166
|
+
print(f"[dim]Opened browser to: {full_url}[/dim]")
|
|
167
|
+
except Exception as e:
|
|
168
|
+
print(f"[yellow]Could not open browser automatically: {e}[/yellow]")
|
|
169
|
+
|
|
170
|
+
# Step 4: Poll for completion
|
|
171
|
+
print(f"\n[dim]Waiting for authentication completion...[/dim]")
|
|
172
|
+
start_time = time.time()
|
|
173
|
+
|
|
174
|
+
with console.status("[bold green]Waiting for authentication...") as status:
|
|
175
|
+
while time.time() - start_time < expires_in:
|
|
176
|
+
token_data = await auth_flow.poll_for_token(device_code, interval)
|
|
177
|
+
|
|
178
|
+
if token_data:
|
|
179
|
+
# Success! Store tokens
|
|
180
|
+
print(f"\n[green]✓ Authentication successful![/green]")
|
|
181
|
+
|
|
182
|
+
# Save tokens using the SDK's token storage
|
|
183
|
+
from cyberwave import Client
|
|
184
|
+
client = Client(base_url=backend_url)
|
|
185
|
+
client._access_token = token_data["access_token"]
|
|
186
|
+
client._refresh_token = token_data.get("refresh_token")
|
|
187
|
+
client._session_info = {k: v for k, v in token_data.items()
|
|
188
|
+
if k not in ["access_token", "refresh_token"]}
|
|
189
|
+
|
|
190
|
+
if client._use_token_cache:
|
|
191
|
+
client._save_token_to_cache()
|
|
192
|
+
|
|
193
|
+
# Update config with URLs
|
|
194
|
+
config.update({
|
|
195
|
+
"backend_url": backend_url,
|
|
196
|
+
"frontend_url": frontend_url,
|
|
197
|
+
"default_workspace": token_data.get("default_workspace"),
|
|
198
|
+
"default_project": token_data.get("default_project")
|
|
199
|
+
})
|
|
200
|
+
save_config(config)
|
|
201
|
+
|
|
202
|
+
# Display user info
|
|
203
|
+
try:
|
|
204
|
+
user_info = await client.get_current_user_info()
|
|
205
|
+
print(f"Logged in as: [bold]{user_info.get('email', 'Unknown')}[/bold]")
|
|
206
|
+
except Exception:
|
|
207
|
+
print("Logged in successfully!")
|
|
208
|
+
|
|
209
|
+
await client.aclose()
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
# Wait before next poll
|
|
213
|
+
await asyncio.sleep(interval)
|
|
214
|
+
status.update(f"[bold green]Waiting for authentication... ({int(expires_in - (time.time() - start_time))}s remaining)")
|
|
215
|
+
|
|
216
|
+
print(f"\n[red]✗ Authentication timed out. Please try again.[/red]")
|
|
217
|
+
|
|
218
|
+
except Exception as e:
|
|
219
|
+
print(f"[red]✗ Authentication failed: {e}[/red]")
|
|
220
|
+
raise typer.Exit(1)
|
|
221
|
+
finally:
|
|
222
|
+
await auth_flow.close()
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@app.command()
|
|
226
|
+
def logout() -> None:
|
|
227
|
+
"""Log out and clear stored authentication."""
|
|
228
|
+
try:
|
|
229
|
+
from cyberwave import Client
|
|
230
|
+
|
|
231
|
+
# Clear tokens from cache
|
|
232
|
+
client = Client()
|
|
233
|
+
asyncio.run(client.logout())
|
|
234
|
+
|
|
235
|
+
print("[green]✓ Successfully logged out[/green]")
|
|
236
|
+
except Exception as e:
|
|
237
|
+
print(f"[red]Error during logout: {e}[/red]")
|
|
238
|
+
raise typer.Exit(1)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@app.command()
|
|
242
|
+
def status() -> None:
|
|
243
|
+
"""Show current authentication and configuration status."""
|
|
244
|
+
config = load_config()
|
|
245
|
+
|
|
246
|
+
# Check if user is authenticated
|
|
247
|
+
from cyberwave import Client
|
|
248
|
+
client = Client()
|
|
249
|
+
|
|
250
|
+
table = Table(title="CyberWave CLI Status", show_header=True, header_style="bold magenta")
|
|
251
|
+
table.add_column("Setting", style="cyan", width=20)
|
|
252
|
+
table.add_column("Value", style="white")
|
|
253
|
+
|
|
254
|
+
# Authentication status
|
|
255
|
+
if client._access_token:
|
|
256
|
+
table.add_row("Authentication", "[green]✓ Authenticated[/green]")
|
|
257
|
+
|
|
258
|
+
# Try to get user info
|
|
259
|
+
try:
|
|
260
|
+
user_info = asyncio.run(client.get_current_user_info())
|
|
261
|
+
table.add_row("User", user_info.get('email', 'Unknown'))
|
|
262
|
+
except Exception:
|
|
263
|
+
table.add_row("User", "[yellow]Token may be expired[/yellow]")
|
|
264
|
+
else:
|
|
265
|
+
table.add_row("Authentication", "[red]✗ Not authenticated[/red]")
|
|
266
|
+
|
|
267
|
+
# Configuration
|
|
268
|
+
table.add_row("Backend URL", config.get("backend_url", DEFAULT_BACKEND_URL))
|
|
269
|
+
table.add_row("Frontend URL", config.get("frontend_url", DEFAULT_FRONTEND_URL))
|
|
270
|
+
table.add_row("Default Workspace", str(config.get("default_workspace", "Not set")))
|
|
271
|
+
table.add_row("Default Project", str(config.get("default_project", "Not set")))
|
|
272
|
+
table.add_row("Config File", str(CONFIG_FILE))
|
|
273
|
+
|
|
274
|
+
console.print(table)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@app.command()
|
|
278
|
+
def config(
|
|
279
|
+
key: Optional[str] = typer.Argument(None, help="Configuration key to set or get"),
|
|
280
|
+
value: Optional[str] = typer.Argument(None, help="Value to set (omit to get current value)"),
|
|
281
|
+
list_all: bool = typer.Option(False, "--list", "-l", help="List all configuration"),
|
|
282
|
+
unset: bool = typer.Option(False, "--unset", "-u", help="Unset a configuration key")
|
|
283
|
+
) -> None:
|
|
284
|
+
"""
|
|
285
|
+
Manage CLI configuration.
|
|
286
|
+
|
|
287
|
+
Examples:
|
|
288
|
+
cyberwave auth config backend_url http://localhost:8000
|
|
289
|
+
cyberwave auth config backend_url
|
|
290
|
+
cyberwave auth config --list
|
|
291
|
+
cyberwave auth config --unset default_workspace
|
|
292
|
+
"""
|
|
293
|
+
current_config = load_config()
|
|
294
|
+
|
|
295
|
+
if list_all or (key is None and value is None):
|
|
296
|
+
# List all configuration
|
|
297
|
+
if not current_config:
|
|
298
|
+
print("[yellow]No configuration found[/yellow]")
|
|
299
|
+
return
|
|
300
|
+
|
|
301
|
+
table = Table(title="Configuration", show_header=True, header_style="bold magenta")
|
|
302
|
+
table.add_column("Key", style="cyan")
|
|
303
|
+
table.add_column("Value", style="white")
|
|
304
|
+
|
|
305
|
+
for k, v in current_config.items():
|
|
306
|
+
table.add_row(k, str(v))
|
|
307
|
+
|
|
308
|
+
console.print(table)
|
|
309
|
+
return
|
|
310
|
+
|
|
311
|
+
if key is None:
|
|
312
|
+
print("[red]Error: Please specify a configuration key[/red]")
|
|
313
|
+
raise typer.Exit(1)
|
|
314
|
+
|
|
315
|
+
if unset:
|
|
316
|
+
# Unset a key
|
|
317
|
+
if key in current_config:
|
|
318
|
+
del current_config[key]
|
|
319
|
+
save_config(current_config)
|
|
320
|
+
print(f"[green]Unset {key}[/green]")
|
|
321
|
+
else:
|
|
322
|
+
print(f"[yellow]Key '{key}' not found[/yellow]")
|
|
323
|
+
return
|
|
324
|
+
|
|
325
|
+
if value is None:
|
|
326
|
+
# Get current value
|
|
327
|
+
if key in current_config:
|
|
328
|
+
print(f"{key} = {current_config[key]}")
|
|
329
|
+
else:
|
|
330
|
+
print(f"[yellow]Key '{key}' not found[/yellow]")
|
|
331
|
+
return
|
|
332
|
+
|
|
333
|
+
# Set value
|
|
334
|
+
current_config[key] = value
|
|
335
|
+
save_config(current_config)
|
|
336
|
+
print(f"[green]Set {key} = {value}[/green]")
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
if __name__ == "__main__":
|
|
340
|
+
app()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Device management CLI plugin
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
from rich import print
|
|
6
|
+
|
|
7
|
+
from cyberwave import Client
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(help="Manage devices (registration and tokens)")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@app.command("register")
|
|
13
|
+
def register_device(
|
|
14
|
+
project: int = typer.Option(..., "--project", "-p", help="Project ID"),
|
|
15
|
+
name: str = typer.Option(..., "--name", "-n", help="Device name"),
|
|
16
|
+
type: str = typer.Option(..., "--type", "-t", help="Device type"),
|
|
17
|
+
asset: Optional[str] = typer.Option(None, "--asset", "-a", help="Asset catalog UUID"),
|
|
18
|
+
) -> None:
|
|
19
|
+
"""Register a new device."""
|
|
20
|
+
|
|
21
|
+
async def _run() -> None:
|
|
22
|
+
client = Client()
|
|
23
|
+
await client.login()
|
|
24
|
+
device = await client.register_device(
|
|
25
|
+
project_id=project,
|
|
26
|
+
name=name,
|
|
27
|
+
device_type=type,
|
|
28
|
+
asset_catalog_uuid=asset,
|
|
29
|
+
)
|
|
30
|
+
print(f":white_check_mark: Registered device [bold]{name}[/bold] (ID {device['id']})")
|
|
31
|
+
|
|
32
|
+
asyncio.run(_run())
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@app.command("issue-offline-token")
|
|
36
|
+
def issue_offline_token(device: int = typer.Option(..., "--device", "-d", help="Device ID")) -> None:
|
|
37
|
+
"""Issue an offline token for a device."""
|
|
38
|
+
|
|
39
|
+
async def _run() -> None:
|
|
40
|
+
client = Client()
|
|
41
|
+
await client.login()
|
|
42
|
+
token = await client.issue_device_token(device_id=device)
|
|
43
|
+
print(f":key: Offline token for device {device}: [bold]{token}[/bold]")
|
|
44
|
+
|
|
45
|
+
asyncio.run(_run())
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
if __name__ == "__main__":
|
|
49
|
+
app()
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich import print
|
|
9
|
+
|
|
10
|
+
from cyberwave import Client
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(help="Configure and run Cyberwave Edge nodes")
|
|
13
|
+
|
|
14
|
+
DEFAULT_EDGE_CONFIG = Path.home() / ".cyberwave" / "edge.json"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _write_config_file(cfg_path: Path, data: dict) -> None:
|
|
18
|
+
cfg_path.parent.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
with open(cfg_path, "w", encoding="utf-8") as f:
|
|
20
|
+
json.dump(data, f, indent=2)
|
|
21
|
+
os.chmod(cfg_path, 0o600)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.command("init")
|
|
25
|
+
def init(
|
|
26
|
+
robot: str = typer.Option(..., "--robot", help="Robot driver type, e.g. so_arm100"),
|
|
27
|
+
port: Optional[str] = typer.Option(None, "--port", help="Serial/connection port for the robot"),
|
|
28
|
+
backend: Optional[str] = typer.Option(None, "--backend", help="Backend base URL (e.g. http://localhost:8000/api/v1)"),
|
|
29
|
+
device_id: Optional[int] = typer.Option(None, "--device-id", help="Existing device ID"),
|
|
30
|
+
project_id: Optional[int] = typer.Option(None, "--project", help="Project ID for auto-registration"),
|
|
31
|
+
device_name: Optional[str] = typer.Option(None, "--device-name", help="Device name for auto-registration"),
|
|
32
|
+
device_type: Optional[str] = typer.Option("robot/so-arm100", "--device-type", help="Device type for auto-registration"),
|
|
33
|
+
auto_register: bool = typer.Option(False, "--auto-register", help="Register device if device_id not provided"),
|
|
34
|
+
use_device_token: bool = typer.Option(False, "--use-device-token", help="Issue and use a device token for offline operation"),
|
|
35
|
+
loop_hz: float = typer.Option(20.0, "--loop-hz", help="Polling frequency (Hz)"),
|
|
36
|
+
config: Path = typer.Option(DEFAULT_EDGE_CONFIG, "--config", help="Where to write the Edge config JSON"),
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Create or update an Edge node configuration file."""
|
|
39
|
+
|
|
40
|
+
data = {
|
|
41
|
+
"robot_type": robot,
|
|
42
|
+
"robot_port": port,
|
|
43
|
+
"backend_url": backend,
|
|
44
|
+
"device_id": device_id,
|
|
45
|
+
"project_id": project_id,
|
|
46
|
+
"device_name": device_name,
|
|
47
|
+
"device_type": device_type,
|
|
48
|
+
"auto_register_device": auto_register,
|
|
49
|
+
"use_device_token": use_device_token,
|
|
50
|
+
"loop_hz": loop_hz,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# Attempt to reuse existing CLI auth to fill backend if not provided
|
|
54
|
+
if backend is None:
|
|
55
|
+
from cyberwave_cli.plugins.auth.app import load_config as _load_cli_cfg, DEFAULT_BACKEND_URL
|
|
56
|
+
cli_cfg = _load_cli_cfg()
|
|
57
|
+
data["backend_url"] = cli_cfg.get("backend_url", DEFAULT_BACKEND_URL) + "/api/v1"
|
|
58
|
+
|
|
59
|
+
# If auto-register requested and no device_id, perform registration and optionally issue a token
|
|
60
|
+
if auto_register and not device_id:
|
|
61
|
+
if not project_id or not device_name or not device_type:
|
|
62
|
+
raise typer.Exit("--project, --device-name, and --device-type are required for --auto-register")
|
|
63
|
+
async def _run():
|
|
64
|
+
client = Client(base_url=data["backend_url"])
|
|
65
|
+
await client.login()
|
|
66
|
+
dev = await client.register_device(project_id=project_id, name=device_name, device_type=device_type)
|
|
67
|
+
data["device_id"] = int(dev.get("id"))
|
|
68
|
+
if use_device_token:
|
|
69
|
+
token = await client.issue_device_token(device_id=data["device_id"])
|
|
70
|
+
data["access_token"] = token
|
|
71
|
+
await client.aclose()
|
|
72
|
+
import asyncio; asyncio.run(_run())
|
|
73
|
+
|
|
74
|
+
_write_config_file(config, data)
|
|
75
|
+
print(f":white_check_mark: Edge config written to [bold]{config}[/bold]")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@app.command("run")
|
|
79
|
+
def run(config: Path = typer.Option(DEFAULT_EDGE_CONFIG, "--config", help="Edge config JSON path")) -> None:
|
|
80
|
+
"""Run the Edge node with the provided configuration file."""
|
|
81
|
+
if not config.exists():
|
|
82
|
+
raise typer.Exit(f"Config file not found: {config}")
|
|
83
|
+
# Prefer running the module directly to use current env
|
|
84
|
+
cmd = ["python", "-m", "cyberwave_edge.main", "--config", str(config)]
|
|
85
|
+
print(f"Starting Edge: {' '.join(cmd)}")
|
|
86
|
+
subprocess.run(cmd, check=False)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@app.command("simulate")
|
|
90
|
+
def simulate(
|
|
91
|
+
sensor: str = typer.Option(..., "--sensor", "-s", help="Sensor UUID"),
|
|
92
|
+
video: Path = typer.Option(..., "--video", "-v", help="Path to local mp4"),
|
|
93
|
+
fps: float = typer.Option(2.0, "--fps"),
|
|
94
|
+
) -> None:
|
|
95
|
+
"""Stream a local video file as a virtual camera to a sensor."""
|
|
96
|
+
cmd = [
|
|
97
|
+
"python", "-m", "cyberwave_edge.camera_worker",
|
|
98
|
+
"--sensor", sensor,
|
|
99
|
+
"--source", str(video),
|
|
100
|
+
"--fps", str(fps),
|
|
101
|
+
]
|
|
102
|
+
print(f"Simulating edge camera: {' '.join(cmd)}")
|
|
103
|
+
subprocess.run(cmd, check=False)
|
|
104
|
+
|
|
105
|
+
@app.command("status")
|
|
106
|
+
def status(config: Path = typer.Option(DEFAULT_EDGE_CONFIG, "--config", help="Edge config JSON path")) -> None:
|
|
107
|
+
"""Quick diagnostics for Edge configuration and cloud connectivity."""
|
|
108
|
+
if not config.exists():
|
|
109
|
+
raise typer.Exit(f"Config file not found: {config}")
|
|
110
|
+
with open(config, "r", encoding="utf-8") as f:
|
|
111
|
+
cfg = json.load(f)
|
|
112
|
+
|
|
113
|
+
missing = [k for k in ("robot_type", "backend_url") if not cfg.get(k)]
|
|
114
|
+
if missing:
|
|
115
|
+
print(f"[red]Missing required config keys: {missing}[/red]")
|
|
116
|
+
raise typer.Exit(1)
|
|
117
|
+
|
|
118
|
+
# Try SDK token and device reachability
|
|
119
|
+
async def _check():
|
|
120
|
+
client = Client(base_url=cfg["backend_url"])
|
|
121
|
+
try:
|
|
122
|
+
await client.login() # uses cached token via SDK
|
|
123
|
+
except Exception:
|
|
124
|
+
if tok := cfg.get("access_token"):
|
|
125
|
+
client._access_token = tok # fallback to provided device token
|
|
126
|
+
else:
|
|
127
|
+
print("[yellow]No active session; telemetry may fail without token[/yellow]")
|
|
128
|
+
did = cfg.get("device_id")
|
|
129
|
+
ok = True
|
|
130
|
+
if did:
|
|
131
|
+
try:
|
|
132
|
+
# simple check: send empty telemetry as dry run
|
|
133
|
+
await client.send_telemetry(did, {"ping": True})
|
|
134
|
+
print(f"[green]✓ Cloud reachable. Device {did} accepted telemetry[/green]")
|
|
135
|
+
except Exception as e:
|
|
136
|
+
ok = False
|
|
137
|
+
print(f"[red]✗ Telemetry check failed for device {did}: {e}[/red]")
|
|
138
|
+
await client.aclose()
|
|
139
|
+
return ok
|
|
140
|
+
|
|
141
|
+
import asyncio; ok = asyncio.run(_check())
|
|
142
|
+
if not ok:
|
|
143
|
+
raise typer.Exit(1)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
if __name__ == "__main__":
|
|
147
|
+
app()
|
|
148
|
+
|
|
149
|
+
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import typer
|
|
3
|
+
from rich import print
|
|
4
|
+
|
|
5
|
+
from cyberwave import Client
|
|
6
|
+
|
|
7
|
+
app = typer.Typer(help="Manage environments")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@app.command("create")
|
|
11
|
+
def create(
|
|
12
|
+
project: str = typer.Option(..., "--project", "-p", help="Project UUID"),
|
|
13
|
+
name: str = typer.Option(..., "--name", "-n"),
|
|
14
|
+
description: str = typer.Option("", "--description", "-d"),
|
|
15
|
+
):
|
|
16
|
+
async def _run():
|
|
17
|
+
client = Client()
|
|
18
|
+
await client.login()
|
|
19
|
+
env = await client.create_environment(project_uuid=project, name=name, description=description)
|
|
20
|
+
print(f"[green]✓[/green] Created environment [bold]{env.get('name')}[/bold] (UUID {env.get('uuid')})")
|
|
21
|
+
await client.aclose()
|
|
22
|
+
asyncio.run(_run())
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.command("list")
|
|
26
|
+
def list_envs(project: str = typer.Option(..., "--project", "-p", help="Project UUID")):
|
|
27
|
+
async def _run():
|
|
28
|
+
client = Client()
|
|
29
|
+
await client.login()
|
|
30
|
+
envs = await client.get_environments(project_uuid=project)
|
|
31
|
+
for e in envs:
|
|
32
|
+
print(f"- {e.get('name')} ({e.get('uuid')})")
|
|
33
|
+
await client.aclose()
|
|
34
|
+
asyncio.run(_run())
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@app.command("events")
|
|
38
|
+
def list_events(
|
|
39
|
+
environment: str = typer.Option(..., "--environment", "-e", help="Environment UUID"),
|
|
40
|
+
limit: int = typer.Option(10, "--limit", "-n", help="Max entries to show"),
|
|
41
|
+
json_out: bool = typer.Option(False, "--json", help="Emit JSON output"),
|
|
42
|
+
):
|
|
43
|
+
"""List recent environment events (latest session per twin), including segment thumbnails when available."""
|
|
44
|
+
async def _run():
|
|
45
|
+
client = Client()
|
|
46
|
+
await client.login()
|
|
47
|
+
resp = await client._client.get(f"/environments/{environment}/events", headers=client._get_headers())
|
|
48
|
+
resp.raise_for_status()
|
|
49
|
+
data = resp.json()
|
|
50
|
+
from datetime import datetime
|
|
51
|
+
if json_out:
|
|
52
|
+
import json as _json
|
|
53
|
+
print(_json.dumps(data[:limit], indent=2))
|
|
54
|
+
else:
|
|
55
|
+
shown = 0
|
|
56
|
+
for item in data:
|
|
57
|
+
if shown >= limit:
|
|
58
|
+
break
|
|
59
|
+
twin = item.get("twin_uuid")
|
|
60
|
+
sess = item.get("session", {})
|
|
61
|
+
seg = item.get("segment") or {}
|
|
62
|
+
ts = sess.get("started_at_unix")
|
|
63
|
+
ts_str = datetime.fromtimestamp(ts).isoformat() if ts else "?"
|
|
64
|
+
seg_url = seg.get("url")
|
|
65
|
+
seg_key = seg.get("storage_key")
|
|
66
|
+
print(f"- twin={twin} started_at={ts_str} segment_key={seg_key} segment_url={seg_url}")
|
|
67
|
+
shown += 1
|
|
68
|
+
await client.aclose()
|
|
69
|
+
asyncio.run(_run())
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
if __name__ == "__main__":
|
|
73
|
+
app()
|
|
74
|
+
|
|
75
|
+
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
import importlib.metadata as metadata
|
|
3
|
+
from rich import print
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
from importlib.metadata import EntryPoint
|
|
7
|
+
from typing import Iterable, List
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _get_entry_points(group: str) -> Iterable[EntryPoint]:
|
|
11
|
+
"""Return entry points for the given group across Python versions."""
|
|
12
|
+
try: # Python >=3.10
|
|
13
|
+
return metadata.entry_points(group=group)
|
|
14
|
+
except TypeError: # Python <3.10
|
|
15
|
+
return metadata.entry_points().get(group, [])
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def discover_plugins() -> List[str]:
|
|
19
|
+
"""Return a list of available plugin names."""
|
|
20
|
+
return [ep.name for ep in _get_entry_points("cyberwave.cli.plugins")]
|
|
21
|
+
|
|
22
|
+
def register_all(root_app: typer.Typer) -> None:
|
|
23
|
+
"""Discovers and registers all plugins declared via entry points."""
|
|
24
|
+
print("[dim]Discovering CLI plugins...[/dim]")
|
|
25
|
+
discovered_plugins = list(_get_entry_points("cyberwave.cli.plugins"))
|
|
26
|
+
|
|
27
|
+
if not discovered_plugins:
|
|
28
|
+
print("[dim]No external plugins found.[/dim]")
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
for ep in discovered_plugins:
|
|
32
|
+
print(f" - Loading plugin: [bold cyan]{ep.name}[/bold cyan]")
|
|
33
|
+
try:
|
|
34
|
+
sub_app = ep.load()
|
|
35
|
+
if isinstance(sub_app, typer.Typer):
|
|
36
|
+
root_app.add_typer(sub_app, name=ep.name)
|
|
37
|
+
print(f" [green]✓[/green] Registered typer app for '[bold]{ep.name}[/bold]'")
|
|
38
|
+
else:
|
|
39
|
+
# Handle single commands if needed, though spec implies Typer apps
|
|
40
|
+
print(f" [yellow]⚠[/yellow] Plugin '[bold]{ep.name}[/bold]' did not load a Typer app. Skipping.")
|
|
41
|
+
except Exception as e:
|
|
42
|
+
print(f" [red]✗[/red] Failed to load plugin '[bold]{ep.name}[/bold]': {e}")
|
|
43
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Init for projects plugin
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import List, Dict
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
from rich import print
|
|
6
|
+
|
|
7
|
+
from cyberwave import Client
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(help="Manage projects within a workspace")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@app.command()
|
|
13
|
+
def create(
|
|
14
|
+
name: str = typer.Option(..., "--name", "-n", help="Project name"),
|
|
15
|
+
workspace: str = typer.Option(..., "--workspace", "-w", help="Workspace UUID"),
|
|
16
|
+
) -> None:
|
|
17
|
+
"""Create a new project."""
|
|
18
|
+
|
|
19
|
+
async def _run() -> None:
|
|
20
|
+
client = Client()
|
|
21
|
+
await client.login()
|
|
22
|
+
# Ensure workspace_id is numeric if API expects int
|
|
23
|
+
try:
|
|
24
|
+
ws_id = int(workspace)
|
|
25
|
+
except Exception:
|
|
26
|
+
ws_id = workspace
|
|
27
|
+
project = await client.create_project(workspace_id=ws_id, name=name)
|
|
28
|
+
print(f":white_check_mark: Created project [bold]{name}[/bold] (UUID {project.get('uuid') or project.get('id')})")
|
|
29
|
+
|
|
30
|
+
asyncio.run(_run())
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@app.command("list")
|
|
34
|
+
def list_projects(
|
|
35
|
+
workspace: str = typer.Option(..., "--workspace", "-w", help="Workspace UUID"),
|
|
36
|
+
) -> None:
|
|
37
|
+
"""List projects within a workspace."""
|
|
38
|
+
|
|
39
|
+
async def _run() -> None:
|
|
40
|
+
client = Client()
|
|
41
|
+
await client.login()
|
|
42
|
+
try:
|
|
43
|
+
ws_id = int(workspace)
|
|
44
|
+
except Exception:
|
|
45
|
+
ws_id = workspace
|
|
46
|
+
projects: List[Dict] = await client.get_projects(workspace_id=ws_id)
|
|
47
|
+
for proj in projects:
|
|
48
|
+
print(f"{proj.get('uuid') or proj.get('id')}: {proj.get('name')}")
|
|
49
|
+
|
|
50
|
+
asyncio.run(_run())
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
if __name__ == "__main__":
|
|
54
|
+
app()
|
|
55
|
+
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import typer
|
|
3
|
+
from rich import print
|
|
4
|
+
|
|
5
|
+
from cyberwave import Client
|
|
6
|
+
import httpx
|
|
7
|
+
import json
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(help="Manage sensors")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@app.command("list")
|
|
13
|
+
def list_sensors(environment: str = typer.Option(..., "--environment", "-e", help="Environment UUID")):
|
|
14
|
+
async def _run():
|
|
15
|
+
client = Client()
|
|
16
|
+
await client.login()
|
|
17
|
+
sensors = await client._client.get(f"/environments/{environment}/sensors", headers=client._get_headers())
|
|
18
|
+
sensors.raise_for_status()
|
|
19
|
+
for s in sensors.json():
|
|
20
|
+
print(f"- {s.get('name')} ({s.get('uuid')}) type={s.get('sensor_type')} twin={s.get('twin_uuid')}")
|
|
21
|
+
await client.aclose()
|
|
22
|
+
asyncio.run(_run())
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.command("events")
|
|
26
|
+
def sensor_events(
|
|
27
|
+
sensor_uuid: str = typer.Option(..., "--sensor", "-s"),
|
|
28
|
+
environment: str = typer.Option(None, "--environment", "-e", help="Environment UUID (to locate latest session)"),
|
|
29
|
+
limit: int = typer.Option(50, "--limit", "-n"),
|
|
30
|
+
):
|
|
31
|
+
"""Tail analyzer events for a sensor.
|
|
32
|
+
|
|
33
|
+
- Backend mode (default): polls /api/v1/sensors/{sensor_uuid}/events and prints latest events.
|
|
34
|
+
- Node+session mode (fallback): if --environment is provided and there is an active teleop session,
|
|
35
|
+
read session NDJSON and filter events for the sensor.
|
|
36
|
+
"""
|
|
37
|
+
async def _run():
|
|
38
|
+
client = Client()
|
|
39
|
+
await client.login()
|
|
40
|
+
# Preferred: backend mode
|
|
41
|
+
try:
|
|
42
|
+
r = await client._client.get(f"/sensors/{sensor_uuid}/events", headers=client._get_headers())
|
|
43
|
+
r.raise_for_status()
|
|
44
|
+
events = r.json()
|
|
45
|
+
for ev in events[-limit:]:
|
|
46
|
+
print(ev)
|
|
47
|
+
await client.aclose(); return
|
|
48
|
+
except httpx.HTTPStatusError:
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
# Fallback: node+session mode requires environment to locate latest session
|
|
52
|
+
if not environment:
|
|
53
|
+
print("[yellow]Backend events endpoint unavailable. Provide --environment to fallback to session log tailing.[/yellow]")
|
|
54
|
+
await client.aclose(); return
|
|
55
|
+
sensors = await client._client.get(f"/environments/{environment}/sensors", headers=client._get_headers())
|
|
56
|
+
sensors.raise_for_status()
|
|
57
|
+
twin_uuid = None
|
|
58
|
+
for s in sensors.json():
|
|
59
|
+
if s.get("uuid") == sensor_uuid:
|
|
60
|
+
twin_uuid = s.get("twin_uuid")
|
|
61
|
+
break
|
|
62
|
+
if not twin_uuid:
|
|
63
|
+
print(f"[red]Sensor {sensor_uuid} not found in environment {environment}[/red]")
|
|
64
|
+
await client.aclose(); return
|
|
65
|
+
evs = await client._client.get(f"/environments/{environment}/events", headers=client._get_headers())
|
|
66
|
+
evs.raise_for_status()
|
|
67
|
+
session_id = None
|
|
68
|
+
for row in evs.json():
|
|
69
|
+
if row.get("twin_uuid") == twin_uuid:
|
|
70
|
+
session_id = row.get("session", {}).get("session_id")
|
|
71
|
+
break
|
|
72
|
+
if not session_id:
|
|
73
|
+
print(f"[yellow]No sessions found for twin {twin_uuid}[/yellow]")
|
|
74
|
+
await client.aclose(); return
|
|
75
|
+
r = await client._client.get(f"/twins/{twin_uuid}/teleop/sessions/{session_id}/events", headers=client._get_headers())
|
|
76
|
+
r.raise_for_status()
|
|
77
|
+
count = 0
|
|
78
|
+
for line in r.text.splitlines():
|
|
79
|
+
try:
|
|
80
|
+
obj = json.loads(line)
|
|
81
|
+
except Exception:
|
|
82
|
+
continue
|
|
83
|
+
if obj.get("type") == "event" and obj.get("payload", {}).get("sensor_uuid") == sensor_uuid:
|
|
84
|
+
analyzer = obj.get("payload", {}).get("analyzer")
|
|
85
|
+
ts = obj.get("ts_unix")
|
|
86
|
+
print(f"{ts} analyzer={analyzer} event={obj.get('payload')}")
|
|
87
|
+
count += 1
|
|
88
|
+
if count >= limit:
|
|
89
|
+
break
|
|
90
|
+
await client.aclose()
|
|
91
|
+
asyncio.run(_run())
|
|
92
|
+
|
|
93
|
+
@app.command("create")
|
|
94
|
+
def create_sensor(
|
|
95
|
+
environment: str = typer.Option(..., "--environment", "-e"),
|
|
96
|
+
name: str = typer.Option(..., "--name", "-n"),
|
|
97
|
+
sensor_type: str = typer.Option("camera", "--type", "-t"),
|
|
98
|
+
twin: str = typer.Option(None, "--twin"),
|
|
99
|
+
):
|
|
100
|
+
async def _run():
|
|
101
|
+
client = Client()
|
|
102
|
+
await client.login()
|
|
103
|
+
payload = {"name": name, "sensor_type": sensor_type, "twin_uuid": twin}
|
|
104
|
+
resp = await client._client.post(f"/environments/{environment}/sensors", json=payload, headers=client._get_headers())
|
|
105
|
+
resp.raise_for_status()
|
|
106
|
+
s = resp.json()
|
|
107
|
+
print(f"[green]✓[/green] Created sensor [bold]{s.get('name')}[/bold] (UUID {s.get('uuid')})")
|
|
108
|
+
await client.aclose()
|
|
109
|
+
asyncio.run(_run())
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@app.command("update")
|
|
113
|
+
def update_sensor(
|
|
114
|
+
environment: str = typer.Option(..., "--environment", "-e"),
|
|
115
|
+
sensor_uuid: str = typer.Option(..., "--sensor", "-s"),
|
|
116
|
+
name: str = typer.Option(None, "--name"),
|
|
117
|
+
sensor_type: str = typer.Option(None, "--type"),
|
|
118
|
+
twin: str = typer.Option(None, "--twin"),
|
|
119
|
+
):
|
|
120
|
+
async def _run():
|
|
121
|
+
client = Client()
|
|
122
|
+
await client.login()
|
|
123
|
+
payload = {}
|
|
124
|
+
if name is not None: payload["name"] = name
|
|
125
|
+
if sensor_type is not None: payload["sensor_type"] = sensor_type
|
|
126
|
+
if twin is not None: payload["twin_uuid"] = twin
|
|
127
|
+
resp = await client._client.put(f"/environments/{environment}/sensors/{sensor_uuid}", json=payload, headers=client._get_headers())
|
|
128
|
+
resp.raise_for_status()
|
|
129
|
+
s = resp.json()
|
|
130
|
+
print(f"[green]✓[/green] Updated sensor [bold]{s.get('name')}[/bold] (UUID {s.get('uuid')})")
|
|
131
|
+
await client.aclose()
|
|
132
|
+
asyncio.run(_run())
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
if __name__ == "__main__":
|
|
136
|
+
app()
|
|
137
|
+
|
|
138
|
+
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from rich import print
|
|
3
|
+
|
|
4
|
+
app = typer.Typer(help="Control local simulations (Isaac Sim, Unity).")
|
|
5
|
+
|
|
6
|
+
@app.command()
|
|
7
|
+
def launch(simulator: str = typer.Option("isaac", help="Simulator type ('isaac' or 'unity')")):
|
|
8
|
+
"""Launch a local simulation environment."""
|
|
9
|
+
print(f":rocket: Launching local [yellow]{simulator}[/yellow] simulation... (not implemented)")
|
|
10
|
+
|
|
11
|
+
@app.command()
|
|
12
|
+
def status():
|
|
13
|
+
"""Check the status of running local simulations."""
|
|
14
|
+
print(":magnifying_glass_right: Checking simulation status... (not implemented)")
|
|
15
|
+
|
|
16
|
+
if __name__ == "__main__":
|
|
17
|
+
app()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Init for telemetry plugin
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from rich import print
|
|
3
|
+
|
|
4
|
+
app = typer.Typer(help="Access robot telemetry data (logs, live streams).")
|
|
5
|
+
|
|
6
|
+
@app.command()
|
|
7
|
+
def logs(robot_id: str = typer.Argument(..., help="ID of the robot")):
|
|
8
|
+
"""Fetch logs for a specific robot."""
|
|
9
|
+
print(f":scroll: Fetching logs for robot [bold blue]{robot_id}[/bold blue]... (not implemented)")
|
|
10
|
+
|
|
11
|
+
@app.command()
|
|
12
|
+
def stream(robot_id: str = typer.Argument(..., help="ID of the robot")):
|
|
13
|
+
"""Stream live telemetry data for a specific robot."""
|
|
14
|
+
print(f":satellite_antenna: Streaming live data for robot [bold blue]{robot_id}[/bold blue]... (not implemented)")
|
|
15
|
+
|
|
16
|
+
if __name__ == "__main__":
|
|
17
|
+
app()
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich import print
|
|
7
|
+
|
|
8
|
+
from cyberwave import Client
|
|
9
|
+
|
|
10
|
+
app = typer.Typer(help="Twin commands and control (teleop)")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@app.command("command")
|
|
14
|
+
def send_command(
|
|
15
|
+
twin: str = typer.Option(..., "--twin", "-t", help="Twin UUID"),
|
|
16
|
+
name: str = typer.Option(..., "--name", "-n", help="Command name, e.g., arm.move_joints"),
|
|
17
|
+
joints: Optional[str] = typer.Option(None, "--joints", help="JSON list of joint positions for arm.move_joints"),
|
|
18
|
+
pose: Optional[str] = typer.Option(None, "--pose", help='JSON dict pose for arm.move_pose/move_to (e.g., {"x":0.1, "y":0.2, "z":0.0})'),
|
|
19
|
+
mode: str = typer.Option("both", "--mode", help="sim|real|both"),
|
|
20
|
+
source: Optional[str] = typer.Option("cli", "--source", help="Source tag, e.g., cli/web-ui/edge-leader"),
|
|
21
|
+
) -> None:
|
|
22
|
+
"""Send a unified command to a twin via the backend TeleopControllerService."""
|
|
23
|
+
|
|
24
|
+
async def _run():
|
|
25
|
+
client = Client()
|
|
26
|
+
await client.login()
|
|
27
|
+
|
|
28
|
+
payload: dict = {"name": name, "payload": {}, "mode": mode, "source": source}
|
|
29
|
+
if name.endswith("move_joints") and joints:
|
|
30
|
+
payload["payload"] = {"joints": json.loads(joints)}
|
|
31
|
+
elif name.endswith("move_pose") or name.endswith("move_to") or name.endswith("fly_to"):
|
|
32
|
+
if pose:
|
|
33
|
+
payload["payload"] = {"pose": json.loads(pose)}
|
|
34
|
+
# Fallback: if user gave raw payload via pose or joints json that doesn't match name, just pass through
|
|
35
|
+
|
|
36
|
+
# Use SDK internals to POST
|
|
37
|
+
headers = client._get_headers() # type: ignore[attr-defined]
|
|
38
|
+
resp = await client._client.post(f"/twins/{twin}/commands", json=payload, headers=headers) # type: ignore[attr-defined]
|
|
39
|
+
if resp.status_code >= 400:
|
|
40
|
+
print(f"[red]Command failed: {resp.status_code} {resp.text}[/red]")
|
|
41
|
+
else:
|
|
42
|
+
print(f"[green]✓ Command sent: {name}[/green]")
|
|
43
|
+
await client.aclose()
|
|
44
|
+
|
|
45
|
+
asyncio.run(_run())
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@app.command("apply-defaults")
|
|
49
|
+
def apply_defaults(twin: str = typer.Option(..., "--twin", "-t", help="Twin UUID")) -> None:
|
|
50
|
+
"""Apply asset catalog state defaults (pose, joints, logical) to the twin."""
|
|
51
|
+
async def _run():
|
|
52
|
+
client = Client()
|
|
53
|
+
await client.login()
|
|
54
|
+
headers = client._get_headers()
|
|
55
|
+
resp = await client._client.post(f"/twins/{twin}/apply-defaults", headers=headers)
|
|
56
|
+
if resp.status_code >= 400:
|
|
57
|
+
print(f"[red]Failed to apply defaults: {resp.status_code} {resp.text}[/red]")
|
|
58
|
+
else:
|
|
59
|
+
print(f"[green]✓ Defaults applied[/green] {resp.json()}")
|
|
60
|
+
await client.aclose()
|
|
61
|
+
asyncio.run(_run())
|
|
62
|
+
|
|
63
|
+
if __name__ == "__main__":
|
|
64
|
+
app()
|
|
65
|
+
|
|
66
|
+
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cyberwave-cli
|
|
3
|
+
Version: 0.11.0
|
|
4
|
+
Summary: Cyberwave command-line interface (plugins, asset pipeline, telemetry)
|
|
5
|
+
Requires-Dist: typer[all]>=0.12
|
|
6
|
+
Requires-Dist: rich>=13
|
|
7
|
+
Requires-Dist: importlib_metadata>=6.0; python_version < "3.10"
|
|
8
|
+
Requires-Dist: setuptools>=61.0
|
|
9
|
+
Requires-Dist: httpx>=0.24.0
|
|
10
|
+
Requires-Dist: tomli>=2.0.0; python_version < "3.11"
|
|
11
|
+
Requires-Dist: tomli-w>=1.0.0
|
|
12
|
+
Provides-Extra: sdk
|
|
13
|
+
Requires-Dist: cyberwave<0.12,>=0.11; extra == "sdk"
|
|
14
|
+
Provides-Extra: test
|
|
15
|
+
Requires-Dist: pytest>=7.0; extra == "test"
|
|
16
|
+
Requires-Dist: pytest-cov; extra == "test"
|
|
17
|
+
Requires-Dist: pytest-mock; extra == "test"
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
cyberwave_cli/__init__.py,sha256=FGDbBQ9Fmw1exvYKdJWlFmWxvAIZAMb-6QxrYiTkMIg,33
|
|
2
|
+
cyberwave_cli/core.py,sha256=aFL63zvGDO7Kd8Z0WwRM2FunKdnznY6542RXueGZcn0,897
|
|
3
|
+
cyberwave_cli/plugins/__init__.py,sha256=Mi4fla-L0jlXM7F62-yEwxJe03ROcv1zuLNDLAyT-g0,41
|
|
4
|
+
cyberwave_cli/plugins/loader.py,sha256=jm_U_-XUHZqmhddJ8vtr6YqLYZH_zsYQMQhgZe2YXvo,1673
|
|
5
|
+
cyberwave_cli/plugins/assets/__init__.py,sha256=T9QRK4DS85JAeu4uq5sK51aWUGqTVyx-AmNwWnJ7E-A,25
|
|
6
|
+
cyberwave_cli/plugins/assets/app.py,sha256=HU3qMJyP2NbRcfYXPIQxYEeKft2DofK-p5ysdptR7Io,3335
|
|
7
|
+
cyberwave_cli/plugins/auth/__init__.py,sha256=_V4d0s0oIMfJJWLxmxiUPdWBYqyLykcaCiAIbknZH7c,43
|
|
8
|
+
cyberwave_cli/plugins/auth/app.py,sha256=8fPp1AvV4TDMW6h6VryqECXy3ljO0BkqlNs98HIgm8k,12370
|
|
9
|
+
cyberwave_cli/plugins/devices/__init__.py,sha256=j_LQ2cQzv34mZoKGT7efEI2TvDiH7F0qMBSuzhhaUmM,31
|
|
10
|
+
cyberwave_cli/plugins/devices/app.py,sha256=NtNCsexEmBoPeVxDNEXUi3gdLcQINdSJF4mCo88ZlVQ,1448
|
|
11
|
+
cyberwave_cli/plugins/edge/app.py,sha256=AIH8nd0XRUd2Gyd3ZRR-bWQJg_qo8IVbaHKzQOhYR2A,6209
|
|
12
|
+
cyberwave_cli/plugins/environments/app.py,sha256=mALlzR2LzjgFy1eF_mQ7J92kce5osK6nvPw2-3D_OZ0,2609
|
|
13
|
+
cyberwave_cli/plugins/projects/__init__.py,sha256=4cVt2vOy-JQ_-gF2CQF__oLZliHUBkPHBihoxt9LezE,27
|
|
14
|
+
cyberwave_cli/plugins/projects/app.py,sha256=zl2Ko9TOFSCIo5EmlXz4Ahj9lTpMk58AMZzN53jVthA,1499
|
|
15
|
+
cyberwave_cli/plugins/sensors/app.py,sha256=_zY_NghwcKfnaUIgdWPOX-fQUYhRZE8FcVSuS8g0MMI,5517
|
|
16
|
+
cyberwave_cli/plugins/sim/app.py,sha256=KNjkR4YU3QkLAtPPDNpIh7mRHNxlVwiFwJG8DuvURiY,581
|
|
17
|
+
cyberwave_cli/plugins/telemetry/__init__.py,sha256=rz87C8_j2gF5JGW8dJjO1WR9PedXYj5yMNDOPQTX6Mc,28
|
|
18
|
+
cyberwave_cli/plugins/telemetry/app.py,sha256=Ae6UAA6JcT9E9wqEYDaTmb1Cme-tdTVmzZUS0o4Ylf4,650
|
|
19
|
+
cyberwave_cli/plugins/twins/app.py,sha256=DG1QUo2mgwTRJ9aG-3oDdo6tDcil4rvIuFYSgMwVKdA,2704
|
|
20
|
+
cyberwave_cli-0.11.0.dist-info/METADATA,sha256=9sdak6CDqkgi92Fb7WmOdQYBMa-KcmE44FIBpqu-aZk,626
|
|
21
|
+
cyberwave_cli-0.11.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
22
|
+
cyberwave_cli-0.11.0.dist-info/entry_points.txt,sha256=KTGLdbgB3E8Dsn1vOkqjgA6GdisabfFhPZ6_VhDcoTo,411
|
|
23
|
+
cyberwave_cli-0.11.0.dist-info/top_level.txt,sha256=2L8SNq9MKHTbggUoUZZ8wqbdr8DuEOzrcecOcBjzaqc,14
|
|
24
|
+
cyberwave_cli-0.11.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
[console_scripts]
|
|
2
|
+
cyberwave = cyberwave_cli.core:main
|
|
3
|
+
|
|
4
|
+
[cyberwave.cli.plugins]
|
|
5
|
+
auth = cyberwave_cli.plugins.auth.app:app
|
|
6
|
+
devices = cyberwave_cli.plugins.devices.app:app
|
|
7
|
+
edge = cyberwave_cli.plugins.edge.app:app
|
|
8
|
+
environments = cyberwave_cli.plugins.environments.app:app
|
|
9
|
+
projects = cyberwave_cli.plugins.projects.app:app
|
|
10
|
+
sensors = cyberwave_cli.plugins.sensors.app:app
|
|
11
|
+
twins = cyberwave_cli.plugins.twins.app:app
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cyberwave_cli
|