RemoteRF 2.3.1__tar.gz → 2.4.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 (98) hide show
  1. {remoterf-2.3.1/src/remoterf.egg-info → remoterf-2.4.0}/PKG-INFO +1 -1
  2. {remoterf-2.3.1 → remoterf-2.4.0}/pyproject.toml +1 -1
  3. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/core/direct_path.py +162 -25
  4. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/core/grpc_client.py +55 -9
  5. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/deployment/backend.py +50 -25
  6. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/deployment/http.py +3 -0
  7. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/drivers/dynamic_device.py +16 -0
  8. {remoterf-2.3.1 → remoterf-2.4.0/src/remoterf.egg-info}/PKG-INFO +1 -1
  9. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf.egg-info/SOURCES.txt +1 -0
  10. {remoterf-2.3.1 → remoterf-2.4.0}/tests/test_device_routes.py +1 -0
  11. remoterf-2.4.0/tests/test_direct_path_global.py +139 -0
  12. {remoterf-2.3.1 → remoterf-2.4.0}/tests/test_direct_path_partner.py +6 -5
  13. {remoterf-2.3.1 → remoterf-2.4.0}/tests/test_dynamic_device_codegen.py +2 -1
  14. {remoterf-2.3.1 → remoterf-2.4.0}/tests/test_federated_devices.py +60 -0
  15. remoterf-2.4.0/tests/test_fetch_idl_fallback.py +98 -0
  16. {remoterf-2.3.1 → remoterf-2.4.0}/tests/test_gate_i_direct_path.py +119 -7
  17. remoterf-2.3.1/tests/test_fetch_idl_fallback.py +0 -45
  18. {remoterf-2.3.1 → remoterf-2.4.0}/LICENSE +0 -0
  19. {remoterf-2.3.1 → remoterf-2.4.0}/README.md +0 -0
  20. {remoterf-2.3.1 → remoterf-2.4.0}/setup.cfg +0 -0
  21. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/__init__.py +0 -0
  22. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/common/__init__.py +0 -0
  23. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/common/grpc/__init__.py +0 -0
  24. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/common/grpc/deployment_capabilities.proto +0 -0
  25. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/common/grpc/deployment_capabilities_pb2.py +0 -0
  26. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/common/grpc/deployment_capabilities_pb2_grpc.py +0 -0
  27. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/common/grpc/grpc.proto +0 -0
  28. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/common/grpc/grpc_pb2.py +0 -0
  29. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/common/grpc/grpc_pb2_grpc.py +0 -0
  30. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/common/grpc/v2_codec.py +0 -0
  31. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/common/tos_notice.txt +0 -0
  32. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/common/utils/__init__.py +0 -0
  33. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/common/utils/ansi_codes.py +0 -0
  34. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/common/utils/api_token.py +0 -0
  35. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/common/utils/banner.py +0 -0
  36. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/common/utils/list_string.py +0 -0
  37. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/common/utils/process_arg.py +0 -0
  38. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/config/__init__.py +0 -0
  39. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/config/cert_fetcher.py +0 -0
  40. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/config/config.py +0 -0
  41. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/core/__init__.py +0 -0
  42. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/core/acc_login.py +0 -0
  43. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/core/app.py +0 -0
  44. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/core/banner_note.txt +0 -0
  45. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/core/dynamic_v2_transport.py +0 -0
  46. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/core/grpc_acc.py +0 -0
  47. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/core/secure_channel.py +0 -0
  48. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/core/v2_errors.py +0 -0
  49. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/deployment/__init__.py +0 -0
  50. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/deployment/direct.py +0 -0
  51. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/deployment/homes.py +0 -0
  52. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/deployment/state.py +0 -0
  53. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/drivers/__init__.py +0 -0
  54. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/drivers/_virtual.py +0 -0
  55. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/drivers/adalm_pluto/__init__.py +0 -0
  56. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/drivers/adalm_pluto/adalm_pluto_remote.py +0 -0
  57. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/drivers/dynamic_v2.py +0 -0
  58. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/drivers/hackrf/__init__.py +0 -0
  59. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/drivers/hackrf/hackrf_remote.py +0 -0
  60. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/drivers/rtl_sdr/__init__.py +0 -0
  61. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/drivers/rtl_sdr/rtl_sdr_remote.py +0 -0
  62. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/drivers/sim_sdr/__init__.py +0 -0
  63. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/drivers/sim_sdr/sim_sdr_remote.py +0 -0
  64. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/drivers/support/__init__.py +0 -0
  65. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/drivers/support/ti_mmwave.py +0 -0
  66. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/drivers/support/uhd.py +0 -0
  67. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/drivers/support/uhd_dsp.py +0 -0
  68. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/drivers/support/uhd_v2.py +0 -0
  69. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/drivers/ti_mmwave/__init__.py +0 -0
  70. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/drivers/ti_mmwave/ti_mmwave_remote.py +0 -0
  71. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/drivers/usrp/__init__.py +0 -0
  72. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/drivers/usrp/usrp_remote.py +0 -0
  73. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/drivers/usrp/usrp_remote.pyi +0 -0
  74. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/remoterf_cli.py +0 -0
  75. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/version.py +0 -0
  76. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf.egg-info/dependency_links.txt +0 -0
  77. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf.egg-info/entry_points.txt +0 -0
  78. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf.egg-info/requires.txt +0 -0
  79. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf.egg-info/top_level.txt +0 -0
  80. {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf.py +0 -0
  81. {remoterf-2.3.1 → remoterf-2.4.0}/tests/test_banner.py +0 -0
  82. {remoterf-2.3.1 → remoterf-2.4.0}/tests/test_cli_login_fresh.py +0 -0
  83. {remoterf-2.3.1 → remoterf-2.4.0}/tests/test_config_address.py +0 -0
  84. {remoterf-2.3.1 → remoterf-2.4.0}/tests/test_deployment_accounts.py +0 -0
  85. {remoterf-2.3.1 → remoterf-2.4.0}/tests/test_direct_path_fixed.py +0 -0
  86. {remoterf-2.3.1 → remoterf-2.4.0}/tests/test_direct_path_race.py +0 -0
  87. {remoterf-2.3.1 → remoterf-2.4.0}/tests/test_dynamic_v2.py +0 -0
  88. {remoterf-2.3.1 → remoterf-2.4.0}/tests/test_finish_prompt.py +0 -0
  89. {remoterf-2.3.1 → remoterf-2.4.0}/tests/test_grpc_client_target.py +0 -0
  90. {remoterf-2.3.1 → remoterf-2.4.0}/tests/test_homes.py +0 -0
  91. {remoterf-2.3.1 → remoterf-2.4.0}/tests/test_native_account_credentials.py +0 -0
  92. {remoterf-2.3.1 → remoterf-2.4.0}/tests/test_native_devices.py +0 -0
  93. {remoterf-2.3.1 → remoterf-2.4.0}/tests/test_partner_dynamic_v2.py +0 -0
  94. {remoterf-2.3.1 → remoterf-2.4.0}/tests/test_pluto_virtual.py +0 -0
  95. {remoterf-2.3.1 → remoterf-2.4.0}/tests/test_process_arg.py +0 -0
  96. {remoterf-2.3.1 → remoterf-2.4.0}/tests/test_remoterf_cli.py +0 -0
  97. {remoterf-2.3.1 → remoterf-2.4.0}/tests/test_ti_mmwave.py +0 -0
  98. {remoterf-2.3.1 → remoterf-2.4.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.4.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.4.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"
@@ -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
 
@@ -25,6 +25,7 @@ import grpc
25
25
  from ..common.grpc import grpc_pb2
26
26
  from ..common.grpc import grpc_pb2_grpc
27
27
  from ..common.utils import *
28
+ from ..common.utils.process_arg import IQ16, fits_iq16, map_iq16
28
29
  from .secure_channel import build_secure_channel
29
30
  from . import direct_path
30
31
  from ..deployment.direct import ConnectionProfile, DirectConnectionProfile, resolve_active_profile
@@ -171,6 +172,9 @@ def get_tcp_calls():
171
172
  # GenericRPCResponse, so generated drivers do not know the difference.
172
173
 
173
174
  _federated_backend = None
175
+ # A direct-path thread may ask at the same moment: two backends would each
176
+ # refresh the stored session, and Global revokes a replayed refresh token.
177
+ _federated_backend_lock = threading.Lock()
174
178
 
175
179
 
176
180
  def bind_federated_backend(backend) -> None:
@@ -181,15 +185,16 @@ def bind_federated_backend(backend) -> None:
181
185
 
182
186
  def federated_backend():
183
187
  global _federated_backend
184
- if _federated_backend is None:
185
- from ..deployment.backend import selected_backend
186
- backend = selected_backend()
187
- if backend.capabilities["account_transport"] != "https-json":
188
- raise RuntimeError("Federated device references require an HTTPS account home")
189
- if not backend.resume():
190
- raise RuntimeError("Log in to the account home first (run `remoterf`, then `login`)")
191
- _federated_backend = backend
192
- return _federated_backend
188
+ with _federated_backend_lock:
189
+ if _federated_backend is None:
190
+ from ..deployment.backend import selected_backend
191
+ backend = selected_backend()
192
+ if backend.capabilities["account_transport"] != "https-json":
193
+ raise RuntimeError("Federated device references require an HTTPS account home")
194
+ if not backend.resume():
195
+ raise RuntimeError("Log in to the account home first (run `remoterf`, then `login`)")
196
+ _federated_backend = backend
197
+ return _federated_backend
193
198
 
194
199
 
195
200
  def _federated_ref(function_name, args):
@@ -207,6 +212,19 @@ def _federated_call(route, function_name, args):
207
212
  """Relay through Global: an ``rfg_`` token needs no login, a ref the bearer."""
208
213
  from ..deployment.http import AccountBackendError
209
214
  ref = getattr(route, "wire", route)
215
+ if direct_path.wants(function_name):
216
+ path = direct_path.global_path(ref, getattr(route, "deployment_id", None))
217
+ if path is not None:
218
+ try:
219
+ response = path.call(function_name, args)
220
+ except direct_path.PathError:
221
+ pass # the path is down now; this call takes the relay, once
222
+ else:
223
+ # The lab refused it unrun: the path's reservation is over, but a
224
+ # newer one on this device may be live, and the relay honours that.
225
+ refused = "a" in response.results and unmap_arg(response.results["a"]) == "the reservation has ended"
226
+ if not refused:
227
+ return response
210
228
  request = grpc_pb2.GenericRPCRequest(function_name=function_name, args=args)
211
229
  try:
212
230
  if ref.startswith("rfg_"):
@@ -455,6 +473,34 @@ def rpc_client(*, function_name, args, connection=None):
455
473
  # array. REMOTERF_RX_WIRE="" or "c128" opts out (servers ignore unknown).
456
474
  # Also through a HOME, whose relay caps each response at ~768 KiB.
457
475
  args = {**args, "wire": map_arg(wire)}
476
+ token = _device_token(function_name, args)
477
+ if function_name.endswith(":tx:CALL1") and "arg1" in args and token in _ci16_tokens:
478
+ args = {**args, "arg1": _tx_wire(args["arg1"])}
479
+ response = _dispatch(function_name, args, connection)
480
+ if token is not None and function_name.endswith(":rx:CALL0") and _is_ci16(response.results.get(function_name.split(":")[1])):
481
+ _ci16_tokens.add(token)
482
+ return response
483
+
484
+
485
+ # Device tokens whose Server answered an rx in ci16: it decodes ci16 too (the
486
+ # same Gate I slice 3 change), so their tx may go out that way.
487
+ _ci16_tokens: set = set()
488
+
489
+
490
+ def _is_ci16(arg) -> bool:
491
+ return arg is not None and arg.WhichOneof("value") == "ndarray_value" and arg.ndarray_value.dtype == IQ16
492
+
493
+
494
+ def _tx_wire(arg):
495
+ """TX samples as int16 IQ, a quarter of complex128, only when that is
496
+ lossless (integer-valued, in range); REMOTERF_TX_WIRE="" opts out."""
497
+ if os.getenv("REMOTERF_TX_WIRE", "i16").strip().lower() != "i16" or arg.WhichOneof("value") != "ndarray_value":
498
+ return arg
499
+ samples = unmap_arg(arg)
500
+ return map_iq16(samples) if fits_iq16(samples) else arg
501
+
502
+
503
+ def _dispatch(function_name, args, connection):
458
504
  ref = _federated_ref(function_name, args) if connection is None else None
459
505
  if ref is not None:
460
506
  return _finish(_federated_call(ref, function_name, args))
@@ -3,6 +3,7 @@
3
3
  import re
4
4
  import time
5
5
  import inspect
6
+ import threading
6
7
  import base64
7
8
  from remoterf_federation_core import (
8
9
  DEVICE_AGGREGATION_TIMEOUT_SECONDS,
@@ -98,6 +99,9 @@ class HttpsJsonAccountBackend(DeploymentAccountBackend):
98
99
  self.clock = clock
99
100
  self.store = store or CredentialStore(self.transport.origin, ident)
100
101
  self.credentials = None
102
+ # One refresh at a time: a direct-path thread shares this session, and
103
+ # Global revokes a session whose refresh token is presented twice.
104
+ self._refresh_lock = threading.RLock()
101
105
 
102
106
  def _operation(self, name):
103
107
  if name not in self.capabilities["supported_operations"]:
@@ -186,33 +190,35 @@ class HttpsJsonAccountBackend(DeploymentAccountBackend):
186
190
 
187
191
  def refresh(self):
188
192
  self._operation("ACC:refresh_session")
189
- if not self.credentials:
190
- raise AccountBackendError("Log in first")
191
- old = self.credentials
192
- try:
193
- pair = self._validate_session(
194
- self.transport.request(
195
- "POST",
196
- self.base + "/refresh",
197
- data={"refresh_token": old["refresh_token"]},
198
- ),
199
- old["subject_id"],
200
- )
201
- except Exception:
202
- # Ambiguous rotation must not automatically replay a spent refresh token.
203
- self.credentials = None
204
- self.store.clear()
205
- raise
206
- self.credentials = pair
207
- self.store.save(pair)
208
- return pair
193
+ with self._refresh_lock:
194
+ if not self.credentials:
195
+ raise AccountBackendError("Log in first")
196
+ old = self.credentials
197
+ try:
198
+ pair = self._validate_session(
199
+ self.transport.request(
200
+ "POST",
201
+ self.base + "/refresh",
202
+ data={"refresh_token": old["refresh_token"]},
203
+ ),
204
+ old["subject_id"],
205
+ )
206
+ except Exception:
207
+ # Ambiguous rotation must not automatically replay a spent refresh token.
208
+ self.credentials = None
209
+ self.store.clear()
210
+ raise
211
+ self.credentials = pair
212
+ self.store.save(pair)
213
+ return pair
209
214
 
210
215
  def _access(self):
211
- if not self.credentials:
212
- raise AccountBackendError("Log in first")
213
- if self.credentials["access_expires_at"] <= self.clock() + 5:
214
- self.refresh()
215
- return self.credentials["access_token"]
216
+ with self._refresh_lock:
217
+ if not self.credentials:
218
+ raise AccountBackendError("Log in first")
219
+ if self.credentials["access_expires_at"] <= self.clock() + 5:
220
+ self.refresh()
221
+ return self.credentials["access_token"]
216
222
 
217
223
  def _own(self, value):
218
224
  if (
@@ -383,6 +389,25 @@ class HttpsJsonAccountBackend(DeploymentAccountBackend):
383
389
  )
384
390
  return self._device_frame(value)
385
391
 
392
+ def device_direct(self, ref, deployment_id, ice):
393
+ """Signal a direct path to the lab behind ``ref`` (a device ref under
394
+ this login, or an ``rfg_`` token with no login): the ICE offer goes in,
395
+ the lab's signed answer comes back as Global relayed it."""
396
+
397
+ if ref.startswith("rfg_"):
398
+ data, auth = {"token": ref, "ice": ice}, {}
399
+ if deployment_id is not None:
400
+ data["deployment_id"] = deployment_id
401
+ else:
402
+ data, auth = {"device_id": self._ref(ref, "device_id"), "ice": ice}, {"access": self._access()}
403
+ value = self.transport.request(
404
+ "POST", self.base + "/devices/direct", data=data,
405
+ overall_timeout=DEVICE_WRITE_TIMEOUT_SECONDS + 5, **auth,
406
+ )
407
+ if value.get("provenance") != "destination" or type(value.get("ice")) is not str:
408
+ raise AccountBackendError("Invalid home device response")
409
+ return value["ice"]
410
+
386
411
  @staticmethod
387
412
  def _device_frame(value):
388
413
  encoded = value.get("response_b64")
@@ -30,6 +30,9 @@ class JsonTransport:
30
30
  timeout=5,
31
31
  follow_redirects=False,
32
32
  trust_env=False,
33
+ # httpx drops an idle connection after 5 s; a notebook's next call
34
+ # would pay a fresh TCP+TLS handshake. Stay under Caddy's 5 min.
35
+ limits=httpx.Limits(max_connections=100, max_keepalive_connections=20, keepalive_expiry=60),
33
36
  )
34
37
 
35
38
  def request(
@@ -1642,6 +1642,11 @@ def _read_schema_hash(path: Path) -> str | None:
1642
1642
  return None
1643
1643
 
1644
1644
 
1645
+ # (wire token, v1 schema hash) pairs this process already checked: a driver
1646
+ # re-created in a notebook makes no schema RPC for the same reservation.
1647
+ _verified: set = set()
1648
+
1649
+
1645
1650
  def install_driver_if_stale(*, token: str, current_hash: str) -> bool:
1646
1651
  """
1647
1652
  Fetch the server schema hash via the reservation token and reinstall the
@@ -1657,6 +1662,17 @@ def install_driver_if_stale(*, token: str, current_hash: str) -> bool:
1657
1662
 
1658
1663
  from remoterf.drivers.pluto import *
1659
1664
  """
1665
+ from ..core.grpc_client import wire_token
1666
+
1667
+ key = (wire_token(token), current_hash)
1668
+ if key in _verified:
1669
+ return False
1670
+ # The v1 schema first: every device publishes one, and a v1 driver (Pluto)
1671
+ # matches it in one RPC. The v2 probe only decides a reinstall.
1672
+ schema = fetch_idl(token=token, prefer_v2=False)
1673
+ if schema.get("schema_hash") == current_hash:
1674
+ _verified.add(key)
1675
+ return False
1660
1676
  schema = fetch_idl(token=token, prefer_v2=True)
1661
1677
  if schema.get("schema_hash") == current_hash:
1662
1678
  return False
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remoterf
3
- Version: 2.3.1
3
+ Version: 2.4.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
@@ -74,6 +74,7 @@ tests/test_config_address.py
74
74
  tests/test_deployment_accounts.py
75
75
  tests/test_device_routes.py
76
76
  tests/test_direct_path_fixed.py
77
+ tests/test_direct_path_global.py
77
78
  tests/test_direct_path_partner.py
78
79
  tests/test_direct_path_race.py
79
80
  tests/test_dynamic_device_codegen.py
@@ -64,6 +64,7 @@ def routes(monkeypatch, tmp_path):
64
64
  (config / ".env").write_text(
65
65
  f"REMOTERF_ADDR=203.0.113.10:12321\nREMOTERF_CA_CERT={ca}\nREMOTERF_PROFILE=ucla\n")
66
66
  monkeypatch.setattr(direct_path, "current", lambda token=None: None)
67
+ monkeypatch.setattr(direct_path, "global_path", lambda ref, deployment_id=None: None)
67
68
  active = Stub()
68
69
  monkeypatch.setattr(grpc_client, "get_active_connection", lambda: SimpleNamespace(stub=active))
69
70
  monkeypatch.setattr(homes, "discover", _never)