RemoteRF 2.11.0__tar.gz → 2.12.2__tar.gz
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.
- {remoterf-2.11.0/src/remoterf.egg-info → remoterf-2.12.2}/PKG-INFO +3 -1
- {remoterf-2.11.0 → remoterf-2.12.2}/pyproject.toml +6 -2
- remoterf-2.12.2/src/remoterf/agent_cli.py +370 -0
- remoterf-2.12.2/src/remoterf/agent_docs.py +265 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/core/app.py +34 -314
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/core/availability.py +17 -68
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/core/grpc_acc.py +27 -16
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/core/grpc_client.py +41 -13
- remoterf-2.12.2/src/remoterf/data/agent_guide.md +135 -0
- remoterf-2.12.2/src/remoterf/data/mcp_tools.json +126 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/deployment/backend.py +28 -0
- remoterf-2.12.2/src/remoterf/mcp_server.py +324 -0
- remoterf-2.12.2/src/remoterf/ops.py +811 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/remoterf_cli.py +100 -14
- {remoterf-2.11.0 → remoterf-2.12.2/src/remoterf.egg-info}/PKG-INFO +3 -1
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf.egg-info/SOURCES.txt +10 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf.egg-info/requires.txt +3 -0
- remoterf-2.12.2/tests/test_agent_cli.py +294 -0
- remoterf-2.12.2/tests/test_agent_docs.py +136 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_cli_login_fresh.py +25 -2
- {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_devices_api.py +21 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_federated_devices.py +16 -0
- remoterf-2.12.2/tests/test_finish_prompt.py +36 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_homes.py +14 -8
- remoterf-2.12.2/tests/test_mcp_server.py +223 -0
- remoterf-2.12.2/tests/test_ops.py +443 -0
- remoterf-2.11.0/tests/test_finish_prompt.py +0 -20
- {remoterf-2.11.0 → remoterf-2.12.2}/LICENSE +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/README.md +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/setup.cfg +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/setup.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/_remoterf_alias.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/__init__.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/admin.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/common/__init__.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/common/grpc/__init__.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/common/grpc/deployment_capabilities.proto +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/common/grpc/deployment_capabilities_pb2.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/common/grpc/deployment_capabilities_pb2_grpc.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/common/grpc/grpc.proto +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/common/grpc/grpc_pb2.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/common/grpc/grpc_pb2_grpc.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/common/grpc/v2_codec.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/common/tos_notice.txt +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/common/utils/__init__.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/common/utils/ansi_codes.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/common/utils/api_token.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/common/utils/banner.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/common/utils/list_string.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/common/utils/process_arg.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/config/__init__.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/config/cert_fetcher.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/config/config.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/core/__init__.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/core/acc_login.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/core/banner_note.txt +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/core/direct_path.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/core/dynamic_v2_transport.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/core/messages.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/core/secure_channel.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/core/v2_errors.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/deployment/__init__.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/deployment/direct.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/deployment/homes.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/deployment/http.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/deployment/lanpass.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/deployment/state.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/drivers/__init__.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/drivers/_virtual.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/drivers/adalm_pluto/__init__.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/drivers/adalm_pluto/adalm_pluto_remote.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/drivers/dynamic_device.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/drivers/dynamic_v2.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/drivers/hackrf/__init__.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/drivers/hackrf/hackrf_remote.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/drivers/rtl_sdr/__init__.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/drivers/rtl_sdr/rtl_sdr_remote.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/drivers/schema_review.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/drivers/sim_sdr/__init__.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/drivers/sim_sdr/sim_sdr_remote.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/drivers/support/__init__.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/drivers/support/ti_mmwave.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/drivers/support/uhd.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/drivers/support/uhd_dsp.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/drivers/support/uhd_v2.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/drivers/ti_mmwave/__init__.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/drivers/ti_mmwave/ti_mmwave_remote.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/drivers/usrp/__init__.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/drivers/usrp/usrp_remote.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/drivers/usrp/usrp_remote.pyi +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/version.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf.egg-info/dependency_links.txt +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf.egg-info/entry_points.txt +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf.egg-info/top_level.txt +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_banner.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_batch.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_bundled_drivers.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_config_address.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_deployment_accounts.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_device_routes.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_direct_path_fixed.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_direct_path_global.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_direct_path_partner.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_direct_path_race.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_direct_path_relay.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_dynamic_device_codegen.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_dynamic_v2.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_fetch_idl_fallback.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_gate_i_direct_path.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_grpc_client_target.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_lan_fallback.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_messages.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_native_account_credentials.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_native_devices.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_old_import_name.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_partner_dynamic_v2.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_pluto_virtual.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_process_arg.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_relay_paging.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_remote_admin.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_remoterf_cli.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_rx_readahead.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_schema_review.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_ti_mmwave.py +0 -0
- {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_upgrade_rescue.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: remoterf
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.12.2
|
|
4
4
|
Summary: A python API to remotely access signal centric hardware. Client-side only! Courtesy of Wireless Lab @ UCLA & Prof. Ian Roberts.
|
|
5
5
|
Author-email: Ethan Ge <ethoGalaxy@gmail.com>
|
|
6
6
|
License-Expression: GPL-3.0-or-later
|
|
@@ -21,6 +21,8 @@ Requires-Dist: pydantic<3.0,>=2.6
|
|
|
21
21
|
Requires-Dist: keyring<26,>=24
|
|
22
22
|
Requires-Dist: aioice<0.11,>=0.10
|
|
23
23
|
Requires-Dist: aioquic<2.0,>=1.2
|
|
24
|
+
Provides-Extra: mcp
|
|
25
|
+
Requires-Dist: mcp<3,>=2.3; extra == "mcp"
|
|
24
26
|
Dynamic: license-file
|
|
25
27
|
|
|
26
28
|
# Remote RF
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "remoterf"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.12.2"
|
|
8
8
|
description = "A python API to remotely access signal centric hardware. Client-side only! Courtesy of Wireless Lab @ UCLA & Prof. Ian Roberts."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "GPL-3.0-or-later"
|
|
@@ -30,6 +30,10 @@ classifiers = [
|
|
|
30
30
|
"Operating System :: OS Independent",
|
|
31
31
|
]
|
|
32
32
|
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
# `remoterf mcp`: the local MCP server for AI assistants.
|
|
35
|
+
mcp = ["mcp>=2.3,<3"]
|
|
36
|
+
|
|
33
37
|
[project.scripts]
|
|
34
38
|
remoterf = "remoterf.remoterf_cli:main"
|
|
35
39
|
|
|
@@ -43,4 +47,4 @@ py-modules = ["remoterf", "_remoterf_alias"]
|
|
|
43
47
|
where = ["src"]
|
|
44
48
|
|
|
45
49
|
[tool.setuptools.package-data]
|
|
46
|
-
"*" = ["*.crt", "*.key", "*.pem", "*.txt", "*.pyi", "*.proto"]
|
|
50
|
+
"*" = ["*.crt", "*.key", "*.pem", "*.txt", "*.pyi", "*.proto", "*.md", "*.json"]
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
# Copyright (C) 2026 RemoteRF
|
|
2
|
+
#
|
|
3
|
+
# This program is free software: you can redistribute it and/or modify
|
|
4
|
+
# it under the terms of the GNU General Public License as published by
|
|
5
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
6
|
+
# (at your option) any later version.
|
|
7
|
+
#
|
|
8
|
+
# This program is distributed in the hope that it will be useful,
|
|
9
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
10
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
11
|
+
# GNU General Public License for more details.
|
|
12
|
+
#
|
|
13
|
+
# You should have received a copy of the GNU General Public License
|
|
14
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
15
|
+
|
|
16
|
+
"""The scriptable CLI over remoterf.ops: no prompts, no colour, no spinner.
|
|
17
|
+
With --json, stdout carries exactly one JSON object; messages go to stderr."""
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
import sys
|
|
22
|
+
from contextlib import redirect_stdout
|
|
23
|
+
from datetime import datetime
|
|
24
|
+
|
|
25
|
+
from . import ops
|
|
26
|
+
from .core.messages import explain
|
|
27
|
+
from .deployment.http import AccountBackendError
|
|
28
|
+
from .ops import RemoteRFOpsError
|
|
29
|
+
|
|
30
|
+
COMMANDS = ("auth", "devices", "slots", "reservations", "reserve", "cancel", "doctor")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _Parser(argparse.ArgumentParser):
|
|
34
|
+
def error(self, message):
|
|
35
|
+
raise RemoteRFOpsError("usage", f"{self.prog}: {message}")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _clean(value):
|
|
39
|
+
"""Server and deployment text, safe for a terminal."""
|
|
40
|
+
return "".join(c for c in str(value) if c.isprintable())
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ── auth ─────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
def _default_home():
|
|
46
|
+
"""The home `remoterf -l` would pick on its own."""
|
|
47
|
+
from .deployment import homes
|
|
48
|
+
|
|
49
|
+
last = homes.last_target()
|
|
50
|
+
if last:
|
|
51
|
+
return homes.GLOBAL_ACCOUNTS if last["kind"] == "global" else last["name"]
|
|
52
|
+
saved = sorted(homes.homes())
|
|
53
|
+
if len(saved) > 1 or saved and homes.has_global_account():
|
|
54
|
+
raise RemoteRFOpsError("usage", "Several logins are saved here; choose one with --home.")
|
|
55
|
+
return saved[0] if saved else homes.GLOBAL_ACCOUNTS
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _login_global(user, password):
|
|
59
|
+
"""`remoterf -l global` then login: Global selected, its session stored."""
|
|
60
|
+
from .deployment import homes
|
|
61
|
+
from .deployment.backend import HttpsJsonAccountBackend
|
|
62
|
+
from .deployment.state import origin, select_target
|
|
63
|
+
from .remoterf_cli import _global_origin
|
|
64
|
+
|
|
65
|
+
selected = origin(_global_origin())
|
|
66
|
+
backend = HttpsJsonAccountBackend(selected)
|
|
67
|
+
try:
|
|
68
|
+
deployment_id = backend.capabilities["deployment_id"]
|
|
69
|
+
select_target({"origin": selected, "transport": "https-json", "deployment_id": deployment_id})
|
|
70
|
+
homes.remember_global_account(selected, deployment_id)
|
|
71
|
+
homes.set_last_target("global")
|
|
72
|
+
backend.login(user, password)
|
|
73
|
+
try:
|
|
74
|
+
homes.sync_linked(backend.me()) # the labs `remoterf auth login --home <lab>` then needs
|
|
75
|
+
except Exception: # noqa: BLE001 - refreshed again at the next login
|
|
76
|
+
pass
|
|
77
|
+
finally:
|
|
78
|
+
backend.close()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _login_lab(home, user, password):
|
|
82
|
+
"""`remoterf -l NAME` then login: the deployment selected, its session token stored."""
|
|
83
|
+
from .core.grpc_acc import RemoteRFAccount
|
|
84
|
+
from .deployment import homes
|
|
85
|
+
from .deployment.backend import LegacyGrpcAccountBackend
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
if home in homes.homes():
|
|
89
|
+
homes.connect(home)
|
|
90
|
+
else:
|
|
91
|
+
home = homes.register_home(f"{home}.{homes.GLOBAL_DOMAIN}")[0]
|
|
92
|
+
backend = LegacyGrpcAccountBackend()
|
|
93
|
+
except (RuntimeError, ValueError) as exc:
|
|
94
|
+
raise RemoteRFOpsError("deployment_unreachable", f"Could not connect to {home}: {explain(exc, where=home)}") from exc
|
|
95
|
+
if "@" in user and homes.is_linked_home(home):
|
|
96
|
+
# Here the login is the username; an email would send the Global password as typed.
|
|
97
|
+
raise RemoteRFOpsError("usage", "Log in here with your username, not your email.")
|
|
98
|
+
# A lab this Global account has a login at gets a secret bound to it, not the password.
|
|
99
|
+
account = RemoteRFAccount(user, homes.lab_password(home, user, password), backend=backend, quiet=True)
|
|
100
|
+
if not account.login_user(remember=True):
|
|
101
|
+
raise RemoteRFOpsError("invalid_credentials")
|
|
102
|
+
if not account.session_token:
|
|
103
|
+
raise RemoteRFOpsError("deployment_error",
|
|
104
|
+
f"{home} issues no session token, so this login can't be saved; use `remoterf -l {home}`.")
|
|
105
|
+
homes.remember_login(home, account.username, account.session_token)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@ops._op
|
|
109
|
+
def _login(a):
|
|
110
|
+
from .config.config import _read_tos_notice, remember_tos_agreement, tos_agreed
|
|
111
|
+
from .deployment import homes
|
|
112
|
+
|
|
113
|
+
if a.accept_tos:
|
|
114
|
+
print(_read_tos_notice(), file=sys.stderr)
|
|
115
|
+
remember_tos_agreement()
|
|
116
|
+
elif not tos_agreed():
|
|
117
|
+
raise RemoteRFOpsError("usage", "Logging in needs your agreement to the RemoteRF Terms of Service: add --accept-tos.")
|
|
118
|
+
password = sys.stdin.readline().rstrip("\r\n")
|
|
119
|
+
if not password:
|
|
120
|
+
raise RemoteRFOpsError("usage", "No password on stdin.")
|
|
121
|
+
homes.migrate_legacy_target()
|
|
122
|
+
name, lab = homes.split_account(a.user)
|
|
123
|
+
home = (a.home or lab or _default_home()).lower()
|
|
124
|
+
user = name if lab == home else a.user # `jdoe@ucla` at ucla is `jdoe` there
|
|
125
|
+
if home == homes.GLOBAL_ACCOUNTS:
|
|
126
|
+
_login_global(user, password)
|
|
127
|
+
else:
|
|
128
|
+
_login_lab(home, user, password)
|
|
129
|
+
return ops.whoami(ops.saved_session())
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@ops._op
|
|
133
|
+
def _logout(a):
|
|
134
|
+
"""Revoke the saved session (best effort), then forget it locally."""
|
|
135
|
+
from .deployment.state import CredentialStore, load_target
|
|
136
|
+
|
|
137
|
+
target = load_target()
|
|
138
|
+
if target and target["transport"] == "https-json":
|
|
139
|
+
from .deployment.backend import selected_backend
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
backend = selected_backend()
|
|
143
|
+
try:
|
|
144
|
+
if backend.resume():
|
|
145
|
+
backend.logout()
|
|
146
|
+
finally:
|
|
147
|
+
backend.close()
|
|
148
|
+
except Exception: # noqa: BLE001 - revocation is best effort
|
|
149
|
+
pass
|
|
150
|
+
CredentialStore(target["origin"], target["deployment_id"]).clear()
|
|
151
|
+
return {"logged_out": True}
|
|
152
|
+
from .core.grpc_acc import RemoteRFAccount
|
|
153
|
+
from .core.grpc_client import _active_home_name
|
|
154
|
+
from .deployment import homes
|
|
155
|
+
|
|
156
|
+
home = _active_home_name()
|
|
157
|
+
saved = homes.recall_login(home) if home else None
|
|
158
|
+
if saved:
|
|
159
|
+
account = RemoteRFAccount(saved["username"], saved["secret"], quiet=True)
|
|
160
|
+
account.session_token = saved["secret"]
|
|
161
|
+
account.logout()
|
|
162
|
+
homes.forget_login(home)
|
|
163
|
+
return {"logged_out": True}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@ops._op
|
|
167
|
+
def _apps(a):
|
|
168
|
+
s = ops.saved_session()
|
|
169
|
+
if s.home_kind != "global":
|
|
170
|
+
raise RemoteRFOpsError("invalid_argument", "Connected apps belong to a Global account (remoterf auth login --home global).")
|
|
171
|
+
backend = s.account.backend
|
|
172
|
+
try:
|
|
173
|
+
return backend.revoke_app(a.revoke) if a.revoke else backend.connected_apps()
|
|
174
|
+
except AccountBackendError as exc:
|
|
175
|
+
if exc.code != "not_found":
|
|
176
|
+
raise
|
|
177
|
+
raise RemoteRFOpsError("not_found", "Connected apps arrive with the RemoteRF Global connector, "
|
|
178
|
+
"which isn't available yet.") from None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# ── devices and reservations ─────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
def _reserve(a):
|
|
184
|
+
if not a.confirm:
|
|
185
|
+
raise RemoteRFOpsError("usage", "Reserving changes the deployment's schedule: re-run with --confirm.")
|
|
186
|
+
return ops.reserve(ops.saved_session(), a.handle, a.start, a.end, reveal_token=a.reveal_token)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _cancel(a):
|
|
190
|
+
if not a.confirm:
|
|
191
|
+
raise RemoteRFOpsError("usage", "Cancelling gives the slot up: re-run with --confirm.")
|
|
192
|
+
return ops.cancel(ops.saved_session(), a.reservation_id)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# ── doctor ───────────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
def _doctor(a):
|
|
198
|
+
"""What this install sees; never a secret, never a failure."""
|
|
199
|
+
import importlib.util
|
|
200
|
+
import platform
|
|
201
|
+
|
|
202
|
+
from .deployment import homes
|
|
203
|
+
from .deployment.state import CredentialStore, load_target, root
|
|
204
|
+
|
|
205
|
+
out = {"version": ops._version(), "python": platform.python_version(), "executable": sys.executable,
|
|
206
|
+
"config_dir": str(root().parent), "home": None, "home_kind": None, "endpoint": None,
|
|
207
|
+
"logged_in": False, "reachable": False, "mcp_installed": importlib.util.find_spec("mcp") is not None,
|
|
208
|
+
"problems": []}
|
|
209
|
+
try:
|
|
210
|
+
target = load_target()
|
|
211
|
+
if target and target["transport"] == "https-json":
|
|
212
|
+
store = CredentialStore(target["origin"], target["deployment_id"])
|
|
213
|
+
out.update(home=homes.GLOBAL_ACCOUNTS, home_kind="global", endpoint=target["origin"],
|
|
214
|
+
logged_in=(store.directory / "selected.json").exists())
|
|
215
|
+
else:
|
|
216
|
+
from .deployment.direct import resolve_active_profile
|
|
217
|
+
|
|
218
|
+
profile = resolve_active_profile()
|
|
219
|
+
if profile:
|
|
220
|
+
out.update(home=profile.home, home_kind="deployment", endpoint=profile.grpc_endpoint,
|
|
221
|
+
logged_in=bool(profile.home and homes.recall_login(profile.home)))
|
|
222
|
+
if out["endpoint"]:
|
|
223
|
+
from .remoterf_cli import _connected_server
|
|
224
|
+
|
|
225
|
+
out["reachable"] = _connected_server(3.0) is not None
|
|
226
|
+
except Exception as exc: # noqa: BLE001 - doctor reports, it never fails
|
|
227
|
+
out["problems"].append(explain(exc))
|
|
228
|
+
if not out["endpoint"]:
|
|
229
|
+
out["problems"].append("No home selected: run `remoterf auth login`.")
|
|
230
|
+
else:
|
|
231
|
+
if not out["logged_in"]:
|
|
232
|
+
out["problems"].append("No saved login: run `remoterf auth login`.")
|
|
233
|
+
if not out["reachable"]:
|
|
234
|
+
out["problems"].append(f"{out['endpoint']} did not answer.")
|
|
235
|
+
if not out["mcp_installed"]:
|
|
236
|
+
out["problems"].append('For `remoterf mcp`: pip install "remoterf[mcp]"')
|
|
237
|
+
return out
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# ── human output ─────────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
def _local(ts):
|
|
243
|
+
return datetime.fromtimestamp(ts).strftime("%a %Y-%m-%d %H:%M") if ts else "-"
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _table(out, rows):
|
|
247
|
+
rows = [[_clean(c) for c in row] for row in rows]
|
|
248
|
+
widths = [max(len(r[i]) for r in rows) for i in range(len(rows[0]))] if rows else []
|
|
249
|
+
for row in rows:
|
|
250
|
+
print(" ".join(c.ljust(w) for c, w in zip(row, widths)).rstrip(), file=out)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _show_whoami(r, out):
|
|
254
|
+
print(_clean(f"{r['account']} ({r['home_kind']}); deployments: {', '.join(r['deployments']) or '-'}"), file=out)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _show_devices(r, out):
|
|
258
|
+
_table(out, [[d["handle"] or d["name"], d["state"] if d["online"] else "offline", d["type"] or "-", d["deployment"] or "-",
|
|
259
|
+
f"until {_local(d['until_ts'])}" if d["until_ts"] else ""] for d in r["devices"]])
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _show_slots(r, out):
|
|
263
|
+
print(_clean(f"{r['handle']}: {r['slot_minutes']}-minute slots ({r['timezone']})"), file=sys.stderr)
|
|
264
|
+
_table(out, [[_local(s["start_ts"]), "-", _local(s["end_ts"])] for s in r["slots"]])
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _ref(x):
|
|
268
|
+
"""A Global reservation id '<deployment id>:10' as '10@ucla', which `remoterf cancel` takes."""
|
|
269
|
+
_, colon, num = str(x["reservation_id"]).rpartition(":")
|
|
270
|
+
return f"{num}@{x['handle'].rpartition('@')[2]}" if colon and x.get("handle") else x["reservation_id"]
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _show_reservations(r, out):
|
|
274
|
+
_table(out, [[_ref(x), x["handle"] or x["device"], _local(x["start_ts"]), "-", _local(x["end_ts"])]
|
|
275
|
+
for x in r["reservations"]])
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _show_reserved(r, out):
|
|
279
|
+
print(_clean(f"Reserved {r['handle']} {_local(r['start_ts'])} - {_local(r['end_ts'])} "
|
|
280
|
+
f"(reservation {_ref(r) or 'unknown'})."), file=out)
|
|
281
|
+
print(_clean(f'Use token="{r["use_as_token"]}"' + (f', or the raw token {r["token"]}' if r.get("token") else "")), file=out)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _show_apps(r, out):
|
|
285
|
+
if "apps" not in r:
|
|
286
|
+
print("Revoked.", file=out)
|
|
287
|
+
return
|
|
288
|
+
_table(out, [[x.get("client_id"), x.get("client_name"), " ".join(x.get("scopes") or ()), f"last used {x.get('last_used_at') or 'never'}"]
|
|
289
|
+
for x in r["apps"]] or [["No connected apps."]])
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _show_doctor(r, out):
|
|
293
|
+
for key, value in r.items():
|
|
294
|
+
if key != "problems":
|
|
295
|
+
print(_clean(f"{key}: {value}"), file=out)
|
|
296
|
+
for problem in r["problems"]:
|
|
297
|
+
print(_clean(f"! {problem}"), file=out)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# ── main ─────────────────────────────────────────────────────────────
|
|
301
|
+
|
|
302
|
+
def _parser():
|
|
303
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
304
|
+
common.add_argument("--json", action="store_true", help="print one JSON object on stdout")
|
|
305
|
+
parser = _Parser(prog="remoterf", description="Scriptable RemoteRF commands (no prompts).")
|
|
306
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
307
|
+
|
|
308
|
+
def command(where, name, run, show, help):
|
|
309
|
+
p = where.add_parser(name, parents=[common], help=help, description=help)
|
|
310
|
+
p.set_defaults(run=run, show=show)
|
|
311
|
+
return p
|
|
312
|
+
|
|
313
|
+
auth = sub.add_parser("auth", help="the saved login").add_subparsers(dest="action", required=True)
|
|
314
|
+
p = command(auth, "login", _login, _show_whoami, "log in without prompts and keep the login saved")
|
|
315
|
+
p.add_argument("--home", help="global, or a deployment name (default: the account's deployment, else the last home used)")
|
|
316
|
+
p.add_argument("--user", required=True, help="NAME, or NAME@deployment")
|
|
317
|
+
p.add_argument("--password-stdin", action="store_true", required=True, help="read the password from stdin")
|
|
318
|
+
p.add_argument("--accept-tos", action="store_true", help="agree to the RemoteRF Terms of Service")
|
|
319
|
+
command(auth, "status", lambda a: ops.whoami(ops.saved_session()), _show_whoami, "who the saved login is")
|
|
320
|
+
command(auth, "logout", _logout, lambda r, out: print("Logged out.", file=out), "end and forget the saved login")
|
|
321
|
+
p = command(auth, "apps", _apps, _show_apps, "apps connected through the RemoteRF Global connector (not available yet)")
|
|
322
|
+
p.add_argument("--revoke", metavar="CLIENT_ID", help="disconnect one app")
|
|
323
|
+
p = command(sub, "devices", lambda a: ops.list_devices(ops.saved_session(), deployment=a.deployment, online_only=not a.all),
|
|
324
|
+
_show_devices, "devices and their state now")
|
|
325
|
+
p.add_argument("--deployment", help="only this deployment's devices")
|
|
326
|
+
p.add_argument("--all", action="store_true", help="include offline devices")
|
|
327
|
+
p = command(sub, "slots", lambda a: ops.find_slots(ops.saved_session(), a.handle, days=a.days),
|
|
328
|
+
_show_slots, "free slots on a device, aligned like resdev")
|
|
329
|
+
p.add_argument("handle", help="Name@deployment, for example Pluto_00@ucla")
|
|
330
|
+
p.add_argument("--days", type=int, default=1)
|
|
331
|
+
command(sub, "reservations", lambda a: ops.list_reservations(ops.saved_session()), _show_reservations, "your reservations")
|
|
332
|
+
p = command(sub, "reserve", _reserve, _show_reserved, "reserve a device (needs --confirm)")
|
|
333
|
+
p.add_argument("handle")
|
|
334
|
+
p.add_argument("--start", required=True, help="RFC 3339, for example 2026-10-15T14:00:00Z")
|
|
335
|
+
p.add_argument("--end", required=True)
|
|
336
|
+
p.add_argument("--confirm", action="store_true", help="really reserve")
|
|
337
|
+
p.add_argument("--reveal-token", action="store_true", help="include the raw reservation token")
|
|
338
|
+
p = command(sub, "cancel", _cancel, lambda r, out: print("Cancelled.", file=out),
|
|
339
|
+
"cancel a reservation (needs --confirm)")
|
|
340
|
+
p.add_argument("reservation_id", help="as `remoterf reservations` shows it, or a JSON reservation_id")
|
|
341
|
+
p.add_argument("--confirm", action="store_true", help="really cancel")
|
|
342
|
+
command(sub, "doctor", _doctor, _show_doctor, "check this install, its home and its login")
|
|
343
|
+
return parser
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def main(argv) -> int:
|
|
347
|
+
out = sys.stdout
|
|
348
|
+
as_json = "--json" in argv
|
|
349
|
+
try:
|
|
350
|
+
args = _parser().parse_args(argv)
|
|
351
|
+
as_json = args.json
|
|
352
|
+
with redirect_stdout(sys.stderr): # a stray library print must not reach stdout
|
|
353
|
+
result = args.run(args)
|
|
354
|
+
except RemoteRFOpsError as exc:
|
|
355
|
+
if as_json:
|
|
356
|
+
print(json.dumps(exc.to_dict()), file=out)
|
|
357
|
+
print(_clean(f"remoterf: {exc.message}"), file=sys.stderr)
|
|
358
|
+
return exc.exit_code
|
|
359
|
+
except KeyboardInterrupt:
|
|
360
|
+
print(file=sys.stderr)
|
|
361
|
+
return 130
|
|
362
|
+
except SystemExit as exc: # --help
|
|
363
|
+
return exc.code if isinstance(exc.code, int) else 0
|
|
364
|
+
if as_json:
|
|
365
|
+
print(json.dumps(result), file=out)
|
|
366
|
+
else:
|
|
367
|
+
for note in result.get("notes", ()):
|
|
368
|
+
print(_clean(note), file=sys.stderr)
|
|
369
|
+
args.show(result, out)
|
|
370
|
+
return 0
|