edgelens-cli 0.2.1__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.
- edgelens_cli/__init__.py +8 -0
- edgelens_cli/client.py +42 -0
- edgelens_cli/commands/__init__.py +0 -0
- edgelens_cli/commands/_resolve.py +20 -0
- edgelens_cli/commands/device.py +163 -0
- edgelens_cli/commands/display.py +89 -0
- edgelens_cli/commands/inference.py +100 -0
- edgelens_cli/commands/session.py +187 -0
- edgelens_cli/main.py +138 -0
- edgelens_cli/output.py +40 -0
- edgelens_cli/state.py +157 -0
- edgelens_cli-0.2.1.dist-info/METADATA +116 -0
- edgelens_cli-0.2.1.dist-info/RECORD +16 -0
- edgelens_cli-0.2.1.dist-info/WHEEL +4 -0
- edgelens_cli-0.2.1.dist-info/entry_points.txt +2 -0
- edgelens_cli-0.2.1.dist-info/licenses/LICENSE +21 -0
edgelens_cli/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""edgelens CLI — interactive sessions and quick tests without a framework.
|
|
2
|
+
|
|
3
|
+
Entry point: ``edgelens`` (set via the ``[project.scripts]`` table in
|
|
4
|
+
``pyproject.toml``). For programmatic use, import ``edgelens_cli.main.cli``
|
|
5
|
+
directly.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.2.1"
|
edgelens_cli/client.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Common client-construction wrapper for every command.
|
|
2
|
+
|
|
3
|
+
Resolves API key from (in priority order):
|
|
4
|
+
- ``--api-key`` flag on the command
|
|
5
|
+
- ``EDGELENS_API_KEY`` env var
|
|
6
|
+
- ``~/.edgelens/config.json``
|
|
7
|
+
|
|
8
|
+
Same for ``--base-url``.
|
|
9
|
+
|
|
10
|
+
Raises a friendly click error instead of letting an
|
|
11
|
+
``AuthenticationError`` bubble.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
import click
|
|
19
|
+
from edgelens import EdgeLensClient
|
|
20
|
+
from edgelens.errors import AuthenticationError
|
|
21
|
+
|
|
22
|
+
from edgelens_cli.state import load_config
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build_client(
|
|
26
|
+
api_key: Optional[str] = None,
|
|
27
|
+
base_url: Optional[str] = None,
|
|
28
|
+
) -> EdgeLensClient:
|
|
29
|
+
config = load_config()
|
|
30
|
+
effective_key = api_key or config.api_key
|
|
31
|
+
effective_url = base_url or config.base_url
|
|
32
|
+
if not effective_key:
|
|
33
|
+
raise click.ClickException(
|
|
34
|
+
"No API key. Set EDGELENS_API_KEY or run `edgelens login`."
|
|
35
|
+
)
|
|
36
|
+
kwargs: dict = {"api_key": effective_key}
|
|
37
|
+
if effective_url:
|
|
38
|
+
kwargs["base_url"] = effective_url
|
|
39
|
+
try:
|
|
40
|
+
return EdgeLensClient(**kwargs)
|
|
41
|
+
except AuthenticationError as e:
|
|
42
|
+
raise click.ClickException(str(e))
|
|
File without changes
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Resolve a session id from explicit argument or current-session state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from edgelens_cli.state import load_state
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def resolve_session_id(explicit: Optional[str]) -> str:
|
|
13
|
+
if explicit:
|
|
14
|
+
return explicit
|
|
15
|
+
state = load_state()
|
|
16
|
+
if state.current_session_id is None:
|
|
17
|
+
raise click.ClickException(
|
|
18
|
+
"No current session. Run `edgelens session create ...` or `edgelens use <id>`."
|
|
19
|
+
)
|
|
20
|
+
return state.current_session_id
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Device operations on the current (or named) session."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
from edgelens.errors import EdgeLensError
|
|
9
|
+
|
|
10
|
+
from edgelens_cli.client import build_client
|
|
11
|
+
from edgelens_cli.commands._resolve import resolve_session_id
|
|
12
|
+
from edgelens_cli.output import emit, error
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _session_with_device(ctx: click.Context, session_id: Optional[str]): # type: ignore[no-untyped-def]
|
|
16
|
+
"""Resolve session id → ready Session, or click-error if not ready."""
|
|
17
|
+
sid = resolve_session_id(session_id)
|
|
18
|
+
client = build_client(ctx.obj.get("api_key"), ctx.obj.get("base_url"))
|
|
19
|
+
try:
|
|
20
|
+
session = client.get_session(sid)
|
|
21
|
+
except EdgeLensError as e:
|
|
22
|
+
error(str(e))
|
|
23
|
+
if session.status.value != "ready":
|
|
24
|
+
error(
|
|
25
|
+
f"session {sid} is in state {session.status.value}, not ready. "
|
|
26
|
+
"Run `edgelens session wait` first.",
|
|
27
|
+
exit_code=2,
|
|
28
|
+
)
|
|
29
|
+
return session
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@click.command("exec")
|
|
33
|
+
@click.argument("command_str")
|
|
34
|
+
@click.option("--session", "session_id", default=None, help="Session id (default: current).")
|
|
35
|
+
@click.option("--timeout", default=60.0, type=float, help="Command timeout in seconds.")
|
|
36
|
+
@click.pass_context
|
|
37
|
+
def exec_cmd(
|
|
38
|
+
ctx: click.Context, command_str: str, session_id: Optional[str], timeout: float
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Run a shell command on the camera and print stdout / exit code."""
|
|
41
|
+
session = _session_with_device(ctx, session_id)
|
|
42
|
+
try:
|
|
43
|
+
result = session.device.exec(command_str, timeout=timeout)
|
|
44
|
+
except EdgeLensError as e:
|
|
45
|
+
error(str(e))
|
|
46
|
+
|
|
47
|
+
payload = {
|
|
48
|
+
"stdout": result.stdout,
|
|
49
|
+
"stderr": result.stderr,
|
|
50
|
+
"exit_code": result.exit_code,
|
|
51
|
+
}
|
|
52
|
+
human_parts = []
|
|
53
|
+
if result.stdout:
|
|
54
|
+
human_parts.append(result.stdout.rstrip())
|
|
55
|
+
if result.stderr:
|
|
56
|
+
human_parts.append(click.style(result.stderr.rstrip(), fg="red"))
|
|
57
|
+
if not human_parts:
|
|
58
|
+
human_parts.append(f"(no output, exit={result.exit_code})")
|
|
59
|
+
emit(payload, json_mode=ctx.obj["json_mode"], human="\n".join(human_parts))
|
|
60
|
+
if result.exit_code != 0:
|
|
61
|
+
ctx.exit(result.exit_code if 0 < result.exit_code < 128 else 1)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@click.command("deploy")
|
|
65
|
+
@click.argument("model_path", type=click.Path(exists=True, dir_okay=False))
|
|
66
|
+
@click.option("--config", "config_path", type=click.Path(exists=True, dir_okay=False), default=None)
|
|
67
|
+
@click.option(
|
|
68
|
+
"--file", "extra_files", type=click.Path(exists=True, dir_okay=False),
|
|
69
|
+
multiple=True, help="Additional supporting files (repeatable).",
|
|
70
|
+
)
|
|
71
|
+
@click.option("--session", "session_id", default=None)
|
|
72
|
+
@click.pass_context
|
|
73
|
+
def deploy_cmd(
|
|
74
|
+
ctx: click.Context,
|
|
75
|
+
model_path: str,
|
|
76
|
+
config_path: Optional[str],
|
|
77
|
+
extra_files: tuple,
|
|
78
|
+
session_id: Optional[str],
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Deploy a model file to the camera."""
|
|
81
|
+
session = _session_with_device(ctx, session_id)
|
|
82
|
+
try:
|
|
83
|
+
result = session.device.deploy(
|
|
84
|
+
model=model_path,
|
|
85
|
+
config=config_path,
|
|
86
|
+
files=list(extra_files) or None,
|
|
87
|
+
)
|
|
88
|
+
except EdgeLensError as e:
|
|
89
|
+
error(str(e))
|
|
90
|
+
emit(
|
|
91
|
+
result,
|
|
92
|
+
json_mode=ctx.obj["json_mode"],
|
|
93
|
+
human=f"✓ deployed {model_path}",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@click.command("info")
|
|
98
|
+
@click.option("--session", "session_id", default=None)
|
|
99
|
+
@click.pass_context
|
|
100
|
+
def info_cmd(ctx: click.Context, session_id: Optional[str]) -> None:
|
|
101
|
+
"""Print the device info report."""
|
|
102
|
+
session = _session_with_device(ctx, session_id)
|
|
103
|
+
try:
|
|
104
|
+
info = session.device.info()
|
|
105
|
+
except EdgeLensError as e:
|
|
106
|
+
error(str(e))
|
|
107
|
+
emit(
|
|
108
|
+
info.model_dump() if hasattr(info, "model_dump") else info,
|
|
109
|
+
json_mode=ctx.obj["json_mode"],
|
|
110
|
+
human=(
|
|
111
|
+
f"hostname: {info.hostname}\n"
|
|
112
|
+
f"hardware: {info.hardware}\n"
|
|
113
|
+
f"firmware: {info.firmware}"
|
|
114
|
+
),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@click.command("ssh")
|
|
119
|
+
@click.option("--session", "session_id", default=None)
|
|
120
|
+
@click.option(
|
|
121
|
+
"--show-password", is_flag=True, default=False,
|
|
122
|
+
help="Print the SSH password in cleartext (or set EDGELENS_SHOW_PASSWORD=1).",
|
|
123
|
+
)
|
|
124
|
+
@click.pass_context
|
|
125
|
+
def ssh_cmd(
|
|
126
|
+
ctx: click.Context, session_id: Optional[str], show_password: bool,
|
|
127
|
+
) -> None:
|
|
128
|
+
"""Print SSH connection details.
|
|
129
|
+
|
|
130
|
+
Password is masked by default in
|
|
131
|
+
both --json and human modes. The plaintext is only emitted when
|
|
132
|
+
--show-password is passed or EDGELENS_SHOW_PASSWORD=1 in the env,
|
|
133
|
+
so the credential never reaches stdout or CI logs by accident.
|
|
134
|
+
"""
|
|
135
|
+
import os as _os
|
|
136
|
+
reveal = show_password or _os.environ.get("EDGELENS_SHOW_PASSWORD") == "1"
|
|
137
|
+
|
|
138
|
+
session = _session_with_device(ctx, session_id)
|
|
139
|
+
try:
|
|
140
|
+
creds = session.device.ssh()
|
|
141
|
+
except EdgeLensError as e:
|
|
142
|
+
error(str(e))
|
|
143
|
+
|
|
144
|
+
payload = {
|
|
145
|
+
"user": creds.user, "host": creds.host, "port": creds.port,
|
|
146
|
+
}
|
|
147
|
+
if reveal:
|
|
148
|
+
payload["password"] = creds.password
|
|
149
|
+
|
|
150
|
+
password_line = (
|
|
151
|
+
f"password: {creds.password}\n"
|
|
152
|
+
if reveal
|
|
153
|
+
else "password: <hidden — pass --show-password to reveal>\n"
|
|
154
|
+
)
|
|
155
|
+
human = (
|
|
156
|
+
f"user: {creds.user}\n"
|
|
157
|
+
f"host: {creds.host}\n"
|
|
158
|
+
f"port: {creds.port}\n"
|
|
159
|
+
f"{password_line}"
|
|
160
|
+
"\n"
|
|
161
|
+
f"ssh -p {creds.port} {creds.user}@{creds.host}"
|
|
162
|
+
)
|
|
163
|
+
emit(payload, json_mode=ctx.obj["json_mode"], human=human)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""``edgelens display`` subcommands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
from edgelens.errors import EdgeLensError
|
|
9
|
+
|
|
10
|
+
from edgelens_cli.commands.device import _session_with_device
|
|
11
|
+
from edgelens_cli.output import emit, error
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@click.group(name="display")
|
|
15
|
+
def display_group() -> None:
|
|
16
|
+
"""Control the camera's external display."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@display_group.command("play")
|
|
20
|
+
@click.argument("source")
|
|
21
|
+
@click.option("--loop/--no-loop", default=False)
|
|
22
|
+
@click.option("--session", "session_id", default=None)
|
|
23
|
+
@click.pass_context
|
|
24
|
+
def display_play(
|
|
25
|
+
ctx: click.Context, source: str, loop: bool, session_id: Optional[str]
|
|
26
|
+
) -> None:
|
|
27
|
+
"""Play a video on the display."""
|
|
28
|
+
session = _session_with_device(ctx, session_id)
|
|
29
|
+
try:
|
|
30
|
+
session.device.display.play(source, loop=loop)
|
|
31
|
+
except EdgeLensError as e:
|
|
32
|
+
error(str(e))
|
|
33
|
+
emit(
|
|
34
|
+
{"action": "play", "source": source, "loop": loop},
|
|
35
|
+
json_mode=ctx.obj["json_mode"],
|
|
36
|
+
human=f"✓ display playing {source}{' (loop)' if loop else ''}",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@display_group.command("show")
|
|
41
|
+
@click.argument("source")
|
|
42
|
+
@click.option("--session", "session_id", default=None)
|
|
43
|
+
@click.pass_context
|
|
44
|
+
def display_show(ctx: click.Context, source: str, session_id: Optional[str]) -> None:
|
|
45
|
+
"""Show a still image on the display."""
|
|
46
|
+
session = _session_with_device(ctx, session_id)
|
|
47
|
+
try:
|
|
48
|
+
session.device.display.show(source)
|
|
49
|
+
except EdgeLensError as e:
|
|
50
|
+
error(str(e))
|
|
51
|
+
emit(
|
|
52
|
+
{"action": "show", "source": source},
|
|
53
|
+
json_mode=ctx.obj["json_mode"],
|
|
54
|
+
human=f"✓ display showing {source}",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@display_group.command("stop")
|
|
59
|
+
@click.option("--session", "session_id", default=None)
|
|
60
|
+
@click.pass_context
|
|
61
|
+
def display_stop(ctx: click.Context, session_id: Optional[str]) -> None:
|
|
62
|
+
"""Stop display playback."""
|
|
63
|
+
session = _session_with_device(ctx, session_id)
|
|
64
|
+
try:
|
|
65
|
+
session.device.display.stop()
|
|
66
|
+
except EdgeLensError as e:
|
|
67
|
+
error(str(e))
|
|
68
|
+
emit({"action": "stop"}, json_mode=ctx.obj["json_mode"], human="✓ display stopped")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@display_group.command("status")
|
|
72
|
+
@click.option("--session", "session_id", default=None)
|
|
73
|
+
@click.pass_context
|
|
74
|
+
def display_status(ctx: click.Context, session_id: Optional[str]) -> None:
|
|
75
|
+
"""Print the current display state."""
|
|
76
|
+
session = _session_with_device(ctx, session_id)
|
|
77
|
+
try:
|
|
78
|
+
state = session.device.display.status()
|
|
79
|
+
except EdgeLensError as e:
|
|
80
|
+
error(str(e))
|
|
81
|
+
emit(
|
|
82
|
+
state,
|
|
83
|
+
json_mode=ctx.obj["json_mode"],
|
|
84
|
+
human=(
|
|
85
|
+
f"ready: {state.get('ready')}\n"
|
|
86
|
+
f"state: {state.get('state')}\n"
|
|
87
|
+
f"source: {state.get('source') or '(none)'}"
|
|
88
|
+
),
|
|
89
|
+
)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""``edgelens inference`` subcommands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
from edgelens.errors import EdgeLensError
|
|
9
|
+
|
|
10
|
+
from edgelens_cli.commands.device import _session_with_device
|
|
11
|
+
from edgelens_cli.output import emit, error
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@click.group(name="inference")
|
|
15
|
+
def inference_group() -> None:
|
|
16
|
+
"""Control on-device inference (stock OS only)."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@inference_group.command("start")
|
|
20
|
+
@click.option("--session", "session_id", default=None)
|
|
21
|
+
@click.pass_context
|
|
22
|
+
def inference_start(ctx: click.Context, session_id: Optional[str]) -> None:
|
|
23
|
+
session = _session_with_device(ctx, session_id)
|
|
24
|
+
try:
|
|
25
|
+
session.device.inference.start()
|
|
26
|
+
except EdgeLensError as e:
|
|
27
|
+
error(str(e))
|
|
28
|
+
emit({"action": "start"}, json_mode=ctx.obj["json_mode"], human="✓ inference started")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@inference_group.command("stop")
|
|
32
|
+
@click.option("--session", "session_id", default=None)
|
|
33
|
+
@click.pass_context
|
|
34
|
+
def inference_stop(ctx: click.Context, session_id: Optional[str]) -> None:
|
|
35
|
+
session = _session_with_device(ctx, session_id)
|
|
36
|
+
try:
|
|
37
|
+
session.device.inference.stop()
|
|
38
|
+
except EdgeLensError as e:
|
|
39
|
+
error(str(e))
|
|
40
|
+
emit({"action": "stop"}, json_mode=ctx.obj["json_mode"], human="✓ inference stopped")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@inference_group.command("once")
|
|
44
|
+
@click.option("--session", "session_id", default=None)
|
|
45
|
+
@click.pass_context
|
|
46
|
+
def inference_once(ctx: click.Context, session_id: Optional[str]) -> None:
|
|
47
|
+
"""Run a single frame and print the detections."""
|
|
48
|
+
session = _session_with_device(ctx, session_id)
|
|
49
|
+
try:
|
|
50
|
+
result = session.device.inference.once()
|
|
51
|
+
except EdgeLensError as e:
|
|
52
|
+
error(str(e))
|
|
53
|
+
emit(
|
|
54
|
+
result,
|
|
55
|
+
json_mode=ctx.obj["json_mode"],
|
|
56
|
+
human=f"detections: {len(result.get('detections', []))}",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@inference_group.command("stats")
|
|
61
|
+
@click.option("--session", "session_id", default=None)
|
|
62
|
+
@click.pass_context
|
|
63
|
+
def inference_stats(ctx: click.Context, session_id: Optional[str]) -> None:
|
|
64
|
+
"""Print a point-in-time inference snapshot."""
|
|
65
|
+
session = _session_with_device(ctx, session_id)
|
|
66
|
+
try:
|
|
67
|
+
snap = session.device.inference.snapshot()
|
|
68
|
+
except EdgeLensError as e:
|
|
69
|
+
error(str(e))
|
|
70
|
+
payload = snap.model_dump() if hasattr(snap, "model_dump") else snap
|
|
71
|
+
human = (
|
|
72
|
+
f"fps: {snap.fps}\n"
|
|
73
|
+
f"latency_ms: {snap.latency_ms}\n"
|
|
74
|
+
f"detections: {snap.detection_count}"
|
|
75
|
+
)
|
|
76
|
+
emit(payload, json_mode=ctx.obj["json_mode"], human=human)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@inference_group.command("run")
|
|
80
|
+
@click.option("--duration", default=30.0, type=float, help="Seconds to run.")
|
|
81
|
+
@click.option("--collect-frames", is_flag=True, default=False)
|
|
82
|
+
@click.option("--session", "session_id", default=None)
|
|
83
|
+
@click.pass_context
|
|
84
|
+
def inference_run(
|
|
85
|
+
ctx: click.Context, duration: float, collect_frames: bool, session_id: Optional[str]
|
|
86
|
+
) -> None:
|
|
87
|
+
"""Run inference for a duration and print aggregated stats."""
|
|
88
|
+
session = _session_with_device(ctx, session_id)
|
|
89
|
+
try:
|
|
90
|
+
stats = session.device.inference.run(duration=duration, collect_frames=collect_frames)
|
|
91
|
+
except EdgeLensError as e:
|
|
92
|
+
error(str(e))
|
|
93
|
+
payload = stats.model_dump() if hasattr(stats, "model_dump") else stats
|
|
94
|
+
human = (
|
|
95
|
+
f"duration_s: {stats.duration_s}\n"
|
|
96
|
+
f"total_frames: {stats.total_frames}\n"
|
|
97
|
+
f"fps avg: {stats.fps.avg:.2f} p95: {stats.fps.p95:.2f}\n"
|
|
98
|
+
f"latency p95: {stats.latency_ms.p95:.2f}ms"
|
|
99
|
+
)
|
|
100
|
+
emit(payload, json_mode=ctx.obj["json_mode"], human=human)
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""``edgelens session`` subcommands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
from edgelens.errors import EdgeLensError
|
|
9
|
+
|
|
10
|
+
from edgelens_cli.client import build_client
|
|
11
|
+
from edgelens_cli.commands._resolve import resolve_session_id
|
|
12
|
+
from edgelens_cli.output import emit, error
|
|
13
|
+
from edgelens_cli.state import clear_current_session, load_state, set_current_session
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@click.group(name="session")
|
|
17
|
+
def session_group() -> None:
|
|
18
|
+
"""Create, inspect, and release device sessions."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@session_group.command("create")
|
|
22
|
+
@click.option(
|
|
23
|
+
"--device", "device_type", default="recamera-2002",
|
|
24
|
+
help="Device type to allocate (default: recamera-2002).",
|
|
25
|
+
)
|
|
26
|
+
@click.option(
|
|
27
|
+
"--os", "os_type", default="stock-os",
|
|
28
|
+
help="Which OS to flash (default: stock-os; alt: recamagined).",
|
|
29
|
+
)
|
|
30
|
+
@click.option(
|
|
31
|
+
"--timeout", "timeout_s", default=3600, type=int,
|
|
32
|
+
help="Max session duration in seconds (default: 3600).",
|
|
33
|
+
)
|
|
34
|
+
@click.option(
|
|
35
|
+
"--wait/--no-wait", default=True,
|
|
36
|
+
help="Block until status=ready (default: wait).",
|
|
37
|
+
)
|
|
38
|
+
@click.option(
|
|
39
|
+
"--wait-timeout", default=300, type=int,
|
|
40
|
+
help="Seconds to wait for ready (only with --wait, default: 300).",
|
|
41
|
+
)
|
|
42
|
+
@click.pass_context
|
|
43
|
+
def session_create(
|
|
44
|
+
ctx: click.Context,
|
|
45
|
+
device_type: str,
|
|
46
|
+
os_type: str,
|
|
47
|
+
timeout_s: int,
|
|
48
|
+
wait: bool,
|
|
49
|
+
wait_timeout: int,
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Allocate a new device. Sets it as the current session on success."""
|
|
52
|
+
client = build_client(ctx.obj.get("api_key"), ctx.obj.get("base_url"))
|
|
53
|
+
try:
|
|
54
|
+
session = client.create_session(
|
|
55
|
+
device_type=device_type, os=os_type, timeout=timeout_s,
|
|
56
|
+
)
|
|
57
|
+
except EdgeLensError as e:
|
|
58
|
+
error(str(e))
|
|
59
|
+
set_current_session(session.id)
|
|
60
|
+
if wait:
|
|
61
|
+
try:
|
|
62
|
+
session.wait_ready(timeout=wait_timeout)
|
|
63
|
+
except EdgeLensError as e:
|
|
64
|
+
emit(
|
|
65
|
+
{"id": session.id, "status": session.status.value, "error": str(e)},
|
|
66
|
+
json_mode=ctx.obj["json_mode"],
|
|
67
|
+
human=f"✗ {session.id}: {e}",
|
|
68
|
+
)
|
|
69
|
+
ctx.exit(1)
|
|
70
|
+
|
|
71
|
+
emit(
|
|
72
|
+
_session_payload(session),
|
|
73
|
+
json_mode=ctx.obj["json_mode"],
|
|
74
|
+
human=_human_session(session),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@session_group.command("get")
|
|
79
|
+
@click.argument("session_id", required=False)
|
|
80
|
+
@click.pass_context
|
|
81
|
+
def session_get(ctx: click.Context, session_id: Optional[str]) -> None:
|
|
82
|
+
"""Show one session. Defaults to the current session."""
|
|
83
|
+
session_id = resolve_session_id(session_id)
|
|
84
|
+
client = build_client(ctx.obj.get("api_key"), ctx.obj.get("base_url"))
|
|
85
|
+
try:
|
|
86
|
+
session = client.get_session(session_id)
|
|
87
|
+
except EdgeLensError as e:
|
|
88
|
+
error(str(e))
|
|
89
|
+
emit(
|
|
90
|
+
_session_payload(session),
|
|
91
|
+
json_mode=ctx.obj["json_mode"],
|
|
92
|
+
human=_human_session(session),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@session_group.command("wait")
|
|
97
|
+
@click.argument("session_id", required=False)
|
|
98
|
+
@click.option("--timeout", "wait_timeout", default=300, type=int)
|
|
99
|
+
@click.pass_context
|
|
100
|
+
def session_wait(ctx: click.Context, session_id: Optional[str], wait_timeout: int) -> None:
|
|
101
|
+
"""Block until a session reaches status=ready."""
|
|
102
|
+
session_id = resolve_session_id(session_id)
|
|
103
|
+
client = build_client(ctx.obj.get("api_key"), ctx.obj.get("base_url"))
|
|
104
|
+
session = client.get_session(session_id)
|
|
105
|
+
try:
|
|
106
|
+
session.wait_ready(timeout=wait_timeout)
|
|
107
|
+
except EdgeLensError as e:
|
|
108
|
+
error(str(e))
|
|
109
|
+
emit(
|
|
110
|
+
_session_payload(session),
|
|
111
|
+
json_mode=ctx.obj["json_mode"],
|
|
112
|
+
human=_human_session(session),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@session_group.command("release")
|
|
117
|
+
@click.argument("session_id", required=False)
|
|
118
|
+
@click.pass_context
|
|
119
|
+
def session_release(ctx: click.Context, session_id: Optional[str]) -> None:
|
|
120
|
+
"""End a session. Defaults to the current session and clears its pointer."""
|
|
121
|
+
session_id = resolve_session_id(session_id)
|
|
122
|
+
client = build_client(ctx.obj.get("api_key"), ctx.obj.get("base_url"))
|
|
123
|
+
try:
|
|
124
|
+
session = client.get_session(session_id)
|
|
125
|
+
session.release()
|
|
126
|
+
except EdgeLensError as e:
|
|
127
|
+
error(str(e))
|
|
128
|
+
|
|
129
|
+
# Clear `current` only if we just released what was pointed at.
|
|
130
|
+
state = load_state()
|
|
131
|
+
if state.current_session_id == session_id:
|
|
132
|
+
clear_current_session()
|
|
133
|
+
|
|
134
|
+
emit(
|
|
135
|
+
{"id": session_id, "status": "released"},
|
|
136
|
+
json_mode=ctx.obj["json_mode"],
|
|
137
|
+
human=f"✓ released {session_id}",
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@session_group.command("list")
|
|
142
|
+
@click.pass_context
|
|
143
|
+
def session_list(ctx: click.Context) -> None:
|
|
144
|
+
"""Show recently-touched sessions from local state.
|
|
145
|
+
|
|
146
|
+
The platform API doesn't yet expose a per-key list endpoint, so
|
|
147
|
+
this is a local-state echo of the last sessions this CLI touched.
|
|
148
|
+
"""
|
|
149
|
+
state = load_state()
|
|
150
|
+
payload = {
|
|
151
|
+
"current_session_id": state.current_session_id,
|
|
152
|
+
"recent": state.recent,
|
|
153
|
+
}
|
|
154
|
+
human_lines = []
|
|
155
|
+
if state.current_session_id:
|
|
156
|
+
human_lines.append(f"* {state.current_session_id} (current)")
|
|
157
|
+
for sid in state.recent:
|
|
158
|
+
if sid != state.current_session_id:
|
|
159
|
+
human_lines.append(f" {sid}")
|
|
160
|
+
human = "\n".join(human_lines) if human_lines else "(no recent sessions)"
|
|
161
|
+
emit(payload, json_mode=ctx.obj["json_mode"], human=human)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _session_payload(session) -> dict: # type: ignore[no-untyped-def]
|
|
165
|
+
payload: dict = {"id": session.id, "status": session.status.value}
|
|
166
|
+
conn = getattr(session, "_connection", None)
|
|
167
|
+
if conn is not None:
|
|
168
|
+
payload["device"] = {
|
|
169
|
+
"mac": conn.mac, "host": conn.host, "ssh_port": conn.ssh_port,
|
|
170
|
+
"ssh_user": conn.ssh_user, "os": conn.os.value,
|
|
171
|
+
}
|
|
172
|
+
if session.expires_at:
|
|
173
|
+
payload["expires_at"] = session.expires_at
|
|
174
|
+
return payload
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _human_session(session) -> str: # type: ignore[no-untyped-def]
|
|
178
|
+
lines = [f"id: {session.id}", f"status: {session.status.value}"]
|
|
179
|
+
conn = getattr(session, "_connection", None)
|
|
180
|
+
if conn is not None:
|
|
181
|
+
lines.extend([
|
|
182
|
+
f"device: {conn.os.value} ({conn.mac})",
|
|
183
|
+
f"ssh: {conn.ssh_user}@{conn.host}:{conn.ssh_port}",
|
|
184
|
+
])
|
|
185
|
+
if session.expires_at:
|
|
186
|
+
lines.append(f"expires: {session.expires_at}")
|
|
187
|
+
return "\n".join(lines)
|
edgelens_cli/main.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""CLI entry point.
|
|
2
|
+
|
|
3
|
+
The top-level group exposes a `--json` flag so every command can emit
|
|
4
|
+
machine-readable output. Subcommands fan out:
|
|
5
|
+
|
|
6
|
+
edgelens login save credentials
|
|
7
|
+
edgelens session ... create / get / wait / release / list
|
|
8
|
+
edgelens use SESSION_ID set current session for follow-up commands
|
|
9
|
+
edgelens exec / deploy / info / ssh device operations on the current session
|
|
10
|
+
edgelens display ... display control
|
|
11
|
+
edgelens inference ... inference control
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import sys
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
import click
|
|
20
|
+
|
|
21
|
+
from edgelens_cli import __version__
|
|
22
|
+
from edgelens_cli.commands import device, display, inference, session
|
|
23
|
+
from edgelens_cli.output import emit, error
|
|
24
|
+
from edgelens_cli.state import Config, clear_current_session, save_config, set_current_session
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
|
|
28
|
+
@click.version_option(__version__, prog_name="edgelens")
|
|
29
|
+
@click.option(
|
|
30
|
+
"--json", "json_mode", is_flag=True, default=False,
|
|
31
|
+
help="Emit machine-readable JSON on stdout.",
|
|
32
|
+
)
|
|
33
|
+
@click.option(
|
|
34
|
+
"--api-key",
|
|
35
|
+
envvar="EDGELENS_API_KEY",
|
|
36
|
+
help=(
|
|
37
|
+
"Override the API key. Default reads EDGELENS_API_KEY or "
|
|
38
|
+
"~/.edgelens/config.json. Prefer EDGELENS_API_KEY: a key on the "
|
|
39
|
+
"command line is visible in the process list and shell history."
|
|
40
|
+
),
|
|
41
|
+
)
|
|
42
|
+
@click.option(
|
|
43
|
+
"--base-url",
|
|
44
|
+
envvar="EDGELENS_BASE_URL",
|
|
45
|
+
help="Override the platform API base URL.",
|
|
46
|
+
)
|
|
47
|
+
@click.pass_context
|
|
48
|
+
def cli(
|
|
49
|
+
ctx: click.Context,
|
|
50
|
+
json_mode: bool,
|
|
51
|
+
api_key: Optional[str],
|
|
52
|
+
base_url: Optional[str],
|
|
53
|
+
) -> None:
|
|
54
|
+
"""edgelens — drive remote AI cameras from the command line."""
|
|
55
|
+
ctx.ensure_object(dict)
|
|
56
|
+
ctx.obj["json_mode"] = json_mode
|
|
57
|
+
ctx.obj["api_key"] = api_key
|
|
58
|
+
ctx.obj["base_url"] = base_url
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _stdin_is_tty() -> bool:
|
|
62
|
+
return sys.stdin.isatty()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _read_api_key() -> str:
|
|
66
|
+
if _stdin_is_tty():
|
|
67
|
+
return click.prompt("API key", hide_input=True)
|
|
68
|
+
return sys.stdin.readline()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@cli.command()
|
|
72
|
+
@click.option(
|
|
73
|
+
"--api-key", default=None,
|
|
74
|
+
help=(
|
|
75
|
+
"API key to persist. Omit it to be prompted (input hidden) or to "
|
|
76
|
+
"pipe the key on stdin. Prefer that: a key on the command line is "
|
|
77
|
+
"visible in the process list and shell history."
|
|
78
|
+
),
|
|
79
|
+
)
|
|
80
|
+
@click.option("--base-url", default=None, help="Optional platform base URL.")
|
|
81
|
+
@click.pass_context
|
|
82
|
+
def login(ctx: click.Context, api_key: Optional[str], base_url: Optional[str]) -> None:
|
|
83
|
+
"""Persist credentials to ~/.edgelens/config.json (chmod 0600).
|
|
84
|
+
|
|
85
|
+
The key is read from a hidden prompt, or from stdin when stdin is not
|
|
86
|
+
a terminal.
|
|
87
|
+
"""
|
|
88
|
+
if api_key is None:
|
|
89
|
+
api_key = _read_api_key().strip()
|
|
90
|
+
if not api_key:
|
|
91
|
+
error("no API key given", exit_code=2)
|
|
92
|
+
save_config(Config(api_key=api_key, base_url=base_url))
|
|
93
|
+
emit(
|
|
94
|
+
{"saved": True, "config_path": "~/.edgelens/config.json"},
|
|
95
|
+
json_mode=ctx.obj["json_mode"],
|
|
96
|
+
human="✓ credentials saved to ~/.edgelens/config.json",
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@cli.command()
|
|
101
|
+
@click.pass_context
|
|
102
|
+
def logout(ctx: click.Context) -> None:
|
|
103
|
+
"""Forget credentials + current session pointer."""
|
|
104
|
+
save_config(Config())
|
|
105
|
+
clear_current_session()
|
|
106
|
+
emit(
|
|
107
|
+
{"cleared": True},
|
|
108
|
+
json_mode=ctx.obj["json_mode"],
|
|
109
|
+
human="✓ cleared ~/.edgelens/config.json and ~/.edgelens/state.json",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@cli.command()
|
|
114
|
+
@click.argument("session_id")
|
|
115
|
+
@click.pass_context
|
|
116
|
+
def use(ctx: click.Context, session_id: str) -> None:
|
|
117
|
+
"""Set the current session for follow-up commands (exec, deploy, ...)."""
|
|
118
|
+
if not session_id.startswith("sess_"):
|
|
119
|
+
error("session ids start with 'sess_'", exit_code=2)
|
|
120
|
+
set_current_session(session_id)
|
|
121
|
+
emit(
|
|
122
|
+
{"current_session_id": session_id},
|
|
123
|
+
json_mode=ctx.obj["json_mode"],
|
|
124
|
+
human=f"✓ current session set to {session_id}",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
cli.add_command(session.session_group)
|
|
129
|
+
cli.add_command(device.exec_cmd)
|
|
130
|
+
cli.add_command(device.deploy_cmd)
|
|
131
|
+
cli.add_command(device.info_cmd)
|
|
132
|
+
cli.add_command(device.ssh_cmd)
|
|
133
|
+
cli.add_command(display.display_group)
|
|
134
|
+
cli.add_command(inference.inference_group)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
if __name__ == "__main__":
|
|
138
|
+
cli()
|
edgelens_cli/output.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Output helpers — human vs JSON modes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def emit(payload: Any, *, json_mode: bool, human: Optional[str] = None) -> None:
|
|
13
|
+
"""Emit ``payload`` as JSON or as the supplied ``human`` string.
|
|
14
|
+
|
|
15
|
+
When ``json_mode`` is True, the JSON form goes to stdout
|
|
16
|
+
regardless of ``human``. When False and ``human`` is None, we
|
|
17
|
+
fall back to ``str(payload)``.
|
|
18
|
+
"""
|
|
19
|
+
if json_mode:
|
|
20
|
+
click.echo(json.dumps(payload, indent=2, default=_default))
|
|
21
|
+
return
|
|
22
|
+
click.echo(human if human is not None else str(payload))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def warn(msg: str) -> None:
|
|
26
|
+
click.echo(click.style(f"warning: {msg}", fg="yellow"), err=True)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def error(msg: str, *, exit_code: int = 1) -> None:
|
|
30
|
+
click.echo(click.style(f"error: {msg}", fg="red"), err=True)
|
|
31
|
+
sys.exit(exit_code)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _default(o: Any) -> Any:
|
|
35
|
+
# pydantic v2 models expose model_dump
|
|
36
|
+
if hasattr(o, "model_dump"):
|
|
37
|
+
return o.model_dump()
|
|
38
|
+
if hasattr(o, "__dict__"):
|
|
39
|
+
return {k: v for k, v in o.__dict__.items() if not k.startswith("_")}
|
|
40
|
+
return str(o)
|
edgelens_cli/state.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Persistent CLI state (api key, current session) at ``~/.edgelens/``.
|
|
2
|
+
|
|
3
|
+
The state is split:
|
|
4
|
+
|
|
5
|
+
- ``~/.edgelens/config.json`` — auth + base URL. Written by ``edgelens login``.
|
|
6
|
+
- ``~/.edgelens/state.json`` — current-session pointer + recent sessions.
|
|
7
|
+
Written by ``edgelens session create`` / ``edgelens use``.
|
|
8
|
+
|
|
9
|
+
Both files have ``0o600`` permissions; absence of either is treated as
|
|
10
|
+
empty.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
from dataclasses import asdict, dataclass, field
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Optional
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
DEFAULT_DIR = Path.home() / ".edgelens"
|
|
24
|
+
CONFIG_PATH = DEFAULT_DIR / "config.json"
|
|
25
|
+
STATE_PATH = DEFAULT_DIR / "state.json"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _fingerprint(api_key: Optional[str]) -> str:
|
|
29
|
+
"""Render a key so two different keys look different and neither is readable.
|
|
30
|
+
|
|
31
|
+
A prefix of a secret is a piece of the secret, so the mask is a
|
|
32
|
+
one-way digest rather than a truncation: six hex characters of
|
|
33
|
+
sha256 identify a key to someone who already holds it and give
|
|
34
|
+
nothing to someone who does not.
|
|
35
|
+
"""
|
|
36
|
+
if api_key is None:
|
|
37
|
+
return "None"
|
|
38
|
+
return f"<set sha256:{hashlib.sha256(api_key.encode()).hexdigest()[:6]}>"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class Config:
|
|
43
|
+
api_key: Optional[str] = field(default=None, repr=False)
|
|
44
|
+
base_url: Optional[str] = None
|
|
45
|
+
|
|
46
|
+
def __repr__(self) -> str:
|
|
47
|
+
# Any traceback carrying a Config reaches the operator's
|
|
48
|
+
# terminal or CI log; `asdict` is unaffected, so what gets
|
|
49
|
+
# persisted to config.json does not change.
|
|
50
|
+
return (
|
|
51
|
+
f"Config(api_key={_fingerprint(self.api_key)}, "
|
|
52
|
+
f"base_url={self.base_url!r})"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class State:
|
|
58
|
+
current_session_id: Optional[str] = None
|
|
59
|
+
recent: list[str] = field(default_factory=list)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _read_json(path: Path) -> dict[str, Any]:
|
|
63
|
+
if not path.exists():
|
|
64
|
+
return {}
|
|
65
|
+
try:
|
|
66
|
+
return json.loads(path.read_text())
|
|
67
|
+
except (json.JSONDecodeError, OSError):
|
|
68
|
+
return {}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _write_json(path: Path, data: dict[str, Any]) -> None:
|
|
72
|
+
"""Atomic create with 0o600 in a
|
|
73
|
+
single syscall instead of write-then-chmod.
|
|
74
|
+
|
|
75
|
+
Creating the file with default perms and chmod-ing it afterwards
|
|
76
|
+
would leave a TOCTOU window in which another user on a multi-user
|
|
77
|
+
host could read the API key.
|
|
78
|
+
``os.open(path, O_WRONLY|O_CREAT|O_TRUNC, 0o600)`` creates the
|
|
79
|
+
file with 0o600 atomically, so that window never exists.
|
|
80
|
+
When os.chmod is the only available knob (Windows, overlay FS,
|
|
81
|
+
network shares), emit a clear warning so the operator knows the
|
|
82
|
+
config file may not be owner-restricted.
|
|
83
|
+
"""
|
|
84
|
+
import logging as _logging
|
|
85
|
+
_log = _logging.getLogger(__name__)
|
|
86
|
+
|
|
87
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
body = json.dumps(data, indent=2)
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
fd = os.open(
|
|
92
|
+
str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600
|
|
93
|
+
)
|
|
94
|
+
except OSError:
|
|
95
|
+
# Windows / overlay paths where O_CREAT with mode is unsupported.
|
|
96
|
+
# Fall back to write_text + best-effort chmod, but loudly.
|
|
97
|
+
_log.warning(
|
|
98
|
+
"%s: atomic 0o600 create unavailable; falling back to "
|
|
99
|
+
"write_text + chmod (file may end up world-readable on "
|
|
100
|
+
"this platform).", path,
|
|
101
|
+
)
|
|
102
|
+
path.write_text(body)
|
|
103
|
+
try:
|
|
104
|
+
os.chmod(path, 0o600)
|
|
105
|
+
except OSError as e:
|
|
106
|
+
_log.warning(
|
|
107
|
+
"%s: chmod 0o600 also failed: %s. The API key in this "
|
|
108
|
+
"file may be readable by other local users.", path, e,
|
|
109
|
+
)
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
with os.fdopen(fd, "w") as f:
|
|
113
|
+
f.write(body)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def load_config() -> Config:
|
|
117
|
+
"""Load ``~/.edgelens/config.json``. ``EDGELENS_*`` env vars override."""
|
|
118
|
+
raw = _read_json(CONFIG_PATH)
|
|
119
|
+
return Config(
|
|
120
|
+
api_key=os.environ.get("EDGELENS_API_KEY") or raw.get("api_key"),
|
|
121
|
+
base_url=os.environ.get("EDGELENS_BASE_URL") or raw.get("base_url"),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def save_config(config: Config) -> None:
|
|
126
|
+
"""Write ``~/.edgelens/config.json``. Empty fields are stripped."""
|
|
127
|
+
payload = {k: v for k, v in asdict(config).items() if v is not None}
|
|
128
|
+
_write_json(CONFIG_PATH, payload)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def load_state() -> State:
|
|
132
|
+
raw = _read_json(STATE_PATH)
|
|
133
|
+
return State(
|
|
134
|
+
current_session_id=raw.get("current_session_id"),
|
|
135
|
+
recent=list(raw.get("recent") or []),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def save_state(state: State) -> None:
|
|
140
|
+
_write_json(STATE_PATH, asdict(state))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def set_current_session(session_id: str, *, remember: int = 10) -> None:
|
|
144
|
+
"""Mark ``session_id`` as the current session and remember it."""
|
|
145
|
+
state = load_state()
|
|
146
|
+
state.current_session_id = session_id
|
|
147
|
+
if session_id in state.recent:
|
|
148
|
+
state.recent.remove(session_id)
|
|
149
|
+
state.recent.insert(0, session_id)
|
|
150
|
+
state.recent = state.recent[:remember]
|
|
151
|
+
save_state(state)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def clear_current_session() -> None:
|
|
155
|
+
state = load_state()
|
|
156
|
+
state.current_session_id = None
|
|
157
|
+
save_state(state)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: edgelens-cli
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: Command-line interface for the EdgeLens Edge AI Device Farm.
|
|
5
|
+
Project-URL: Homepage, https://edgelens.ai
|
|
6
|
+
Project-URL: Repository, https://github.com/edgelens-ai/edgelens-sdk
|
|
7
|
+
Project-URL: Issues, https://github.com/edgelens-ai/edgelens-sdk/issues
|
|
8
|
+
Author-email: EdgeLens <support@edgelens.ai>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Software Development :: Testing
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Requires-Dist: click>=8.1
|
|
18
|
+
Requires-Dist: edgelens-sdk<0.3,>=0.2.1
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: mypy>=1.0; extra == 'dev'
|
|
21
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
22
|
+
Requires-Dist: ruff>=0.1; extra == 'dev'
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# edgelens-cli
|
|
26
|
+
|
|
27
|
+
Command-line interface for the EdgeLens Edge AI Device Farm. Drives the
|
|
28
|
+
same `EdgeLensClient` as the Python SDK; useful for ad-hoc sessions,
|
|
29
|
+
quick experiments, and shell scripts.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
pip install edgelens-cli
|
|
35
|
+
# or
|
|
36
|
+
pipx install edgelens-cli
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Adds an `edgelens` executable to your PATH.
|
|
40
|
+
|
|
41
|
+
## Quickstart
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
edgelens login # prompts for the key; input is hidden
|
|
45
|
+
edgelens session create --device recamera-2002 --os stock-os --timeout 600
|
|
46
|
+
# session id is remembered as 'current' — follow-up commands target it
|
|
47
|
+
edgelens info
|
|
48
|
+
edgelens exec 'uname -a'
|
|
49
|
+
edgelens deploy ./face.cvimodel
|
|
50
|
+
edgelens inference run --duration 30
|
|
51
|
+
edgelens session release
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
In a script, pipe the key on stdin instead of passing it as an argument:
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
printf '%s\n' "$EDGELENS_API_KEY" | edgelens login
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`--json` on the root command emits machine-readable JSON on every call,
|
|
61
|
+
useful for shell pipelines or CI:
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
edgelens --json session create --no-wait | jq -r .id
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Commands
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
edgelens login persist credentials (~/.edgelens/config.json)
|
|
71
|
+
edgelens logout forget credentials + current-session pointer
|
|
72
|
+
edgelens use <SESSION_ID> set current session for follow-up commands
|
|
73
|
+
|
|
74
|
+
edgelens session create [--device --os --timeout --wait/--no-wait]
|
|
75
|
+
edgelens session get [SESSION_ID] defaults to current
|
|
76
|
+
edgelens session wait [SESSION_ID] block until status=ready
|
|
77
|
+
edgelens session release [SESSION_ID]
|
|
78
|
+
edgelens session list recent sessions (from local state)
|
|
79
|
+
|
|
80
|
+
edgelens exec '<command>' run via SSH (proxied)
|
|
81
|
+
edgelens deploy <model_path> [--config --file ...]
|
|
82
|
+
edgelens info device info report
|
|
83
|
+
edgelens ssh print SSH connection details
|
|
84
|
+
|
|
85
|
+
edgelens display play <url> [--loop]
|
|
86
|
+
edgelens display show <url>
|
|
87
|
+
edgelens display stop
|
|
88
|
+
edgelens display status
|
|
89
|
+
|
|
90
|
+
edgelens inference start
|
|
91
|
+
edgelens inference stop
|
|
92
|
+
edgelens inference once
|
|
93
|
+
edgelens inference stats
|
|
94
|
+
edgelens inference run [--duration N --collect-frames]
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Configuration
|
|
98
|
+
|
|
99
|
+
| Source | Used for |
|
|
100
|
+
|---|---|
|
|
101
|
+
| `EDGELENS_API_KEY`, `EDGELENS_BASE_URL` env vars | Shell-session-scoped; the recommended way to pass a key in CI |
|
|
102
|
+
| `--base-url` flag | One-off override |
|
|
103
|
+
| `~/.edgelens/config.json` (chmod 0600) | Persistent default — set via `edgelens login` |
|
|
104
|
+
|
|
105
|
+
Current-session state lives at `~/.edgelens/state.json` (chmod 0600).
|
|
106
|
+
Session ids touched in the last ten commands are kept under `recent`
|
|
107
|
+
so `edgelens session list` shows what you were doing.
|
|
108
|
+
|
|
109
|
+
## Develop
|
|
110
|
+
|
|
111
|
+
```sh
|
|
112
|
+
python -m venv .venv && source .venv/bin/activate
|
|
113
|
+
pip install -e ".[dev]"
|
|
114
|
+
pytest
|
|
115
|
+
```
|
|
116
|
+
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
edgelens_cli/__init__.py,sha256=oVSerpnUWzvsL82iEP0LKhQ2qa9UJn-tuZOJS2z2E4I,262
|
|
2
|
+
edgelens_cli/client.py,sha256=ugNfFm7kkcWly0KQWOkgA0sPI8urIL8j05_h7CVzjFs,1137
|
|
3
|
+
edgelens_cli/main.py,sha256=gOHyhLF3zCe0FGTm-gqQ3uDLyU-1L5QzVHZPv6dgBhY,4296
|
|
4
|
+
edgelens_cli/output.py,sha256=kHydpl2KOg23XT6lBhPlA9IhT7VJBbgkv51jr7frnBk,1145
|
|
5
|
+
edgelens_cli/state.py,sha256=_SdFvZSDEjeA905fBLu7XlRVyXKXYa8Dw58PwGF-F2I,4972
|
|
6
|
+
edgelens_cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
edgelens_cli/commands/_resolve.py,sha256=PmBwKOPSTKxXMyF0FSj9aKCESJ9DeWe38NQdPlAZpB8,537
|
|
8
|
+
edgelens_cli/commands/device.py,sha256=cH8XSxEcaSw-rdXaqWl5SrjJmWLbGZj4E7zggX5JAxk,5340
|
|
9
|
+
edgelens_cli/commands/display.py,sha256=Log76k03bXoXCIGxBkifZyjCkbBU7IlIubkUCB12ijY,2727
|
|
10
|
+
edgelens_cli/commands/inference.py,sha256=UWBtZmJydUnYZkgTgJo7O6ujwsI6nbuYJk4qK9nTBQ8,3506
|
|
11
|
+
edgelens_cli/commands/session.py,sha256=eeuWmqPIzjrGum1QrxiNjsir_YVjd6DnbXHFjAPXg5I,6161
|
|
12
|
+
edgelens_cli-0.2.1.dist-info/METADATA,sha256=dfCuqaOo4yqfbFSrYal85mUsxxR4H61mBWtlp39klXU,3607
|
|
13
|
+
edgelens_cli-0.2.1.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
14
|
+
edgelens_cli-0.2.1.dist-info/entry_points.txt,sha256=MjdPv8zj4sGwSPRE88lw43mFes8Uw4bvlzLu0gw4TNI,51
|
|
15
|
+
edgelens_cli-0.2.1.dist-info/licenses/LICENSE,sha256=JR_6_gvFCZzmYkGGa1BL36k0ZzhakowmxnXVhhPPKpc,1065
|
|
16
|
+
edgelens_cli-0.2.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 EdgeLens
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|