RemoteRF 2.3.1__tar.gz → 2.5.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 (101) hide show
  1. {remoterf-2.3.1/src/remoterf.egg-info → remoterf-2.5.0}/PKG-INFO +1 -1
  2. {remoterf-2.3.1 → remoterf-2.5.0}/pyproject.toml +1 -1
  3. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/common/utils/process_arg.py +8 -6
  4. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/core/app.py +21 -1
  5. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/core/direct_path.py +162 -25
  6. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/core/grpc_client.py +247 -23
  7. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/deployment/backend.py +82 -41
  8. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/deployment/http.py +35 -1
  9. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/drivers/adalm_pluto/adalm_pluto_remote.py +4 -0
  10. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/drivers/dynamic_device.py +23 -0
  11. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/drivers/hackrf/hackrf_remote.py +4 -0
  12. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/drivers/rtl_sdr/rtl_sdr_remote.py +4 -0
  13. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/drivers/sim_sdr/sim_sdr_remote.py +4 -0
  14. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/drivers/ti_mmwave/ti_mmwave_remote.py +4 -0
  15. {remoterf-2.3.1 → remoterf-2.5.0/src/remoterf.egg-info}/PKG-INFO +1 -1
  16. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf.egg-info/SOURCES.txt +3 -0
  17. remoterf-2.5.0/tests/test_batch.py +187 -0
  18. {remoterf-2.3.1 → remoterf-2.5.0}/tests/test_device_routes.py +32 -0
  19. remoterf-2.5.0/tests/test_direct_path_global.py +139 -0
  20. {remoterf-2.3.1 → remoterf-2.5.0}/tests/test_direct_path_partner.py +6 -5
  21. {remoterf-2.3.1 → remoterf-2.5.0}/tests/test_dynamic_device_codegen.py +24 -1
  22. remoterf-2.5.0/tests/test_federated_devices.py +289 -0
  23. remoterf-2.5.0/tests/test_fetch_idl_fallback.py +98 -0
  24. {remoterf-2.3.1 → remoterf-2.5.0}/tests/test_gate_i_direct_path.py +119 -7
  25. {remoterf-2.3.1 → remoterf-2.5.0}/tests/test_process_arg.py +9 -0
  26. remoterf-2.5.0/tests/test_rx_readahead.py +158 -0
  27. remoterf-2.3.1/tests/test_federated_devices.py +0 -115
  28. remoterf-2.3.1/tests/test_fetch_idl_fallback.py +0 -45
  29. {remoterf-2.3.1 → remoterf-2.5.0}/LICENSE +0 -0
  30. {remoterf-2.3.1 → remoterf-2.5.0}/README.md +0 -0
  31. {remoterf-2.3.1 → remoterf-2.5.0}/setup.cfg +0 -0
  32. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/__init__.py +0 -0
  33. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/common/__init__.py +0 -0
  34. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/common/grpc/__init__.py +0 -0
  35. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/common/grpc/deployment_capabilities.proto +0 -0
  36. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/common/grpc/deployment_capabilities_pb2.py +0 -0
  37. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/common/grpc/deployment_capabilities_pb2_grpc.py +0 -0
  38. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/common/grpc/grpc.proto +0 -0
  39. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/common/grpc/grpc_pb2.py +0 -0
  40. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/common/grpc/grpc_pb2_grpc.py +0 -0
  41. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/common/grpc/v2_codec.py +0 -0
  42. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/common/tos_notice.txt +0 -0
  43. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/common/utils/__init__.py +0 -0
  44. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/common/utils/ansi_codes.py +0 -0
  45. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/common/utils/api_token.py +0 -0
  46. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/common/utils/banner.py +0 -0
  47. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/common/utils/list_string.py +0 -0
  48. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/config/__init__.py +0 -0
  49. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/config/cert_fetcher.py +0 -0
  50. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/config/config.py +0 -0
  51. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/core/__init__.py +0 -0
  52. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/core/acc_login.py +0 -0
  53. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/core/banner_note.txt +0 -0
  54. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/core/dynamic_v2_transport.py +0 -0
  55. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/core/grpc_acc.py +0 -0
  56. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/core/secure_channel.py +0 -0
  57. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/core/v2_errors.py +0 -0
  58. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/deployment/__init__.py +0 -0
  59. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/deployment/direct.py +0 -0
  60. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/deployment/homes.py +0 -0
  61. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/deployment/state.py +0 -0
  62. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/drivers/__init__.py +0 -0
  63. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/drivers/_virtual.py +0 -0
  64. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/drivers/adalm_pluto/__init__.py +0 -0
  65. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/drivers/dynamic_v2.py +0 -0
  66. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/drivers/hackrf/__init__.py +0 -0
  67. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/drivers/rtl_sdr/__init__.py +0 -0
  68. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/drivers/sim_sdr/__init__.py +0 -0
  69. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/drivers/support/__init__.py +0 -0
  70. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/drivers/support/ti_mmwave.py +0 -0
  71. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/drivers/support/uhd.py +0 -0
  72. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/drivers/support/uhd_dsp.py +0 -0
  73. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/drivers/support/uhd_v2.py +0 -0
  74. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/drivers/ti_mmwave/__init__.py +0 -0
  75. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/drivers/usrp/__init__.py +0 -0
  76. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/drivers/usrp/usrp_remote.py +0 -0
  77. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/drivers/usrp/usrp_remote.pyi +0 -0
  78. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/remoterf_cli.py +0 -0
  79. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/version.py +0 -0
  80. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf.egg-info/dependency_links.txt +0 -0
  81. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf.egg-info/entry_points.txt +0 -0
  82. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf.egg-info/requires.txt +0 -0
  83. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf.egg-info/top_level.txt +0 -0
  84. {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf.py +0 -0
  85. {remoterf-2.3.1 → remoterf-2.5.0}/tests/test_banner.py +0 -0
  86. {remoterf-2.3.1 → remoterf-2.5.0}/tests/test_cli_login_fresh.py +0 -0
  87. {remoterf-2.3.1 → remoterf-2.5.0}/tests/test_config_address.py +0 -0
  88. {remoterf-2.3.1 → remoterf-2.5.0}/tests/test_deployment_accounts.py +0 -0
  89. {remoterf-2.3.1 → remoterf-2.5.0}/tests/test_direct_path_fixed.py +0 -0
  90. {remoterf-2.3.1 → remoterf-2.5.0}/tests/test_direct_path_race.py +0 -0
  91. {remoterf-2.3.1 → remoterf-2.5.0}/tests/test_dynamic_v2.py +0 -0
  92. {remoterf-2.3.1 → remoterf-2.5.0}/tests/test_finish_prompt.py +0 -0
  93. {remoterf-2.3.1 → remoterf-2.5.0}/tests/test_grpc_client_target.py +0 -0
  94. {remoterf-2.3.1 → remoterf-2.5.0}/tests/test_homes.py +0 -0
  95. {remoterf-2.3.1 → remoterf-2.5.0}/tests/test_native_account_credentials.py +0 -0
  96. {remoterf-2.3.1 → remoterf-2.5.0}/tests/test_native_devices.py +0 -0
  97. {remoterf-2.3.1 → remoterf-2.5.0}/tests/test_partner_dynamic_v2.py +0 -0
  98. {remoterf-2.3.1 → remoterf-2.5.0}/tests/test_pluto_virtual.py +0 -0
  99. {remoterf-2.3.1 → remoterf-2.5.0}/tests/test_remoterf_cli.py +0 -0
  100. {remoterf-2.3.1 → remoterf-2.5.0}/tests/test_ti_mmwave.py +0 -0
  101. {remoterf-2.3.1 → remoterf-2.5.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.1
3
+ Version: 2.5.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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "remoterf"
7
- version = "2.3.1"
7
+ version = "2.5.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"
@@ -98,7 +98,7 @@ def _json_safe(value: Any):
98
98
  return _json_safe(value.as_payload())
99
99
  if isinstance(value, np.ndarray):
100
100
  if value.dtype == object:
101
- raise ValueError(f"Cannot JSON-map object-dtype array: {value!r}")
101
+ return _json_safe(value.tolist())
102
102
  array = np.asarray(value)
103
103
  if np.iscomplexobj(array):
104
104
  data = [[float(num.real), float(num.imag)] for num in array.ravel()]
@@ -206,13 +206,15 @@ def map_arg(value: Any) -> grpc_pb2.Argument:
206
206
  try:
207
207
  array = np.stack(value)
208
208
  except Exception:
209
- arg.json_value = json.dumps(_json_safe(value), separators=(",", ":"))
210
- return arg
209
+ pass
210
+ if array.dtype == object:
211
+ arg.json_value = json.dumps(_json_safe(value), separators=(",", ":"))
212
+ return arg
211
213
  return map_arg(array)
212
214
  elif isinstance(value, np.ndarray):
213
- if value.dtype == object:
214
- raise ValueError(f"Cannot map object-dtype array: {value!r}")
215
- if value.dtype.kind not in _SUPPORTED_NDARRAY_KINDS:
215
+ # Object arrays (a list of dicts, say) and non-numeric dtypes go as
216
+ # JSON, which the client decodes back to lists.
217
+ if value.dtype == object or value.dtype.kind not in _SUPPORTED_NDARRAY_KINDS:
216
218
  arg.json_value = json.dumps(
217
219
  _json_safe(value.tolist()), separators=(",", ":")
218
220
  )
@@ -177,11 +177,31 @@ def _print_reserved(where, when, token, handle, logged_in_to, install, no_handle
177
177
  printf(" Works anywhere, no login needed. Keep it safe: it can't be shown again.", Sty.DEFAULT)
178
178
  printf(f" ({no_handle}. Lost the token? Cancel and reserve again.)", Sty.DEFAULT)
179
179
  printf("", Sty.DEFAULT)
180
- call = DRIVER_CALLS.get(install())
180
+ device_type = install()
181
+ call = DRIVER_CALLS.get(device_type)
181
182
  if call:
182
183
  printf("Use either one as the token:" if handle else "Use it as the token:", Sty.DEFAULT)
183
184
  for text in (token, handle) if handle else (token,):
184
185
  printf(f' sdr = {call}(token="{text}")', Sty.CYAN)
186
+ elif device_type and (cls := _client_class(device_type)):
187
+ # A lab's own driver (bluetooth, gps, ...): its class, from its IDL.
188
+ printf("Use either one as the token:" if handle else "Use it as the token:", Sty.DEFAULT)
189
+ printf(" from remoterf.drivers import ensure_driver", Sty.CYAN)
190
+ printf(f' ensure_driver(token="{token}") # installs it on any other machine', Sty.CYAN)
191
+ printf(f" from remoterf.drivers.{device_type} import {cls}", Sty.CYAN)
192
+ for text in (token, handle) if handle else (token,):
193
+ printf(f' dev = {cls}(token="{text}")', Sty.CYAN)
194
+
195
+
196
+ def _client_class(device_type):
197
+ """The client class a generated driver package exports (beside its `adi` alias)."""
198
+ from importlib import import_module
199
+
200
+ try:
201
+ names = import_module(f"remoterf.drivers.{device_type}").__all__
202
+ except Exception: # noqa: BLE001 - no usage lines is all a bad package costs
203
+ return None
204
+ return next((n for n in names if n != "adi"), None)
185
205
 
186
206
 
187
207
  def _print_native_reserved(device_id, label, when, token):
@@ -19,9 +19,11 @@ On a home reached over its relay, ICE (aioice) punches one UDP pair to the
19
19
  Server and QUIC (aioquic) runs over it; device RPCs then travel as one stream
20
20
  per call (4-byte big-endian length + GenericRPCRequest, reply likewise)
21
21
  instead of through the relay. Control RPCs (ACC:*) stay on gRPC. When the
22
- punch fails or the path dies, calls fall back to gRPC and the path is retried
23
- every 30 s. Signaling is ACC:direct_offer over the channel the client already
24
- has. See RemoteRF-Server docs/gate-i-direct-path.md.
22
+ punch fails or the path dies, calls fall back to gRPC; a path that died is
23
+ retried after 30 s, a failed attempt only once a device call wants it.
24
+ Signaling is ACC:direct_offer over the channel the client already has. See
25
+ RemoteRF-Server docs/gate-i-direct-path.md. A Global device ref or rfg_
26
+ token signals through Global instead (global_path).
25
27
 
26
28
  A partner lab's device (one the home lists on a federation agreement) gets a
27
29
  path of its own, punched to that lab: the offer goes to the home with the
@@ -42,6 +44,7 @@ import asyncio
42
44
  import atexit
43
45
  import collections
44
46
  import contextlib
47
+ import hashlib
45
48
  import json
46
49
  import os
47
50
  import secrets
@@ -74,6 +77,8 @@ FIXED_SECONDS = 2.0 # a reachable fixed port answers in one round trip; a filte
74
77
  SETTLE_SECONDS = 20.0 # gathering, the offer RPC and both budgets: one attempt, worst case
75
78
  WAIT_SECONDS = 3.0 # how long a login or a first device call waits for that attempt
76
79
  RETRY_SECONDS = 30.0
80
+ MEMO_SECONDS = 600 # a route that failed here is not signaled again for this long, by any process
81
+ MEMO_FILE = "direct-v1.json"
77
82
  IDLE_SECONDS = 600
78
83
  SLEEP_CHECK_SECONDS = 5.0 # how often the path looks for a system sleep it slept through
79
84
  async def close_ice(ice) -> None:
@@ -202,6 +207,53 @@ class PathError(Exception):
202
207
  """The direct path could not carry this call and is down now."""
203
208
 
204
209
 
210
+ class PathUnavailable(PathError):
211
+ """This route can never carry a direct path: stop trying."""
212
+
213
+
214
+ # ---- what the last attempt per (route, network) came to, across processes ----
215
+ #
216
+ # Keys are hashes: no ref, token or address is stored in clear.
217
+
218
+ def _memo_key(scope: str) -> str:
219
+ return hashlib.sha256(f"{scope}|{default_route_address()}".encode()).hexdigest()[:32]
220
+
221
+
222
+ def _memo_entries() -> dict:
223
+ from ..deployment.state import root
224
+
225
+ memo = json.loads((root() / MEMO_FILE).read_text())
226
+ now = time.time()
227
+ return {key: [str(value[0]), int(value[1])] for key, value in memo.items() if now - value[1] < 86400}
228
+
229
+
230
+ def _memo_outcome(scope: Optional[str]) -> Optional[tuple[str, int]]:
231
+ """("ready" | "failed" | "unavailable", when) for ``scope`` on this network, if known."""
232
+ if scope is None:
233
+ return None
234
+ try:
235
+ outcome, at = _memo_entries()[_memo_key(scope)]
236
+ return outcome, at
237
+ except Exception: # noqa: BLE001 - ponytail: a lost memo costs one extra attempt
238
+ return None
239
+
240
+
241
+ def _remember(scope: Optional[str], outcome: str) -> None:
242
+ if scope is None:
243
+ return
244
+ try:
245
+ from ..deployment.state import private_write, root
246
+
247
+ try:
248
+ memo = _memo_entries()
249
+ except Exception: # noqa: BLE001 - none yet, or unreadable: start over
250
+ memo = {}
251
+ memo[_memo_key(scope)] = [outcome, int(time.time())]
252
+ private_write(root() / MEMO_FILE, memo)
253
+ except Exception: # noqa: BLE001 - ponytail: a lost memo costs one extra attempt
254
+ pass
255
+
256
+
205
257
  def _why(exc: BaseException) -> str:
206
258
  return "timed out" if isinstance(exc, asyncio.TimeoutError) else f"{type(exc).__name__}: {exc}"
207
259
 
@@ -257,14 +309,18 @@ class DirectPath:
257
309
  ``offer`` sends the ICE offer dict and returns the answer dict (it runs
258
310
  the ACC:direct_offer RPC, blocking). ``server_name`` is the hostname the
259
311
  Server's certificate must carry; ``ca_pem`` the CA pinned for this home.
312
+ ``memo`` names the route in the cross-process record of how the last
313
+ attempt on this network went (None: no record, and calls always wait).
260
314
  """
261
315
 
262
316
  def __init__(self, *, offer: Callable[[dict], dict], server_name: str, ca_pem: bytes,
263
- stun: Optional[tuple[str, int]] = STUN):
317
+ stun: Optional[tuple[str, int]] = STUN, memo: Optional[str] = None):
264
318
  self._offer = offer
265
319
  self._server_name = server_name
266
320
  self._ca_pem = ca_pem
267
321
  self._stun = stun
322
+ self._memo = memo
323
+ self._answered = False # this attempt's offer was answered: a failure now is the network's
268
324
  self._id = secrets.token_hex(8) # tells this process's path from the session's others
269
325
  self._loop = asyncio.new_event_loop()
270
326
  quiet_aioice_errors(self._loop)
@@ -300,11 +356,20 @@ class DirectPath:
300
356
  """Block until the first attempt has settled, or ``timeout``; True when ready.
301
357
 
302
358
  A punch that works takes well under a second; one that cannot work
303
- spends the whole ICE budget, and nobody should wait for that.
359
+ spends the whole ICE budget, and nobody should wait for that. Nor
360
+ for a path that has not worked on this network before.
304
361
  """
305
- self._settled.wait(timeout)
362
+ if self._worth_waiting():
363
+ self._settled.wait(timeout)
306
364
  return self.ready
307
365
 
366
+ def _worth_waiting(self) -> bool:
367
+ """Only where the last attempt on this network reached ready."""
368
+ if self._memo is None:
369
+ return True
370
+ outcome = _memo_outcome(self._memo)
371
+ return outcome is not None and outcome[0] == "ready"
372
+
308
373
  @property
309
374
  def ready(self) -> bool:
310
375
  return self.state == "ready"
@@ -342,6 +407,8 @@ class DirectPath:
342
407
  """A device call wants the idle path back; with ``wait`` it gives the
343
408
  re-punch that long before settling for the relay."""
344
409
  self._loop.call_soon_threadsafe(lambda: self._wanted and self._wanted.set())
410
+ if wait and self.state in ("idle", "connecting") and not self._worth_waiting():
411
+ wait = 0.0
345
412
  deadline = time.monotonic() + wait
346
413
  while wait and not self.ready and self.state in ("idle", "connecting") and time.monotonic() < deadline:
347
414
  time.sleep(0.05)
@@ -368,16 +435,35 @@ class DirectPath:
368
435
  try:
369
436
  while not self._stopping:
370
437
  self._dead = asyncio.Event()
438
+ memo = _memo_outcome(self._memo)
439
+ remaining = MEMO_SECONDS - (time.time() - memo[1]) if memo and memo[0] != "ready" else 0
440
+ if remaining > 0:
441
+ # At most one signaling attempt per MEMO_SECONDS on a network
442
+ # that cannot carry the path, whichever process makes it.
443
+ self.state, self.reason = "down", f"the last attempt on this network: {memo[0]}"
444
+ self._settled.set()
445
+ await self._on_demand(remaining)
446
+ continue
371
447
  try:
372
448
  await asyncio.wait_for(self._establish(), SETTLE_SECONDS)
373
449
  except Exception as exc: # noqa: BLE001 - a failed punch is expected on some networks
374
450
  self.reason = str(exc) if isinstance(exc, PathError) else _why(exc)
375
- self.state = "down"
376
- if "No matching server function" in self.reason:
451
+ if isinstance(exc, PathUnavailable) or "No matching server function" in self.reason:
377
452
  self.state = "unavailable"
378
- self.reason = "this Server has no direct path"
453
+ if not isinstance(exc, PathUnavailable):
454
+ self.reason = "this Server has no direct path"
455
+ _remember(self._memo, "unavailable")
379
456
  return
457
+ if self._answered:
458
+ _remember(self._memo, "failed")
459
+ self.state = "down"
460
+ self._settled.set()
461
+ await self._teardown()
462
+ # No polling: the next attempt waits for a device call to want it.
463
+ await self._on_demand(RETRY_SECONDS)
464
+ continue
380
465
  else:
466
+ _remember(self._memo, "ready")
381
467
  self.state = "ready"
382
468
  self._settled.set()
383
469
  await self._dead.wait()
@@ -398,14 +484,19 @@ class DirectPath:
398
484
  self._settled.set()
399
485
  await self._teardown()
400
486
 
487
+ async def _on_demand(self, delay: float) -> None:
488
+ """Until a device call wants the path, and no sooner than ``delay``."""
489
+ self._wanted = asyncio.Event()
490
+ await asyncio.sleep(delay)
491
+ await self._wanted.wait()
492
+
401
493
  async def _establish(self) -> None:
402
494
  loop = asyncio.get_running_loop()
495
+ self._answered = False
403
496
  ice = self._ice = Ice(ice_controlling=True, stun_server=self._stun)
497
+ # A silent STUN server still leaves the host candidate: a lab on this
498
+ # machine or LAN, or its fixed port, needs nothing more.
404
499
  await ice.gather_candidates()
405
- if self._stun and not any(c.type == "srflx" for c in ice.local_candidates):
406
- # Without a reflexive address there is nothing to punch with, and a
407
- # network that drops STUN drops the checks too: skip the 5 s of them.
408
- raise PathError("no STUN answer (UDP blocked?)")
409
500
  offer = {
410
501
  "id": self._id,
411
502
  "ufrag": ice.local_username,
@@ -417,6 +508,7 @@ class DirectPath:
417
508
  "race": True,
418
509
  }
419
510
  answer = await loop.run_in_executor(None, self._offer, offer)
511
+ self._answered = True
420
512
 
421
513
  # A partner lab's answer names the lab whose certificate to pin.
422
514
  server_name = str(answer.get("server_name") or self._server_name)
@@ -633,7 +725,7 @@ def start(*, username: str, secret: str, route: dict) -> Optional[DirectPath]:
633
725
 
634
726
  _path = DirectPath(
635
727
  offer=_offer_rpc(username, secret), server_name=route["host"],
636
- ca_pem=_current_profile().ca_path.read_bytes(),
728
+ ca_pem=_current_profile().ca_path.read_bytes(), memo=route["host"],
637
729
  )
638
730
  _path.start()
639
731
  return _path
@@ -675,7 +767,7 @@ def _from_stored_login() -> Optional[DirectPath]:
675
767
  return None
676
768
  return DirectPath(
677
769
  offer=_offer_rpc(login["username"], login["secret"]), server_name=route["host"],
678
- ca_pem=profile.ca_path.read_bytes(),
770
+ ca_pem=profile.ca_path.read_bytes(), memo=route["host"],
679
771
  )
680
772
 
681
773
 
@@ -698,25 +790,70 @@ def partner_of(token: Optional[str]) -> Optional[str]:
698
790
  return _partner_owners.get(token) if token else None
699
791
 
700
792
 
701
- def _partner(token: str) -> Optional[DirectPath]:
702
- """The path to the lab owning the device behind ``token``, punched on first use."""
793
+ def _side_path(key: str, make: Callable[[], Optional[DirectPath]]) -> Optional[DirectPath]:
794
+ """The path kept for ``key`` (``make`` builds it on first use; None: no
795
+ path), when it is ready for this call."""
703
796
  with _lock:
704
- path = _partners.get(token)
797
+ path = _partners.get(key)
705
798
  if path is None:
706
- credentials = _credentials()
707
- if credentials is None:
799
+ path = make()
800
+ if path is None:
708
801
  return None
709
- path = _partners[token] = DirectPath(
710
- offer=_offer_rpc(*credentials, token=token), server_name="", ca_pem=b"",
711
- )
802
+ _partners[key] = path
712
803
  path.start()
713
- if path.state == "idle":
804
+ if path.state in ("idle", "down"):
714
805
  path.wake(WAIT_SECONDS) # a punch takes well under a second; a 2 MB buffer will not fit the relay
715
806
  elif path.state == "connecting":
716
807
  path.wait() # the first call waits briefly, as a script's first capture does
717
808
  return path if path.ready else None
718
809
 
719
810
 
811
+ def _partner(token: str) -> Optional[DirectPath]:
812
+ """The path to the lab owning the device behind ``token``, punched on first use."""
813
+ def make() -> Optional[DirectPath]:
814
+ credentials = _credentials()
815
+ if credentials is None:
816
+ return None
817
+ return DirectPath(
818
+ offer=_offer_rpc(*credentials, token=token), server_name="", ca_pem=b"",
819
+ memo=_partner_owners.get(token),
820
+ )
821
+
822
+ return _side_path(token, make)
823
+
824
+
825
+ def _global_offer(ref: str, deployment_id: Optional[str]) -> Callable[[dict], dict]:
826
+ """The offer through Global: an ``rfg_`` token is its own credential, a ref takes the login."""
827
+ def send(offer: dict) -> dict:
828
+ from ..deployment.http import AccountBackendError
829
+ from . import grpc_client
830
+
831
+ backend = grpc_client._token_backend() if ref.startswith("rfg_") else grpc_client.federated_backend()
832
+ try:
833
+ answer = json.loads(backend.device_direct(ref, deployment_id, json.dumps(offer)))
834
+ except AccountBackendError as exc:
835
+ # not_found: a Global without the route; the others: a lab without it.
836
+ if exc.code in {"not_found", "action_unavailable", "direct_path_unavailable"}:
837
+ raise PathUnavailable(f"no direct path: {exc.code}") from None
838
+ raise PathError(str(exc)) from None
839
+ if not (answer.get("server_name") and answer.get("ca_pem")):
840
+ raise PathUnavailable("the lab's Server predates direct paths for Global users")
841
+ return answer
842
+
843
+ return send
844
+
845
+
846
+ def global_path(ref: str, deployment_id: Optional[str] = None) -> Optional[DirectPath]:
847
+ """The path to the lab behind a Global device ref or ``rfg_`` token,
848
+ signaled through Global, when it is ready for this call."""
849
+ if not enabled():
850
+ return None
851
+ return _side_path(ref, lambda: DirectPath(
852
+ offer=_global_offer(ref, deployment_id), server_name="", ca_pem=b"",
853
+ memo=deployment_id or ref.rsplit(":", 1)[0],
854
+ ))
855
+
856
+
720
857
  def current(token: Optional[str] = None) -> Optional[DirectPath]:
721
858
  """The path a call with ``token`` should take when it is ready, else None.
722
859
 
@@ -742,7 +879,7 @@ def current(token: Optional[str] = None) -> Optional[DirectPath]:
742
879
  _path.start()
743
880
  _path.wait()
744
881
  path = _path
745
- if path is not None and path.state == "idle":
882
+ if path is not None and path.state in ("idle", "down"):
746
883
  path.wake(WAIT_SECONDS)
747
884
  return path if path is not None and path.ready else None
748
885