RemoteRF 2.5.1__tar.gz → 2.6.0__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 (108) hide show
  1. {remoterf-2.5.1/src/remoterf.egg-info → remoterf-2.6.0}/PKG-INFO +28 -1
  2. {remoterf-2.5.1 → remoterf-2.6.0}/README.md +27 -0
  3. {remoterf-2.5.1 → remoterf-2.6.0}/pyproject.toml +3 -2
  4. remoterf-2.6.0/setup.py +16 -0
  5. remoterf-2.6.0/src/_remoterf_alias.py +60 -0
  6. remoterf-2.6.0/src/remoterf/__init__.py +22 -0
  7. remoterf-2.6.0/src/remoterf/core/availability.py +185 -0
  8. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/core/direct_path.py +3 -1
  9. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/core/dynamic_v2_transport.py +3 -0
  10. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/core/grpc_client.py +45 -6
  11. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/deployment/backend.py +13 -0
  12. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/deployment/http.py +6 -2
  13. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/drivers/adalm_pluto/adalm_pluto_remote.py +2 -2
  14. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/drivers/dynamic_device.py +12 -2
  15. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/drivers/rtl_sdr/rtl_sdr_remote.py +41 -9
  16. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/drivers/ti_mmwave/ti_mmwave_remote.py +39 -7
  17. {remoterf-2.5.1 → remoterf-2.6.0/src/remoterf.egg-info}/PKG-INFO +28 -1
  18. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf.egg-info/SOURCES.txt +7 -0
  19. remoterf-2.6.0/src/remoterf.egg-info/top_level.txt +2 -0
  20. remoterf-2.6.0/tests/test_bundled_drivers.py +57 -0
  21. remoterf-2.6.0/tests/test_devices_api.py +228 -0
  22. {remoterf-2.5.1 → remoterf-2.6.0}/tests/test_direct_path_global.py +12 -0
  23. {remoterf-2.5.1 → remoterf-2.6.0}/tests/test_fetch_idl_fallback.py +19 -0
  24. remoterf-2.6.0/tests/test_old_import_name.py +31 -0
  25. remoterf-2.6.0/tests/test_relay_paging.py +63 -0
  26. remoterf-2.5.1/src/remoterf/drivers/support/__init__.py +0 -14
  27. remoterf-2.5.1/src/remoterf.egg-info/top_level.txt +0 -1
  28. {remoterf-2.5.1 → remoterf-2.6.0}/LICENSE +0 -0
  29. {remoterf-2.5.1 → remoterf-2.6.0}/setup.cfg +0 -0
  30. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/common/__init__.py +0 -0
  31. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/common/grpc/__init__.py +0 -0
  32. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/common/grpc/deployment_capabilities.proto +0 -0
  33. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/common/grpc/deployment_capabilities_pb2.py +0 -0
  34. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/common/grpc/deployment_capabilities_pb2_grpc.py +0 -0
  35. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/common/grpc/grpc.proto +0 -0
  36. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/common/grpc/grpc_pb2.py +0 -0
  37. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/common/grpc/grpc_pb2_grpc.py +0 -0
  38. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/common/grpc/v2_codec.py +0 -0
  39. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/common/tos_notice.txt +0 -0
  40. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/common/utils/__init__.py +0 -0
  41. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/common/utils/ansi_codes.py +0 -0
  42. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/common/utils/api_token.py +0 -0
  43. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/common/utils/banner.py +0 -0
  44. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/common/utils/list_string.py +0 -0
  45. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/common/utils/process_arg.py +0 -0
  46. {remoterf-2.5.1/src/remoterf → remoterf-2.6.0/src/remoterf/config}/__init__.py +0 -0
  47. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/config/cert_fetcher.py +0 -0
  48. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/config/config.py +0 -0
  49. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/core/__init__.py +0 -0
  50. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/core/acc_login.py +0 -0
  51. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/core/app.py +0 -0
  52. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/core/banner_note.txt +0 -0
  53. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/core/grpc_acc.py +0 -0
  54. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/core/secure_channel.py +0 -0
  55. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/core/v2_errors.py +0 -0
  56. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/deployment/__init__.py +0 -0
  57. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/deployment/direct.py +0 -0
  58. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/deployment/homes.py +0 -0
  59. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/deployment/state.py +0 -0
  60. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/drivers/__init__.py +0 -0
  61. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/drivers/_virtual.py +0 -0
  62. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/drivers/adalm_pluto/__init__.py +0 -0
  63. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/drivers/dynamic_v2.py +0 -0
  64. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/drivers/hackrf/__init__.py +0 -0
  65. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/drivers/hackrf/hackrf_remote.py +0 -0
  66. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/drivers/rtl_sdr/__init__.py +0 -0
  67. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/drivers/sim_sdr/__init__.py +0 -0
  68. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/drivers/sim_sdr/sim_sdr_remote.py +0 -0
  69. {remoterf-2.5.1/src/remoterf/config → remoterf-2.6.0/src/remoterf/drivers/support}/__init__.py +0 -0
  70. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/drivers/support/ti_mmwave.py +0 -0
  71. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/drivers/support/uhd.py +0 -0
  72. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/drivers/support/uhd_dsp.py +0 -0
  73. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/drivers/support/uhd_v2.py +0 -0
  74. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/drivers/ti_mmwave/__init__.py +0 -0
  75. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/drivers/usrp/__init__.py +0 -0
  76. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/drivers/usrp/usrp_remote.py +0 -0
  77. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/drivers/usrp/usrp_remote.pyi +0 -0
  78. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/remoterf_cli.py +0 -0
  79. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/version.py +0 -0
  80. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf.egg-info/dependency_links.txt +0 -0
  81. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf.egg-info/entry_points.txt +0 -0
  82. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf.egg-info/requires.txt +0 -0
  83. {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf.py +0 -0
  84. {remoterf-2.5.1 → remoterf-2.6.0}/tests/test_banner.py +0 -0
  85. {remoterf-2.5.1 → remoterf-2.6.0}/tests/test_batch.py +0 -0
  86. {remoterf-2.5.1 → remoterf-2.6.0}/tests/test_cli_login_fresh.py +0 -0
  87. {remoterf-2.5.1 → remoterf-2.6.0}/tests/test_config_address.py +0 -0
  88. {remoterf-2.5.1 → remoterf-2.6.0}/tests/test_deployment_accounts.py +0 -0
  89. {remoterf-2.5.1 → remoterf-2.6.0}/tests/test_device_routes.py +0 -0
  90. {remoterf-2.5.1 → remoterf-2.6.0}/tests/test_direct_path_fixed.py +0 -0
  91. {remoterf-2.5.1 → remoterf-2.6.0}/tests/test_direct_path_partner.py +0 -0
  92. {remoterf-2.5.1 → remoterf-2.6.0}/tests/test_direct_path_race.py +0 -0
  93. {remoterf-2.5.1 → remoterf-2.6.0}/tests/test_dynamic_device_codegen.py +0 -0
  94. {remoterf-2.5.1 → remoterf-2.6.0}/tests/test_dynamic_v2.py +0 -0
  95. {remoterf-2.5.1 → remoterf-2.6.0}/tests/test_federated_devices.py +0 -0
  96. {remoterf-2.5.1 → remoterf-2.6.0}/tests/test_finish_prompt.py +0 -0
  97. {remoterf-2.5.1 → remoterf-2.6.0}/tests/test_gate_i_direct_path.py +0 -0
  98. {remoterf-2.5.1 → remoterf-2.6.0}/tests/test_grpc_client_target.py +0 -0
  99. {remoterf-2.5.1 → remoterf-2.6.0}/tests/test_homes.py +0 -0
  100. {remoterf-2.5.1 → remoterf-2.6.0}/tests/test_native_account_credentials.py +0 -0
  101. {remoterf-2.5.1 → remoterf-2.6.0}/tests/test_native_devices.py +0 -0
  102. {remoterf-2.5.1 → remoterf-2.6.0}/tests/test_partner_dynamic_v2.py +0 -0
  103. {remoterf-2.5.1 → remoterf-2.6.0}/tests/test_pluto_virtual.py +0 -0
  104. {remoterf-2.5.1 → remoterf-2.6.0}/tests/test_process_arg.py +0 -0
  105. {remoterf-2.5.1 → remoterf-2.6.0}/tests/test_remoterf_cli.py +0 -0
  106. {remoterf-2.5.1 → remoterf-2.6.0}/tests/test_rx_readahead.py +0 -0
  107. {remoterf-2.5.1 → remoterf-2.6.0}/tests/test_ti_mmwave.py +0 -0
  108. {remoterf-2.5.1 → remoterf-2.6.0}/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.6.0
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
 
@@ -346,6 +354,25 @@ If converting a existing `non-remoterf` compatible script:
346
354
 
347
355
  Nothing else needs changing!
348
356
 
357
+ #### List devices and availability
358
+
359
+ With a saved login (lab or Global), list the devices you can use, each with the handle to paste into `token=`, and whether it is free right now:
360
+
361
+ ```python
362
+ import remoterf
363
+
364
+ rows = remoterf.devices() # prints a table and returns it as a list of dicts
365
+ ```
366
+
367
+ ```
368
+ DEVICE (token=) TYPE ONLINE NOW NEXT
369
+ Pluto_01@lab pluto online free 14:30
370
+ Pluto_02@lab pluto online in use until 13:45 -
371
+ Pluto_03@lab pluto offline yours until 15:00 -
372
+ ```
373
+
374
+ Each account may ask once every 10 seconds; a repeat call within that reprints the last table with the remaining wait.
375
+
349
376
  ## Closing
350
377
 
351
378
  This is fundamentally a experimental platform, and there will be many unknown bugs and issues. Some devices do not have universal support for all its functions at the moment, I am working on that aspect.
@@ -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
 
@@ -321,6 +329,25 @@ If converting a existing `non-remoterf` compatible script:
321
329
 
322
330
  Nothing else needs changing!
323
331
 
332
+ #### List devices and availability
333
+
334
+ With a saved login (lab or Global), list the devices you can use, each with the handle to paste into `token=`, and whether it is free right now:
335
+
336
+ ```python
337
+ import remoterf
338
+
339
+ rows = remoterf.devices() # prints a table and returns it as a list of dicts
340
+ ```
341
+
342
+ ```
343
+ DEVICE (token=) TYPE ONLINE NOW NEXT
344
+ Pluto_01@lab pluto online free 14:30
345
+ Pluto_02@lab pluto online in use until 13:45 -
346
+ Pluto_03@lab pluto offline yours until 15:00 -
347
+ ```
348
+
349
+ Each account may ask once every 10 seconds; a repeat call within that reprints the last table with the remaining wait.
350
+
324
351
  ## Closing
325
352
 
326
353
  This is fundamentally a experimental platform, and there will be many unknown bugs and issues. Some devices do not have universal support for all its functions at the moment, I am working on that aspect.
@@ -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.6.0"
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())
@@ -0,0 +1,22 @@
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
+
17
+ def devices():
18
+ """Print the devices your saved login can use (handles to paste into
19
+ ``Pluto(token=...)``) with who-has-it-now availability; returns the rows."""
20
+ from .core.availability import devices as _devices
21
+
22
+ return _devices()
@@ -0,0 +1,185 @@
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
+ """remoterf.devices(): the devices the saved login can use, with handles and
17
+ what is free right now. Homes allow one call per account per interval; within
18
+ it this process reprints its last answer instead of asking again."""
19
+
20
+ import json
21
+ import time
22
+ from datetime import datetime
23
+
24
+ from ..common.utils import map_arg, unmap_arg
25
+
26
+ _cache = None # (key, monotonic at fetch, rows or None after a refusal, interval)
27
+ NOT_LOGGED_IN = "Not logged in: run `remoterf` and log in (keep the login saved)."
28
+ STALE_LOGIN = "Saved login is no longer valid: run `remoterf` and log in again."
29
+
30
+
31
+ class _Refused(Exception):
32
+ def __init__(self, wait):
33
+ self.wait = wait
34
+
35
+
36
+ def _when(ts):
37
+ try:
38
+ return datetime.fromtimestamp(ts) if ts else None
39
+ except (OverflowError, ValueError, OSError, TypeError):
40
+ return None
41
+
42
+
43
+ def _hhmm(dt):
44
+ return dt.strftime("%H:%M") if dt.date() == datetime.now().date() else dt.strftime("%b %d %H:%M")
45
+
46
+
47
+ def _row(handle, name, dtype, lab, online, state, until, nxt):
48
+ return {"handle": handle, "name": name, "type": dtype, "lab": lab, "online": bool(online),
49
+ "state": state if state in ("free", "busy", "mine") else "unknown",
50
+ "until": _when(until), "next": _when(nxt)}
51
+
52
+
53
+ def _global(backend):
54
+ from .app import _global_slug
55
+ from ..deployment.http import AccountBackendError
56
+
57
+ note = None
58
+ try:
59
+ data = backend.availability()
60
+ except AccountBackendError as exc:
61
+ if exc.retry_after:
62
+ raise _Refused(exc.retry_after) from None
63
+ if exc.code != "not_found":
64
+ raise
65
+ data, note = backend.devices(), "This Global home does not report availability yet; showing devices only."
66
+ if data.get("incomplete") or any(d.get("status") == "unavailable" for d in data["destinations"]):
67
+ note = "\n".join(filter(None, [note, "Some labs did not answer; the list may be incomplete."]))
68
+ slugs, rows = {}, []
69
+ for d in data["devices"]:
70
+ key = (d.get("deployment_id"), d.get("deployment_name"))
71
+ if key not in slugs:
72
+ slugs[key] = _global_slug(d.get("device_id", ""), d.get("deployment_name"))
73
+ slug = slugs[key]
74
+ handle = f"{d['handle']}@{slug}" if d.get("handle") and slug else None
75
+ rows.append(_row(handle, d.get("display_name"), d.get("device_type"), d.get("deployment_name"),
76
+ d.get("online"), d.get("state"), d.get("until"), d.get("next")))
77
+ return rows, data.get("interval"), note
78
+
79
+
80
+ def _lab(home, creds):
81
+ from .grpc_client import rpc_client
82
+
83
+ args = {"un": map_arg(creds[0]), "pw": map_arg(creds[1])}
84
+ res = rpc_client(function_name="ACC:availability", args=args).results
85
+ if "UC" in res:
86
+ data = json.loads(unmap_arg(res["UC"]))
87
+ rows = [_row(f"{d['handle']}@{home}" if d.get("handle") and home else None, d.get("display_name"),
88
+ d.get("device_type"), home, d.get("online"), d.get("state"), d.get("until"), d.get("next"))
89
+ for d in data["devices"]]
90
+ return rows, data.get("interval"), None
91
+ if "retry_after" in res:
92
+ raise _Refused(int(unmap_arg(res["retry_after"])))
93
+ error = str(unmap_arg(res.get("ace", map_arg("Unexpected reply from the lab."))))
94
+ if error == "Invalid login credentials.":
95
+ raise RuntimeError(STALE_LOGIN)
96
+ if "No matching server function" not in error:
97
+ raise RuntimeError(error)
98
+ # A lab older than ACC:availability: its device list, no availability.
99
+ from .app import _LOCAL_LABEL, _PARTNER_LABEL, _name_and_handle
100
+
101
+ res = rpc_client(function_name="ACC:get_dev", args=args).results
102
+ if "ace" in res:
103
+ raise RuntimeError(str(unmap_arg(res["ace"])))
104
+ rows = []
105
+ for key in sorted(res, key=lambda k: int(k) if str(k).isdigit() else 0):
106
+ label = str(unmap_arg(res[key]))
107
+ local = _LOCAL_LABEL.match(label)
108
+ if _PARTNER_LABEL.match(label) or not local:
109
+ continue # partner devices have no handles yet
110
+ name, handle = _name_and_handle(local["name"])
111
+ rows.append(_row(f"{handle}@{home}" if handle and home else None, name, None, home,
112
+ local["state"].startswith("online"), "unknown", None, None))
113
+ return rows, None, "This lab does not report availability yet; showing devices only."
114
+
115
+
116
+ def _print(rows, footer=None):
117
+ def now(r):
118
+ if r["state"] == "free":
119
+ return "free"
120
+ if r["state"] in ("busy", "mine") and r["until"]:
121
+ return f"{'yours' if r['state'] == 'mine' else 'in use'} until {_hhmm(r['until'])}"
122
+ return "?"
123
+
124
+ table = [("DEVICE (token=)", "TYPE", "ONLINE", "NOW", "NEXT")] + [
125
+ (r["handle"] or f"{r['name']}{' @' + r['lab'] if r['lab'] else ''} (no handle)", r["type"] or "-", "online" if r["online"] else "offline",
126
+ now(r), _hhmm(r["next"]) if r["next"] else "-") for r in rows]
127
+ widths = [max(len(str(line[i])) for line in table) for i in range(5)]
128
+ for line in table:
129
+ print(" ".join(str(c).ljust(w) for c, w in zip(line, widths)).rstrip())
130
+ if not rows:
131
+ print("(no devices)")
132
+ if footer:
133
+ print(footer)
134
+
135
+
136
+ def devices():
137
+ global _cache
138
+ from ..deployment.state import load_target
139
+
140
+ target = load_target()
141
+ try:
142
+ if target and target["transport"] == "https-json":
143
+ from .grpc_client import federated_backend
144
+
145
+ try:
146
+ backend = federated_backend()
147
+ except RuntimeError as exc:
148
+ print(exc)
149
+ return []
150
+ key, fetch = ("global", target["origin"]), lambda: _global(backend)
151
+ else:
152
+ from . import direct_path
153
+ from .grpc_client import _active_home_name
154
+
155
+ home, creds = _active_home_name(), direct_path._credentials()
156
+ if not creds:
157
+ print(NOT_LOGGED_IN)
158
+ return []
159
+ key, fetch = ("lab", home, creds[0]), lambda: _lab(home, creds)
160
+
161
+ if _cache and _cache[0] == key:
162
+ left = _cache[3] - (time.monotonic() - _cache[1])
163
+ if left > 0:
164
+ if _cache[2] is None:
165
+ print(f"Asked too recently; try again in {max(1, round(left))} s.")
166
+ return []
167
+ _print(_cache[2], f"(cached; refresh in {max(1, round(left))} s)")
168
+ return _cache[2]
169
+ try:
170
+ rows, interval, note = fetch()
171
+ except _Refused as exc:
172
+ # Remember the refusal so calls inside the wait stay local.
173
+ old = _cache[2] if _cache and _cache[0] == key else None
174
+ _cache = (key, time.monotonic(), old, float(exc.wait))
175
+ if old is not None:
176
+ _print(old, f"(cached; refresh in {exc.wait} s)")
177
+ return old
178
+ print(f"Asked too recently; try again in {exc.wait} s.")
179
+ return []
180
+ except RuntimeError as exc:
181
+ print(exc)
182
+ return []
183
+ _cache = (key, time.monotonic(), rows, float(interval or 10))
184
+ _print(rows, note)
185
+ return rows
@@ -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()
@@ -325,6 +325,19 @@ class HttpsJsonAccountBackend(DeploymentAccountBackend):
325
325
  raise AccountBackendError("Invalid home device response")
326
326
  return value
327
327
 
328
+ def availability(self):
329
+ """Devices plus now/until/next state (Global >= the availability
330
+ route; an older one answers code not_found)."""
331
+ self._operation("ACC:get_dev")
332
+ value = self.transport.request(
333
+ "GET", self.base + "/availability", access=self._access(),
334
+ overall_timeout=DEVICE_AGGREGATION_TIMEOUT_SECONDS + 3,
335
+ max_response_bytes=MAX_DEVICE_LIST_HTTP_RESPONSE_BYTES,
336
+ )
337
+ if type(value.get("devices")) is not list or type(value.get("destinations")) is not list:
338
+ raise AccountBackendError("Invalid home device response")
339
+ return value
340
+
328
341
  def reservations(self):
329
342
  self._operation("ACC:get_res")
330
343
  value = self.transport.request(
@@ -21,9 +21,10 @@ FRAMED_CONTENT_TYPE = "application/x-remoterf-framed"
21
21
 
22
22
 
23
23
  class AccountBackendError(RuntimeError):
24
- def __init__(self, code, *, provenance=None):
24
+ def __init__(self, code, *, provenance=None, retry_after=None):
25
25
  self.code = code
26
26
  self.provenance = provenance
27
+ self.retry_after = retry_after
27
28
  super().__init__(code)
28
29
 
29
30
 
@@ -120,7 +121,10 @@ class JsonTransport:
120
121
  )
121
122
  if provenance not in {None, "home", "transport", "destination"}:
122
123
  provenance = None
123
- raise AccountBackendError(code, provenance=provenance)
124
+ wait = error.get("retry_after") if isinstance(error, dict) else None
125
+ if type(wait) is not int or not 1 <= wait <= 3600:
126
+ wait = None
127
+ raise AccountBackendError(code, provenance=provenance, retry_after=wait)
124
128
  if type(value) is not dict:
125
129
  raise AccountBackendError("Invalid account response")
126
130
  return value
@@ -14,10 +14,10 @@
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: adalm_pluto driver_version: 0.0.3 schema_hash: sha256:35b66a1e4383a344046be27ff80d40aff3ebd147e7aa5dd0513303fd3c0cfab4
17
+ # device_type: adalm_pluto driver_version: 0.0.4 schema_hash: sha256:9cbbbbc0cd76fc7897dd702adc0c4a0aa9b5042484c6cb38d0057ca217cb92ce
18
18
 
19
19
  _PREFIX = "Adalm_pluto"
20
- _SCHEMA_HASH = "sha256:35b66a1e4383a344046be27ff80d40aff3ebd147e7aa5dd0513303fd3c0cfab4"
20
+ _SCHEMA_HASH = "sha256:9cbbbbc0cd76fc7897dd702adc0c4a0aa9b5042484c6cb38d0057ca217cb92ce"
21
21
  _CLIENT_MODULES = {}
22
22
  _CLIENT_OBJECTS = {}
23
23
 
@@ -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(