RemoteRF 2.1.0__tar.gz → 2.1.1__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 (95) hide show
  1. {remoterf-2.1.0 → remoterf-2.1.1}/PKG-INFO +1 -1
  2. {remoterf-2.1.0 → remoterf-2.1.1}/pyproject.toml +1 -1
  3. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/core/direct_path.py +105 -40
  4. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/core/grpc_client.py +6 -8
  5. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/remoterf_cli.py +2 -2
  6. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoterf.egg-info/PKG-INFO +1 -1
  7. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoterf.egg-info/SOURCES.txt +1 -1
  8. remoterf-2.1.1/tests/test_direct_path_race.py +129 -0
  9. {remoterf-2.1.0 → remoterf-2.1.1}/tests/test_finish_prompt.py +8 -3
  10. {remoterf-2.1.0 → remoterf-2.1.1}/tests/test_gate_i_direct_path.py +10 -4
  11. remoterf-2.1.0/src/remoteRF/drivers/adalm_pluto/pluto_remote.py +0 -291
  12. {remoterf-2.1.0 → remoterf-2.1.1}/LICENSE +0 -0
  13. {remoterf-2.1.0 → remoterf-2.1.1}/README.md +0 -0
  14. {remoterf-2.1.0 → remoterf-2.1.1}/setup.cfg +0 -0
  15. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/__init__.py +0 -0
  16. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/common/__init__.py +0 -0
  17. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/common/grpc/__init__.py +0 -0
  18. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/common/grpc/deployment_capabilities.proto +0 -0
  19. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/common/grpc/deployment_capabilities_pb2.py +0 -0
  20. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/common/grpc/deployment_capabilities_pb2_grpc.py +0 -0
  21. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/common/grpc/grpc.proto +0 -0
  22. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/common/grpc/grpc_pb2.py +0 -0
  23. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/common/grpc/grpc_pb2_grpc.py +0 -0
  24. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/common/grpc/v2_codec.py +0 -0
  25. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/common/tos_notice.txt +0 -0
  26. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/common/utils/__init__.py +0 -0
  27. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/common/utils/ansi_codes.py +0 -0
  28. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/common/utils/api_token.py +0 -0
  29. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/common/utils/banner.py +0 -0
  30. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/common/utils/list_string.py +0 -0
  31. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/common/utils/process_arg.py +0 -0
  32. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/config/__init__.py +0 -0
  33. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/config/cert_fetcher.py +0 -0
  34. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/config/config.py +0 -0
  35. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/core/__init__.py +0 -0
  36. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/core/acc_login.py +0 -0
  37. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/core/app.py +0 -0
  38. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/core/banner_note.txt +0 -0
  39. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/core/dynamic_v2_transport.py +0 -0
  40. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/core/grpc_acc.py +0 -0
  41. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/core/secure_channel.py +0 -0
  42. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/core/v2_errors.py +0 -0
  43. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/deployment/__init__.py +0 -0
  44. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/deployment/backend.py +0 -0
  45. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/deployment/direct.py +0 -0
  46. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/deployment/homes.py +0 -0
  47. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/deployment/http.py +0 -0
  48. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/deployment/state.py +0 -0
  49. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/drivers/__init__.py +0 -0
  50. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/drivers/_virtual.py +0 -0
  51. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/drivers/adalm_pluto/__init__.py +0 -0
  52. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/drivers/adalm_pluto/adalm_pluto_remote.py +0 -0
  53. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/drivers/dynamic_device.py +0 -0
  54. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/drivers/dynamic_v2.py +0 -0
  55. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/drivers/hackrf/__init__.py +0 -0
  56. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/drivers/hackrf/hackrf_remote.py +0 -0
  57. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/drivers/rtl_sdr/__init__.py +0 -0
  58. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/drivers/rtl_sdr/rtl_sdr_remote.py +0 -0
  59. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/drivers/sim_sdr/__init__.py +0 -0
  60. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/drivers/sim_sdr/sim_sdr_remote.py +0 -0
  61. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/drivers/support/__init__.py +0 -0
  62. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/drivers/support/ti_mmwave.py +0 -0
  63. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/drivers/support/uhd.py +0 -0
  64. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/drivers/support/uhd_dsp.py +0 -0
  65. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/drivers/support/uhd_v2.py +0 -0
  66. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/drivers/ti_mmwave/__init__.py +0 -0
  67. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/drivers/ti_mmwave/ti_mmwave_remote.py +0 -0
  68. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/drivers/usrp/__init__.py +0 -0
  69. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/drivers/usrp/usrp_remote.py +0 -0
  70. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/drivers/usrp/usrp_remote.pyi +0 -0
  71. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF/version.py +0 -0
  72. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoteRF.py +0 -0
  73. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoterf.egg-info/dependency_links.txt +0 -0
  74. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoterf.egg-info/entry_points.txt +0 -0
  75. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoterf.egg-info/requires.txt +0 -0
  76. {remoterf-2.1.0 → remoterf-2.1.1}/src/remoterf.egg-info/top_level.txt +0 -0
  77. {remoterf-2.1.0 → remoterf-2.1.1}/tests/test_banner.py +0 -0
  78. {remoterf-2.1.0 → remoterf-2.1.1}/tests/test_config_address.py +0 -0
  79. {remoterf-2.1.0 → remoterf-2.1.1}/tests/test_deployment_accounts.py +0 -0
  80. {remoterf-2.1.0 → remoterf-2.1.1}/tests/test_direct_path_fixed.py +0 -0
  81. {remoterf-2.1.0 → remoterf-2.1.1}/tests/test_direct_path_partner.py +0 -0
  82. {remoterf-2.1.0 → remoterf-2.1.1}/tests/test_dynamic_device_codegen.py +0 -0
  83. {remoterf-2.1.0 → remoterf-2.1.1}/tests/test_dynamic_v2.py +0 -0
  84. {remoterf-2.1.0 → remoterf-2.1.1}/tests/test_federated_devices.py +0 -0
  85. {remoterf-2.1.0 → remoterf-2.1.1}/tests/test_fetch_idl_fallback.py +0 -0
  86. {remoterf-2.1.0 → remoterf-2.1.1}/tests/test_grpc_client_target.py +0 -0
  87. {remoterf-2.1.0 → remoterf-2.1.1}/tests/test_homes.py +0 -0
  88. {remoterf-2.1.0 → remoterf-2.1.1}/tests/test_native_account_credentials.py +0 -0
  89. {remoterf-2.1.0 → remoterf-2.1.1}/tests/test_native_devices.py +0 -0
  90. {remoterf-2.1.0 → remoterf-2.1.1}/tests/test_partner_dynamic_v2.py +0 -0
  91. {remoterf-2.1.0 → remoterf-2.1.1}/tests/test_pluto_virtual.py +0 -0
  92. {remoterf-2.1.0 → remoterf-2.1.1}/tests/test_process_arg.py +0 -0
  93. {remoterf-2.1.0 → remoterf-2.1.1}/tests/test_remoterf_cli.py +0 -0
  94. {remoterf-2.1.0 → remoterf-2.1.1}/tests/test_ti_mmwave.py +0 -0
  95. {remoterf-2.1.0 → remoterf-2.1.1}/tests/test_upgrade_rescue.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remoterf
3
- Version: 2.1.0
3
+ Version: 2.1.1
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.1.0"
7
+ version = "2.1.1"
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"
@@ -32,7 +32,8 @@ path; everything else still takes the home's.
32
32
  When the answer names a *fixed port* (a Server with a reachable address
33
33
  listening for QUIC on one admitted UDP port), the path dials it like any QUIC
34
34
  server -- no punch, so it works behind any NAT -- and claims the session with
35
- the secret the answer carried. ICE is the fallback.
35
+ the secret the answer carried. The dial and the punch run at once; the first
36
+ to connect wins.
36
37
  """
37
38
 
38
39
  from __future__ import annotations
@@ -112,7 +113,10 @@ def quiet_aioice_errors(loop) -> None:
112
113
 
113
114
  The retry fires from a TimerHandle named for aioice's Transaction and
114
115
  dies inside asyncio's own transport code once the socket is gone (an
115
- AttributeError on the closed transport or its dropped loop)."""
116
+ AttributeError on the closed transport or its dropped loop).
117
+
118
+ Likewise aioquic's shielded connect waiter: a handshake that ran out of
119
+ time leaves it to fail with a ConnectionError nobody reads."""
116
120
  default = loop.default_exception_handler
117
121
 
118
122
  def handler(loop, context):
@@ -120,6 +124,8 @@ def quiet_aioice_errors(loop) -> None:
120
124
  where = repr(context.get("handle", "")) + str(context.get("message", ""))
121
125
  if isinstance(exc, (AttributeError, OSError)) and "Transaction.__retry" in where:
122
126
  return
127
+ if isinstance(exc, ConnectionError) and "exception was never retrieved" in where:
128
+ return
123
129
  default(context)
124
130
 
125
131
  loop.set_exception_handler(handler)
@@ -189,6 +195,10 @@ class PathError(Exception):
189
195
  """The direct path could not carry this call and is down now."""
190
196
 
191
197
 
198
+ def _why(exc: BaseException) -> str:
199
+ return "timed out" if isinstance(exc, asyncio.TimeoutError) else f"{type(exc).__name__}: {exc}"
200
+
201
+
192
202
  class _IceTransport:
193
203
  """The sendto() surface aioquic's protocol needs, on a nominated ICE pair."""
194
204
 
@@ -354,10 +364,7 @@ class DirectPath:
354
364
  try:
355
365
  await asyncio.wait_for(self._establish(), SETTLE_SECONDS)
356
366
  except Exception as exc: # noqa: BLE001 - a failed punch is expected on some networks
357
- self.reason = (
358
- "timed out" if isinstance(exc, asyncio.TimeoutError)
359
- else f"{type(exc).__name__}: {exc}"
360
- )
367
+ self.reason = str(exc) if isinstance(exc, PathError) else _why(exc)
361
368
  self.state = "down"
362
369
  if "No matching server function" in self.reason:
363
370
  self.state = "unavailable"
@@ -397,6 +404,10 @@ class DirectPath:
397
404
  "ufrag": ice.local_username,
398
405
  "pwd": ice.local_password,
399
406
  "candidates": [candidate.to_sdp() for candidate in ice.local_candidates],
407
+ # This client punches at once (no fixed-port head start), so the
408
+ # Server may send its first check sooner; a Server that predates
409
+ # the flag ignores it.
410
+ "race": True,
400
411
  }
401
412
  answer = await loop.run_in_executor(None, self._offer, offer)
402
413
 
@@ -409,35 +420,68 @@ class DirectPath:
409
420
  )
410
421
  configuration.load_verify_locations(cadata=ca_pem)
411
422
 
423
+ # Both at once. The Server starts its checks the moment it answers,
424
+ # and a NAT admits them only once this side has sent its own: a punch
425
+ # held back for the fixed dial (filtered from off campus) misses that
426
+ # window. On campus the fixed port still wins in milliseconds.
427
+ racers: dict[str, asyncio.Task] = {}
412
428
  if answer.get("udp") and answer.get("session"):
413
- try:
414
- await self._dial_fixed(answer, configuration)
415
- except Exception as exc: # noqa: BLE001 - filtered or wrong: the punch is next
416
- self.reason = f"fixed port: {type(exc).__name__}: {exc}"
417
- else:
418
- await close_ice(ice)
419
- self._ice = None
420
- return
421
- ice.remote_username = str(answer["ufrag"])
422
- ice.remote_password = str(answer["pwd"])
423
- for sdp in answer["candidates"]:
424
- await ice.add_remote_candidate(Candidate.from_sdp(str(sdp)))
425
- await ice.add_remote_candidate(None)
426
- await asyncio.wait_for(ice.connect(), ICE_SECONDS)
427
- peer = peer_address(ice)
428
- protocol = _Protocol(QuicConnection(configuration=configuration))
429
- self._transport = _IceTransport(ice)
430
- protocol.connection_made(self._transport)
431
- self._tasks = [
432
- asyncio.ensure_future(self._pump(ice, protocol, peer)),
433
- asyncio.ensure_future(self._watch(protocol)),
434
- asyncio.ensure_future(self._watch_sleep()),
435
- ]
436
- protocol.connect(peer)
429
+ racers["fixed port"] = asyncio.ensure_future(self._dial_fixed(answer, configuration))
430
+ racers["punch"] = asyncio.ensure_future(self._punch(ice, answer, configuration))
431
+ failures: dict[asyncio.Task, Exception] = {}
432
+ pending = set(racers.values())
437
433
  try:
438
- await asyncio.wait_for(protocol.wait_connected(), QUIC_SECONDS)
439
- except ConnectionError:
440
- raise ConnectionError(f"QUIC handshake refused: {protocol.terminated}") from None
434
+ while pending:
435
+ done, pending = await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED)
436
+ for task in done:
437
+ try:
438
+ task.result()
439
+ except Exception as exc: # noqa: BLE001 - filtered or refused: the other may still win
440
+ failures[task] = exc
441
+ else:
442
+ return
443
+ finally:
444
+ await cancel_tasks(pending)
445
+ if len(failures) == 1:
446
+ raise next(iter(failures.values()))
447
+ raise PathError("; ".join(f"{name}: {_why(failures[task])}" for name, task in racers.items()))
448
+
449
+ async def _punch(self, ice: Connection, answer: dict, configuration: QuicConfiguration) -> None:
450
+ """QUIC over the pair ICE nominates; the ICE goes with it when this loses."""
451
+ tasks: list[asyncio.Task] = []
452
+ transport = None
453
+ try:
454
+ ice.remote_username = str(answer["ufrag"])
455
+ ice.remote_password = str(answer["pwd"])
456
+ for sdp in answer["candidates"]:
457
+ await ice.add_remote_candidate(Candidate.from_sdp(str(sdp)))
458
+ await ice.add_remote_candidate(None)
459
+ await asyncio.wait_for(ice.connect(), ICE_SECONDS)
460
+ peer = peer_address(ice)
461
+ protocol = _Protocol(QuicConnection(configuration=configuration))
462
+ transport = _IceTransport(ice)
463
+ protocol.connection_made(transport)
464
+ tasks = [
465
+ asyncio.ensure_future(self._pump(ice, protocol, peer)),
466
+ asyncio.ensure_future(self._watch(protocol)),
467
+ asyncio.ensure_future(self._watch_sleep()),
468
+ ]
469
+ protocol.connect(peer)
470
+ try:
471
+ await asyncio.wait_for(protocol.wait_connected(), QUIC_SECONDS)
472
+ except ConnectionError:
473
+ raise ConnectionError(f"QUIC handshake refused: {protocol.terminated}") from None
474
+ if self._protocol is not None:
475
+ raise PathError("the fixed port connected first")
476
+ except BaseException:
477
+ await cancel_tasks(tasks) # before the close, or _watch reports it as a death
478
+ if transport is not None:
479
+ protocol.close()
480
+ transport.close()
481
+ await close_ice(ice)
482
+ self._ice = None
483
+ raise
484
+ self._transport, self._tasks = transport, tasks
441
485
  self._protocol, self.remote = protocol, peer
442
486
 
443
487
  async def _dial_fixed(self, answer: dict, configuration: QuicConfiguration) -> None:
@@ -457,6 +501,8 @@ class DirectPath:
457
501
  reply = json.loads(await asyncio.wait_for(read_frame(reader), FIXED_SECONDS))
458
502
  if not reply.get("ok"):
459
503
  raise PathError(str(reply.get("a", "session refused")))
504
+ if self._protocol is not None:
505
+ raise PathError("the punch connected first")
460
506
  except BaseException:
461
507
  protocol.close()
462
508
  transport.close()
@@ -595,21 +641,30 @@ def _stored_login() -> Optional[dict]:
595
641
  return homes.recall_login(profile.home) if profile.home else None
596
642
 
597
643
 
598
- def _from_stored_login() -> Optional[DirectPath]:
599
- """A path for a process that never logged in (a script using a driver)."""
644
+ def _active_route(profile) -> Optional[dict]:
645
+ """The active home's route this process is connected over, if it has one."""
600
646
  from ..deployment import homes
601
- from .grpc_client import _current_profile
602
647
 
603
- profile = _current_profile()
604
648
  if not profile.home:
605
649
  return None
606
- route = next(
650
+ return next(
607
651
  (r for r in homes.load_home(profile.home)["routes"]
608
652
  if f"{r['host']}:{r['port']}" == profile.grpc_endpoint),
609
653
  None,
610
654
  )
655
+
656
+
657
+ def _from_stored_login() -> Optional[DirectPath]:
658
+ """A path for a process that never logged in (a script using a driver)."""
659
+ from ..deployment import homes
660
+ from .grpc_client import _current_profile
661
+
662
+ profile = _current_profile()
663
+ route = _active_route(profile)
664
+ if route is None or route["kind"] != "relay":
665
+ return None
611
666
  login = homes.recall_login(profile.home)
612
- if route is None or route["kind"] != "relay" or login is None:
667
+ if login is None:
613
668
  return None
614
669
  return DirectPath(
615
670
  offer=_offer_rpc(login["username"], login["secret"]), server_name=route["host"],
@@ -690,7 +745,17 @@ def describe() -> str:
690
745
  if not enabled():
691
746
  return "relay (direct path disabled: REMOTERF_DIRECT=0)"
692
747
  path = _path
693
- return path.describe() if path is not None else "relay (direct path off)"
748
+ if path is not None:
749
+ return path.describe()
750
+ try:
751
+ from .grpc_client import _current_profile
752
+
753
+ route = _active_route(_current_profile())
754
+ except Exception: # noqa: BLE001 - not configured, no home: nothing to add
755
+ route = None
756
+ if route is None or route["kind"] == "relay":
757
+ return "relay (direct path off)"
758
+ return "LAN (direct)" if route["kind"] == "lan" else str(route["kind"])
694
759
 
695
760
 
696
761
  def stop() -> None:
@@ -14,7 +14,6 @@
14
14
  # along with this program. If not, see <https://www.gnu.org/licenses/>.
15
15
 
16
16
  import os
17
- import sys
18
17
  from dataclasses import dataclass
19
18
  from pathlib import Path
20
19
  from typing import Any, Dict, Optional
@@ -31,7 +30,7 @@ _CONFIG_HELP = (
31
30
  "Run:\n"
32
31
  " remoterf --config --addr <host:port>\n"
33
32
  "Example:\n"
34
- " remoterf --config --addr 123.45.654.321:12321"
33
+ " remoterf --config --addr 203.0.113.10:12321"
35
34
  )
36
35
 
37
36
 
@@ -229,10 +228,11 @@ def rpc_client(*, function_name, args, connection=None):
229
228
  ref = _federated_ref(function_name, args) if connection is None else None
230
229
  if ref is not None:
231
230
  return _finish(_federated_call(ref, function_name, args))
232
- wire = os.getenv("REMOTERF_RX_WIRE", "").strip().lower()
231
+ wire = os.getenv("REMOTERF_RX_WIRE", "i16").strip().lower()
233
232
  if wire and function_name.endswith(":rx:CALL0"):
234
- # Gate I slice 3, opt-in: c64 halves the samples on the wire, i16 quarters
235
- # them when they are integer IQ; the driver still sees a complex array.
233
+ # Gate I slice 3: c64 halves the samples on the wire, i16 (the default)
234
+ # quarters them when they are integer IQ; the driver still sees a complex
235
+ # array. REMOTERF_RX_WIRE="" or "c128" opts out (servers ignore unknown).
236
236
  args = {**args, "wire": map_arg(wire)}
237
237
  if connection is None and direct_path.wants(function_name):
238
238
  path = direct_path.current(token=_device_token(function_name, args))
@@ -265,9 +265,7 @@ def _finish(response):
265
265
 
266
266
  if 'UE' in response.results:
267
267
  print(f"UserError: {unmap_arg(response.results['UE'])}")
268
- if sys.stdin.isatty(): # a script has nobody to hit enter
269
- input("Hit enter to continue...")
270
-
268
+
271
269
  if 'Message' in response.results:
272
270
  print(f"{unmap_arg(response.results['Message'])}")
273
271
 
@@ -114,7 +114,7 @@ def _ensure_config_present() -> tuple[bool, str]:
114
114
  "Run:\n"
115
115
  " remoterf --config --addr <host:port>\n"
116
116
  "Example:\n"
117
- " remoterf --config --addr 123.45.654.321:12321\n",
117
+ " remoterf --config --addr 203.0.113.10:12321\n",
118
118
  )
119
119
 
120
120
  kv = _read_dotenv_kv(env_file)
@@ -173,7 +173,7 @@ def print_help() -> None:
173
173
  printf(" remoterf --login global", Sty.GREEN)
174
174
  printf(" remoterf homes", Sty.GREEN)
175
175
  printf(" remoterf --version", Sty.GREEN)
176
- printf(" remoterf --config --addr 123.45.654.321:12321", Sty.GREEN)
176
+ printf(" remoterf --config --addr 203.0.113.10:12321", Sty.GREEN)
177
177
  printf(" remoterf --config --addr global.remoterf.net --register", Sty.GREEN)
178
178
  printf(" remoterf --config --wipe", Sty.GREEN)
179
179
  printf(" remoterf --config --wipe --yes", Sty.GREEN)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remoterf
3
- Version: 2.1.0
3
+ Version: 2.1.1
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
@@ -46,7 +46,6 @@ src/remoteRF/drivers/dynamic_device.py
46
46
  src/remoteRF/drivers/dynamic_v2.py
47
47
  src/remoteRF/drivers/adalm_pluto/__init__.py
48
48
  src/remoteRF/drivers/adalm_pluto/adalm_pluto_remote.py
49
- src/remoteRF/drivers/adalm_pluto/pluto_remote.py
50
49
  src/remoteRF/drivers/hackrf/__init__.py
51
50
  src/remoteRF/drivers/hackrf/hackrf_remote.py
52
51
  src/remoteRF/drivers/rtl_sdr/__init__.py
@@ -74,6 +73,7 @@ tests/test_config_address.py
74
73
  tests/test_deployment_accounts.py
75
74
  tests/test_direct_path_fixed.py
76
75
  tests/test_direct_path_partner.py
76
+ tests/test_direct_path_race.py
77
77
  tests/test_dynamic_device_codegen.py
78
78
  tests/test_dynamic_v2.py
79
79
  tests/test_federated_devices.py
@@ -0,0 +1,129 @@
1
+ """The fixed dial and the punch run at once: whichever connects first is the
2
+ path, the other is cancelled, and when both fail the reason names both."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ import tempfile
8
+ import unittest
9
+ from pathlib import Path
10
+ from unittest import mock
11
+
12
+ from remoteRF.core import direct_path
13
+
14
+ from test_gate_i_direct_path import SERVER_NAME, FakeServer, write_certs
15
+
16
+
17
+ class RaceTests(unittest.TestCase):
18
+ def setUp(self):
19
+ self._tmp = tempfile.TemporaryDirectory()
20
+ self.addCleanup(self._tmp.cleanup)
21
+ self.ca_pem = write_certs(Path(self._tmp.name))
22
+
23
+ def _path(self, offer):
24
+ path = direct_path.DirectPath(offer=offer, server_name=SERVER_NAME, ca_pem=self.ca_pem, stun=None)
25
+ self.addCleanup(path.stop)
26
+ path.start()
27
+ return path
28
+
29
+ def test_the_punch_wins_while_the_fixed_dial_still_hangs(self):
30
+ server = FakeServer(Path(self._tmp.name), lambda request: {})
31
+ self.addCleanup(server.close)
32
+ outcome = []
33
+
34
+ async def hang(self, answer, configuration):
35
+ try:
36
+ await asyncio.Event().wait()
37
+ except asyncio.CancelledError:
38
+ outcome.append("cancelled")
39
+ raise
40
+
41
+ with mock.patch.object(direct_path.DirectPath, "_dial_fixed", hang):
42
+ path = self._path(lambda offer: {**server.offer(offer), "udp": "127.0.0.1:9", "session": "s"})
43
+ self.assertTrue(path.wait(15), path.describe())
44
+ self.assertFalse(path.fixed)
45
+ self.assertTrue(path.describe().startswith("direct "), path.describe())
46
+ self.assertEqual(outcome, ["cancelled"])
47
+ self.assertEqual(len(path._tasks), 3)
48
+
49
+ def test_both_failing_names_both(self):
50
+ async def fixed(self, answer, configuration):
51
+ raise asyncio.TimeoutError
52
+
53
+ async def punch(self, ice, answer, configuration):
54
+ raise ConnectionError("ICE negotiation failed")
55
+
56
+ with mock.patch.object(direct_path.DirectPath, "_dial_fixed", fixed), \
57
+ mock.patch.object(direct_path.DirectPath, "_punch", punch):
58
+ path = self._path(lambda offer: {"ufrag": "a", "pwd": "b", "candidates": [], "udp": "h:1", "session": "s"})
59
+ self.assertFalse(path.wait(15))
60
+ self.assertEqual(path.state, "down")
61
+ self.assertEqual(path.reason, "fixed port: timed out; punch: ConnectionError: ICE negotiation failed")
62
+
63
+ def test_a_punch_alone_keeps_its_own_reason(self):
64
+ async def punch(self, ice, answer, configuration):
65
+ raise asyncio.TimeoutError
66
+
67
+ with mock.patch.object(direct_path.DirectPath, "_punch", punch):
68
+ path = self._path(lambda offer: {"ufrag": "a", "pwd": "b", "candidates": []})
69
+ self.assertFalse(path.wait(15))
70
+ self.assertEqual(path.reason, "timed out")
71
+
72
+ def test_the_offer_says_this_client_punches_at_once(self):
73
+ seen = []
74
+
75
+ def offer(request):
76
+ seen.append(request)
77
+ return {"ufrag": "a", "pwd": "b", "candidates": []}
78
+
79
+ path = self._path(offer)
80
+ path.wait(15)
81
+ self.assertEqual(len(seen), 1)
82
+ self.assertIs(seen[0]["race"], True)
83
+
84
+
85
+
86
+
87
+
88
+ class DescribeTests(unittest.TestCase):
89
+ def setUp(self):
90
+ self.addCleanup(setattr, direct_path, "_path", None)
91
+ direct_path._path = None
92
+
93
+ def _describe(self, routes, endpoint="10.0.0.5:12321"):
94
+ profile = mock.Mock(home="lab", grpc_endpoint=endpoint)
95
+ with mock.patch("remoteRF.core.grpc_client._current_profile", return_value=profile), \
96
+ mock.patch("remoteRF.deployment.homes.load_home", return_value={"routes": routes}):
97
+ return direct_path.describe()
98
+
99
+ def test_a_lan_route_is_not_the_relay(self):
100
+ self.assertEqual(self._describe([{"kind": "lan", "host": "10.0.0.5", "port": 12321}]), "LAN (direct)")
101
+ self.assertEqual(self._describe([{"kind": "direct", "host": "10.0.0.5", "port": 12321}]), "direct")
102
+
103
+ def test_a_relay_route_and_a_failed_lookup_keep_the_old_wording(self):
104
+ self.assertEqual(self._describe([{"kind": "relay", "host": "10.0.0.5", "port": 12321}]), "relay (direct path off)")
105
+ with mock.patch("remoteRF.core.grpc_client._current_profile", side_effect=RuntimeError("not configured")):
106
+ self.assertEqual(direct_path.describe(), "relay (direct path off)")
107
+
108
+
109
+ class QuietWaiterTests(unittest.TestCase):
110
+ def _run(self, exc):
111
+ loop = asyncio.new_event_loop()
112
+ seen = []
113
+ try:
114
+ loop.default_exception_handler = lambda context: seen.append(context)
115
+ direct_path.quiet_aioice_errors(loop)
116
+ loop.call_exception_handler({"message": "Future exception was never retrieved", "exception": exc})
117
+ finally:
118
+ loop.close()
119
+ return seen
120
+
121
+ def test_aioquic_orphaned_connect_waiter_is_silent(self):
122
+ self.assertEqual(self._run(ConnectionError()), [])
123
+
124
+ def test_other_unretrieved_exceptions_still_reach_the_default_handler(self):
125
+ self.assertEqual(len(self._run(ValueError("not ours"))), 1)
126
+
127
+
128
+ if __name__ == "__main__":
129
+ unittest.main()
@@ -1,15 +1,20 @@
1
- """A user-error reply never blocks a script on stdin."""
1
+ """A user-error reply never blocks on stdin, terminal or not."""
2
2
 
3
3
  import io
4
4
 
5
+ import pytest
6
+
5
7
  from remoteRF.common.grpc import grpc_pb2
6
8
  from remoteRF.common.utils import map_arg
7
9
  from remoteRF.core import grpc_client
8
10
 
9
11
 
10
- def test_a_user_error_only_prompts_on_a_terminal(monkeypatch, capsys):
12
+ @pytest.mark.parametrize("isatty", [False, True])
13
+ def test_a_user_error_never_prompts(monkeypatch, capsys, isatty):
11
14
  response = grpc_pb2.GenericRPCResponse(results={"UE": map_arg("Username already exists.")})
12
- monkeypatch.setattr("sys.stdin", io.StringIO("")) # not a tty
15
+ stdin = io.StringIO("")
16
+ monkeypatch.setattr(stdin, "isatty", lambda: isatty)
17
+ monkeypatch.setattr("sys.stdin", stdin)
13
18
  monkeypatch.setattr("builtins.input", lambda *a: (_ for _ in ()).throw(AssertionError("prompted")))
14
19
  assert grpc_client._finish(response) is response
15
20
  assert "Username already exists" in capsys.readouterr().out
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
 
6
6
  import asyncio
7
7
  import datetime
8
+ import os
8
9
  import threading
9
10
  import time
10
11
  import unittest
@@ -235,12 +236,17 @@ class RoutingTests(unittest.TestCase):
235
236
 
236
237
  seen = []
237
238
  self._path(lambda name, args: seen.append((name, dict(args))) or grpc_pb2.GenericRPCResponse())
238
- with mock.patch.dict("os.environ", {"REMOTERF_RX_WIRE": "i16"}):
239
+ with mock.patch.dict("os.environ", {"REMOTERF_RX_WIRE": "c64"}):
239
240
  rpc_client(function_name="adalm_pluto:rx:CALL0", args={"a": map_arg("t")})
240
241
  rpc_client(function_name="adalm_pluto:rx_lo:GET", args={"a": map_arg("t")})
241
- rpc_client(function_name="adalm_pluto:rx:CALL0", args={"a": map_arg("t")})
242
- self.assertEqual([sorted(args) for _, args in seen], [["a", "wire"], ["a"], ["a"]])
243
- self.assertEqual(unmap_arg(seen[0][1]["wire"]), "i16")
242
+ with mock.patch.dict("os.environ", {"REMOTERF_RX_WIRE": ""}): # opt out
243
+ rpc_client(function_name="adalm_pluto:rx:CALL0", args={"a": map_arg("t")})
244
+ with mock.patch.dict("os.environ"):
245
+ os.environ.pop("REMOTERF_RX_WIRE", None) # unset: i16 by default
246
+ rpc_client(function_name="adalm_pluto:rx:CALL0", args={"a": map_arg("t")})
247
+ self.assertEqual([sorted(args) for _, args in seen], [["a", "wire"], ["a"], ["a"], ["a", "wire"]])
248
+ self.assertEqual(unmap_arg(seen[0][1]["wire"]), "c64")
249
+ self.assertEqual(unmap_arg(seen[3][1]["wire"]), "i16")
244
250
 
245
251
  def test_a_path_that_is_not_ready_is_skipped(self):
246
252
  from remoteRF.core.grpc_client import rpc_client
@@ -1,291 +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/>.
15
-
16
- from ...common.utils import *
17
- from .._virtual import (
18
- is_virtual_token,
19
- make_virtual_token,
20
- retag_virtual_token,
21
- virtual_call,
22
- virtual_get,
23
- virtual_set,
24
- )
25
-
26
-
27
- def _rpc_client(*, function_name, args):
28
- # Keep transport/configuration initialization out of the virtual path.
29
- from ...core.grpc_client import rpc_client
30
-
31
- return rpc_client(function_name=function_name, args=args)
32
-
33
- def try_get(function_name, token):
34
- if is_virtual_token(token):
35
- return virtual_get(token, function_name)
36
- try:
37
- return unmap_arg(_rpc_client(function_name=f"Pluto:{function_name}:GET", args={'a':map_arg(token)}).results[function_name])
38
- except Exception as e:
39
- input(f"Error: {e}\nHit enter to continue...")
40
- return None
41
-
42
- def try_set(function_name, value, token):
43
- if is_virtual_token(token):
44
- virtual_set(token, function_name, value)
45
- return None
46
- try:
47
- _rpc_client(function_name=f"Pluto:{function_name}:SET", args={function_name: map_arg(value), 'a':map_arg(token)})
48
- except Exception as e:
49
- input(f"Error: {e}\nHit enter to continue...")
50
-
51
- def try_call_0_arg(function_name, token): # 0 argument call
52
- if is_virtual_token(token):
53
- return virtual_call(token, function_name)
54
- try:
55
- response = _rpc_client(
56
- function_name=f"Pluto:{function_name}:CALL0",
57
- args={
58
- 'a': map_arg(token)
59
- }
60
- )
61
- return unmap_arg(response.results[function_name])
62
- except Exception as e:
63
- input(f"RPC_0_call Error: {e}\nHit enter to continue...")
64
- return None
65
-
66
- def try_call_1_arg(function_name, arg, token): # 1 argument call
67
- if is_virtual_token(token):
68
- return virtual_call(token, function_name, arg, has_arg=True)
69
- try:
70
- response = _rpc_client(
71
- function_name=f"Pluto:{function_name}:CALL1",
72
- args={
73
- 'a': map_arg(token),
74
- 'arg1': map_arg(arg)
75
- }
76
- )
77
- # The server should return something like {function_name: <something>}
78
- return unmap_arg(response.results[function_name])
79
- except Exception as e:
80
- input(f"RPC_1_call Error: {e}\nHit enter to continue...")
81
- return None
82
-
83
- class rx_def:
84
- pass
85
-
86
- class tx_def:
87
- pass
88
-
89
- class rx_tx_def(rx_def, tx_def):
90
- pass
91
-
92
- class ad9364(rx_tx_def):
93
- pass
94
-
95
- class Pluto: # client
96
-
97
- def __init__(self, token: str | None = None, debug=False, *, virtual=False):
98
- self.virtual = bool(virtual)
99
- if self.virtual:
100
- self.token = make_virtual_token(token, device_type="adalm_pluto")
101
- return
102
- if token is None:
103
- raise ValueError("A reservation token is required unless virtual=True")
104
- self.token = token
105
- try_call_0_arg(function_name="ip", token=token)
106
-
107
-
108
- def api_token(self, token:str) -> None:
109
- if self.virtual:
110
- self.token = retag_virtual_token(self.token, token)
111
- return
112
- self.token = token
113
- try_call_0_arg(function_name="ip", token=token)
114
-
115
- # PlutoSDR
116
-
117
- _device_name = "PlutoSDR"
118
- _uri_auto = "ip:pluto.local"
119
-
120
- def __repr__(self): # ! UNTESTED !
121
- return try_get("__repr__", self.token)
122
-
123
- #region ad9364
124
- """AD9364 Transceiver"""
125
-
126
- @property
127
- def filter(self):
128
- return try_get("filter", self.token)
129
-
130
- @filter.setter
131
- def filter(self, value):
132
- try_set("filter", value, self.token)
133
-
134
- @property
135
- def loopback(self):
136
- """loopback: Set loopback mode. Options are:
137
- 0 (Disable), 1 (Digital), 2 (RF)"""
138
- return try_get("loopback", self.token)
139
-
140
- @loopback.setter
141
- def loopback(self, value):
142
- try_set("loopback", value, self.token)
143
-
144
- @property
145
- def gain_control_mode_chan0(self):
146
- """gain_control_mode_chan0: Mode of receive path AGC. Options are:
147
- slow_attack, fast_attack, manual"""
148
- return try_get("gain_control_mode_chan0", self.token)
149
-
150
- @gain_control_mode_chan0.setter
151
- def gain_control_mode_chan0(self, value):
152
- try_set("gain_control_mode_chan0", value, self.token)
153
-
154
- @property
155
- def rx_hardwaregain_chan0(self):
156
- """rx_hardwaregain_chan0: Gain applied to RX path. Only applicable when
157
- gain_control_mode is set to 'manual'"""
158
- return try_get("rx_hardwaregain_chan0", self.token)
159
-
160
- @rx_hardwaregain_chan0.setter
161
- def rx_hardwaregain_chan0(self, value):
162
- try_set("rx_hardwaregain_chan0", value, self.token)
163
-
164
- @property
165
- def tx_hardwaregain_chan0(self):
166
- """tx_hardwaregain_chan0: Attenuation applied to TX path"""
167
- return try_get("tx_hardwaregain_chan0", self.token)
168
-
169
- @tx_hardwaregain_chan0.setter
170
- def tx_hardwaregain_chan0(self, value):
171
- try_set("tx_hardwaregain_chan0", value, self.token)
172
-
173
- @property
174
- def rx_rf_bandwidth(self):
175
- """rx_rf_bandwidth: Bandwidth of front-end analog filter of RX path"""
176
- return try_get("rx_rf_bandwidth", self.token)
177
-
178
- @rx_rf_bandwidth.setter
179
- def rx_rf_bandwidth(self, value):
180
- try_set("rx_rf_bandwidth", value, self.token)
181
-
182
- @property
183
- def tx_rf_bandwidth(self):
184
- """tx_rf_bandwidth: Bandwidth of front-end analog filter of TX path"""
185
- return try_get("tx_rf_bandwidth", self.token)
186
-
187
- @tx_rf_bandwidth.setter
188
- def tx_rf_bandwidth(self, value):
189
- try_set("tx_rf_bandwidth", value, self.token)
190
-
191
- @property
192
- def sample_rate(self): # ! UNTESTED !
193
- """sample_rate: Sample rate RX and TX paths in samples per second"""
194
- return try_get("sample_rate", self.token)
195
-
196
- @sample_rate.setter
197
- def sample_rate(self, rate): # ! UNTESTED !
198
- try_set("sample_rate", rate, self.token)
199
-
200
- @property
201
- def rx_lo(self):
202
- """rx_lo: Carrier frequency of RX path"""
203
- return try_get("rx_lo", self.token)
204
-
205
- @rx_lo.setter
206
- def rx_lo(self, value):
207
- try_set("rx_lo", value, self.token)
208
-
209
- @property
210
- def tx_lo(self):
211
- """tx_lo: Carrier frequency of TX path"""
212
- return try_get("tx_lo", self.token)
213
-
214
- @tx_lo.setter
215
- def tx_lo(self, value):
216
- try_set("tx_lo", value, self.token)
217
-
218
- @property
219
- def tx_cyclic_buffer(self):
220
- """tx_cyclic_buffer: Size of cyclic buffer"""
221
- return try_get("tx_cyclic_buffer", self.token)
222
-
223
- @tx_cyclic_buffer.setter
224
- def tx_cyclic_buffer(self, value):
225
- try_set("tx_cyclic_buffer", value, self.token)
226
-
227
- def tx_destroy_buffer(self):
228
- try_call_0_arg("tx_destroy_buffer", self.token)
229
-
230
- def rx_destroy_buffer(self):
231
- try_call_0_arg("rx_destroy_buffer", self.token)
232
-
233
- #endregion
234
- #region rx_def
235
-
236
- def rx(self):
237
- return try_get("rx", self.token)
238
-
239
- @property
240
- def rx_buffer_size(self):
241
- return try_get("rx_buffer_size", self.token)
242
-
243
- @rx_buffer_size.setter
244
- def rx_buffer_size(self, value):
245
- try_set("rx_buffer_size", value, self.token)
246
-
247
- #endregion
248
- #region tx_def
249
-
250
- def tx(self, value):
251
- return try_call_1_arg("tx", value, self.token)
252
-
253
- # @tx.setter
254
- # def tx(self, value):
255
- # try_set("tx", value, self.token)
256
-
257
- #endregion
258
-
259
- #region tx
260
-
261
- #endregion
262
-
263
- #region _dec_int_fpga_filter
264
-
265
- """Decimator and interpolator fpga filter controls"""
266
-
267
- def _get_rates(self, dev, output): # ! UNTESTED !
268
- """Get the decimation and interpolation rates"""
269
- return try_get("rates", self.token)
270
-
271
- @property
272
- def rx_dec8_filter_en(self) -> bool: # ! UNTESTED !
273
- """rx_dec8_filter_en: Enable decimate by 8 filter in FPGA"""
274
- return try_get("rx_dec8_filter_en", self.token)
275
-
276
- @rx_dec8_filter_en.setter
277
- def rx_dec8_filter_en(self, value: bool): # ! UNTESTED !
278
- """rx_dec8_filter_en: Enable decimate by 8 filter in FPGA"""
279
- return try_set("rx_dec8_filter_en", value, self.token)
280
-
281
- @property
282
- def tx_int8_filter_en(self) -> bool: # ! UNTESTED !
283
- """tx_int8_filter_en: Enable interpolate by 8 filter in FPGA"""
284
- return try_get("tx_int8_filter_en", self.token)
285
-
286
- @tx_int8_filter_en.setter
287
- def tx_int8_filter_en(self, value: bool): # ! UNTESTED !
288
- """tx_int8_filter_en: Enable interpolate by 8 filter in FPGA"""
289
- return try_set("tx_int8_filter_en", value, self.token)
290
-
291
- #endregion
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes