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.
- {remoterf-2.5.1/src/remoterf.egg-info → remoterf-2.6.0}/PKG-INFO +28 -1
- {remoterf-2.5.1 → remoterf-2.6.0}/README.md +27 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/pyproject.toml +3 -2
- remoterf-2.6.0/setup.py +16 -0
- remoterf-2.6.0/src/_remoterf_alias.py +60 -0
- remoterf-2.6.0/src/remoterf/__init__.py +22 -0
- remoterf-2.6.0/src/remoterf/core/availability.py +185 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/core/direct_path.py +3 -1
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/core/dynamic_v2_transport.py +3 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/core/grpc_client.py +45 -6
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/deployment/backend.py +13 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/deployment/http.py +6 -2
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/drivers/adalm_pluto/adalm_pluto_remote.py +2 -2
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/drivers/dynamic_device.py +12 -2
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/drivers/rtl_sdr/rtl_sdr_remote.py +41 -9
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/drivers/ti_mmwave/ti_mmwave_remote.py +39 -7
- {remoterf-2.5.1 → remoterf-2.6.0/src/remoterf.egg-info}/PKG-INFO +28 -1
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf.egg-info/SOURCES.txt +7 -0
- remoterf-2.6.0/src/remoterf.egg-info/top_level.txt +2 -0
- remoterf-2.6.0/tests/test_bundled_drivers.py +57 -0
- remoterf-2.6.0/tests/test_devices_api.py +228 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/tests/test_direct_path_global.py +12 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/tests/test_fetch_idl_fallback.py +19 -0
- remoterf-2.6.0/tests/test_old_import_name.py +31 -0
- remoterf-2.6.0/tests/test_relay_paging.py +63 -0
- remoterf-2.5.1/src/remoterf/drivers/support/__init__.py +0 -14
- remoterf-2.5.1/src/remoterf.egg-info/top_level.txt +0 -1
- {remoterf-2.5.1 → remoterf-2.6.0}/LICENSE +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/setup.cfg +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/common/__init__.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/common/grpc/__init__.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/common/grpc/deployment_capabilities.proto +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/common/grpc/deployment_capabilities_pb2.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/common/grpc/deployment_capabilities_pb2_grpc.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/common/grpc/grpc.proto +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/common/grpc/grpc_pb2.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/common/grpc/grpc_pb2_grpc.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/common/grpc/v2_codec.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/common/tos_notice.txt +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/common/utils/__init__.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/common/utils/ansi_codes.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/common/utils/api_token.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/common/utils/banner.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/common/utils/list_string.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/common/utils/process_arg.py +0 -0
- {remoterf-2.5.1/src/remoterf → remoterf-2.6.0/src/remoterf/config}/__init__.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/config/cert_fetcher.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/config/config.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/core/__init__.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/core/acc_login.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/core/app.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/core/banner_note.txt +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/core/grpc_acc.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/core/secure_channel.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/core/v2_errors.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/deployment/__init__.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/deployment/direct.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/deployment/homes.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/deployment/state.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/drivers/__init__.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/drivers/_virtual.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/drivers/adalm_pluto/__init__.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/drivers/dynamic_v2.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/drivers/hackrf/__init__.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/drivers/hackrf/hackrf_remote.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/drivers/rtl_sdr/__init__.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/drivers/sim_sdr/__init__.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/drivers/sim_sdr/sim_sdr_remote.py +0 -0
- {remoterf-2.5.1/src/remoterf/config → remoterf-2.6.0/src/remoterf/drivers/support}/__init__.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/drivers/support/ti_mmwave.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/drivers/support/uhd.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/drivers/support/uhd_dsp.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/drivers/support/uhd_v2.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/drivers/ti_mmwave/__init__.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/drivers/usrp/__init__.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/drivers/usrp/usrp_remote.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/drivers/usrp/usrp_remote.pyi +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/remoterf_cli.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf/version.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf.egg-info/dependency_links.txt +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf.egg-info/entry_points.txt +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf.egg-info/requires.txt +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/src/remoterf.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/tests/test_banner.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/tests/test_batch.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/tests/test_cli_login_fresh.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/tests/test_config_address.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/tests/test_deployment_accounts.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/tests/test_device_routes.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/tests/test_direct_path_fixed.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/tests/test_direct_path_partner.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/tests/test_direct_path_race.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/tests/test_dynamic_device_codegen.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/tests/test_dynamic_v2.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/tests/test_federated_devices.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/tests/test_finish_prompt.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/tests/test_gate_i_direct_path.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/tests/test_grpc_client_target.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/tests/test_homes.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/tests/test_native_account_credentials.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/tests/test_native_devices.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/tests/test_partner_dynamic_v2.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/tests/test_pluto_virtual.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/tests/test_process_arg.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/tests/test_remoterf_cli.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/tests/test_rx_readahead.py +0 -0
- {remoterf-2.5.1 → remoterf-2.6.0}/tests/test_ti_mmwave.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
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"]
|
remoterf-2.6.0/setup.py
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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(
|