RemoteRF 2.5.1__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.1/src/remoterf.egg-info → remoterf-2.5.2}/PKG-INFO +9 -1
  2. {remoterf-2.5.1 → remoterf-2.5.2}/README.md +8 -0
  3. {remoterf-2.5.1 → 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.1 → remoterf-2.5.2}/src/remoterf/core/direct_path.py +3 -1
  7. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/core/dynamic_v2_transport.py +3 -0
  8. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/core/grpc_client.py +45 -6
  9. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/drivers/dynamic_device.py +12 -2
  10. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/drivers/rtl_sdr/rtl_sdr_remote.py +41 -9
  11. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/drivers/ti_mmwave/ti_mmwave_remote.py +39 -7
  12. {remoterf-2.5.1 → remoterf-2.5.2/src/remoterf.egg-info}/PKG-INFO +9 -1
  13. {remoterf-2.5.1 → 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.1 → remoterf-2.5.2}/tests/test_direct_path_global.py +12 -0
  17. {remoterf-2.5.1 → remoterf-2.5.2}/tests/test_fetch_idl_fallback.py +19 -0
  18. remoterf-2.5.2/tests/test_old_import_name.py +31 -0
  19. remoterf-2.5.2/tests/test_relay_paging.py +63 -0
  20. remoterf-2.5.1/src/remoterf.egg-info/top_level.txt +0 -1
  21. {remoterf-2.5.1 → remoterf-2.5.2}/LICENSE +0 -0
  22. {remoterf-2.5.1 → remoterf-2.5.2}/setup.cfg +0 -0
  23. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/__init__.py +0 -0
  24. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/common/__init__.py +0 -0
  25. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/common/grpc/__init__.py +0 -0
  26. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/common/grpc/deployment_capabilities.proto +0 -0
  27. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/common/grpc/deployment_capabilities_pb2.py +0 -0
  28. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/common/grpc/deployment_capabilities_pb2_grpc.py +0 -0
  29. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/common/grpc/grpc.proto +0 -0
  30. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/common/grpc/grpc_pb2.py +0 -0
  31. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/common/grpc/grpc_pb2_grpc.py +0 -0
  32. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/common/grpc/v2_codec.py +0 -0
  33. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/common/tos_notice.txt +0 -0
  34. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/common/utils/__init__.py +0 -0
  35. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/common/utils/ansi_codes.py +0 -0
  36. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/common/utils/api_token.py +0 -0
  37. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/common/utils/banner.py +0 -0
  38. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/common/utils/list_string.py +0 -0
  39. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/common/utils/process_arg.py +0 -0
  40. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/config/__init__.py +0 -0
  41. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/config/cert_fetcher.py +0 -0
  42. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/config/config.py +0 -0
  43. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/core/__init__.py +0 -0
  44. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/core/acc_login.py +0 -0
  45. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/core/app.py +0 -0
  46. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/core/banner_note.txt +0 -0
  47. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/core/grpc_acc.py +0 -0
  48. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/core/secure_channel.py +0 -0
  49. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/core/v2_errors.py +0 -0
  50. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/deployment/__init__.py +0 -0
  51. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/deployment/backend.py +0 -0
  52. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/deployment/direct.py +0 -0
  53. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/deployment/homes.py +0 -0
  54. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/deployment/http.py +0 -0
  55. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/deployment/state.py +0 -0
  56. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/drivers/__init__.py +0 -0
  57. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/drivers/_virtual.py +0 -0
  58. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/drivers/adalm_pluto/__init__.py +0 -0
  59. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/drivers/adalm_pluto/adalm_pluto_remote.py +0 -0
  60. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/drivers/dynamic_v2.py +0 -0
  61. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/drivers/hackrf/__init__.py +0 -0
  62. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/drivers/hackrf/hackrf_remote.py +0 -0
  63. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/drivers/rtl_sdr/__init__.py +0 -0
  64. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/drivers/sim_sdr/__init__.py +0 -0
  65. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/drivers/sim_sdr/sim_sdr_remote.py +0 -0
  66. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/drivers/support/__init__.py +0 -0
  67. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/drivers/support/ti_mmwave.py +0 -0
  68. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/drivers/support/uhd.py +0 -0
  69. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/drivers/support/uhd_dsp.py +0 -0
  70. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/drivers/support/uhd_v2.py +0 -0
  71. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/drivers/ti_mmwave/__init__.py +0 -0
  72. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/drivers/usrp/__init__.py +0 -0
  73. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/drivers/usrp/usrp_remote.py +0 -0
  74. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/drivers/usrp/usrp_remote.pyi +0 -0
  75. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/remoterf_cli.py +0 -0
  76. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf/version.py +0 -0
  77. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf.egg-info/dependency_links.txt +0 -0
  78. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf.egg-info/entry_points.txt +0 -0
  79. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf.egg-info/requires.txt +0 -0
  80. {remoterf-2.5.1 → remoterf-2.5.2}/src/remoterf.py +0 -0
  81. {remoterf-2.5.1 → remoterf-2.5.2}/tests/test_banner.py +0 -0
  82. {remoterf-2.5.1 → remoterf-2.5.2}/tests/test_batch.py +0 -0
  83. {remoterf-2.5.1 → remoterf-2.5.2}/tests/test_cli_login_fresh.py +0 -0
  84. {remoterf-2.5.1 → remoterf-2.5.2}/tests/test_config_address.py +0 -0
  85. {remoterf-2.5.1 → remoterf-2.5.2}/tests/test_deployment_accounts.py +0 -0
  86. {remoterf-2.5.1 → remoterf-2.5.2}/tests/test_device_routes.py +0 -0
  87. {remoterf-2.5.1 → remoterf-2.5.2}/tests/test_direct_path_fixed.py +0 -0
  88. {remoterf-2.5.1 → remoterf-2.5.2}/tests/test_direct_path_partner.py +0 -0
  89. {remoterf-2.5.1 → remoterf-2.5.2}/tests/test_direct_path_race.py +0 -0
  90. {remoterf-2.5.1 → remoterf-2.5.2}/tests/test_dynamic_device_codegen.py +0 -0
  91. {remoterf-2.5.1 → remoterf-2.5.2}/tests/test_dynamic_v2.py +0 -0
  92. {remoterf-2.5.1 → remoterf-2.5.2}/tests/test_federated_devices.py +0 -0
  93. {remoterf-2.5.1 → remoterf-2.5.2}/tests/test_finish_prompt.py +0 -0
  94. {remoterf-2.5.1 → remoterf-2.5.2}/tests/test_gate_i_direct_path.py +0 -0
  95. {remoterf-2.5.1 → remoterf-2.5.2}/tests/test_grpc_client_target.py +0 -0
  96. {remoterf-2.5.1 → remoterf-2.5.2}/tests/test_homes.py +0 -0
  97. {remoterf-2.5.1 → remoterf-2.5.2}/tests/test_native_account_credentials.py +0 -0
  98. {remoterf-2.5.1 → remoterf-2.5.2}/tests/test_native_devices.py +0 -0
  99. {remoterf-2.5.1 → remoterf-2.5.2}/tests/test_partner_dynamic_v2.py +0 -0
  100. {remoterf-2.5.1 → remoterf-2.5.2}/tests/test_pluto_virtual.py +0 -0
  101. {remoterf-2.5.1 → remoterf-2.5.2}/tests/test_process_arg.py +0 -0
  102. {remoterf-2.5.1 → remoterf-2.5.2}/tests/test_remoterf_cli.py +0 -0
  103. {remoterf-2.5.1 → remoterf-2.5.2}/tests/test_rx_readahead.py +0 -0
  104. {remoterf-2.5.1 → remoterf-2.5.2}/tests/test_ti_mmwave.py +0 -0
  105. {remoterf-2.5.1 → 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.1
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.1"
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())
@@ -904,10 +904,12 @@ def describe() -> str:
904
904
  return f"relay (direct path unavailable: {_BROKEN}; fix: pip install --force-reinstall remoterf)"
905
905
  if not enabled():
906
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.
907
908
  partners = "; ".join(
908
909
  f"partner {_partner_owners[key]}: "
909
910
  + (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
+ if key in _partner_owners else f"Global: {path.describe()}"
912
+ for key, path in list(_partners.items())
911
913
  )
912
914
  path = _path
913
915
  if path is not 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.1
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
@@ -0,0 +1,57 @@
1
+ """Each bundled driver is what the client would generate from the Server's
2
+ schema, so a first device call does not rewrite site-packages."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import os
8
+ import subprocess
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ import pytest
13
+
14
+ from remoterf.drivers import dynamic_device
15
+
16
+ SERVER_SRC = Path(
17
+ os.environ.get("REMOTERF_SERVER_SRC", Path(__file__).resolve().parents[2] / "RemoteRF-Server" / "src")
18
+ )
19
+ SCHEMAS = {
20
+ "adalm_pluto": "remoteRF_server.drivers.adalm_pluto.pluto_schema:PlutoSchema",
21
+ "hackrf": "remoteRF_server.drivers.hackrf.hackrf_schema:HackRFSchema",
22
+ "rtl_sdr": "remoteRF_server.drivers.rtl_sdr.rtl_sdr_schema:RtlSdrSchema",
23
+ "ti_mmwave": "remoteRF_server.drivers.ti_mmwave.ti_mmwave_schema:TiMmWaveSchema",
24
+ }
25
+ # A subprocess: the Server package stays out of this process's imports.
26
+ DUMP = """
27
+ import importlib, json, sys, types
28
+ try:
29
+ import adi
30
+ except ImportError:
31
+ sys.modules["adi"] = types.ModuleType("adi") # the Pluto schema imports pyadi at load
32
+ out = {}
33
+ for device_type, spec in json.loads(sys.argv[1]).items():
34
+ module, cls = spec.split(":")
35
+ out[device_type] = getattr(importlib.import_module(module), cls)().get_idl()
36
+ print(json.dumps(out))
37
+ """
38
+
39
+
40
+ @pytest.fixture(scope="module")
41
+ def server_schemas():
42
+ if not (SERVER_SRC / "remoteRF_server").is_dir():
43
+ pytest.skip(f"no Server source at {SERVER_SRC} (set REMOTERF_SERVER_SRC)")
44
+ result = subprocess.run(
45
+ [sys.executable, "-c", DUMP, json.dumps(SCHEMAS)],
46
+ env={**os.environ, "PYTHONPATH": str(SERVER_SRC)},
47
+ capture_output=True, text=True, check=True,
48
+ )
49
+ return json.loads(result.stdout.splitlines()[-1])
50
+
51
+
52
+ @pytest.mark.parametrize("device_type", sorted(SCHEMAS))
53
+ def test_bundled_driver_matches_the_server_schema(server_schemas, device_type):
54
+ schema = server_schemas[device_type]
55
+ path = dynamic_device._DRIVERS_DIR / device_type / f"{device_type}_remote.py"
56
+ assert dynamic_device._read_schema_hash(path) == schema["schema_hash"]
57
+ assert path.read_text(encoding="utf-8") == dynamic_device._codegen(schema)
@@ -137,3 +137,15 @@ def test_the_login_backend_is_built_once_when_a_path_thread_asks_too(monkeypatch
137
137
  for thread in threads:
138
138
  thread.join(5)
139
139
  assert len(built) == 1 and got == built * 2
140
+
141
+
142
+ def test_describe_reports_a_global_path(fresh, monkeypatch):
143
+ monkeypatch.setattr(direct_path, "_from_stored_login", lambda: None)
144
+ monkeypatch.setattr(grpc_client, "_current_profile", lambda: None)
145
+ monkeypatch.setattr(direct_path, "_active_route", lambda profile: None)
146
+ path = direct_path.global_path(REF)
147
+ path.describe = lambda: "direct 192.0.2.7:4433 (48 calls)"
148
+ assert direct_path.describe() == "Global: direct 192.0.2.7:4433 (48 calls)"
149
+ path.state = "down"
150
+ path.describe = lambda: "relay (direct path down: timed out; retrying)"
151
+ assert direct_path.describe() == "Global: relay (direct path down: timed out; retrying)"
@@ -96,3 +96,22 @@ def test_a_v1_hash_that_differs_still_lets_v2_decide_and_reinstall(monkeypatch,
96
96
  # A stale v1 driver: reinstalled from what v2 says, exactly as before.
97
97
  assert dynamic_device.install_driver_if_stale(token="tok5", current_hash="sha256:old") is True
98
98
  assert written == [v2]
99
+
100
+
101
+ def test_a_read_only_install_keeps_its_driver(monkeypatch, capsys):
102
+ import remoterf.drivers.dynamic_v2 as dynamic_v2
103
+
104
+ def v1_only(token):
105
+ raise v2_errors.RemoteRFProtocolError("partner devices use the v1 device plane")
106
+
107
+ monkeypatch.setattr(dynamic_v2, "fetch_schema_v2", v1_only)
108
+ calls = _schema_rpc(monkeypatch, "sha256:new")
109
+
110
+ def read_only(schema):
111
+ raise PermissionError(13, "Permission denied")
112
+
113
+ monkeypatch.setattr(dynamic_device, "_write_driver_files", read_only)
114
+ assert dynamic_device.install_driver_if_stale(token="tok5", current_hash="sha256:old") is False
115
+ assert "could not be updated" in capsys.readouterr().out
116
+ dynamic_device.install_driver_if_stale(token="tok5", current_hash="sha256:old")
117
+ assert len(calls) == 2 # fetched once (v1, then the v2 probe's fallback), not per device
@@ -0,0 +1,31 @@
1
+ """Code written before 2.2.0 imports remoteRF: it gets the remoterf modules
2
+ themselves, with one FutureWarning pointing at that code."""
3
+
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ SRC = Path(__file__).resolve().parents[1] / "src"
9
+ PROBE = """
10
+ import site, sys, warnings
11
+ sys.path.insert(0, sys.argv[1])
12
+ site.addsitedir(sys.argv[2]) # runs the .pth, as site-packages does at startup
13
+ assert not {"remoterf", "grpc", "numpy"} & set(sys.modules) # the hook alone stays light
14
+ with warnings.catch_warnings(record=True) as caught:
15
+ warnings.simplefilter("always")
16
+ import remoteRF
17
+ from remoteRF.drivers.adalm_pluto import Pluto
18
+ import remoteRF.drivers.adalm_pluto as old
19
+ import remoterf
20
+ import remoterf.drivers.adalm_pluto as new
21
+ assert remoteRF is remoterf and old is new and Pluto is new.Pluto
22
+ assert remoterf.__spec__.name == "remoterf" and new.__spec__.name == "remoterf.drivers.adalm_pluto"
23
+ assert [(w.category, w.filename) for w in caught if "remoteRF" in str(w.message)] == [(FutureWarning, "<string>")], caught
24
+ print("ok")
25
+ """
26
+
27
+
28
+ def test_import_remoteRF_aliases_remoterf_with_one_warning(tmp_path):
29
+ (tmp_path / "_remoterf_alias.pth").write_text("import _remoterf_alias\n") # what setup.py ships
30
+ result = subprocess.run([sys.executable, "-c", PROBE, str(SRC), str(tmp_path)], capture_output=True, text=True)
31
+ assert result.stdout.strip() == "ok", result.stderr
@@ -0,0 +1,63 @@
1
+ import pytest
2
+
3
+ from remoterf.common.grpc import grpc_pb2
4
+ from remoterf.common.utils import map_arg, unmap_arg
5
+ from remoterf.core import direct_path, dynamic_v2_transport, grpc_client
6
+ from remoterf.core.v2_errors import RemoteRFPolicyError
7
+ from remoterf.deployment.http import AccountBackendError
8
+
9
+ REF = "00000000-0000-4000-8000-000000000001:7"
10
+ WHOLE = grpc_pb2.GenericRPCResponse(results={"rx": map_arg(bytes(range(256)) * 4000)}).SerializeToString()
11
+
12
+
13
+ class Lab:
14
+ def __init__(self, pages=True):
15
+ self.pages = pages
16
+ self.requests = []
17
+
18
+ def device_rpc(self, ref, raw):
19
+ request = grpc_pb2.GenericRPCRequest.FromString(raw)
20
+ self.requests.append(request)
21
+ if request.function_name != "Federation:page:CALL2":
22
+ raise AccountBackendError("response_too_large", provenance="destination")
23
+ if not self.pages: # an older lab: not a device function
24
+ raise AccountBackendError("device_unavailable", provenance="destination")
25
+ offset = unmap_arg(request.args["arg1"])
26
+ return grpc_pb2.GenericRPCResponse(results={
27
+ "page": map_arg(WHOLE[offset:offset + 600_000]),
28
+ "size": map_arg(len(WHOLE)),
29
+ "nonce": map_arg("n1"),
30
+ }).SerializeToString()
31
+
32
+
33
+ @pytest.fixture
34
+ def lab(monkeypatch):
35
+ lab = Lab()
36
+ monkeypatch.setattr(direct_path, "wants", lambda function_name: False)
37
+ monkeypatch.setattr(grpc_client, "federated_backend", lambda: lab)
38
+ return lab
39
+
40
+
41
+ def test_an_oversized_reply_is_fetched_in_pages(lab):
42
+ response = grpc_client._federated_call(REF, "Rtl_sdr:read_samples:CALL1", {"a": map_arg(REF)})
43
+ assert response.SerializeToString() == WHOLE
44
+ pages = lab.requests[1:]
45
+ assert [unmap_arg(r.args["arg1"]) for r in pages] == [0, 600_000]
46
+ assert [unmap_arg(r.args["arg2"]) for r in pages] == ["", "n1"]
47
+
48
+
49
+ def test_an_older_lab_gets_a_clear_error(lab):
50
+ lab.pages = False
51
+ with pytest.raises(RuntimeError, match=r"response_too_large \[destination\].*768 KiB"):
52
+ grpc_client._federated_call(REF, "Rtl_sdr:read_samples:CALL1", {"a": map_arg(REF)})
53
+ assert len(lab.requests) == 2
54
+
55
+
56
+ def test_dynamic_v2_reports_it_as_a_policy_error(monkeypatch):
57
+ def refused(**_):
58
+ raise RuntimeError("response_too_large [destination]: over the relay limit")
59
+
60
+ monkeypatch.setattr(grpc_client, "rpc_client", refused)
61
+ stub = dynamic_v2_transport._DevicePlaneStub("token")
62
+ with pytest.raises(RemoteRFPolicyError, match="response_too_large"):
63
+ stub.Invoke(grpc_pb2.InvokeRequest())
@@ -1 +0,0 @@
1
- remoterf
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes