RemoteRF 2.5.2__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 (107) hide show
  1. {remoterf-2.5.2/src/remoterf.egg-info → remoterf-2.6.0}/PKG-INFO +20 -1
  2. {remoterf-2.5.2 → remoterf-2.6.0}/README.md +19 -0
  3. {remoterf-2.5.2 → remoterf-2.6.0}/pyproject.toml +1 -1
  4. remoterf-2.6.0/src/remoterf/__init__.py +22 -0
  5. remoterf-2.6.0/src/remoterf/core/availability.py +185 -0
  6. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/deployment/backend.py +13 -0
  7. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/deployment/http.py +6 -2
  8. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/drivers/adalm_pluto/adalm_pluto_remote.py +2 -2
  9. {remoterf-2.5.2 → remoterf-2.6.0/src/remoterf.egg-info}/PKG-INFO +20 -1
  10. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf.egg-info/SOURCES.txt +2 -0
  11. remoterf-2.6.0/tests/test_devices_api.py +228 -0
  12. remoterf-2.5.2/src/remoterf/drivers/support/__init__.py +0 -14
  13. {remoterf-2.5.2 → remoterf-2.6.0}/LICENSE +0 -0
  14. {remoterf-2.5.2 → remoterf-2.6.0}/setup.cfg +0 -0
  15. {remoterf-2.5.2 → remoterf-2.6.0}/setup.py +0 -0
  16. {remoterf-2.5.2 → remoterf-2.6.0}/src/_remoterf_alias.py +0 -0
  17. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/common/__init__.py +0 -0
  18. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/common/grpc/__init__.py +0 -0
  19. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/common/grpc/deployment_capabilities.proto +0 -0
  20. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/common/grpc/deployment_capabilities_pb2.py +0 -0
  21. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/common/grpc/deployment_capabilities_pb2_grpc.py +0 -0
  22. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/common/grpc/grpc.proto +0 -0
  23. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/common/grpc/grpc_pb2.py +0 -0
  24. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/common/grpc/grpc_pb2_grpc.py +0 -0
  25. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/common/grpc/v2_codec.py +0 -0
  26. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/common/tos_notice.txt +0 -0
  27. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/common/utils/__init__.py +0 -0
  28. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/common/utils/ansi_codes.py +0 -0
  29. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/common/utils/api_token.py +0 -0
  30. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/common/utils/banner.py +0 -0
  31. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/common/utils/list_string.py +0 -0
  32. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/common/utils/process_arg.py +0 -0
  33. {remoterf-2.5.2/src/remoterf → remoterf-2.6.0/src/remoterf/config}/__init__.py +0 -0
  34. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/config/cert_fetcher.py +0 -0
  35. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/config/config.py +0 -0
  36. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/core/__init__.py +0 -0
  37. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/core/acc_login.py +0 -0
  38. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/core/app.py +0 -0
  39. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/core/banner_note.txt +0 -0
  40. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/core/direct_path.py +0 -0
  41. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/core/dynamic_v2_transport.py +0 -0
  42. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/core/grpc_acc.py +0 -0
  43. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/core/grpc_client.py +0 -0
  44. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/core/secure_channel.py +0 -0
  45. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/core/v2_errors.py +0 -0
  46. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/deployment/__init__.py +0 -0
  47. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/deployment/direct.py +0 -0
  48. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/deployment/homes.py +0 -0
  49. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/deployment/state.py +0 -0
  50. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/drivers/__init__.py +0 -0
  51. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/drivers/_virtual.py +0 -0
  52. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/drivers/adalm_pluto/__init__.py +0 -0
  53. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/drivers/dynamic_device.py +0 -0
  54. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/drivers/dynamic_v2.py +0 -0
  55. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/drivers/hackrf/__init__.py +0 -0
  56. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/drivers/hackrf/hackrf_remote.py +0 -0
  57. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/drivers/rtl_sdr/__init__.py +0 -0
  58. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/drivers/rtl_sdr/rtl_sdr_remote.py +0 -0
  59. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/drivers/sim_sdr/__init__.py +0 -0
  60. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/drivers/sim_sdr/sim_sdr_remote.py +0 -0
  61. {remoterf-2.5.2/src/remoterf/config → remoterf-2.6.0/src/remoterf/drivers/support}/__init__.py +0 -0
  62. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/drivers/support/ti_mmwave.py +0 -0
  63. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/drivers/support/uhd.py +0 -0
  64. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/drivers/support/uhd_dsp.py +0 -0
  65. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/drivers/support/uhd_v2.py +0 -0
  66. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/drivers/ti_mmwave/__init__.py +0 -0
  67. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/drivers/ti_mmwave/ti_mmwave_remote.py +0 -0
  68. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/drivers/usrp/__init__.py +0 -0
  69. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/drivers/usrp/usrp_remote.py +0 -0
  70. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/drivers/usrp/usrp_remote.pyi +0 -0
  71. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/remoterf_cli.py +0 -0
  72. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf/version.py +0 -0
  73. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf.egg-info/dependency_links.txt +0 -0
  74. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf.egg-info/entry_points.txt +0 -0
  75. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf.egg-info/requires.txt +0 -0
  76. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf.egg-info/top_level.txt +0 -0
  77. {remoterf-2.5.2 → remoterf-2.6.0}/src/remoterf.py +0 -0
  78. {remoterf-2.5.2 → remoterf-2.6.0}/tests/test_banner.py +0 -0
  79. {remoterf-2.5.2 → remoterf-2.6.0}/tests/test_batch.py +0 -0
  80. {remoterf-2.5.2 → remoterf-2.6.0}/tests/test_bundled_drivers.py +0 -0
  81. {remoterf-2.5.2 → remoterf-2.6.0}/tests/test_cli_login_fresh.py +0 -0
  82. {remoterf-2.5.2 → remoterf-2.6.0}/tests/test_config_address.py +0 -0
  83. {remoterf-2.5.2 → remoterf-2.6.0}/tests/test_deployment_accounts.py +0 -0
  84. {remoterf-2.5.2 → remoterf-2.6.0}/tests/test_device_routes.py +0 -0
  85. {remoterf-2.5.2 → remoterf-2.6.0}/tests/test_direct_path_fixed.py +0 -0
  86. {remoterf-2.5.2 → remoterf-2.6.0}/tests/test_direct_path_global.py +0 -0
  87. {remoterf-2.5.2 → remoterf-2.6.0}/tests/test_direct_path_partner.py +0 -0
  88. {remoterf-2.5.2 → remoterf-2.6.0}/tests/test_direct_path_race.py +0 -0
  89. {remoterf-2.5.2 → remoterf-2.6.0}/tests/test_dynamic_device_codegen.py +0 -0
  90. {remoterf-2.5.2 → remoterf-2.6.0}/tests/test_dynamic_v2.py +0 -0
  91. {remoterf-2.5.2 → remoterf-2.6.0}/tests/test_federated_devices.py +0 -0
  92. {remoterf-2.5.2 → remoterf-2.6.0}/tests/test_fetch_idl_fallback.py +0 -0
  93. {remoterf-2.5.2 → remoterf-2.6.0}/tests/test_finish_prompt.py +0 -0
  94. {remoterf-2.5.2 → remoterf-2.6.0}/tests/test_gate_i_direct_path.py +0 -0
  95. {remoterf-2.5.2 → remoterf-2.6.0}/tests/test_grpc_client_target.py +0 -0
  96. {remoterf-2.5.2 → remoterf-2.6.0}/tests/test_homes.py +0 -0
  97. {remoterf-2.5.2 → remoterf-2.6.0}/tests/test_native_account_credentials.py +0 -0
  98. {remoterf-2.5.2 → remoterf-2.6.0}/tests/test_native_devices.py +0 -0
  99. {remoterf-2.5.2 → remoterf-2.6.0}/tests/test_old_import_name.py +0 -0
  100. {remoterf-2.5.2 → remoterf-2.6.0}/tests/test_partner_dynamic_v2.py +0 -0
  101. {remoterf-2.5.2 → remoterf-2.6.0}/tests/test_pluto_virtual.py +0 -0
  102. {remoterf-2.5.2 → remoterf-2.6.0}/tests/test_process_arg.py +0 -0
  103. {remoterf-2.5.2 → remoterf-2.6.0}/tests/test_relay_paging.py +0 -0
  104. {remoterf-2.5.2 → remoterf-2.6.0}/tests/test_remoterf_cli.py +0 -0
  105. {remoterf-2.5.2 → remoterf-2.6.0}/tests/test_rx_readahead.py +0 -0
  106. {remoterf-2.5.2 → remoterf-2.6.0}/tests/test_ti_mmwave.py +0 -0
  107. {remoterf-2.5.2 → 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.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
@@ -354,6 +354,25 @@ If converting a existing `non-remoterf` compatible script:
354
354
 
355
355
  Nothing else needs changing!
356
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
+
357
376
  ## Closing
358
377
 
359
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.
@@ -329,6 +329,25 @@ If converting a existing `non-remoterf` compatible script:
329
329
 
330
330
  Nothing else needs changing!
331
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
+
332
351
  ## Closing
333
352
 
334
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.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"
@@ -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
@@ -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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remoterf
3
- Version: 2.5.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
@@ -354,6 +354,25 @@ If converting a existing `non-remoterf` compatible script:
354
354
 
355
355
  Nothing else needs changing!
356
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
+
357
376
  ## Closing
358
377
 
359
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.
@@ -35,6 +35,7 @@ src/remoterf/config/config.py
35
35
  src/remoterf/core/__init__.py
36
36
  src/remoterf/core/acc_login.py
37
37
  src/remoterf/core/app.py
38
+ src/remoterf/core/availability.py
38
39
  src/remoterf/core/banner_note.txt
39
40
  src/remoterf/core/direct_path.py
40
41
  src/remoterf/core/dynamic_v2_transport.py
@@ -77,6 +78,7 @@ tests/test_cli_login_fresh.py
77
78
  tests/test_config_address.py
78
79
  tests/test_deployment_accounts.py
79
80
  tests/test_device_routes.py
81
+ tests/test_devices_api.py
80
82
  tests/test_direct_path_fixed.py
81
83
  tests/test_direct_path_global.py
82
84
  tests/test_direct_path_partner.py
@@ -0,0 +1,228 @@
1
+ """remoterf.devices(): handles + availability from the saved login's home."""
2
+
3
+ import json
4
+ import time
5
+ from types import SimpleNamespace
6
+
7
+ import pytest
8
+
9
+ import remoterf
10
+ from remoterf.common.utils import map_arg
11
+ from remoterf.core import app, availability, direct_path, grpc_client
12
+ from remoterf.deployment import state
13
+ from remoterf.deployment.http import AccountBackendError
14
+
15
+ NOW = int(time.time())
16
+
17
+
18
+ @pytest.fixture(autouse=True)
19
+ def fresh(monkeypatch):
20
+ monkeypatch.setattr(availability, "_cache", None)
21
+
22
+
23
+ def _lab(monkeypatch, replies, creds=("jdoe", "s3cret")):
24
+ calls = []
25
+ monkeypatch.setattr(state, "load_target", lambda: {"origin": None, "transport": "grpc", "deployment_id": None})
26
+ monkeypatch.setattr(grpc_client, "_active_home_name", lambda: "ucla")
27
+ monkeypatch.setattr(direct_path, "_credentials", lambda: creds)
28
+
29
+ def rpc(*, function_name, args):
30
+ calls.append(function_name)
31
+ return SimpleNamespace(results=replies[function_name])
32
+
33
+ monkeypatch.setattr(grpc_client, "rpc_client", rpc)
34
+ return calls
35
+
36
+
37
+ def _ok(devices, interval=10):
38
+ return {"UC": map_arg(json.dumps({"now": NOW, "interval": interval, "window": 21600, "devices": devices}))}
39
+
40
+
41
+ LAB_DEVICES = [
42
+ {"local_device_id": "1", "display_name": "Pluto 01", "device_type": "pluto", "online": True,
43
+ "handle": "Pluto_01", "state": "free", "until": None, "next": NOW + 3600},
44
+ {"local_device_id": "2", "display_name": "Pluto 02", "device_type": "pluto", "online": True,
45
+ "handle": "Pluto_02", "state": "busy", "until": NOW + 600, "next": None},
46
+ {"local_device_id": "3", "display_name": "bench", "device_type": None, "online": False,
47
+ "state": "mine", "until": NOW + 900, "next": None},
48
+ ]
49
+
50
+
51
+ def test_lab_table_and_rows(monkeypatch, capsys):
52
+ calls = _lab(monkeypatch, {"ACC:availability": _ok(LAB_DEVICES)})
53
+ rows = remoterf.devices()
54
+ assert calls == ["ACC:availability"]
55
+ assert [(r["handle"], r["state"], r["online"]) for r in rows] == [
56
+ ("Pluto_01@ucla", "free", True), ("Pluto_02@ucla", "busy", True), (None, "mine", False)]
57
+ assert rows[1]["until"].timestamp() == NOW + 600
58
+ out = capsys.readouterr().out
59
+ assert "DEVICE (token=)" in out and "Pluto_01@ucla" in out
60
+ assert "in use until" in out and "yours until" in out and "bench @ucla (no handle)" in out
61
+ assert availability._hhmm(rows[0]["next"]) in out
62
+
63
+
64
+ def test_hhmm_and_bad_epochs():
65
+ from datetime import datetime, timedelta
66
+
67
+ noon = datetime.now().replace(hour=12, minute=5)
68
+ assert availability._hhmm(noon) == "12:05"
69
+ assert availability._hhmm(noon + timedelta(days=1)) == (noon + timedelta(days=1)).strftime("%b %d 12:05")
70
+ assert availability._when(1_760_000_000_000) is None and availability._when(2**62) is None
71
+
72
+
73
+ def test_refusal_is_remembered_locally(monkeypatch, capsys):
74
+ calls = _lab(monkeypatch, {"ACC:availability": {"ace": map_arg("Too many availability requests; try again in 8 s."),
75
+ "retry_after": map_arg(8)}})
76
+ assert remoterf.devices() == [] and remoterf.devices() == []
77
+ assert calls == ["ACC:availability"]
78
+ out = capsys.readouterr().out
79
+ assert out.count("try again in") == 2
80
+
81
+
82
+ def test_stale_saved_login(monkeypatch, capsys):
83
+ _lab(monkeypatch, {"ACC:availability": {"ace": map_arg("Invalid login credentials.")}})
84
+ assert remoterf.devices() == []
85
+ assert "no longer valid" in capsys.readouterr().out
86
+
87
+
88
+ def test_cached_within_interval_and_server_retry_after(monkeypatch, capsys):
89
+ calls = _lab(monkeypatch, {"ACC:availability": _ok(LAB_DEVICES)})
90
+ first = remoterf.devices()
91
+ assert remoterf.devices() is first
92
+ assert calls == ["ACC:availability"]
93
+ assert "(cached; refresh in" in capsys.readouterr().out
94
+
95
+ # Cache expired locally but the server still refuses: show the cache and its wait.
96
+ availability._cache = (*availability._cache[:3], 0.0)
97
+ _lab(monkeypatch, {"ACC:availability": {"ace": map_arg("Too many availability requests; try again in 4 s."),
98
+ "retry_after": map_arg(4)}})
99
+ assert remoterf.devices() is first
100
+ assert "(cached; refresh in 4 s)" in capsys.readouterr().out
101
+
102
+ availability._cache = None
103
+ assert remoterf.devices() == []
104
+ assert "try again in 4 s" in capsys.readouterr().out
105
+
106
+
107
+ def test_old_lab_falls_back_to_get_dev(monkeypatch, capsys):
108
+ calls = _lab(monkeypatch, {
109
+ "ACC:availability": {"ace": map_arg("No matching server function to call: availability")},
110
+ "ACC:get_dev": {"3": map_arg("Pluto 01 (online)"), "4": map_arg("Remote @mit (online)")},
111
+ })
112
+ rows = remoterf.devices()
113
+ assert calls == ["ACC:availability", "ACC:get_dev"]
114
+ assert [(r["handle"], r["state"]) for r in rows] == [("Pluto_01@ucla", "unknown")]
115
+ assert "does not report availability" in capsys.readouterr().out
116
+
117
+
118
+ def test_lab_not_logged_in(monkeypatch, capsys):
119
+ calls = _lab(monkeypatch, {}, creds=None)
120
+ assert remoterf.devices() == []
121
+ assert calls == []
122
+ assert "Not logged in" in capsys.readouterr().out
123
+
124
+
125
+ class FakeBackend:
126
+ def __init__(self, error=None):
127
+ self.error = error
128
+
129
+ def availability(self):
130
+ if self.error:
131
+ raise self.error
132
+ return {"devices": [
133
+ {"device_id": "u1:1", "deployment_id": "u1", "deployment_name": "ucla", "local_device_id": "1",
134
+ "display_name": "Pluto 01", "device_type": "pluto", "online": True, "handle": "Pluto_01",
135
+ "state": "mine", "until": NOW + 60, "next": None},
136
+ {"device_id": "u2:5", "deployment_id": "u2", "deployment_name": "Some Lab", "local_device_id": "5",
137
+ "display_name": "Pluto 05", "device_type": "pluto", "online": True, "handle": "Pluto_05",
138
+ "state": "unknown", "until": None, "next": None},
139
+ ], "destinations": [], "interval": 10}
140
+
141
+ def devices(self):
142
+ return {"devices": [{"device_id": "u1:1", "deployment_id": "u1", "deployment_name": "ucla",
143
+ "local_device_id": "1", "display_name": "Pluto 01", "device_type": "pluto",
144
+ "online": False, "handle": "Pluto_01"}], "destinations": []}
145
+
146
+
147
+ def _global(monkeypatch, backend):
148
+ monkeypatch.setattr(state, "load_target", lambda: {"origin": "https://global.example.org",
149
+ "transport": "https-json", "deployment_id": "x"})
150
+ if isinstance(backend, Exception):
151
+ def fail():
152
+ raise backend
153
+ monkeypatch.setattr(grpc_client, "federated_backend", fail)
154
+ else:
155
+ monkeypatch.setattr(grpc_client, "federated_backend", lambda: backend)
156
+ monkeypatch.setattr(app, "_global_slug", lambda device_id, name: name if name == "ucla" else None)
157
+
158
+
159
+ def test_global_rows(monkeypatch, capsys):
160
+ _global(monkeypatch, FakeBackend())
161
+ rows = remoterf.devices()
162
+ assert [(r["handle"], r["state"], r["lab"]) for r in rows] == [
163
+ ("Pluto_01@ucla", "mine", "ucla"), (None, "unknown", "Some Lab")]
164
+ out = capsys.readouterr().out
165
+ assert "yours until" in out and "Pluto 05 @Some Lab (no handle)" in out and "incomplete" not in out
166
+
167
+
168
+ def test_old_global_falls_back_to_devices(monkeypatch, capsys):
169
+ _global(monkeypatch, FakeBackend(AccountBackendError("not_found")))
170
+ rows = remoterf.devices()
171
+ assert [(r["handle"], r["state"], r["online"]) for r in rows] == [("Pluto_01@ucla", "unknown", False)]
172
+ assert "does not report availability" in capsys.readouterr().out
173
+
174
+
175
+ def test_old_global_over_http_falls_back(monkeypatch, capsys):
176
+ import httpx
177
+ from remoterf_federation_core import local_capabilities
178
+
179
+ from remoterf.deployment.backend import HttpsJsonAccountBackend
180
+ from remoterf.deployment.http import JsonTransport
181
+
182
+ caps = local_capabilities(deployment_id="550e8400-e29b-41d4-a716-446655440000")
183
+ caps.update(account_transport="https-json", account_api_base="/v2/deployment")
184
+ listing = FakeBackend().devices()
185
+ listing["destinations"] = [{"status": "unavailable", "display_name": "Some Lab"}]
186
+ seen = []
187
+
188
+ def handle(request):
189
+ seen.append(request.url.path)
190
+ if request.url.path == "/.well-known/remoterf-deployment":
191
+ return httpx.Response(200, json=caps)
192
+ if request.url.path == "/v2/deployment/devices":
193
+ return httpx.Response(200, json=listing)
194
+ return httpx.Response(404, json={"detail": "Not Found"}) # FastAPI, no such route
195
+
196
+ transport = JsonTransport("https://global.example.org", client=httpx.Client(transport=httpx.MockTransport(handle)))
197
+ backend = HttpsJsonAccountBackend(transport.origin, transport=transport, store=object())
198
+ backend.credentials = {"access_token": "t", "access_expires_at": 2**31}
199
+ _global(monkeypatch, backend)
200
+ rows = remoterf.devices()
201
+ assert seen[1:] == ["/v2/deployment/availability", "/v2/deployment/devices"]
202
+ assert [(r["handle"], r["state"]) for r in rows] == [("Pluto_01@ucla", "unknown")]
203
+ out = capsys.readouterr().out
204
+ assert "does not report availability" in out and "may be incomplete" in out
205
+
206
+
207
+ def test_global_rate_limited_without_cache(monkeypatch, capsys):
208
+ _global(monkeypatch, FakeBackend(AccountBackendError("rate_limited", retry_after=7)))
209
+ assert remoterf.devices() == []
210
+ assert "try again in 7 s" in capsys.readouterr().out
211
+
212
+
213
+ def test_global_not_logged_in(monkeypatch, capsys):
214
+ _global(monkeypatch, RuntimeError("Log in to the account home first (run `remoterf`, then `login`)"))
215
+ assert remoterf.devices() == []
216
+ assert "Log in to the account home first" in capsys.readouterr().out
217
+
218
+
219
+ def test_http_error_carries_retry_after():
220
+ import httpx
221
+
222
+ from remoterf.deployment.http import JsonTransport
223
+
224
+ body = {"error": {"code": "rate_limited", "retry_after": 9}}
225
+ client = httpx.Client(transport=httpx.MockTransport(lambda request: httpx.Response(429, json=body)))
226
+ with pytest.raises(AccountBackendError) as raised:
227
+ JsonTransport("https://global.example.org", client=client).request("GET", "/v2/deployment/availability")
228
+ assert (raised.value.code, raised.value.retry_after) == ("rate_limited", 9)
@@ -1,14 +0,0 @@
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/>.
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes