RemoteRF 2.1.0__tar.gz → 2.1.9__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 (96) hide show
  1. {remoterf-2.1.0 → remoterf-2.1.9}/PKG-INFO +1 -1
  2. {remoterf-2.1.0 → remoterf-2.1.9}/pyproject.toml +1 -1
  3. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/core/app.py +35 -18
  4. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/core/direct_path.py +105 -40
  5. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/core/grpc_acc.py +43 -30
  6. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/core/grpc_client.py +6 -8
  7. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/deployment/backend.py +2 -2
  8. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/deployment/http.py +1 -1
  9. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/remoterf_cli.py +11 -7
  10. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoterf.egg-info/PKG-INFO +1 -1
  11. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoterf.egg-info/SOURCES.txt +2 -1
  12. remoterf-2.1.9/tests/test_cli_login_fresh.py +54 -0
  13. remoterf-2.1.9/tests/test_direct_path_race.py +129 -0
  14. {remoterf-2.1.0 → remoterf-2.1.9}/tests/test_finish_prompt.py +8 -3
  15. {remoterf-2.1.0 → remoterf-2.1.9}/tests/test_gate_i_direct_path.py +10 -4
  16. {remoterf-2.1.0 → remoterf-2.1.9}/tests/test_homes.py +6 -4
  17. {remoterf-2.1.0 → remoterf-2.1.9}/tests/test_remoterf_cli.py +1 -24
  18. remoterf-2.1.0/src/remoteRF/drivers/adalm_pluto/pluto_remote.py +0 -291
  19. {remoterf-2.1.0 → remoterf-2.1.9}/LICENSE +0 -0
  20. {remoterf-2.1.0 → remoterf-2.1.9}/README.md +0 -0
  21. {remoterf-2.1.0 → remoterf-2.1.9}/setup.cfg +0 -0
  22. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/__init__.py +0 -0
  23. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/common/__init__.py +0 -0
  24. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/common/grpc/__init__.py +0 -0
  25. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/common/grpc/deployment_capabilities.proto +0 -0
  26. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/common/grpc/deployment_capabilities_pb2.py +0 -0
  27. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/common/grpc/deployment_capabilities_pb2_grpc.py +0 -0
  28. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/common/grpc/grpc.proto +0 -0
  29. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/common/grpc/grpc_pb2.py +0 -0
  30. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/common/grpc/grpc_pb2_grpc.py +0 -0
  31. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/common/grpc/v2_codec.py +0 -0
  32. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/common/tos_notice.txt +0 -0
  33. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/common/utils/__init__.py +0 -0
  34. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/common/utils/ansi_codes.py +0 -0
  35. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/common/utils/api_token.py +0 -0
  36. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/common/utils/banner.py +0 -0
  37. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/common/utils/list_string.py +0 -0
  38. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/common/utils/process_arg.py +0 -0
  39. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/config/__init__.py +0 -0
  40. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/config/cert_fetcher.py +0 -0
  41. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/config/config.py +0 -0
  42. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/core/__init__.py +0 -0
  43. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/core/acc_login.py +0 -0
  44. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/core/banner_note.txt +0 -0
  45. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/core/dynamic_v2_transport.py +0 -0
  46. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/core/secure_channel.py +0 -0
  47. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/core/v2_errors.py +0 -0
  48. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/deployment/__init__.py +0 -0
  49. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/deployment/direct.py +0 -0
  50. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/deployment/homes.py +0 -0
  51. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/deployment/state.py +0 -0
  52. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/drivers/__init__.py +0 -0
  53. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/drivers/_virtual.py +0 -0
  54. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/drivers/adalm_pluto/__init__.py +0 -0
  55. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/drivers/adalm_pluto/adalm_pluto_remote.py +0 -0
  56. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/drivers/dynamic_device.py +0 -0
  57. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/drivers/dynamic_v2.py +0 -0
  58. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/drivers/hackrf/__init__.py +0 -0
  59. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/drivers/hackrf/hackrf_remote.py +0 -0
  60. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/drivers/rtl_sdr/__init__.py +0 -0
  61. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/drivers/rtl_sdr/rtl_sdr_remote.py +0 -0
  62. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/drivers/sim_sdr/__init__.py +0 -0
  63. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/drivers/sim_sdr/sim_sdr_remote.py +0 -0
  64. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/drivers/support/__init__.py +0 -0
  65. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/drivers/support/ti_mmwave.py +0 -0
  66. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/drivers/support/uhd.py +0 -0
  67. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/drivers/support/uhd_dsp.py +0 -0
  68. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/drivers/support/uhd_v2.py +0 -0
  69. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/drivers/ti_mmwave/__init__.py +0 -0
  70. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/drivers/ti_mmwave/ti_mmwave_remote.py +0 -0
  71. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/drivers/usrp/__init__.py +0 -0
  72. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/drivers/usrp/usrp_remote.py +0 -0
  73. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/drivers/usrp/usrp_remote.pyi +0 -0
  74. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF/version.py +0 -0
  75. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoteRF.py +0 -0
  76. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoterf.egg-info/dependency_links.txt +0 -0
  77. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoterf.egg-info/entry_points.txt +0 -0
  78. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoterf.egg-info/requires.txt +0 -0
  79. {remoterf-2.1.0 → remoterf-2.1.9}/src/remoterf.egg-info/top_level.txt +0 -0
  80. {remoterf-2.1.0 → remoterf-2.1.9}/tests/test_banner.py +0 -0
  81. {remoterf-2.1.0 → remoterf-2.1.9}/tests/test_config_address.py +0 -0
  82. {remoterf-2.1.0 → remoterf-2.1.9}/tests/test_deployment_accounts.py +0 -0
  83. {remoterf-2.1.0 → remoterf-2.1.9}/tests/test_direct_path_fixed.py +0 -0
  84. {remoterf-2.1.0 → remoterf-2.1.9}/tests/test_direct_path_partner.py +0 -0
  85. {remoterf-2.1.0 → remoterf-2.1.9}/tests/test_dynamic_device_codegen.py +0 -0
  86. {remoterf-2.1.0 → remoterf-2.1.9}/tests/test_dynamic_v2.py +0 -0
  87. {remoterf-2.1.0 → remoterf-2.1.9}/tests/test_federated_devices.py +0 -0
  88. {remoterf-2.1.0 → remoterf-2.1.9}/tests/test_fetch_idl_fallback.py +0 -0
  89. {remoterf-2.1.0 → remoterf-2.1.9}/tests/test_grpc_client_target.py +0 -0
  90. {remoterf-2.1.0 → remoterf-2.1.9}/tests/test_native_account_credentials.py +0 -0
  91. {remoterf-2.1.0 → remoterf-2.1.9}/tests/test_native_devices.py +0 -0
  92. {remoterf-2.1.0 → remoterf-2.1.9}/tests/test_partner_dynamic_v2.py +0 -0
  93. {remoterf-2.1.0 → remoterf-2.1.9}/tests/test_pluto_virtual.py +0 -0
  94. {remoterf-2.1.0 → remoterf-2.1.9}/tests/test_process_arg.py +0 -0
  95. {remoterf-2.1.0 → remoterf-2.1.9}/tests/test_ti_mmwave.py +0 -0
  96. {remoterf-2.1.0 → remoterf-2.1.9}/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.9
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.9"
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"
@@ -230,8 +230,8 @@ def _account_command(command):
230
230
  # out of quota); there is no token coming. Straight on to login.
231
231
  return False
232
232
  printf("Verify your email", (Sty.BOLD, Sty.BLUE))
233
- printf(" Paste the token from the verification email.", Sty.GRAY)
234
- account.backend.verify(_ask('Verification token', hidden=True))
233
+ printf(" Enter the 6-digit code from the verification email.", Sty.GRAY)
234
+ account.backend.verify(account.username or account.email, _ask('Verification code').strip())
235
235
  printf('Email verified. You can now log in.', (Sty.BOLD, Sty.GREEN))
236
236
  return False
237
237
  if command in ('forgot-password', 'reset-password'):
@@ -349,7 +349,8 @@ def commands():
349
349
  printf("'cancelres' ", Sty.MAGENTA, " : ", Sty.GRAY, "Cancel a reservation", Sty.DEFAULT)
350
350
  printf("'getres' ", Sty.MAGENTA, " : ", Sty.GRAY, "View all reservations", Sty.DEFAULT)
351
351
  printf("'myres' ", Sty.MAGENTA, " : ", Sty.GRAY, "View my reservations", Sty.DEFAULT)
352
- printf("'perms' ", Sty.MAGENTA, " : ", Sty.GRAY, "View permissions", Sty.DEFAULT)
352
+ if not account.is_https_home:
353
+ printf("'perms' ", Sty.MAGENTA, " : ", Sty.GRAY, "View permissions", Sty.DEFAULT)
353
354
  printf("'enroll' ", Sty.MAGENTA, " : ", Sty.GRAY, "Enroll with an enrollment code", Sty.DEFAULT)
354
355
  printf("'logout' ", Sty.MAGENTA, " : ", Sty.GRAY, "Log out and forget the stored login", Sty.DEFAULT)
355
356
  printf("'status' ", Sty.MAGENTA, " : ", Sty.GRAY, "Connection and the path device calls use", Sty.DEFAULT)
@@ -538,6 +539,8 @@ def _federated_reserve_slot(device_id, chosen_day, chosen_slot, slot_start_str,
538
539
  printf("Reservation successful for ", (Sty.BOLD, Sty.GREEN), f"{chosen_day.strftime('%Y-%m-%d')} {slot_start_str}-{slot_end_str}.", Sty.CYAN)
539
540
  printf("Your device handle -> ", Sty.BOLD, f"{handle}", (Sty.BOLD, Sty.GREEN))
540
541
  printf("Use it where a token goes, e.g. adi.Pluto(\"" + handle + "\"); it works while you are logged in to this home.", Sty.DEFAULT)
542
+ printf("The first call can take ~20 s while the radio powers on.", Sty.GRAY)
543
+ account.install_reserved_driver(handle, device_id)
541
544
 
542
545
 
543
546
  def _federated_reserve():
@@ -581,7 +584,8 @@ def _federated_reserve():
581
584
  )
582
585
  return
583
586
  _pick_slot_and_reserve(dev["device_id"], dev["display_name"], dev["block_min"], _federated_reservations_for_range, _federated_reserve_slot,
584
- device_label=f"{dev['display_name']} @{dev['deployment_name']}")
587
+ device_label=f"{dev['display_name']} @{dev['deployment_name']}",
588
+ max_days=-(-int(dev.get("max_horizon_sec") or 0) // 86400) or None)
585
589
 
586
590
 
587
591
  def reservations():
@@ -841,6 +845,7 @@ def reserve():
841
845
  if token != '':
842
846
  printf(f"Reservation successful. Your Token -> ", Sty.BOLD, f"{token}", Sty.BG_GREEN)
843
847
  printf(f"Please keep this token safe, as it is not saved on the server and cannot be retrieved again. If you lose it, cancel your reservation and make a new one. ", Sty.DEFAULT)
848
+ account.install_reserved_driver(token, rows[int(pick) - 1]["id"])
844
849
  except Exception as e:
845
850
  printf(f"Error: {e}", Sty.BRIGHT_RED)
846
851
 
@@ -985,7 +990,7 @@ def perms():
985
990
  printf("Accessible Devices: ", (Sty.BOLD, Sty.BLUE), f"{devices}", Sty.MAGENTA)
986
991
 
987
992
  # Build per-device caps and group identical limits together
988
- buckets: dict[tuple[int, int], list[int]] = {} # (max_r, max_t_sec) -> [dev_ids]
993
+ buckets: dict[tuple[int, int, int], list[int]] = {} # (max_r, max_t_sec, horizon_sec) -> [dev_ids]
989
994
  for d in devices:
990
995
  try:
991
996
  did = int(d)
@@ -994,7 +999,8 @@ def perms():
994
999
  c = _cap_for(did)
995
1000
  max_t = int(c.get("max_reservation_time_sec", 0) or 0)
996
1001
  max_r = int(c.get("max_reservations", 0) or 0)
997
- buckets.setdefault((max_r, max_t), []).append(did)
1002
+ max_h = int(c.get("max_horizon_sec", 0) or 0)
1003
+ buckets.setdefault((max_r, max_t, max_h), []).append(did)
998
1004
 
999
1005
  if not buckets:
1000
1006
  print("Limits per device: (none)")
@@ -1003,16 +1009,17 @@ def perms():
1003
1009
 
1004
1010
  # If everything shares the same limits, print once
1005
1011
  if len(buckets) == 1:
1006
- (max_r, max_t), _devs = next(iter(buckets.items()))
1012
+ (max_r, max_t, max_h), _devs = next(iter(buckets.items()))
1007
1013
  printf("Permissions:", (Sty.BOLD, Sty.BLUE))
1008
1014
  printf(" Max Reservations: ", Sty.GRAY, f"{max_r}", Sty.CYAN)
1009
1015
  printf(" Reservation Duration (min): ", Sty.GRAY, f"{max_t // 60}", Sty.CYAN)
1016
+ printf(" Days Ahead: ", Sty.GRAY, f"{max_h / 86400:g}" if max_h else "no limit", Sty.CYAN)
1010
1017
  render_remote_permissions()
1011
1018
  return
1012
1019
 
1013
1020
  # Otherwise print grouped limits
1014
1021
  printf("Permissions per device (grouped):", (Sty.BOLD, Sty.BLUE))
1015
- for (max_r, max_t), devs in sorted(buckets.items(), key=lambda kv: (kv[0][0], kv[0][1], kv[1])):
1022
+ for (max_r, max_t, max_h), devs in sorted(buckets.items(), key=lambda kv: (kv[0][0], kv[0][1], kv[0][2], kv[1])):
1016
1023
  devs = sorted(devs)
1017
1024
 
1018
1025
  # compress ranges like 0-3,5,7-9
@@ -1031,7 +1038,7 @@ def perms():
1031
1038
  ranges.append(f"{start}-{prev}" if start != prev else f"{start}")
1032
1039
 
1033
1040
  dev_str = ",".join(ranges)
1034
- printf(" devices[", Sty.GRAY, f"{dev_str}", Sty.MAGENTA, "]: ", Sty.GRAY, f"max_reservations={max_r}, max_time_min={max_t // 60}", Sty.CYAN)
1041
+ printf(" devices[", Sty.GRAY, f"{dev_str}", Sty.MAGENTA, "]: ", Sty.GRAY, f"max_reservations={max_r}, max_time_min={max_t // 60}, days_ahead={f'{max_h / 86400:g}' if max_h else 'no limit'}", Sty.CYAN)
1035
1042
 
1036
1043
  render_remote_permissions()
1037
1044
 
@@ -1278,6 +1285,7 @@ def interactive_reserve_next_days(block_minutes=60):
1278
1285
  printf("Reservation successful on device ", (Sty.BOLD, Sty.GREEN), f"{chosen_device_id}", Sty.MAGENTA, " for ", Sty.DEFAULT, f"{chosen_day.strftime('%Y-%m-%d')} {slot_start_str}-{slot_end_str}.", Sty.CYAN)
1279
1286
  printf("Your Token -> ", Sty.BOLD, f"{token}", (Sty.BOLD, Sty.GREEN))
1280
1287
  printf("Please keep this token safe, as it is not saved on the server and cannot be retrieved again. If you lose it, cancel your reservation and make a new one.", Sty.DEFAULT)
1288
+ account.install_reserved_driver(token, int(chosen_device_id))
1281
1289
 
1282
1290
  except Exception as e:
1283
1291
  print(f"Error: {e}")
@@ -1306,16 +1314,18 @@ def _auto_block_minutes(max_t_sec: int) -> int:
1306
1314
  break
1307
1315
  return best
1308
1316
 
1309
- def _pick_slot_and_reserve(chosen_device_id, chosen_device_name, block_minutes, reservations_for_range, do_reserve, *, device_label=None):
1317
+ def _pick_slot_and_reserve(chosen_device_id, chosen_device_name, block_minutes, reservations_for_range, do_reserve, *, device_label=None, max_days=None):
1310
1318
  """Shared `resdev` slot picker: days -> free slots -> pick -> confirm -> reserve.
1311
1319
  Native and federated homes differ only in how reservations are fetched and
1312
- how the reservation is made (token vs. HOME handle)."""
1320
+ how the reservation is made (token vs. HOME handle). `max_days` is the
1321
+ group's reservation horizon; the server refuses starts beyond it anyway."""
1313
1322
  device_label = chosen_device_id if device_label is None else device_label
1314
- num_days_s = session.prompt(stylize("Enter the number of days to check for available reservations (starting today): ", (Sty.BOLD, Sty.GREEN))).strip()
1323
+ limit = f" (up to {max_days})" if max_days else ""
1324
+ num_days_s = session.prompt(stylize(f"Enter the number of days to check for available reservations (starting today){limit}: ", (Sty.BOLD, Sty.GREEN))).strip()
1315
1325
  try:
1316
1326
  num_days = int(num_days_s)
1317
- if num_days <= 0:
1318
- print("Invalid number of days.")
1327
+ if num_days <= 0 or (max_days and num_days > max_days):
1328
+ print("Invalid number of days." if num_days <= 0 else f"Your group may reserve at most {max_days} days ahead.")
1319
1329
  return
1320
1330
  except ValueError:
1321
1331
  print("Invalid input. Please enter a number.")
@@ -1419,6 +1429,7 @@ def _native_reserve_slot(chosen_device_id, chosen_day, chosen_slot, slot_start_s
1419
1429
  printf("Reservation successful on device ", (Sty.BOLD, Sty.GREEN), f"{chosen_device_id}", Sty.MAGENTA, " for ", Sty.DEFAULT, f"{chosen_day.strftime('%Y-%m-%d')} {slot_start_str}-{slot_end_str}.", Sty.CYAN)
1420
1430
  printf("Your Token -> ", Sty.BOLD, f"{token}", (Sty.BOLD, Sty.GREEN))
1421
1431
  printf("Please keep this token safe, as it is not saved on the server and cannot be retrieved again. If you lose it, cancel your reservation and make a new one.", Sty.DEFAULT)
1432
+ account.install_reserved_driver(token, int(chosen_device_id))
1422
1433
 
1423
1434
 
1424
1435
  def interactive_reserve_next_days_auto():
@@ -1492,7 +1503,10 @@ def interactive_reserve_next_days_auto():
1492
1503
  sorted_device_ids = sorted(dev_resp.results.keys(), key=int) # strings
1493
1504
 
1494
1505
  if allowed_dev_ids is not None:
1495
- sorted_device_ids = [d for d in sorted_device_ids if int(d) in allowed_dev_ids]
1506
+ # A partner lab's device is listed only when this account is enrolled
1507
+ # there; it needs no local group (and has no local caps: 30 min blocks).
1508
+ sorted_device_ids = [d for d in sorted_device_ids
1509
+ if int(d) in allowed_dev_ids or _PARTNER_LABEL.match(str(unmap_arg(dev_resp.results[d])))]
1496
1510
 
1497
1511
  if not sorted_device_ids:
1498
1512
  printf("No devices available for your permission level.", Sty.WARNING)
@@ -1501,6 +1515,7 @@ def interactive_reserve_next_days_auto():
1501
1515
  printf("Devices:", Sty.BOLD)
1502
1516
  block_by_dev: dict[str, int] = {}
1503
1517
  max_by_dev: dict[str, int] = {}
1518
+ days_by_dev: dict[str, int] = {}
1504
1519
 
1505
1520
  for idx, dev_id in enumerate(sorted_device_ids):
1506
1521
  dev_name = unmap_arg(dev_resp.results[dev_id])
@@ -1510,6 +1525,7 @@ def interactive_reserve_next_days_auto():
1510
1525
  c = _cap_for(int(dev_id))
1511
1526
  try:
1512
1527
  max_t_sec = int(c.get("max_reservation_time_sec", 0) or 0)
1528
+ days_by_dev[str(dev_id)] = -(-int(c.get("max_horizon_sec", 0) or 0) // 86400)
1513
1529
  except Exception:
1514
1530
  max_t_sec = 0
1515
1531
  elif perm_level == "Power User" and power_user_max_t_sec is not None:
@@ -1562,7 +1578,8 @@ def interactive_reserve_next_days_auto():
1562
1578
  )
1563
1579
  return
1564
1580
 
1565
- _pick_slot_and_reserve(chosen_device_id, chosen_device_name, block_minutes, fetch_reservations_for_range, _native_reserve_slot)
1581
+ _pick_slot_and_reserve(chosen_device_id, chosen_device_name, block_minutes, fetch_reservations_for_range, _native_reserve_slot,
1582
+ max_days=days_by_dev.get(chosen_device_id) or None)
1566
1583
 
1567
1584
  except Exception as e:
1568
1585
  print(f"Error: {e}")
@@ -1635,7 +1652,7 @@ def run(backend=None, *, register=False, enrollment_code=None, server_label=None
1635
1652
  devices()
1636
1653
  elif inpu == "help" or inpu == "h":
1637
1654
  commands()
1638
- elif inpu == "perms":
1655
+ elif inpu == "perms" and not account.is_https_home:
1639
1656
  perms()
1640
1657
  elif inpu == "enroll":
1641
1658
  enroll()
@@ -1666,7 +1683,7 @@ def run(backend=None, *, register=False, enrollment_code=None, server_label=None
1666
1683
  reserve()
1667
1684
  elif account.is_admin and inpu.strip().startswith("admin"):
1668
1685
  handle_admin_command(inpu)
1669
- else:
1686
+ elif inpu.strip():
1670
1687
  print(f"Unknown command: {inpu}")
1671
1688
  except AccountBackendError as exc:
1672
1689
  label = f" [{exc.provenance}]" if exc.provenance else ""
@@ -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 or "shielded future" 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:
@@ -20,6 +20,12 @@ from ..common.utils import *
20
20
 
21
21
  import datetime
22
22
 
23
+ def _busy(label):
24
+ """A spinner while the home asks its labs: a slow lab is a wait, not a hang."""
25
+ from ..remoterf_cli import _working
26
+ return _working(label)
27
+
28
+
23
29
  class RemoteRFAccount:
24
30
  def __init__(self, username:str=None, password:str=None, email:str=None, *, backend=None):
25
31
  self.username = username
@@ -93,51 +99,58 @@ class RemoteRFAccount:
93
99
 
94
100
  def reserve_device(self, device_id, start_time:datetime, end_time:datetime):
95
101
  if self.is_https_home:
96
- reservation = self.backend.reserve(device_id, int(start_time.timestamp()), int(end_time.timestamp()))
97
- try:
98
- from ..drivers.dynamic_device import install_driver
99
- install_driver(device_id=reservation['device_id'])
100
- except Exception as e:
101
- print(f"Warning: could not install driver for device {reservation['device_id']}: {e}")
102
+ with _busy("Reserving…"):
103
+ reservation = self.backend.reserve(device_id, int(start_time.timestamp()), int(end_time.timestamp()))
102
104
  # The HOME reference is the driver handle: adi.Pluto("<deployment>:<id>")
103
105
  return reservation['device_id']
104
106
  username, credential_secret = self._rpc_credentials()
105
- response = self._call(function_name="ACC:reserve_device", args={"un":map_arg(username), "pw":map_arg(credential_secret), "dd":map_arg(device_id), "st":map_arg(int(start_time.timestamp())), "et":map_arg(int(end_time.timestamp()))})
107
+ with _busy("Reserving…"):
108
+ response = self._call(function_name="ACC:reserve_device", args={"un":map_arg(username), "pw":map_arg(credential_secret), "dd":map_arg(device_id), "st":map_arg(int(start_time.timestamp())), "et":map_arg(int(end_time.timestamp()))})
106
109
 
107
110
  if 'ace' in response.results:
108
111
  raise Exception(f'{unmap_arg(response.results["ace"])}')
109
112
  elif 'Token' in response.results:
110
- token = unmap_arg(response.results["Token"])
111
- try:
112
- from ..drivers.dynamic_device import install_driver
113
- install_driver(token=token, device_id=device_id)
114
- except Exception as e:
115
- print(f"Warning: could not install driver for device {device_id}: {e}")
116
- return token
113
+ return unmap_arg(response.results["Token"])
114
+
115
+ def install_reserved_driver(self, handle, device_id):
116
+ """Separate from reserve_device: the token is shown before this network
117
+ step runs, so a failed or stuck driver fetch cannot lose it."""
118
+ try:
119
+ from ..drivers.dynamic_device import install_driver
120
+ if self.is_https_home:
121
+ install_driver(device_id=handle)
122
+ else:
123
+ install_driver(token=handle, device_id=device_id)
124
+ except Exception as e:
125
+ print(f"Warning: could not install driver for device {device_id}: {e}")
117
126
 
118
127
  def get_reservations(self):
119
- if self.is_https_home:
120
- return self.backend.reservations()
121
- username, credential_secret = self._rpc_credentials()
122
- return self._call(function_name='ACC:get_res', args={"un":map_arg(username), "pw":map_arg(credential_secret)})
128
+ with _busy("Asking the labs for reservations…"):
129
+ if self.is_https_home:
130
+ return self.backend.reservations()
131
+ username, credential_secret = self._rpc_credentials()
132
+ return self._call(function_name='ACC:get_res', args={"un":map_arg(username), "pw":map_arg(credential_secret)})
123
133
 
124
134
  def get_devices(self):
125
- if self.is_https_home:
126
- return self.backend.devices()
127
- username, credential_secret = self._rpc_credentials()
128
- return self._call(function_name='ACC:get_dev', args={"un":map_arg(username), "pw":map_arg(credential_secret)})
135
+ with _busy("Asking the labs for devices…"):
136
+ if self.is_https_home:
137
+ return self.backend.devices()
138
+ username, credential_secret = self._rpc_credentials()
139
+ return self._call(function_name='ACC:get_dev', args={"un":map_arg(username), "pw":map_arg(credential_secret)})
129
140
 
130
141
  def cancel_reservation(self, res_id):
131
- if self.is_https_home:
132
- return self.backend.cancel(res_id)
133
- username, credential_secret = self._rpc_credentials()
134
- return self._call(function_name='ACC:cancel_res', args={"un":map_arg(username), "pw":map_arg(credential_secret), "res_id":map_arg(res_id)})
142
+ with _busy("Cancelling…"):
143
+ if self.is_https_home:
144
+ return self.backend.cancel(res_id)
145
+ username, credential_secret = self._rpc_credentials()
146
+ return self._call(function_name='ACC:cancel_res', args={"un":map_arg(username), "pw":map_arg(credential_secret), "res_id":map_arg(res_id)})
135
147
 
136
148
  def get_perms(self):
137
- if self.is_https_home:
138
- return self.backend.permissions()
139
- username, credential_secret = self._rpc_credentials()
140
- return self._call(function_name='ACC:get_perms', args={"un":map_arg(username), "pw":map_arg(credential_secret)})
149
+ with _busy("Asking the labs for permissions…"):
150
+ if self.is_https_home:
151
+ return self.backend.permissions()
152
+ username, credential_secret = self._rpc_credentials()
153
+ return self._call(function_name='ACC:get_perms', args={"un":map_arg(username), "pw":map_arg(credential_secret)})
141
154
 
142
155
  def logout(self):
143
156
  """Revoke the stored session on the deployment (best effort)."""
@@ -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
 
@@ -111,10 +111,10 @@ class HttpsJsonAccountBackend(DeploymentAccountBackend):
111
111
  data={"username": name, "email": email, "password": password},
112
112
  )
113
113
 
114
- def verify(self, token):
114
+ def verify(self, login, code):
115
115
  self._operation("ACC:verify_email")
116
116
  return self.transport.request(
117
- "POST", self.base + "/verify-email", data={"token": token}
117
+ "POST", self.base + "/verify-email", data={"login": login, "code": code}
118
118
  )
119
119
 
120
120
  def _validate_session(self, value, expected_subject=None):
@@ -73,7 +73,7 @@ class JsonTransport:
73
73
  timeout=timeout,
74
74
  ) as response:
75
75
  raw = bytearray()
76
- for chunk in response.iter_bytes(chunk_size=1):
76
+ for chunk in response.iter_bytes(chunk_size=65536):
77
77
  if time.monotonic() >= deadline:
78
78
  raise AccountBackendError("Account operation deadline exceeded")
79
79
  raw.extend(chunk)