RemoteRF 2.4.0__tar.gz → 2.5.1__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.4.0/src/remoterf.egg-info → remoterf-2.5.1}/PKG-INFO +1 -1
- {remoterf-2.4.0 → remoterf-2.5.1}/pyproject.toml +1 -1
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/common/utils/process_arg.py +8 -6
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/core/app.py +21 -1
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/core/direct_path.py +40 -17
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/core/grpc_client.py +193 -15
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/deployment/backend.py +33 -17
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/deployment/http.py +32 -1
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/drivers/adalm_pluto/adalm_pluto_remote.py +4 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/drivers/dynamic_device.py +7 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/drivers/hackrf/hackrf_remote.py +4 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/drivers/rtl_sdr/rtl_sdr_remote.py +4 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/drivers/sim_sdr/sim_sdr_remote.py +4 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/drivers/ti_mmwave/ti_mmwave_remote.py +4 -0
- {remoterf-2.4.0 → remoterf-2.5.1/src/remoterf.egg-info}/PKG-INFO +1 -1
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf.egg-info/SOURCES.txt +2 -0
- remoterf-2.5.1/tests/test_batch.py +187 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/tests/test_device_routes.py +31 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/tests/test_direct_path_partner.py +65 -3
- {remoterf-2.4.0 → remoterf-2.5.1}/tests/test_dynamic_device_codegen.py +22 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/tests/test_federated_devices.py +115 -1
- {remoterf-2.4.0 → remoterf-2.5.1}/tests/test_process_arg.py +9 -0
- remoterf-2.5.1/tests/test_rx_readahead.py +158 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/LICENSE +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/README.md +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/setup.cfg +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/__init__.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/common/__init__.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/common/grpc/__init__.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/common/grpc/deployment_capabilities.proto +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/common/grpc/deployment_capabilities_pb2.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/common/grpc/deployment_capabilities_pb2_grpc.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/common/grpc/grpc.proto +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/common/grpc/grpc_pb2.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/common/grpc/grpc_pb2_grpc.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/common/grpc/v2_codec.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/common/tos_notice.txt +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/common/utils/__init__.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/common/utils/ansi_codes.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/common/utils/api_token.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/common/utils/banner.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/common/utils/list_string.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/config/__init__.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/config/cert_fetcher.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/config/config.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/core/__init__.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/core/acc_login.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/core/banner_note.txt +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/core/dynamic_v2_transport.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/core/grpc_acc.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/core/secure_channel.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/core/v2_errors.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/deployment/__init__.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/deployment/direct.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/deployment/homes.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/deployment/state.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/drivers/__init__.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/drivers/_virtual.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/drivers/adalm_pluto/__init__.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/drivers/dynamic_v2.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/drivers/hackrf/__init__.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/drivers/rtl_sdr/__init__.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/drivers/sim_sdr/__init__.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/drivers/support/__init__.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/drivers/support/ti_mmwave.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/drivers/support/uhd.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/drivers/support/uhd_dsp.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/drivers/support/uhd_v2.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/drivers/ti_mmwave/__init__.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/drivers/usrp/__init__.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/drivers/usrp/usrp_remote.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/drivers/usrp/usrp_remote.pyi +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/remoterf_cli.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf/version.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf.egg-info/dependency_links.txt +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf.egg-info/entry_points.txt +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf.egg-info/requires.txt +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf.egg-info/top_level.txt +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/src/remoterf.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/tests/test_banner.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/tests/test_cli_login_fresh.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/tests/test_config_address.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/tests/test_deployment_accounts.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/tests/test_direct_path_fixed.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/tests/test_direct_path_global.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/tests/test_direct_path_race.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/tests/test_dynamic_v2.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/tests/test_fetch_idl_fallback.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/tests/test_finish_prompt.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/tests/test_gate_i_direct_path.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/tests/test_grpc_client_target.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/tests/test_homes.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/tests/test_native_account_credentials.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/tests/test_native_devices.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/tests/test_partner_dynamic_v2.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/tests/test_pluto_virtual.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/tests/test_remoterf_cli.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/tests/test_ti_mmwave.py +0 -0
- {remoterf-2.4.0 → remoterf-2.5.1}/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.5.1
|
|
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
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "remoterf"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.5.1"
|
|
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"
|
|
@@ -98,7 +98,7 @@ def _json_safe(value: Any):
|
|
|
98
98
|
return _json_safe(value.as_payload())
|
|
99
99
|
if isinstance(value, np.ndarray):
|
|
100
100
|
if value.dtype == object:
|
|
101
|
-
|
|
101
|
+
return _json_safe(value.tolist())
|
|
102
102
|
array = np.asarray(value)
|
|
103
103
|
if np.iscomplexobj(array):
|
|
104
104
|
data = [[float(num.real), float(num.imag)] for num in array.ravel()]
|
|
@@ -206,13 +206,15 @@ def map_arg(value: Any) -> grpc_pb2.Argument:
|
|
|
206
206
|
try:
|
|
207
207
|
array = np.stack(value)
|
|
208
208
|
except Exception:
|
|
209
|
-
|
|
210
|
-
|
|
209
|
+
pass
|
|
210
|
+
if array.dtype == object:
|
|
211
|
+
arg.json_value = json.dumps(_json_safe(value), separators=(",", ":"))
|
|
212
|
+
return arg
|
|
211
213
|
return map_arg(array)
|
|
212
214
|
elif isinstance(value, np.ndarray):
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
if value.dtype.kind not in _SUPPORTED_NDARRAY_KINDS:
|
|
215
|
+
# Object arrays (a list of dicts, say) and non-numeric dtypes go as
|
|
216
|
+
# JSON, which the client decodes back to lists.
|
|
217
|
+
if value.dtype == object or value.dtype.kind not in _SUPPORTED_NDARRAY_KINDS:
|
|
216
218
|
arg.json_value = json.dumps(
|
|
217
219
|
_json_safe(value.tolist()), separators=(",", ":")
|
|
218
220
|
)
|
|
@@ -177,11 +177,31 @@ def _print_reserved(where, when, token, handle, logged_in_to, install, no_handle
|
|
|
177
177
|
printf(" Works anywhere, no login needed. Keep it safe: it can't be shown again.", Sty.DEFAULT)
|
|
178
178
|
printf(f" ({no_handle}. Lost the token? Cancel and reserve again.)", Sty.DEFAULT)
|
|
179
179
|
printf("", Sty.DEFAULT)
|
|
180
|
-
|
|
180
|
+
device_type = install()
|
|
181
|
+
call = DRIVER_CALLS.get(device_type)
|
|
181
182
|
if call:
|
|
182
183
|
printf("Use either one as the token:" if handle else "Use it as the token:", Sty.DEFAULT)
|
|
183
184
|
for text in (token, handle) if handle else (token,):
|
|
184
185
|
printf(f' sdr = {call}(token="{text}")', Sty.CYAN)
|
|
186
|
+
elif device_type and (cls := _client_class(device_type)):
|
|
187
|
+
# A lab's own driver (bluetooth, gps, ...): its class, from its IDL.
|
|
188
|
+
printf("Use either one as the token:" if handle else "Use it as the token:", Sty.DEFAULT)
|
|
189
|
+
printf(" from remoterf.drivers import ensure_driver", Sty.CYAN)
|
|
190
|
+
printf(f' ensure_driver(token="{token}") # installs it on any other machine', Sty.CYAN)
|
|
191
|
+
printf(f" from remoterf.drivers.{device_type} import {cls}", Sty.CYAN)
|
|
192
|
+
for text in (token, handle) if handle else (token,):
|
|
193
|
+
printf(f' dev = {cls}(token="{text}")', Sty.CYAN)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _client_class(device_type):
|
|
197
|
+
"""The client class a generated driver package exports (beside its `adi` alias)."""
|
|
198
|
+
from importlib import import_module
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
names = import_module(f"remoterf.drivers.{device_type}").__all__
|
|
202
|
+
except Exception: # noqa: BLE001 - no usage lines is all a bad package costs
|
|
203
|
+
return None
|
|
204
|
+
return next((n for n in names if n != "adi"), None)
|
|
185
205
|
|
|
186
206
|
|
|
187
207
|
def _print_native_reserved(device_id, label, when, token):
|
|
@@ -691,17 +691,25 @@ _partners: dict[str, DirectPath] = {} # device token -> the path to the lab tha
|
|
|
691
691
|
_partner_owners: dict[str, str] = {} # device token -> owner deployment id
|
|
692
692
|
|
|
693
693
|
|
|
694
|
-
def _offer_rpc(username: str, secret: str, token: Optional[str] = None) -> Callable[[dict], dict]:
|
|
695
|
-
"""The offer RPC; with ``token`` the home has the partner lab answer instead.
|
|
694
|
+
def _offer_rpc(username: Optional[str], secret: Optional[str], token: Optional[str] = None) -> Callable[[dict], dict]:
|
|
695
|
+
"""The offer RPC; with ``token`` the home has the partner lab answer instead.
|
|
696
|
+
|
|
697
|
+
Without a login (``username`` None) the partner token alone authorizes it."""
|
|
696
698
|
def send(offer: dict) -> dict:
|
|
697
699
|
from .grpc_client import rpc_client
|
|
698
700
|
|
|
699
|
-
args = {"
|
|
701
|
+
args = {"ice": map_arg(json.dumps(offer))}
|
|
702
|
+
if username is not None:
|
|
703
|
+
args.update(un=map_arg(username), pw=map_arg(secret))
|
|
700
704
|
if token is not None:
|
|
701
705
|
args["a"] = map_arg(token)
|
|
702
706
|
response = rpc_client(function_name="ACC:direct_offer", args=args)
|
|
703
707
|
if "ace" in response.results:
|
|
704
|
-
|
|
708
|
+
reason = unmap_arg(response.results["ace"])
|
|
709
|
+
if username is None and reason.startswith("Internal server error"):
|
|
710
|
+
# A home Server that predates token offers fails on the missing login.
|
|
711
|
+
raise PathUnavailable("the home's Server needs a login for a direct path")
|
|
712
|
+
raise PathError(reason)
|
|
705
713
|
answer = json.loads(unmap_arg(response.results["ice"]))
|
|
706
714
|
for key in ("server_name", "ca_pem"):
|
|
707
715
|
if key in response.results:
|
|
@@ -811,12 +819,18 @@ def _side_path(key: str, make: Callable[[], Optional[DirectPath]]) -> Optional[D
|
|
|
811
819
|
def _partner(token: str) -> Optional[DirectPath]:
|
|
812
820
|
"""The path to the lab owning the device behind ``token``, punched on first use."""
|
|
813
821
|
def make() -> Optional[DirectPath]:
|
|
822
|
+
owner = _partner_owners.get(token)
|
|
814
823
|
credentials = _credentials()
|
|
815
824
|
if credentials is None:
|
|
816
|
-
|
|
825
|
+
# No login (a token-only script): the token is its own credential.
|
|
826
|
+
# Its own memo, so an old home refusing it cannot hold back a
|
|
827
|
+
# logged-in process's path to the same lab.
|
|
828
|
+
return DirectPath(
|
|
829
|
+
offer=_offer_rpc(None, None, token=token), server_name="", ca_pem=b"",
|
|
830
|
+
memo=owner and f"{owner}|token",
|
|
831
|
+
)
|
|
817
832
|
return DirectPath(
|
|
818
|
-
offer=_offer_rpc(*credentials, token=token), server_name="", ca_pem=b"",
|
|
819
|
-
memo=_partner_owners.get(token),
|
|
833
|
+
offer=_offer_rpc(*credentials, token=token), server_name="", ca_pem=b"", memo=owner,
|
|
820
834
|
)
|
|
821
835
|
|
|
822
836
|
return _side_path(token, make)
|
|
@@ -890,18 +904,27 @@ def describe() -> str:
|
|
|
890
904
|
return f"relay (direct path unavailable: {_BROKEN}; fix: pip install --force-reinstall remoterf)"
|
|
891
905
|
if not enabled():
|
|
892
906
|
return "relay (direct path disabled: REMOTERF_DIRECT=0)"
|
|
907
|
+
partners = "; ".join(
|
|
908
|
+
f"partner {_partner_owners[key]}: "
|
|
909
|
+
+ (path.describe() if path.ready else path.describe().replace("relay", "via HOME", 1))
|
|
910
|
+
for key, path in list(_partners.items()) if key in _partner_owners
|
|
911
|
+
)
|
|
893
912
|
path = _path
|
|
894
913
|
if path is not None:
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
914
|
+
home = path.describe()
|
|
915
|
+
else:
|
|
916
|
+
try:
|
|
917
|
+
from .grpc_client import _current_profile
|
|
918
|
+
|
|
919
|
+
route = _active_route(_current_profile())
|
|
920
|
+
except Exception: # noqa: BLE001 - not configured, no home: nothing to add
|
|
921
|
+
route = None
|
|
922
|
+
if route is None or route["kind"] == "relay":
|
|
923
|
+
if partners: # the home path is not what these device calls take
|
|
924
|
+
return partners
|
|
925
|
+
return "relay (direct path off)"
|
|
926
|
+
home = "LAN (direct)" if route["kind"] == "lan" else str(route["kind"])
|
|
927
|
+
return f"{home}; {partners}" if partners else home
|
|
905
928
|
|
|
906
929
|
|
|
907
930
|
def stop() -> None:
|
|
@@ -17,6 +17,9 @@ import os
|
|
|
17
17
|
import re
|
|
18
18
|
import sys
|
|
19
19
|
import threading
|
|
20
|
+
import time
|
|
21
|
+
from collections import deque
|
|
22
|
+
from contextlib import contextmanager
|
|
20
23
|
from dataclasses import dataclass
|
|
21
24
|
from pathlib import Path
|
|
22
25
|
from typing import Any, Dict, Optional
|
|
@@ -226,20 +229,26 @@ def _federated_call(route, function_name, args):
|
|
|
226
229
|
if not refused:
|
|
227
230
|
return response
|
|
228
231
|
request = grpc_pb2.GenericRPCRequest(function_name=function_name, args=args)
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
232
|
+
for retry in (function_name.endswith(":GET"), False):
|
|
233
|
+
try:
|
|
234
|
+
if ref.startswith("rfg_"):
|
|
235
|
+
raw = _token_backend().device_token_rpc(
|
|
236
|
+
ref, getattr(route, "deployment_id", None), request.SerializeToString()
|
|
237
|
+
)
|
|
238
|
+
else:
|
|
239
|
+
raw = federated_backend().device_rpc(ref, request.SerializeToString())
|
|
240
|
+
break
|
|
241
|
+
except AccountBackendError as exc:
|
|
242
|
+
if retry and exc.code == "internal_error" and exc.provenance == "transport":
|
|
243
|
+
# An older lab drops ~0.1% of relayed results when a clock second
|
|
244
|
+
# ticks mid-call; a GET changes nothing, so it is sent once more.
|
|
245
|
+
continue
|
|
246
|
+
if ref.startswith("rfg_") and str(exc) == "not_found":
|
|
247
|
+
raise RuntimeError(
|
|
248
|
+
"this RemoteRF Global does not accept device tokens yet; use the device reference"
|
|
249
|
+
) from None
|
|
250
|
+
label = f" [{exc.provenance}]" if exc.provenance else ""
|
|
251
|
+
raise RuntimeError(f"{exc}{label}") from None
|
|
243
252
|
response = grpc_pb2.GenericRPCResponse()
|
|
244
253
|
response.ParseFromString(raw)
|
|
245
254
|
return response
|
|
@@ -476,12 +485,181 @@ def rpc_client(*, function_name, args, connection=None):
|
|
|
476
485
|
token = _device_token(function_name, args)
|
|
477
486
|
if function_name.endswith(":tx:CALL1") and "arg1" in args and token in _ci16_tokens:
|
|
478
487
|
args = {**args, "arg1": _tx_wire(args["arg1"])}
|
|
479
|
-
response =
|
|
488
|
+
response = _driver_hooks(function_name, args, token) if connection is None else None
|
|
489
|
+
if response is None:
|
|
490
|
+
response = _dispatch(function_name, args, connection)
|
|
480
491
|
if token is not None and function_name.endswith(":rx:CALL0") and _is_ci16(response.results.get(function_name.split(":")[1])):
|
|
481
492
|
_ci16_tokens.add(token)
|
|
493
|
+
if token is not None and function_name.endswith(":rx:CALL0") and _readahead_k():
|
|
494
|
+
_rx_sizes[token] = response.ByteSize()
|
|
495
|
+
return response
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _driver_hooks(function_name, args, token):
|
|
499
|
+
"""Batching and RX read-ahead for driver calls; None: send it as usual."""
|
|
500
|
+
parts = function_name.split(":")
|
|
501
|
+
driver_call = token is not None and len(parts) == 3
|
|
502
|
+
if driver_call and parts[2] != "GET" and parts[1:] != ["rx", "CALL0"]:
|
|
503
|
+
_rx_queues.clear()
|
|
504
|
+
pending = _batch_local.pending
|
|
505
|
+
same = driver_call and pending is not None and pending[:2] == (token, parts[0])
|
|
506
|
+
if same and parts[2] != "SET":
|
|
507
|
+
return _flush((function_name, args))
|
|
508
|
+
if pending is not None and not same:
|
|
509
|
+
_flush()
|
|
510
|
+
if driver_call and parts[2] == "SET" and parts[0] != "DynamicV2" and token in _batch_local.depth:
|
|
511
|
+
if _batch_local.pending is None:
|
|
512
|
+
_batch_local.pending = (token, parts[0], [])
|
|
513
|
+
_batch_local.pending[2].append((function_name, args))
|
|
514
|
+
if len(_batch_local.pending[2]) >= _BATCH_MAX_OPS:
|
|
515
|
+
_flush()
|
|
516
|
+
return grpc_pb2.GenericRPCResponse() # setters' results are never read
|
|
517
|
+
if driver_call and parts[1:] == ["rx", "CALL0"] and _readahead_k():
|
|
518
|
+
return _rx_readahead(parts[0], args, token)
|
|
519
|
+
return None
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
# ── config batching: with sdr.batch(): ───────────────────────────────
|
|
523
|
+
#
|
|
524
|
+
# A driver's property SETs queue up and go out as one <Prefix>:batch:CALLN
|
|
525
|
+
# RPC, which the lab runs in order under the device lock. The queue is sent
|
|
526
|
+
# when the block ends or at any other call; a call on the batching driver
|
|
527
|
+
# rides along as the last op and gets its own results back. A failed SET
|
|
528
|
+
# raises there, naming it; the SETs before it ran, the ones after did not.
|
|
529
|
+
|
|
530
|
+
_BATCH_MAX_OPS = 64
|
|
531
|
+
_batch_support: Dict[str, bool] = {} # token -> the lab runs batches (unknown until the first)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
class _BatchState(threading.local):
|
|
535
|
+
pending = None # (token, prefix, [(function_name, args), ...])
|
|
536
|
+
|
|
537
|
+
def __init__(self):
|
|
538
|
+
self.depth = {} # token -> nesting of its open `with batch(token)`
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
_batch_local = _BatchState()
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
@contextmanager
|
|
545
|
+
def batch(token):
|
|
546
|
+
"""Queue this token's property SETs until the block ends (re-entrant)."""
|
|
547
|
+
from ..drivers._virtual import is_virtual_token
|
|
548
|
+
|
|
549
|
+
if is_virtual_token(token):
|
|
550
|
+
yield
|
|
551
|
+
return
|
|
552
|
+
depth = _batch_local.depth
|
|
553
|
+
depth[token] = depth.get(token, 0) + 1
|
|
554
|
+
try:
|
|
555
|
+
yield
|
|
556
|
+
finally:
|
|
557
|
+
depth[token] -= 1
|
|
558
|
+
if not depth[token]:
|
|
559
|
+
del depth[token]
|
|
560
|
+
pending = _batch_local.pending
|
|
561
|
+
if pending is not None and pending[0] == token:
|
|
562
|
+
_flush()
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _flush(last=None):
|
|
566
|
+
token, prefix, ops = _batch_local.pending
|
|
567
|
+
_batch_local.pending = None
|
|
568
|
+
if last is not None:
|
|
569
|
+
ops.append(last)
|
|
570
|
+
if len(ops) == 1 or _batch_support.get(token) is False:
|
|
571
|
+
return _replay(ops)
|
|
572
|
+
args = {"a": ops[0][1]["a"]}
|
|
573
|
+
for i, (name, op_args) in enumerate(ops):
|
|
574
|
+
op = grpc_pb2.GenericRPCRequest(function_name=name, args={k: v for k, v in op_args.items() if k != "a"})
|
|
575
|
+
args[f"op{i}"] = grpc_pb2.Argument(bytes_value=op.SerializeToString())
|
|
576
|
+
try:
|
|
577
|
+
response = _dispatch(f"{prefix}:batch:CALLN", args, None)
|
|
578
|
+
except RuntimeError as exc:
|
|
579
|
+
# An older lab or Host refuses it before running anything: as before, one by one.
|
|
580
|
+
if not any(s in str(exc) for s in ("call_batch", "device_unavailable")):
|
|
581
|
+
raise
|
|
582
|
+
response = None
|
|
583
|
+
if response is None or "batch_done" not in response.results:
|
|
584
|
+
_batch_support[token] = False
|
|
585
|
+
return _replay(ops)
|
|
586
|
+
_batch_support[token] = True
|
|
587
|
+
if "batch_failed" in response.results:
|
|
588
|
+
failed = unmap_arg(response.results["batch_failed"])
|
|
589
|
+
name = ops[failed][0] if isinstance(failed, int) and 0 <= failed < len(ops) else f"{prefix}:batch:CALLN"
|
|
590
|
+
raise RuntimeError(f"{name}: {unmap_arg(response.results['batch_error'])}")
|
|
591
|
+
del response.results["batch_done"]
|
|
482
592
|
return response
|
|
483
593
|
|
|
484
594
|
|
|
595
|
+
def _replay(ops):
|
|
596
|
+
for name, op_args in ops:
|
|
597
|
+
response = _dispatch(name, op_args, None)
|
|
598
|
+
return response
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
# ── RX read-ahead (opt-in): REMOTERF_RX_READAHEAD=<k> ────────────────
|
|
602
|
+
#
|
|
603
|
+
# One <Prefix>:rx_n:CALL1 RPC captures up to k buffers back to back; the
|
|
604
|
+
# next rx() calls are served from them. They were captured when the first
|
|
605
|
+
# rx() of the group ran, not when each later rx() is called, so use this only
|
|
606
|
+
# for continuous-capture loops. They are dropped after 1 s and on any SET or
|
|
607
|
+
# CALL (not GET) on any device of this process; changes made elsewhere
|
|
608
|
+
# (another program, another user's TX, the RF environment) are not seen.
|
|
609
|
+
|
|
610
|
+
# ponytail: fixed 1 s bound; make it configurable if a real loop needs another.
|
|
611
|
+
_RX_READAHEAD_MAX_AGE = 1.0
|
|
612
|
+
# The relay's response frame cap (core MAX_DEVICE_RPC_RESPONSE_FRAME_BYTES), less headroom.
|
|
613
|
+
_RX_N_BYTES = 786432 - 4096
|
|
614
|
+
_rx_queues: Dict[str, tuple] = {} # token -> (captured at, deque of rx Arguments)
|
|
615
|
+
_rx_sizes: Dict[str, int] = {} # token -> size of its last rx response
|
|
616
|
+
_rx_n_unsupported: set = set()
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _readahead_k():
|
|
620
|
+
try:
|
|
621
|
+
k = int(os.getenv("REMOTERF_RX_READAHEAD") or 0)
|
|
622
|
+
except ValueError:
|
|
623
|
+
return 0
|
|
624
|
+
return min(k, 64) if k >= 2 else 0
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _rx_readahead(prefix, args, token):
|
|
628
|
+
stamp, buffers = _rx_queues.get(token, (0.0, None))
|
|
629
|
+
if buffers and time.monotonic() - stamp <= _RX_READAHEAD_MAX_AGE:
|
|
630
|
+
try:
|
|
631
|
+
return grpc_pb2.GenericRPCResponse(results={"rx": buffers.popleft()})
|
|
632
|
+
except IndexError:
|
|
633
|
+
pass # another thread took the last one
|
|
634
|
+
size = _rx_sizes.get(token)
|
|
635
|
+
k = min(_readahead_k(), _RX_N_BYTES // size) if size and token not in _rx_n_unsupported else 0
|
|
636
|
+
if k < 2:
|
|
637
|
+
return None
|
|
638
|
+
stamp = time.monotonic()
|
|
639
|
+
try:
|
|
640
|
+
response = _dispatch(f"{prefix}:rx_n:CALL1", {**args, "arg1": map_arg(k)}, None)
|
|
641
|
+
except RuntimeError as exc:
|
|
642
|
+
# An older lab or Host refused it unrun, or it outgrew the relay: plain rx from now on.
|
|
643
|
+
if not any(s in str(exc) for s in ("call_rx_n", "device_unavailable", "response_too_large")):
|
|
644
|
+
raise
|
|
645
|
+
response = None
|
|
646
|
+
arg = response.results.get("rx_n") if response is not None else None
|
|
647
|
+
nd = arg.ndarray_value if arg is not None and arg.WhichOneof("value") == "ndarray_value" else None
|
|
648
|
+
m = nd.shape[0] if nd is not None and nd.shape else 0
|
|
649
|
+
if m < 1 or len(nd.data) % m:
|
|
650
|
+
_rx_n_unsupported.add(token)
|
|
651
|
+
return None
|
|
652
|
+
step = len(nd.data) // m
|
|
653
|
+
buffers = deque(
|
|
654
|
+
grpc_pb2.Argument(ndarray_value=grpc_pb2.NDArrayValue(
|
|
655
|
+
dtype=nd.dtype, shape=nd.shape[1:], data=nd.data[i * step:(i + 1) * step]))
|
|
656
|
+
for i in range(m)
|
|
657
|
+
)
|
|
658
|
+
first = buffers.popleft()
|
|
659
|
+
_rx_queues[token] = (stamp, buffers)
|
|
660
|
+
return grpc_pb2.GenericRPCResponse(results={"rx": first})
|
|
661
|
+
|
|
662
|
+
|
|
485
663
|
# Device tokens whose Server answered an rx in ci16: it decodes ci16 too (the
|
|
486
664
|
# same Gate I slice 3 change), so their tx may go out that way.
|
|
487
665
|
_ci16_tokens: set = set()
|
|
@@ -102,6 +102,7 @@ class HttpsJsonAccountBackend(DeploymentAccountBackend):
|
|
|
102
102
|
# One refresh at a time: a direct-path thread shares this session, and
|
|
103
103
|
# Global revokes a session whose refresh token is presented twice.
|
|
104
104
|
self._refresh_lock = threading.RLock()
|
|
105
|
+
self._framed = False # this deployment answered a device RPC with a raw frame
|
|
105
106
|
|
|
106
107
|
def _operation(self, name):
|
|
107
108
|
if name not in self.capabilities["supported_operations"]:
|
|
@@ -365,29 +366,40 @@ class HttpsJsonAccountBackend(DeploymentAccountBackend):
|
|
|
365
366
|
"""Relay one serialized GenericRPCRequest; returns the response bytes."""
|
|
366
367
|
|
|
367
368
|
self._operation("DEV:rpc")
|
|
368
|
-
|
|
369
|
-
"
|
|
370
|
-
|
|
371
|
-
"request_b64": base64.b64encode(bytes(request_bytes)).decode("ascii")},
|
|
372
|
-
overall_timeout=DEVICE_RPC_CLIENT_TIMEOUT_SECONDS,
|
|
373
|
-
max_response_bytes=MAX_DEVICE_HTTP_RESPONSE_BYTES,
|
|
369
|
+
return self._device_call(
|
|
370
|
+
"/devices/rpc", {"device_id": self._ref(device_id, "device_id")}, request_bytes,
|
|
371
|
+
access=self._access(),
|
|
374
372
|
)
|
|
375
|
-
return self._device_frame(value)
|
|
376
373
|
|
|
377
374
|
def device_token_rpc(self, token, deployment_id, request_bytes):
|
|
378
375
|
"""Relay one frame on a Global device API token (``rfg_``): the token
|
|
379
376
|
is the credential, so there is no login and no bearer."""
|
|
380
377
|
|
|
381
|
-
data = {"token": token
|
|
382
|
-
"request_b64": base64.b64encode(bytes(request_bytes)).decode("ascii")}
|
|
378
|
+
data = {"token": token}
|
|
383
379
|
if deployment_id is not None:
|
|
384
380
|
data["deployment_id"] = deployment_id
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
)
|
|
390
|
-
|
|
381
|
+
return self._device_call("/devices/token-rpc", data, request_bytes)
|
|
382
|
+
|
|
383
|
+
def _device_call(self, path, data, request_bytes, **auth):
|
|
384
|
+
"""Raw frames once this deployment has answered with one; base64 JSON before."""
|
|
385
|
+
request_bytes = bytes(request_bytes)
|
|
386
|
+
framed = self._framed
|
|
387
|
+
while True:
|
|
388
|
+
try:
|
|
389
|
+
value = self.transport.request(
|
|
390
|
+
"POST", self.base + path, **auth,
|
|
391
|
+
data=data if framed else {**data, "request_b64": base64.b64encode(request_bytes).decode("ascii")},
|
|
392
|
+
frame=request_bytes if framed else None, accept_framed=True,
|
|
393
|
+
overall_timeout=DEVICE_RPC_CLIENT_TIMEOUT_SECONDS,
|
|
394
|
+
max_response_bytes=MAX_DEVICE_HTTP_RESPONSE_BYTES,
|
|
395
|
+
)
|
|
396
|
+
except AccountBackendError as exc:
|
|
397
|
+
# A rolled-back deployment refuses the framed body before routing it.
|
|
398
|
+
if framed and exc.code == "invalid_request":
|
|
399
|
+
self._framed = framed = False
|
|
400
|
+
continue
|
|
401
|
+
raise
|
|
402
|
+
return self._device_frame(value)
|
|
391
403
|
|
|
392
404
|
def device_direct(self, ref, deployment_id, ice):
|
|
393
405
|
"""Signal a direct path to the lab behind ``ref`` (a device ref under
|
|
@@ -408,8 +420,12 @@ class HttpsJsonAccountBackend(DeploymentAccountBackend):
|
|
|
408
420
|
raise AccountBackendError("Invalid home device response")
|
|
409
421
|
return value["ice"]
|
|
410
422
|
|
|
411
|
-
|
|
412
|
-
|
|
423
|
+
def _device_frame(self, value):
|
|
424
|
+
if "frame" in value:
|
|
425
|
+
if value.get("provenance") != "destination" or type(value["frame"]) is not bytes:
|
|
426
|
+
raise AccountBackendError("Invalid home device response")
|
|
427
|
+
self._framed = True
|
|
428
|
+
return value["frame"]
|
|
413
429
|
encoded = value.get("response_b64")
|
|
414
430
|
# An empty frame is a valid setter response (no results).
|
|
415
431
|
if value.get("provenance") != "destination" or type(encoded) is not str:
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Verified same-origin JSON transport with bounded responses and no retries."""
|
|
2
2
|
|
|
3
|
+
import json
|
|
3
4
|
import os
|
|
4
5
|
import ssl
|
|
5
6
|
import time
|
|
@@ -14,6 +15,10 @@ from remoterf_federation_core import (
|
|
|
14
15
|
)
|
|
15
16
|
from .state import origin
|
|
16
17
|
|
|
18
|
+
# Device RPC bodies: u32be len(envelope) || JSON envelope || raw frame. The
|
|
19
|
+
# core's FRAMED_CONTENT_TYPE, kept here while the client pins an older core.
|
|
20
|
+
FRAMED_CONTENT_TYPE = "application/x-remoterf-framed"
|
|
21
|
+
|
|
17
22
|
|
|
18
23
|
class AccountBackendError(RuntimeError):
|
|
19
24
|
def __init__(self, code, *, provenance=None):
|
|
@@ -44,6 +49,8 @@ class JsonTransport:
|
|
|
44
49
|
access=None,
|
|
45
50
|
overall_timeout=None,
|
|
46
51
|
max_response_bytes=65536,
|
|
52
|
+
frame=None,
|
|
53
|
+
accept_framed=False,
|
|
47
54
|
):
|
|
48
55
|
if (
|
|
49
56
|
not path.startswith("/")
|
|
@@ -51,7 +58,12 @@ class JsonTransport:
|
|
|
51
58
|
or any(c in path for c in ("?", "#", "\\", "%"))
|
|
52
59
|
):
|
|
53
60
|
raise ValidationError("Invalid same-origin account path")
|
|
54
|
-
headers = {"Accept": "application/json"}
|
|
61
|
+
headers = {"Accept": f"{FRAMED_CONTENT_TYPE}, application/json" if accept_framed else "application/json"}
|
|
62
|
+
content = None
|
|
63
|
+
if frame is not None:
|
|
64
|
+
envelope = json.dumps(data, separators=(",", ":")).encode()
|
|
65
|
+
content, data = len(envelope).to_bytes(4, "big") + envelope + bytes(frame), None
|
|
66
|
+
headers["Content-Type"] = FRAMED_CONTENT_TYPE
|
|
55
67
|
if access:
|
|
56
68
|
headers["Authorization"] = "Bearer " + access
|
|
57
69
|
overall_timeout = (
|
|
@@ -71,6 +83,7 @@ class JsonTransport:
|
|
|
71
83
|
method,
|
|
72
84
|
self.origin + path,
|
|
73
85
|
json=data,
|
|
86
|
+
content=content,
|
|
74
87
|
headers=headers,
|
|
75
88
|
follow_redirects=False,
|
|
76
89
|
timeout=timeout,
|
|
@@ -86,6 +99,10 @@ class JsonTransport:
|
|
|
86
99
|
)
|
|
87
100
|
if 300 <= response.status_code < 400:
|
|
88
101
|
raise AccountBackendError("Account redirects are not allowed")
|
|
102
|
+
if accept_framed and response.status_code < 300 and (
|
|
103
|
+
response.headers.get("content-type", "").split(";")[0].strip() == FRAMED_CONTENT_TYPE
|
|
104
|
+
):
|
|
105
|
+
return _unframe(bytes(raw), max_response_bytes)
|
|
89
106
|
value = strict_json(bytes(raw), max_bytes=max_response_bytes)
|
|
90
107
|
if response.status_code >= 400:
|
|
91
108
|
# Never print arbitrary server text (could contain secrets/terminal escapes).
|
|
@@ -123,3 +140,17 @@ class JsonTransport:
|
|
|
123
140
|
|
|
124
141
|
def close(self):
|
|
125
142
|
self.client.close()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _unframe(raw, max_bytes):
|
|
146
|
+
"""A framed body -> {**envelope, "frame": frame}."""
|
|
147
|
+
size = int.from_bytes(raw[:4], "big")
|
|
148
|
+
if len(raw) < 4 or 4 + size > len(raw):
|
|
149
|
+
raise AccountBackendError("Invalid home device response")
|
|
150
|
+
try:
|
|
151
|
+
value = strict_json(raw[4:4 + size], max_bytes=max_bytes)
|
|
152
|
+
except ValidationError:
|
|
153
|
+
raise AccountBackendError("Invalid home device response") from None
|
|
154
|
+
if type(value) is not dict:
|
|
155
|
+
raise AccountBackendError("Invalid home device response")
|
|
156
|
+
return {**value, "frame": raw[4 + size:]}
|
|
@@ -152,6 +152,10 @@ class Pluto:
|
|
|
152
152
|
install_driver_if_stale(token=token, current_hash=_SCHEMA_HASH)
|
|
153
153
|
_try_call("ip", token)
|
|
154
154
|
|
|
155
|
+
def batch(self):
|
|
156
|
+
from ...core.grpc_client import batch
|
|
157
|
+
return batch(self.token)
|
|
158
|
+
|
|
155
159
|
@property
|
|
156
160
|
def dds_enabled(self):
|
|
157
161
|
return _try_get("dds_enabled", self.token)
|
|
@@ -1267,6 +1267,13 @@ def _codegen(schema: dict) -> str:
|
|
|
1267
1267
|
if "ip" in call_map:
|
|
1268
1268
|
lines.append(' _try_call("ip", token)')
|
|
1269
1269
|
lines.append("")
|
|
1270
|
+
if "batch" not in getter_map and "batch" not in setter_map and "batch" not in call_map:
|
|
1271
|
+
lines += [
|
|
1272
|
+
" def batch(self):",
|
|
1273
|
+
" from ...core.grpc_client import batch",
|
|
1274
|
+
" return batch(self.token)",
|
|
1275
|
+
"",
|
|
1276
|
+
]
|
|
1270
1277
|
|
|
1271
1278
|
# ── Properties ────────────────────────────────────────────────────
|
|
1272
1279
|
all_props = sorted(set(getter_map) | set(setter_map))
|
|
@@ -151,6 +151,10 @@ class HackRF:
|
|
|
151
151
|
from ..dynamic_device import install_driver_if_stale
|
|
152
152
|
install_driver_if_stale(token=token, current_hash=_SCHEMA_HASH)
|
|
153
153
|
|
|
154
|
+
def batch(self):
|
|
155
|
+
from ...core.grpc_client import batch
|
|
156
|
+
return batch(self.token)
|
|
157
|
+
|
|
154
158
|
@property
|
|
155
159
|
def amplifier_on(self):
|
|
156
160
|
'Whether the 14 dB RF front-end amplifier is enabled.'
|
|
@@ -119,6 +119,10 @@ class RtlSdr:
|
|
|
119
119
|
from ..dynamic_device import install_driver_if_stale
|
|
120
120
|
install_driver_if_stale(token=token, current_hash=_SCHEMA_HASH)
|
|
121
121
|
|
|
122
|
+
def batch(self):
|
|
123
|
+
from ...core.grpc_client import batch
|
|
124
|
+
return batch(self.token)
|
|
125
|
+
|
|
122
126
|
@property
|
|
123
127
|
def agc_mode(self):
|
|
124
128
|
return _try_get("agc_mode", self.token)
|
|
@@ -152,6 +152,10 @@ class SimSDR:
|
|
|
152
152
|
install_driver_if_stale(token=token, current_hash=_SCHEMA_HASH)
|
|
153
153
|
_try_call("ip", token)
|
|
154
154
|
|
|
155
|
+
def batch(self):
|
|
156
|
+
from ...core.grpc_client import batch
|
|
157
|
+
return batch(self.token)
|
|
158
|
+
|
|
155
159
|
@property
|
|
156
160
|
def rx_buffer_size(self):
|
|
157
161
|
return _try_get("rx_buffer_size", self.token)
|
|
@@ -119,6 +119,10 @@ class TiMmWave:
|
|
|
119
119
|
from ..dynamic_device import install_driver_if_stale
|
|
120
120
|
install_driver_if_stale(token=token, current_hash=_SCHEMA_HASH)
|
|
121
121
|
|
|
122
|
+
def batch(self):
|
|
123
|
+
from ...core.grpc_client import batch
|
|
124
|
+
return batch(self.token)
|
|
125
|
+
|
|
122
126
|
@property
|
|
123
127
|
def device_info(self):
|
|
124
128
|
'USB/UART identity, firmware profile, and current stream state.'
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: remoterf
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.5.1
|
|
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
|