RemoteRF 2.5.0__tar.gz → 2.5.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.
Files changed (105) hide show
  1. {remoterf-2.5.0/src/remoterf.egg-info → remoterf-2.5.2}/PKG-INFO +9 -1
  2. {remoterf-2.5.0 → remoterf-2.5.2}/README.md +8 -0
  3. {remoterf-2.5.0 → remoterf-2.5.2}/pyproject.toml +3 -2
  4. remoterf-2.5.2/setup.py +16 -0
  5. remoterf-2.5.2/src/_remoterf_alias.py +60 -0
  6. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/core/direct_path.py +42 -17
  7. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/core/dynamic_v2_transport.py +3 -0
  8. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/core/grpc_client.py +45 -6
  9. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/drivers/dynamic_device.py +12 -2
  10. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/drivers/rtl_sdr/rtl_sdr_remote.py +41 -9
  11. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/drivers/ti_mmwave/ti_mmwave_remote.py +39 -7
  12. {remoterf-2.5.0 → remoterf-2.5.2/src/remoterf.egg-info}/PKG-INFO +9 -1
  13. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf.egg-info/SOURCES.txt +5 -0
  14. remoterf-2.5.2/src/remoterf.egg-info/top_level.txt +2 -0
  15. remoterf-2.5.2/tests/test_bundled_drivers.py +57 -0
  16. {remoterf-2.5.0 → remoterf-2.5.2}/tests/test_direct_path_global.py +12 -0
  17. {remoterf-2.5.0 → remoterf-2.5.2}/tests/test_direct_path_partner.py +65 -3
  18. {remoterf-2.5.0 → remoterf-2.5.2}/tests/test_fetch_idl_fallback.py +19 -0
  19. remoterf-2.5.2/tests/test_old_import_name.py +31 -0
  20. remoterf-2.5.2/tests/test_relay_paging.py +63 -0
  21. remoterf-2.5.0/src/remoterf.egg-info/top_level.txt +0 -1
  22. {remoterf-2.5.0 → remoterf-2.5.2}/LICENSE +0 -0
  23. {remoterf-2.5.0 → remoterf-2.5.2}/setup.cfg +0 -0
  24. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/__init__.py +0 -0
  25. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/common/__init__.py +0 -0
  26. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/common/grpc/__init__.py +0 -0
  27. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/common/grpc/deployment_capabilities.proto +0 -0
  28. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/common/grpc/deployment_capabilities_pb2.py +0 -0
  29. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/common/grpc/deployment_capabilities_pb2_grpc.py +0 -0
  30. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/common/grpc/grpc.proto +0 -0
  31. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/common/grpc/grpc_pb2.py +0 -0
  32. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/common/grpc/grpc_pb2_grpc.py +0 -0
  33. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/common/grpc/v2_codec.py +0 -0
  34. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/common/tos_notice.txt +0 -0
  35. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/common/utils/__init__.py +0 -0
  36. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/common/utils/ansi_codes.py +0 -0
  37. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/common/utils/api_token.py +0 -0
  38. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/common/utils/banner.py +0 -0
  39. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/common/utils/list_string.py +0 -0
  40. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/common/utils/process_arg.py +0 -0
  41. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/config/__init__.py +0 -0
  42. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/config/cert_fetcher.py +0 -0
  43. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/config/config.py +0 -0
  44. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/core/__init__.py +0 -0
  45. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/core/acc_login.py +0 -0
  46. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/core/app.py +0 -0
  47. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/core/banner_note.txt +0 -0
  48. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/core/grpc_acc.py +0 -0
  49. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/core/secure_channel.py +0 -0
  50. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/core/v2_errors.py +0 -0
  51. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/deployment/__init__.py +0 -0
  52. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/deployment/backend.py +0 -0
  53. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/deployment/direct.py +0 -0
  54. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/deployment/homes.py +0 -0
  55. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/deployment/http.py +0 -0
  56. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/deployment/state.py +0 -0
  57. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/drivers/__init__.py +0 -0
  58. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/drivers/_virtual.py +0 -0
  59. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/drivers/adalm_pluto/__init__.py +0 -0
  60. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/drivers/adalm_pluto/adalm_pluto_remote.py +0 -0
  61. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/drivers/dynamic_v2.py +0 -0
  62. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/drivers/hackrf/__init__.py +0 -0
  63. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/drivers/hackrf/hackrf_remote.py +0 -0
  64. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/drivers/rtl_sdr/__init__.py +0 -0
  65. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/drivers/sim_sdr/__init__.py +0 -0
  66. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/drivers/sim_sdr/sim_sdr_remote.py +0 -0
  67. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/drivers/support/__init__.py +0 -0
  68. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/drivers/support/ti_mmwave.py +0 -0
  69. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/drivers/support/uhd.py +0 -0
  70. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/drivers/support/uhd_dsp.py +0 -0
  71. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/drivers/support/uhd_v2.py +0 -0
  72. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/drivers/ti_mmwave/__init__.py +0 -0
  73. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/drivers/usrp/__init__.py +0 -0
  74. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/drivers/usrp/usrp_remote.py +0 -0
  75. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/drivers/usrp/usrp_remote.pyi +0 -0
  76. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/remoterf_cli.py +0 -0
  77. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf/version.py +0 -0
  78. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf.egg-info/dependency_links.txt +0 -0
  79. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf.egg-info/entry_points.txt +0 -0
  80. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf.egg-info/requires.txt +0 -0
  81. {remoterf-2.5.0 → remoterf-2.5.2}/src/remoterf.py +0 -0
  82. {remoterf-2.5.0 → remoterf-2.5.2}/tests/test_banner.py +0 -0
  83. {remoterf-2.5.0 → remoterf-2.5.2}/tests/test_batch.py +0 -0
  84. {remoterf-2.5.0 → remoterf-2.5.2}/tests/test_cli_login_fresh.py +0 -0
  85. {remoterf-2.5.0 → remoterf-2.5.2}/tests/test_config_address.py +0 -0
  86. {remoterf-2.5.0 → remoterf-2.5.2}/tests/test_deployment_accounts.py +0 -0
  87. {remoterf-2.5.0 → remoterf-2.5.2}/tests/test_device_routes.py +0 -0
  88. {remoterf-2.5.0 → remoterf-2.5.2}/tests/test_direct_path_fixed.py +0 -0
  89. {remoterf-2.5.0 → remoterf-2.5.2}/tests/test_direct_path_race.py +0 -0
  90. {remoterf-2.5.0 → remoterf-2.5.2}/tests/test_dynamic_device_codegen.py +0 -0
  91. {remoterf-2.5.0 → remoterf-2.5.2}/tests/test_dynamic_v2.py +0 -0
  92. {remoterf-2.5.0 → remoterf-2.5.2}/tests/test_federated_devices.py +0 -0
  93. {remoterf-2.5.0 → remoterf-2.5.2}/tests/test_finish_prompt.py +0 -0
  94. {remoterf-2.5.0 → remoterf-2.5.2}/tests/test_gate_i_direct_path.py +0 -0
  95. {remoterf-2.5.0 → remoterf-2.5.2}/tests/test_grpc_client_target.py +0 -0
  96. {remoterf-2.5.0 → remoterf-2.5.2}/tests/test_homes.py +0 -0
  97. {remoterf-2.5.0 → remoterf-2.5.2}/tests/test_native_account_credentials.py +0 -0
  98. {remoterf-2.5.0 → remoterf-2.5.2}/tests/test_native_devices.py +0 -0
  99. {remoterf-2.5.0 → remoterf-2.5.2}/tests/test_partner_dynamic_v2.py +0 -0
  100. {remoterf-2.5.0 → remoterf-2.5.2}/tests/test_pluto_virtual.py +0 -0
  101. {remoterf-2.5.0 → remoterf-2.5.2}/tests/test_process_arg.py +0 -0
  102. {remoterf-2.5.0 → remoterf-2.5.2}/tests/test_remoterf_cli.py +0 -0
  103. {remoterf-2.5.0 → remoterf-2.5.2}/tests/test_rx_readahead.py +0 -0
  104. {remoterf-2.5.0 → remoterf-2.5.2}/tests/test_ti_mmwave.py +0 -0
  105. {remoterf-2.5.0 → remoterf-2.5.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.5.0
3
+ Version: 2.5.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
@@ -52,6 +52,8 @@ source venv/bin/activate # Activate virtual environment
52
52
  pip install remoterf # Install remoterf
53
53
  ```
54
54
 
55
+ The package was named `remoteRF` before 2.2.0. Old code that does `import remoteRF` or `from remoteRF.drivers... import ...` still runs, with a one-time warning; change it to `remoterf`.
56
+
55
57
  ## NI USRP-2901, Ettus B205-mini, B200-family, and USRP N210
56
58
 
57
59
  The packaged client natively includes the shared USRP schema for the qualified
@@ -145,6 +147,12 @@ not close the physical radio. `read_samples` and `read_bytes` are bounded to a
145
147
  4 MiB response. Call them repeatedly for longer captures. Native asynchronous
146
148
  callbacks are not transported remotely.
147
149
 
150
+ Through the RemoteRF Global relay one reply is capped at 768 KiB. A lab on a
151
+ current Server keeps a larger reply (up to 4 MiB) and the client fetches it in
152
+ pages; an older lab refuses it with `response_too_large`, so keep reads there
153
+ to about 98k complex64 samples (`read_samples(65536)` is safe). LAN and direct
154
+ paths are not capped.
155
+
148
156
  When a Tailscale address differs from the IP or DNS identity in the server
149
157
  certificate, keep TLS verification enabled and set the expected identity:
150
158
 
@@ -27,6 +27,8 @@ source venv/bin/activate # Activate virtual environment
27
27
  pip install remoterf # Install remoterf
28
28
  ```
29
29
 
30
+ The package was named `remoteRF` before 2.2.0. Old code that does `import remoteRF` or `from remoteRF.drivers... import ...` still runs, with a one-time warning; change it to `remoterf`.
31
+
30
32
  ## NI USRP-2901, Ettus B205-mini, B200-family, and USRP N210
31
33
 
32
34
  The packaged client natively includes the shared USRP schema for the qualified
@@ -120,6 +122,12 @@ not close the physical radio. `read_samples` and `read_bytes` are bounded to a
120
122
  4 MiB response. Call them repeatedly for longer captures. Native asynchronous
121
123
  callbacks are not transported remotely.
122
124
 
125
+ Through the RemoteRF Global relay one reply is capped at 768 KiB. A lab on a
126
+ current Server keeps a larger reply (up to 4 MiB) and the client fetches it in
127
+ pages; an older lab refuses it with `response_too_large`, so keep reads there
128
+ to about 98k complex64 samples (`read_samples(65536)` is safe). LAN and direct
129
+ paths are not capped.
130
+
123
131
  When a Tailscale address differs from the IP or DNS identity in the server
124
132
  certificate, keep TLS verification enabled and set the expected identity:
125
133
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "remoterf"
7
- version = "2.5.0"
7
+ version = "2.5.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"
@@ -36,7 +36,8 @@ remoterf = "remoterf.remoterf_cli:main"
36
36
  [tool.setuptools]
37
37
  package-dir = { "" = "src" }
38
38
  # src/remoterf.py: see its docstring (upgrades from 2.0.14-2.1.x).
39
- py-modules = ["remoterf"]
39
+ # src/_remoterf_alias.py: `import remoteRF` (setup.py adds its .pth).
40
+ py-modules = ["remoterf", "_remoterf_alias"]
40
41
 
41
42
  [tool.setuptools.packages.find]
42
43
  where = ["src"]
@@ -0,0 +1,16 @@
1
+ from pathlib import Path
2
+
3
+ from setuptools import setup
4
+ from setuptools.command.build_py import build_py
5
+
6
+
7
+ class BuildPy(build_py):
8
+ """The wheel also ships _remoterf_alias.pth, so `import remoteRF` works
9
+ (see src/_remoterf_alias.py)."""
10
+
11
+ def run(self):
12
+ super().run()
13
+ Path(self.build_lib, "_remoterf_alias.pth").write_text("import _remoterf_alias\n")
14
+
15
+
16
+ setup(cmdclass={"build_py": BuildPy})
@@ -0,0 +1,60 @@
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
+ """``import remoteRF``, the package's name before 2.2.0, still works.
17
+
18
+ _remoterf_alias.pth imports this module at startup. It maps ``remoteRF`` and
19
+ every ``remoteRF.*`` to the same module objects as ``remoterf``, warning once.
20
+ Until something imports ``remoteRF``, it imports nothing (no remoterf, grpc, numpy).
21
+ A ``remoteRF.py`` file cannot do this: on a case-insensitive disk it would
22
+ collide with the ``remoterf.py`` rescue module.
23
+ """
24
+
25
+ import importlib
26
+ import importlib.machinery
27
+ import sys
28
+ import warnings
29
+
30
+ _OLD, _NEW = "remoteRF", "remoterf"
31
+
32
+
33
+ class _Alias: # a meta path finder and its loader
34
+ warned = False
35
+
36
+ def find_spec(self, name, path=None, target=None):
37
+ if name == _OLD or name.startswith(_OLD + "."):
38
+ return importlib.machinery.ModuleSpec(name, self)
39
+ return None
40
+
41
+ def create_module(self, spec):
42
+ if not _Alias.warned:
43
+ _Alias.warned = True
44
+ # FutureWarning: shown by default (a DeprecationWarning is hidden outside __main__).
45
+ # stacklevel skips the import machinery, so it points at the importing line.
46
+ warnings.warn(
47
+ "'remoteRF' was renamed 'remoterf' in 2.2.0; import remoterf instead",
48
+ FutureWarning, stacklevel=2,
49
+ )
50
+ module = importlib.import_module(_NEW + spec.name[len(_OLD):])
51
+ spec.loader_state = module.__spec__
52
+ return module
53
+
54
+ def exec_module(self, module):
55
+ module.__spec__ = module.__spec__.loader_state # loading set it to the alias's spec
56
+
57
+
58
+ # First: on an upgraded machine a surviving remoteRF folder would load a second copy.
59
+ if not any(isinstance(finder, _Alias) for finder in sys.meta_path):
60
+ sys.meta_path.insert(0, _Alias())
@@ -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 = {"un": map_arg(username), "pw": map_arg(secret), "ice": map_arg(json.dumps(offer))}
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
- raise PathError(unmap_arg(response.results["ace"]))
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
- return None
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,29 @@ 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
+ # A Global ref's path has no owner; its calls fall back to Global's relay.
908
+ partners = "; ".join(
909
+ f"partner {_partner_owners[key]}: "
910
+ + (path.describe() if path.ready else path.describe().replace("relay", "via HOME", 1))
911
+ if key in _partner_owners else f"Global: {path.describe()}"
912
+ for key, path in list(_partners.items())
913
+ )
893
914
  path = _path
894
915
  if path is not None:
895
- return path.describe()
896
- try:
897
- from .grpc_client import _current_profile
898
-
899
- route = _active_route(_current_profile())
900
- except Exception: # noqa: BLE001 - not configured, no home: nothing to add
901
- route = None
902
- if route is None or route["kind"] == "relay":
903
- return "relay (direct path off)"
904
- return "LAN (direct)" if route["kind"] == "lan" else str(route["kind"])
916
+ home = path.describe()
917
+ else:
918
+ try:
919
+ from .grpc_client import _current_profile
920
+
921
+ route = _active_route(_current_profile())
922
+ except Exception: # noqa: BLE001 - not configured, no home: nothing to add
923
+ route = None
924
+ if route is None or route["kind"] == "relay":
925
+ if partners: # the home path is not what these device calls take
926
+ return partners
927
+ return "relay (direct path off)"
928
+ home = "LAN (direct)" if route["kind"] == "lan" else str(route["kind"])
929
+ return f"{home}; {partners}" if partners else home
905
930
 
906
931
 
907
932
  def stop() -> None:
@@ -27,6 +27,7 @@ from ..common.grpc import grpc_pb2, grpc_pb2_grpc
27
27
  from ..common.grpc.v2_codec import decode_value, encode_value
28
28
  from .v2_errors import (
29
29
  RemoteRFError,
30
+ RemoteRFPolicyError,
30
31
  RemoteRFProtocolError,
31
32
  RemoteRFReservationError,
32
33
  RemoteRFTransportError,
@@ -96,6 +97,8 @@ class _DevicePlaneStub:
96
97
  args={"a": map_arg(self._token), "wire": map_arg(request.SerializeToString())},
97
98
  )
98
99
  except RuntimeError as exc:
100
+ if "response_too_large" in str(exc):
101
+ raise RemoteRFPolicyError(f"{self._label}{exc}") from exc
99
102
  # A refusal the HOME or the owner carried in "a": no live
100
103
  # reservation, a revoked agreement, a device no longer visible.
101
104
  raise RemoteRFReservationError(f"{self._label}{exc}") from exc
@@ -228,21 +228,32 @@ def _federated_call(route, function_name, args):
228
228
  refused = "a" in response.results and unmap_arg(response.results["a"]) == "the reservation has ended"
229
229
  if not refused:
230
230
  return response
231
+ def send(request):
232
+ if ref.startswith("rfg_"):
233
+ return _token_backend().device_token_rpc(
234
+ ref, getattr(route, "deployment_id", None), request.SerializeToString()
235
+ )
236
+ return federated_backend().device_rpc(ref, request.SerializeToString())
237
+
231
238
  request = grpc_pb2.GenericRPCRequest(function_name=function_name, args=args)
232
239
  for retry in (function_name.endswith(":GET"), False):
233
240
  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())
241
+ raw = send(request)
240
242
  break
241
243
  except AccountBackendError as exc:
242
244
  if retry and exc.code == "internal_error" and exc.provenance == "transport":
243
245
  # An older lab drops ~0.1% of relayed results when a clock second
244
246
  # ticks mid-call; a GET changes nothing, so it is sent once more.
245
247
  continue
248
+ if exc.code == "response_too_large" and exc.provenance == "destination":
249
+ raw = _kept_reply(send, args)
250
+ if raw is not None:
251
+ break
252
+ raise RuntimeError(
253
+ "response_too_large [destination]: the reply is over the Global relay's "
254
+ "768 KiB limit for one call and this lab does not page it; read fewer "
255
+ "samples per call (about 98k complex64) or use a direct path"
256
+ ) from None
246
257
  if ref.startswith("rfg_") and str(exc) == "not_found":
247
258
  raise RuntimeError(
248
259
  "this RemoteRF Global does not accept device tokens yet; use the device reference"
@@ -660,6 +671,34 @@ def _rx_readahead(prefix, args, token):
660
671
  return grpc_pb2.GenericRPCResponse(results={"rx": first})
661
672
 
662
673
 
674
+ # A newer lab keeps a reply too large for the relay (up to 4 MiB) for its
675
+ # caller, who fetches it in pages; an older lab refuses the first page unrun.
676
+ _PAGE_FUNCTION = "Federation:page:CALL2"
677
+ _MAX_KEPT_REPLY = 4 * 1024 * 1024 + 65536
678
+
679
+
680
+ def _kept_reply(send, args):
681
+ from ..deployment.http import AccountBackendError
682
+ data, nonce = b"", ""
683
+ while True:
684
+ page_args = {"arg1": map_arg(len(data)), "arg2": map_arg(nonce)}
685
+ if "a" in args:
686
+ page_args["a"] = args["a"]
687
+ try:
688
+ raw = send(grpc_pb2.GenericRPCRequest(function_name=_PAGE_FUNCTION, args=page_args))
689
+ except AccountBackendError:
690
+ return None
691
+ reply = grpc_pb2.GenericRPCResponse.FromString(raw)
692
+ if not {"page", "size", "nonce"} <= set(reply.results):
693
+ return None
694
+ page, size, nonce = (unmap_arg(reply.results[key]) for key in ("page", "size", "nonce"))
695
+ if not isinstance(page, bytes) or not page or type(size) is not int or size > _MAX_KEPT_REPLY:
696
+ return None
697
+ data += page
698
+ if len(data) >= size:
699
+ return data if len(data) == size else None
700
+
701
+
663
702
  # Device tokens whose Server answered an rx in ci16: it decodes ci16 too (the
664
703
  # same Gate I slice 3 change), so their tx may go out that way.
665
704
  _ci16_tokens: set = set()
@@ -252,6 +252,9 @@ def _validate_client_modules(value) -> dict:
252
252
  out = {}
253
253
  for alias, module_path in value.items():
254
254
  alias = _require_identifier(alias, field="client_modules alias")
255
+ module_path = str(module_path or "").strip()
256
+ if module_path.startswith("remoteRF."): # Servers still name the package as before 2.2.0
257
+ module_path = "remoterf." + module_path[len("remoteRF."):]
255
258
  module_path = _require_dotted_identifier(
256
259
  module_path,
257
260
  field=f"client_modules[{alias!r}]",
@@ -1415,7 +1418,8 @@ _DRIVERS_DIR = Path(__file__).parent
1415
1418
  def _write_driver_files(schema: dict) -> Path:
1416
1419
  pkg_dir = _write_v2_driver_files(schema) if str(schema.get("schema_version")) == "2.0" else _write_v1_driver_files(schema)
1417
1420
  stale = f"remoterf.drivers.{pkg_dir.name}"
1418
- for name in [m for m in sys.modules if m == stale or m.startswith(stale + ".")]:
1421
+ old = "remoteRF" + stale[len("remoterf"):] # the `import remoteRF` alias keys
1422
+ for name in [m for m in sys.modules if m in (stale, old) or m.startswith((stale + ".", old + "."))]:
1419
1423
  del sys.modules[name]
1420
1424
  return pkg_dir
1421
1425
 
@@ -1685,7 +1689,13 @@ def install_driver_if_stale(*, token: str, current_hash: str) -> bool:
1685
1689
  return False
1686
1690
 
1687
1691
  device_type = _schema_device_type(schema)
1688
- pkg_dir = _write_driver_files(schema)
1692
+ try:
1693
+ pkg_dir = _write_driver_files(schema)
1694
+ except OSError as exc:
1695
+ # A read-only install (system site-packages): the installed driver keeps working.
1696
+ _verified.add(key)
1697
+ print(f"Driver for '{device_type}' is out of date and could not be updated ({exc}).")
1698
+ return False
1689
1699
  if _read_schema_hash(pkg_dir / f"{device_type}_remote.py") == current_hash:
1690
1700
  return False
1691
1701
  print(
@@ -14,21 +14,35 @@
14
14
  # along with this program. If not, see <https://www.gnu.org/licenses/>.
15
15
 
16
16
  # Auto-generated from IDL schema — do not edit by hand.
17
- # device_type: rtl_sdr driver_version: 0.1.1 schema_hash: sha256:1c84effc2c21be734398a87d24bb9da52ab5b7ea0def955c503da484b8d818c0
17
+ # device_type: rtl_sdr driver_version: 0.1.1 schema_hash: sha256:6603ec8e642c87073c35202980de9a3d9e4bb6eb88967686d3810c389fb4fb97
18
18
 
19
19
  _PREFIX = "Rtl_sdr"
20
- _SCHEMA_HASH = "sha256:1c84effc2c21be734398a87d24bb9da52ab5b7ea0def955c503da484b8d818c0"
20
+ _SCHEMA_HASH = "sha256:6603ec8e642c87073c35202980de9a3d9e4bb6eb88967686d3810c389fb4fb97"
21
21
  _CLIENT_MODULES = {}
22
22
  _CLIENT_OBJECTS = {}
23
23
 
24
+ import os
25
+
24
26
  from importlib import import_module as _import_module
25
27
 
26
- from ...core.grpc_client import rpc_client
27
28
  from ...common.utils import map_arg, unmap_arg
29
+ from .._virtual import (
30
+ is_virtual_token,
31
+ make_virtual_token,
32
+ virtual_call,
33
+ virtual_get,
34
+ virtual_set,
35
+ )
28
36
 
29
37
  _NO_ARG = object()
30
38
 
31
39
 
40
+ def _rpc_client(*, function_name, args):
41
+ from ...core.grpc_client import rpc_client
42
+
43
+ return rpc_client(function_name=function_name, args=args)
44
+
45
+
32
46
  for _alias, _module_path in _CLIENT_MODULES.items():
33
47
  globals()[_alias] = _import_module(_module_path)
34
48
 
@@ -71,27 +85,34 @@ def _wrap_client_return(spec, self_obj, result):
71
85
 
72
86
 
73
87
  def _try_get(prop, token):
74
- return unmap_arg(rpc_client(
88
+ if is_virtual_token(token):
89
+ return virtual_get(token, prop)
90
+ return unmap_arg(_rpc_client(
75
91
  function_name=f"{_PREFIX}:{prop}:GET",
76
92
  args={'a': map_arg(token)},
77
93
  ).results[prop])
78
94
 
79
95
 
80
96
  def _try_set(prop, value, token):
81
- rpc_client(
97
+ if is_virtual_token(token):
98
+ virtual_set(token, prop, value)
99
+ return None
100
+ _rpc_client(
82
101
  function_name=f"{_PREFIX}:{prop}:SET",
83
102
  args={prop: map_arg(value), 'a': map_arg(token)},
84
103
  )
85
104
 
86
105
 
87
106
  def _try_call(prop, token, arg=_NO_ARG):
107
+ if is_virtual_token(token):
108
+ return virtual_call(token, prop, arg, has_arg=arg is not _NO_ARG)
88
109
  if arg is _NO_ARG:
89
- resp = rpc_client(
110
+ resp = _rpc_client(
90
111
  function_name=f"{_PREFIX}:{prop}:CALL0",
91
112
  args={'a': map_arg(token)},
92
113
  )
93
114
  else:
94
- resp = rpc_client(
115
+ resp = _rpc_client(
95
116
  function_name=f"{_PREFIX}:{prop}:CALL1",
96
117
  args={'a': map_arg(token), 'arg1': map_arg(arg)},
97
118
  )
@@ -100,12 +121,15 @@ def _try_call(prop, token, arg=_NO_ARG):
100
121
 
101
122
 
102
123
  def _try_calln(prop, token, kwargs):
124
+ if is_virtual_token(token):
125
+ filtered = {key: value for key, value in dict(kwargs).items() if value is not _NO_ARG}
126
+ return virtual_call(token, prop, filtered, has_arg=True)
103
127
  payload = {'a': map_arg(token)}
104
128
  for key, value in dict(kwargs).items():
105
129
  if value is _NO_ARG:
106
130
  continue
107
131
  payload[str(key)] = map_arg(value)
108
- resp = rpc_client(
132
+ resp = _rpc_client(
109
133
  function_name=f"{_PREFIX}:{prop}:CALLN",
110
134
  args=payload,
111
135
  )
@@ -114,7 +138,15 @@ def _try_calln(prop, token, kwargs):
114
138
 
115
139
  class RtlSdr:
116
140
 
117
- def __init__(self, token: str):
141
+ def __init__(self, token: str = None, *, virtual: bool = False):
142
+ self.virtual = bool(virtual)
143
+ if self.virtual:
144
+ self.token = make_virtual_token(token, device_type="rtl_sdr")
145
+ return
146
+ if token is None:
147
+ token = os.getenv("REMOTERF_TOKEN")
148
+ if not token:
149
+ raise ValueError("A reservation token is required; pass token= or set REMOTERF_TOKEN")
118
150
  self.token = token
119
151
  from ..dynamic_device import install_driver_if_stale
120
152
  install_driver_if_stale(token=token, current_hash=_SCHEMA_HASH)
@@ -21,14 +21,28 @@ _SCHEMA_HASH = "sha256:320a89d95075a472d7899ff2f98b9fdee05c0be96fa5651bf5c4f0e5d
21
21
  _CLIENT_MODULES = {'ti_mmwave': 'remoterf.drivers.support.ti_mmwave'}
22
22
  _CLIENT_OBJECTS = {}
23
23
 
24
+ import os
25
+
24
26
  from importlib import import_module as _import_module
25
27
 
26
- from ...core.grpc_client import rpc_client
27
28
  from ...common.utils import map_arg, unmap_arg
29
+ from .._virtual import (
30
+ is_virtual_token,
31
+ make_virtual_token,
32
+ virtual_call,
33
+ virtual_get,
34
+ virtual_set,
35
+ )
28
36
 
29
37
  _NO_ARG = object()
30
38
 
31
39
 
40
+ def _rpc_client(*, function_name, args):
41
+ from ...core.grpc_client import rpc_client
42
+
43
+ return rpc_client(function_name=function_name, args=args)
44
+
45
+
32
46
  for _alias, _module_path in _CLIENT_MODULES.items():
33
47
  globals()[_alias] = _import_module(_module_path)
34
48
 
@@ -71,27 +85,34 @@ def _wrap_client_return(spec, self_obj, result):
71
85
 
72
86
 
73
87
  def _try_get(prop, token):
74
- return unmap_arg(rpc_client(
88
+ if is_virtual_token(token):
89
+ return virtual_get(token, prop)
90
+ return unmap_arg(_rpc_client(
75
91
  function_name=f"{_PREFIX}:{prop}:GET",
76
92
  args={'a': map_arg(token)},
77
93
  ).results[prop])
78
94
 
79
95
 
80
96
  def _try_set(prop, value, token):
81
- rpc_client(
97
+ if is_virtual_token(token):
98
+ virtual_set(token, prop, value)
99
+ return None
100
+ _rpc_client(
82
101
  function_name=f"{_PREFIX}:{prop}:SET",
83
102
  args={prop: map_arg(value), 'a': map_arg(token)},
84
103
  )
85
104
 
86
105
 
87
106
  def _try_call(prop, token, arg=_NO_ARG):
107
+ if is_virtual_token(token):
108
+ return virtual_call(token, prop, arg, has_arg=arg is not _NO_ARG)
88
109
  if arg is _NO_ARG:
89
- resp = rpc_client(
110
+ resp = _rpc_client(
90
111
  function_name=f"{_PREFIX}:{prop}:CALL0",
91
112
  args={'a': map_arg(token)},
92
113
  )
93
114
  else:
94
- resp = rpc_client(
115
+ resp = _rpc_client(
95
116
  function_name=f"{_PREFIX}:{prop}:CALL1",
96
117
  args={'a': map_arg(token), 'arg1': map_arg(arg)},
97
118
  )
@@ -100,12 +121,15 @@ def _try_call(prop, token, arg=_NO_ARG):
100
121
 
101
122
 
102
123
  def _try_calln(prop, token, kwargs):
124
+ if is_virtual_token(token):
125
+ filtered = {key: value for key, value in dict(kwargs).items() if value is not _NO_ARG}
126
+ return virtual_call(token, prop, filtered, has_arg=True)
103
127
  payload = {'a': map_arg(token)}
104
128
  for key, value in dict(kwargs).items():
105
129
  if value is _NO_ARG:
106
130
  continue
107
131
  payload[str(key)] = map_arg(value)
108
- resp = rpc_client(
132
+ resp = _rpc_client(
109
133
  function_name=f"{_PREFIX}:{prop}:CALLN",
110
134
  args=payload,
111
135
  )
@@ -114,7 +138,15 @@ def _try_calln(prop, token, kwargs):
114
138
 
115
139
  class TiMmWave:
116
140
 
117
- def __init__(self, token: str):
141
+ def __init__(self, token: str = None, *, virtual: bool = False):
142
+ self.virtual = bool(virtual)
143
+ if self.virtual:
144
+ self.token = make_virtual_token(token, device_type="ti_mmwave")
145
+ return
146
+ if token is None:
147
+ token = os.getenv("REMOTERF_TOKEN")
148
+ if not token:
149
+ raise ValueError("A reservation token is required; pass token= or set REMOTERF_TOKEN")
118
150
  self.token = token
119
151
  from ..dynamic_device import install_driver_if_stale
120
152
  install_driver_if_stale(token=token, current_hash=_SCHEMA_HASH)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remoterf
3
- Version: 2.5.0
3
+ Version: 2.5.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
@@ -52,6 +52,8 @@ source venv/bin/activate # Activate virtual environment
52
52
  pip install remoterf # Install remoterf
53
53
  ```
54
54
 
55
+ The package was named `remoteRF` before 2.2.0. Old code that does `import remoteRF` or `from remoteRF.drivers... import ...` still runs, with a one-time warning; change it to `remoterf`.
56
+
55
57
  ## NI USRP-2901, Ettus B205-mini, B200-family, and USRP N210
56
58
 
57
59
  The packaged client natively includes the shared USRP schema for the qualified
@@ -145,6 +147,12 @@ not close the physical radio. `read_samples` and `read_bytes` are bounded to a
145
147
  4 MiB response. Call them repeatedly for longer captures. Native asynchronous
146
148
  callbacks are not transported remotely.
147
149
 
150
+ Through the RemoteRF Global relay one reply is capped at 768 KiB. A lab on a
151
+ current Server keeps a larger reply (up to 4 MiB) and the client fetches it in
152
+ pages; an older lab refuses it with `response_too_large`, so keep reads there
153
+ to about 98k complex64 samples (`read_samples(65536)` is safe). LAN and direct
154
+ paths are not capped.
155
+
148
156
  When a Tailscale address differs from the IP or DNS identity in the server
149
157
  certificate, keep TLS verification enabled and set the expected identity:
150
158
 
@@ -1,6 +1,8 @@
1
1
  LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
+ setup.py
5
+ src/_remoterf_alias.py
4
6
  src/remoterf.py
5
7
  src/remoterf/__init__.py
6
8
  src/remoterf/remoterf_cli.py
@@ -70,6 +72,7 @@ src/remoterf/drivers/usrp/usrp_remote.py
70
72
  src/remoterf/drivers/usrp/usrp_remote.pyi
71
73
  tests/test_banner.py
72
74
  tests/test_batch.py
75
+ tests/test_bundled_drivers.py
73
76
  tests/test_cli_login_fresh.py
74
77
  tests/test_config_address.py
75
78
  tests/test_deployment_accounts.py
@@ -88,9 +91,11 @@ tests/test_grpc_client_target.py
88
91
  tests/test_homes.py
89
92
  tests/test_native_account_credentials.py
90
93
  tests/test_native_devices.py
94
+ tests/test_old_import_name.py
91
95
  tests/test_partner_dynamic_v2.py
92
96
  tests/test_pluto_virtual.py
93
97
  tests/test_process_arg.py
98
+ tests/test_relay_paging.py
94
99
  tests/test_remoterf_cli.py
95
100
  tests/test_rx_readahead.py
96
101
  tests/test_ti_mmwave.py
@@ -0,0 +1,2 @@
1
+ _remoterf_alias
2
+ remoterf