RemoteRF 2.1.0.dev7__py3-none-any.whl → 2.1.2__py3-none-any.whl

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.
@@ -48,7 +48,7 @@ def _json_safe(value):
48
48
  if hasattr(value, "as_payload"):
49
49
  return _json_safe(value.as_payload())
50
50
  if isinstance(value, np.generic):
51
- return value.item()
51
+ return _json_safe(value.item())
52
52
  if isinstance(value, complex):
53
53
  return {
54
54
  _JSON_TYPE_KEY: "complex",
remoteRF/core/app.py CHANGED
@@ -581,7 +581,8 @@ def _federated_reserve():
581
581
  )
582
582
  return
583
583
  _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']}")
584
+ device_label=f"{dev['display_name']} @{dev['deployment_name']}",
585
+ max_days=-(-int(dev.get("max_horizon_sec") or 0) // 86400) or None)
585
586
 
586
587
 
587
588
  def reservations():
@@ -985,7 +986,7 @@ def perms():
985
986
  printf("Accessible Devices: ", (Sty.BOLD, Sty.BLUE), f"{devices}", Sty.MAGENTA)
986
987
 
987
988
  # Build per-device caps and group identical limits together
988
- buckets: dict[tuple[int, int], list[int]] = {} # (max_r, max_t_sec) -> [dev_ids]
989
+ buckets: dict[tuple[int, int, int], list[int]] = {} # (max_r, max_t_sec, horizon_sec) -> [dev_ids]
989
990
  for d in devices:
990
991
  try:
991
992
  did = int(d)
@@ -994,7 +995,8 @@ def perms():
994
995
  c = _cap_for(did)
995
996
  max_t = int(c.get("max_reservation_time_sec", 0) or 0)
996
997
  max_r = int(c.get("max_reservations", 0) or 0)
997
- buckets.setdefault((max_r, max_t), []).append(did)
998
+ max_h = int(c.get("max_horizon_sec", 0) or 0)
999
+ buckets.setdefault((max_r, max_t, max_h), []).append(did)
998
1000
 
999
1001
  if not buckets:
1000
1002
  print("Limits per device: (none)")
@@ -1003,16 +1005,17 @@ def perms():
1003
1005
 
1004
1006
  # If everything shares the same limits, print once
1005
1007
  if len(buckets) == 1:
1006
- (max_r, max_t), _devs = next(iter(buckets.items()))
1008
+ (max_r, max_t, max_h), _devs = next(iter(buckets.items()))
1007
1009
  printf("Permissions:", (Sty.BOLD, Sty.BLUE))
1008
1010
  printf(" Max Reservations: ", Sty.GRAY, f"{max_r}", Sty.CYAN)
1009
1011
  printf(" Reservation Duration (min): ", Sty.GRAY, f"{max_t // 60}", Sty.CYAN)
1012
+ printf(" Days Ahead: ", Sty.GRAY, f"{max_h / 86400:g}" if max_h else "no limit", Sty.CYAN)
1010
1013
  render_remote_permissions()
1011
1014
  return
1012
1015
 
1013
1016
  # Otherwise print grouped limits
1014
1017
  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])):
1018
+ 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
1019
  devs = sorted(devs)
1017
1020
 
1018
1021
  # compress ranges like 0-3,5,7-9
@@ -1031,7 +1034,7 @@ def perms():
1031
1034
  ranges.append(f"{start}-{prev}" if start != prev else f"{start}")
1032
1035
 
1033
1036
  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)
1037
+ 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
1038
 
1036
1039
  render_remote_permissions()
1037
1040
 
@@ -1306,16 +1309,18 @@ def _auto_block_minutes(max_t_sec: int) -> int:
1306
1309
  break
1307
1310
  return best
1308
1311
 
1309
- def _pick_slot_and_reserve(chosen_device_id, chosen_device_name, block_minutes, reservations_for_range, do_reserve, *, device_label=None):
1312
+ 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
1313
  """Shared `resdev` slot picker: days -> free slots -> pick -> confirm -> reserve.
1311
1314
  Native and federated homes differ only in how reservations are fetched and
1312
- how the reservation is made (token vs. HOME handle)."""
1315
+ how the reservation is made (token vs. HOME handle). `max_days` is the
1316
+ group's reservation horizon; the server refuses starts beyond it anyway."""
1313
1317
  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()
1318
+ limit = f" (up to {max_days})" if max_days else ""
1319
+ 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
1320
  try:
1316
1321
  num_days = int(num_days_s)
1317
- if num_days <= 0:
1318
- print("Invalid number of days.")
1322
+ if num_days <= 0 or (max_days and num_days > max_days):
1323
+ print("Invalid number of days." if num_days <= 0 else f"Your group may reserve at most {max_days} days ahead.")
1319
1324
  return
1320
1325
  except ValueError:
1321
1326
  print("Invalid input. Please enter a number.")
@@ -1501,6 +1506,7 @@ def interactive_reserve_next_days_auto():
1501
1506
  printf("Devices:", Sty.BOLD)
1502
1507
  block_by_dev: dict[str, int] = {}
1503
1508
  max_by_dev: dict[str, int] = {}
1509
+ days_by_dev: dict[str, int] = {}
1504
1510
 
1505
1511
  for idx, dev_id in enumerate(sorted_device_ids):
1506
1512
  dev_name = unmap_arg(dev_resp.results[dev_id])
@@ -1510,6 +1516,7 @@ def interactive_reserve_next_days_auto():
1510
1516
  c = _cap_for(int(dev_id))
1511
1517
  try:
1512
1518
  max_t_sec = int(c.get("max_reservation_time_sec", 0) or 0)
1519
+ days_by_dev[str(dev_id)] = -(-int(c.get("max_horizon_sec", 0) or 0) // 86400)
1513
1520
  except Exception:
1514
1521
  max_t_sec = 0
1515
1522
  elif perm_level == "Power User" and power_user_max_t_sec is not None:
@@ -1562,7 +1569,8 @@ def interactive_reserve_next_days_auto():
1562
1569
  )
1563
1570
  return
1564
1571
 
1565
- _pick_slot_and_reserve(chosen_device_id, chosen_device_name, block_minutes, fetch_reservations_for_range, _native_reserve_slot)
1572
+ _pick_slot_and_reserve(chosen_device_id, chosen_device_name, block_minutes, fetch_reservations_for_range, _native_reserve_slot,
1573
+ max_days=days_by_dev.get(chosen_device_id) or None)
1566
1574
 
1567
1575
  except Exception as e:
1568
1576
  print(f"Error: {e}")
@@ -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:
@@ -28,6 +28,7 @@ from ..common.grpc.v2_codec import decode_value, encode_value
28
28
  from .v2_errors import (
29
29
  RemoteRFError,
30
30
  RemoteRFProtocolError,
31
+ RemoteRFReservationError,
31
32
  RemoteRFTransportError,
32
33
  raise_for_envelope,
33
34
  )
@@ -75,6 +76,55 @@ def _payload(value):
75
76
  return value
76
77
 
77
78
 
79
+ class _DevicePlaneStub:
80
+ """DynamicControlV2 and SampleDataV1 for a partner lab's device: each
81
+ request is one ``DynamicV2:<Operation>:WIRE`` device RPC, routed like any
82
+ call on that token (the direct path to the owner when it is up, else the
83
+ HOME, which relays it). The owner runs it through its own servicers."""
84
+
85
+ def __init__(self, token: str):
86
+ self._token = str(token)
87
+
88
+ def _rpc(self, operation, request, response_type):
89
+ from ..common.utils import map_arg, unmap_arg
90
+ from .grpc_client import rpc_client
91
+
92
+ try:
93
+ response = rpc_client(
94
+ function_name=f"DynamicV2:{operation}:WIRE",
95
+ args={"a": map_arg(self._token), "wire": map_arg(request.SerializeToString())},
96
+ )
97
+ except RuntimeError as exc:
98
+ # A refusal the HOME or the owner carried in "a": no live
99
+ # reservation, a revoked agreement, a device no longer visible.
100
+ raise RemoteRFReservationError(f"partner device: {exc}") from exc
101
+ wire = unmap_arg(response.results["wire"]) if "wire" in response.results else None
102
+ if not isinstance(wire, bytes):
103
+ raise RemoteRFProtocolError("partner device plane returned no Dynamic v2 reply")
104
+ return response_type.FromString(wire)
105
+
106
+ def Negotiate(self, request, timeout=None):
107
+ return self._rpc("Negotiate", request, grpc_pb2.NegotiateResponse)
108
+
109
+ def GetSchema(self, request, timeout=None):
110
+ return self._rpc("GetSchema", request, grpc_pb2.GetSchemaResponse)
111
+
112
+ def OpenSession(self, request, timeout=None):
113
+ return self._rpc("OpenSession", request, grpc_pb2.OpenSessionResponse)
114
+
115
+ def Invoke(self, request, timeout=None):
116
+ return self._rpc("Invoke", request, grpc_pb2.InvokeResponse)
117
+
118
+ def CloseHandle(self, request, timeout=None):
119
+ return self._rpc("CloseHandle", request, grpc_pb2.CloseResponse)
120
+
121
+ def CloseSession(self, request, timeout=None):
122
+ return self._rpc("CloseSession", request, grpc_pb2.CloseResponse)
123
+
124
+ def SampleStream(self, frames, timeout=None):
125
+ return [self._rpc("SampleFrame", frame, grpc_pb2.SampleFrame) for frame in frames]
126
+
127
+
78
128
  class DynamicV2Transport:
79
129
  def __init__(
80
130
  self,
@@ -82,15 +132,21 @@ class DynamicV2Transport:
82
132
  control_stub=None,
83
133
  sample_stub=None,
84
134
  control_timeout_sec: float = 30.0,
135
+ open_timeout_sec: float = 120.0,
85
136
  stream_timeout_margin_sec: float = 5.0,
86
137
  ):
87
138
  self.control_timeout_sec = float(control_timeout_sec)
139
+ # A cold USRP open loads firmware/FPGA (B2xx re-enumerates on USB),
140
+ # which can outlast the normal control deadline.
141
+ self.open_timeout_sec = float(open_timeout_sec)
88
142
  self.stream_timeout_margin_sec = float(stream_timeout_margin_sec)
89
143
  if (
90
144
  not math.isfinite(self.control_timeout_sec)
91
145
  or self.control_timeout_sec <= 0
92
146
  ):
93
147
  raise ValueError("control_timeout_sec must be finite and positive")
148
+ if not math.isfinite(self.open_timeout_sec) or self.open_timeout_sec <= 0:
149
+ raise ValueError("open_timeout_sec must be finite and positive")
94
150
  if (
95
151
  not math.isfinite(self.stream_timeout_margin_sec)
96
152
  or self.stream_timeout_margin_sec <= 0
@@ -107,9 +163,35 @@ class DynamicV2Transport:
107
163
  self.control = control_stub
108
164
  self.samples = sample_stub
109
165
 
110
- def _call(self, fn, request):
166
+ def _to_owner(self, token: str, owner: str | None = None) -> bool:
167
+ """Carry this transport over the device plane when ``token`` drives a
168
+ partner lab's device (named by the HOME's refusal, or already known)."""
169
+ from . import direct_path
170
+
171
+ if isinstance(self.control, _DevicePlaneStub):
172
+ return False
173
+ owner = owner or direct_path.partner_of(str(token))
174
+ if not owner:
175
+ return False
176
+ # Marked, the token's device calls take the direct path to the owner.
177
+ direct_path.mark_partner(str(token), str(owner))
178
+ self.control = self.samples = _DevicePlaneStub(token)
179
+ return True
180
+
181
+ def _checked(self, response, token: str) -> bool:
182
+ """raise_for_envelope, except for the refusal of a partner's token:
183
+ True when the call should be repeated over the device plane."""
184
+ try:
185
+ raise_for_envelope(response.error)
186
+ except RemoteRFProtocolError as exc:
187
+ if self._to_owner(token, exc.details.get("federated")):
188
+ return True
189
+ raise
190
+ return False
191
+
192
+ def _call(self, fn, request, *, timeout: float | None = None):
111
193
  try:
112
- return fn(request, timeout=self.control_timeout_sec)
194
+ return fn(request, timeout=timeout or self.control_timeout_sec)
113
195
  except grpc.RpcError as exc:
114
196
  raise RemoteRFTransportError(
115
197
  f"Dynamic v2 RPC failed: {exc.code().name}: {exc.details()}",
@@ -141,6 +223,7 @@ class DynamicV2Transport:
141
223
  return response
142
224
 
143
225
  def get_schema(self, token: str) -> dict:
226
+ self._to_owner(token)
144
227
  self.negotiate()
145
228
  response = self._call(
146
229
  self.control.GetSchema,
@@ -149,7 +232,8 @@ class DynamicV2Transport:
149
232
  schema_versions=[SCHEMA_VERSION],
150
233
  ),
151
234
  )
152
- raise_for_envelope(response.error)
235
+ if self._checked(response, token):
236
+ return self.get_schema(token)
153
237
  try:
154
238
  schema = json.loads(response.schema_json)
155
239
  except (TypeError, json.JSONDecodeError) as exc:
@@ -165,6 +249,7 @@ class DynamicV2Transport:
165
249
  return schema
166
250
 
167
251
  def open_session(self, token: str, schema_hash: str):
252
+ self._to_owner(token)
168
253
  response = self._call(
169
254
  self.control.OpenSession,
170
255
  grpc_pb2.OpenSessionRequest(
@@ -173,8 +258,10 @@ class DynamicV2Transport:
173
258
  control_protocol_version=CONTROL_PROTOCOL_VERSION,
174
259
  streaming_protocol_version=STREAMING_PROTOCOL_VERSION,
175
260
  ),
261
+ timeout=self.open_timeout_sec,
176
262
  )
177
- raise_for_envelope(response.error)
263
+ if self._checked(response, token):
264
+ return self.open_session(token, schema_hash)
178
265
  try:
179
266
  schema = json.loads(response.schema_json)
180
267
  capabilities = json.loads(response.capabilities_json or "{}")
remoteRF/core/grpc_acc.py CHANGED
@@ -110,7 +110,7 @@ class RemoteRFAccount:
110
110
  token = unmap_arg(response.results["Token"])
111
111
  try:
112
112
  from ..drivers.dynamic_device import install_driver
113
- install_driver(device_id=device_id)
113
+ install_driver(token=token, device_id=device_id)
114
114
  except Exception as e:
115
115
  print(f"Warning: could not install driver for device {device_id}: {e}")
116
116
  return token
@@ -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
 
@@ -36,6 +36,15 @@ import grpc
36
36
  DEFAULT_OPTIONS: Tuple[Tuple[str, int], ...] = (
37
37
  ("grpc.max_send_message_length", 100 * 1024 * 1024),
38
38
  ("grpc.max_receive_message_length", 100 * 1024 * 1024),
39
+ # A call in flight when the network changes under it (a laptop roaming to
40
+ # another access point) fails within ~30 s instead of waiting out TCP's
41
+ # own retransmission timeout, ~9 minutes on macOS. Pings go out only while
42
+ # a call is open, at most two per quiet stretch (gRPC's default), which
43
+ # even a Server that never raised its ping allowance accepts.
44
+ ("grpc.keepalive_time_ms", 20_000),
45
+ ("grpc.keepalive_timeout_ms", 10_000),
46
+ # The ack deadline a keepalive ping actually waits on (default 60 s).
47
+ ("grpc.http2.ping_timeout_ms", 10_000),
39
48
  )
40
49
 
41
50
 
@@ -14,10 +14,10 @@
14
14
  # along with this program. If not, see <https://www.gnu.org/licenses/>.
15
15
 
16
16
  # Auto-generated from IDL schema — do not edit by hand.
17
- # device_type: adalm_pluto driver_version: 0.0.2 schema_hash: sha256:fa4603755fdfde9ff8286c95b2f30996df0d6253fdec608976b1aaae9f48354e
17
+ # device_type: adalm_pluto driver_version: 0.0.3 schema_hash: sha256:35b66a1e4383a344046be27ff80d40aff3ebd147e7aa5dd0513303fd3c0cfab4
18
18
 
19
19
  _PREFIX = "Adalm_pluto"
20
- _SCHEMA_HASH = "sha256:fa4603755fdfde9ff8286c95b2f30996df0d6253fdec608976b1aaae9f48354e"
20
+ _SCHEMA_HASH = "sha256:35b66a1e4383a344046be27ff80d40aff3ebd147e7aa5dd0513303fd3c0cfab4"
21
21
  _CLIENT_MODULES = {}
22
22
  _CLIENT_OBJECTS = {}
23
23
 
@@ -375,6 +375,18 @@ class Pluto:
375
375
  'Return a connection identifier for this device (ping / verify).'
376
376
  return _try_call("ip", self.token)
377
377
 
378
+ def power_cycle(self):
379
+ 'PoE Plutos: power off, then on, and wait for boot. No-op on other Plutos.'
380
+ return _try_call("power_cycle", self.token)
381
+
382
+ def power_down(self):
383
+ 'PoE Plutos: cut power; the next command powers it back up. No-op on other Plutos.'
384
+ return _try_call("power_down", self.token)
385
+
386
+ def power_up(self):
387
+ 'PoE Plutos: power on and wait for boot (any command does this when needed). No-op on other Plutos.'
388
+ return _try_call("power_up", self.token)
389
+
378
390
  def rx(self):
379
391
  return _try_call("rx", self.token)
380
392
 
@@ -36,6 +36,7 @@ from __future__ import annotations
36
36
 
37
37
  import json
38
38
  import keyword
39
+ import sys
39
40
  from pathlib import Path
40
41
 
41
42
  from ..common.utils import map_arg, unmap_arg
@@ -94,7 +95,7 @@ def fetch_idl(
94
95
  # A HOME-minted device reference has no direct v2 control channel; the
95
96
  # schema is fetched through the same relayed IDL RPC every driver uses.
96
97
  federated = is_global_ref(token) or is_global_ref(device_id)
97
- if token is not None and prefer_v2 and not federated:
98
+ if token is not None and device_id is None and prefer_v2 and not federated:
98
99
  from .dynamic_v2 import fetch_schema_v2
99
100
  from ..core.v2_errors import RemoteRFProtocolError, RemoteRFReservationError, RemoteRFTransportError
100
101
 
@@ -111,14 +112,19 @@ def fetch_idl(
111
112
  raise
112
113
 
113
114
  args: dict = {}
114
- if token is not None:
115
- args['token'] = map_arg(str(token))
116
- elif device_id is not None:
115
+ if device_id is not None:
117
116
  args['device_id'] = map_arg(device_id if is_global_ref(device_id) else int(device_id))
117
+ elif token is not None:
118
+ args['token'] = map_arg(str(token))
118
119
  elif device_name is not None:
119
120
  args['device_name'] = map_arg(str(device_name))
120
121
  else:
121
122
  raise ValueError("fetch_idl requires token, device_id, or device_name")
123
+ if prefer_v2 and 'token' not in args and not federated:
124
+ # Servers that know v2 answer with it when the device publishes one
125
+ # (by device_id, which needs no active reservation); v1-only devices
126
+ # and older servers answer v1.
127
+ args['schema_versions'] = map_arg(["2.0"])
122
128
 
123
129
  from ..core.grpc_client import rpc_client
124
130
 
@@ -126,13 +132,18 @@ def fetch_idl(
126
132
 
127
133
  if 'error' in resp.results:
128
134
  raise RuntimeError(f"IDL fetch failed: {unmap_arg(resp.results['error'])}")
129
- if token is not None and 'federated' in resp.results:
130
- # A partner lab answered: device calls with this token can punch to it.
131
- from ..core import direct_path
132
-
133
- direct_path.mark_partner(str(token), str(unmap_arg(resp.results['federated'])))
135
+ schema = json.loads(unmap_arg(resp.results['schema']))
136
+ if 'federated' in resp.results:
137
+ if token is not None:
138
+ # A partner lab answered: device calls with this token can punch to it.
139
+ from ..core import direct_path
134
140
 
135
- return json.loads(unmap_arg(resp.results['schema']))
141
+ direct_path.mark_partner(str(token), str(unmap_arg(resp.results['federated'])))
142
+ if str(schema.get("schema_version")) != "2.0":
143
+ # A partner's v1 answer may replace an installed v2 driver (see
144
+ # _write_v1_driver_files); a v2 schema is hashed, so left as is.
145
+ schema["federated"] = True
146
+ return schema
136
147
 
137
148
 
138
149
  # ─────────────────────────────────────────────────────────────────────────────
@@ -1393,8 +1404,14 @@ _DRIVERS_DIR = Path(__file__).parent
1393
1404
 
1394
1405
 
1395
1406
  def _write_driver_files(schema: dict) -> Path:
1396
- if str(schema.get("schema_version")) == "2.0":
1397
- return _write_v2_driver_files(schema)
1407
+ pkg_dir = _write_v2_driver_files(schema) if str(schema.get("schema_version")) == "2.0" else _write_v1_driver_files(schema)
1408
+ stale = f"remoteRF.drivers.{pkg_dir.name}"
1409
+ for name in [m for m in sys.modules if m == stale or m.startswith(stale + ".")]:
1410
+ del sys.modules[name]
1411
+ return pkg_dir
1412
+
1413
+
1414
+ def _write_v1_driver_files(schema: dict) -> Path:
1398
1415
 
1399
1416
  (
1400
1417
  device_type,
@@ -1409,8 +1426,14 @@ def _write_driver_files(schema: dict) -> Path:
1409
1426
  _call_map,
1410
1427
  ) = _schema_maps(schema)
1411
1428
  pkg_dir = _DRIVERS_DIR / device_type
1429
+ remote_path = pkg_dir / f"{device_type}_remote.py"
1430
+ if remote_path.exists() and _is_v2_driver(remote_path) and not schema.get("federated"):
1431
+ # The server answered v1 (older server, or a device that no longer
1432
+ # publishes v2); don't trade an installed Dynamic v2 driver for it.
1433
+ # A partner's device has only the v1 plane, so its answer does.
1434
+ return pkg_dir
1412
1435
  pkg_dir.mkdir(exist_ok=True)
1413
- (pkg_dir / f"{device_type}_remote.py").write_text(_codegen(schema), encoding="utf-8")
1436
+ remote_path.write_text(_codegen(schema), encoding="utf-8")
1414
1437
  init_lines = [
1415
1438
  *_GPL_NOTICE_LINES,
1416
1439
  "from importlib import import_module as _import_module",
@@ -1562,11 +1585,7 @@ def ensure_driver(*, token: str = None, device_id: int = None, device_name: str
1562
1585
  device_name=device_name,
1563
1586
  prefer_v2=True,
1564
1587
  )
1565
- device_type = (
1566
- _require_package_name(schema.get("device_type"), field="device_type")
1567
- if str(schema.get("schema_version")) == "2.0"
1568
- else _schema_maps(schema)[0]
1569
- )
1588
+ device_type = _schema_device_type(schema)
1570
1589
  remote_path = _DRIVERS_DIR / device_type / f"{device_type}_remote.py"
1571
1590
  init_path = remote_path.with_name("__init__.py")
1572
1591
 
@@ -1586,6 +1605,8 @@ def ensure_driver(*, token: str = None, device_id: int = None, device_name: str
1586
1605
  current_hash = _read_schema_hash(remote_path)
1587
1606
  if current_hash != schema.get("schema_hash"):
1588
1607
  _write_driver_files(schema)
1608
+ if _read_schema_hash(remote_path) == current_hash:
1609
+ return
1589
1610
  print(
1590
1611
  f"Driver for '{device_type}' updated "
1591
1612
  f"(was {(current_hash or '?')[:16]}…, "
@@ -1594,6 +1615,19 @@ def ensure_driver(*, token: str = None, device_id: int = None, device_name: str
1594
1615
  )
1595
1616
 
1596
1617
 
1618
+ def _schema_device_type(schema: dict) -> str:
1619
+ if str(schema.get("schema_version")) == "2.0":
1620
+ return _require_package_name(schema.get("device_type"), field="device_type")
1621
+ return _schema_maps(schema)[0]
1622
+
1623
+
1624
+ def _is_v2_driver(path: Path) -> bool:
1625
+ try:
1626
+ return "build_uhd_bindings" in path.read_text(encoding="utf-8")
1627
+ except OSError:
1628
+ return False
1629
+
1630
+
1597
1631
  def _read_schema_hash(path: Path) -> str | None:
1598
1632
  """Extract _SCHEMA_HASH value from a generated driver file without importing it."""
1599
1633
  try:
@@ -1621,12 +1655,14 @@ def install_driver_if_stale(*, token: str, current_hash: str) -> bool:
1621
1655
 
1622
1656
  from remoteRF.drivers.pluto import *
1623
1657
  """
1624
- schema = fetch_idl(token=token, prefer_v2=False)
1658
+ schema = fetch_idl(token=token, prefer_v2=True)
1625
1659
  if schema.get("schema_hash") == current_hash:
1626
1660
  return False
1627
1661
 
1628
- device_type = _schema_maps(schema)[0]
1629
- _write_driver_files(schema)
1662
+ device_type = _schema_device_type(schema)
1663
+ pkg_dir = _write_driver_files(schema)
1664
+ if _read_schema_hash(pkg_dir / f"{device_type}_remote.py") == current_hash:
1665
+ return False
1630
1666
  print(
1631
1667
  f"Driver for '{device_type}' updated "
1632
1668
  f"(was {current_hash[:16]}…, now {schema.get('schema_hash','?')[:16]}…).\n"
@@ -21,7 +21,6 @@ import hashlib
21
21
  import json
22
22
  import keyword
23
23
  import numbers
24
- import re
25
24
  import types
26
25
  import weakref
27
26
 
@@ -83,24 +82,6 @@ _PARAMETER_KINDS = {
83
82
  _PARAMETER_DIRECTIONS = {"in", "out", "inout"}
84
83
 
85
84
 
86
- def _canonical_native_version(value) -> str:
87
- """Strip distro/build metadata while retaining the pinned upstream API."""
88
- text = str(value or "").strip()
89
- if text.lower().startswith("uhd "):
90
- text = text[4:].strip()
91
- match = re.match(
92
- r"^(\d+)\.(\d+)\.(\d+)(?:\.(\d+|main|head))?",
93
- text,
94
- flags=re.IGNORECASE,
95
- )
96
- if match is None:
97
- return text.partition("-")[0].strip()
98
- major, minor, patch, build = match.groups()
99
- if build is None or build.lower() in {"main", "head"}:
100
- build = "0"
101
- return ".".join((major, minor, patch, build))
102
-
103
-
104
85
  def validate_schema_v2(schema: dict) -> dict:
105
86
  if not isinstance(schema, dict) or schema.get("schema_version") != "2.0":
106
87
  raise RemoteRFProtocolError("Dynamic schema_version must be '2.0'")
@@ -223,9 +204,13 @@ def _matches_type(value, wire_type: str) -> bool:
223
204
  return True
224
205
  if choice == "bytes" and isinstance(value, (bytes, bytearray, memoryview)):
225
206
  return True
226
- if choice == "complex" and isinstance(value, (complex, np.complexfloating)):
207
+ if (
208
+ choice == "complex"
209
+ and isinstance(value, (numbers.Complex, np.number))
210
+ and not isinstance(value, bool)
211
+ ):
227
212
  return True
228
- if choice.startswith("list") and isinstance(value, (list, tuple)):
213
+ if choice.startswith("list") and isinstance(value, (list, tuple, np.ndarray)):
229
214
  if "[" not in choice:
230
215
  return True
231
216
  item_type = choice.partition("[")[2].rpartition("]")[0]
@@ -295,7 +280,11 @@ class OverloadBinder:
295
280
  else:
296
281
  raise TypeError(f"missing required argument {name!r}")
297
282
 
298
- if supplied and not _matches_type(value, spec.get("type", "any")):
283
+ if (
284
+ supplied
285
+ and not (value is None and not spec.get("required", True))
286
+ and not _matches_type(value, spec.get("type", "any"))
287
+ ):
299
288
  raise TypeError(
300
289
  f"argument {name!r} must match {spec.get('type')}, "
301
290
  f"not {type(value).__name__}"
@@ -678,23 +667,21 @@ def build_uhd_bindings(schema: dict, *, transport_factory=DynamicV2Transport):
678
667
  self.token = str(token)
679
668
  self._transport = transport_factory()
680
669
  self._transport.negotiate()
681
- opened = self._transport.open_session(self.token, schema["schema_hash"])
682
- required_uhd = str(
683
- schema.get("native_api", {}).get("version") or ""
684
- )
685
- actual_uhd = str(opened.get("uhd_version") or "")
686
- if (
687
- required_uhd
688
- and _canonical_native_version(actual_uhd)
689
- != _canonical_native_version(required_uhd)
690
- ):
691
- try:
692
- self._transport.close_session(opened["session_id"])
693
- finally:
670
+ try:
671
+ opened = self._transport.open_session(self.token, schema["schema_hash"])
672
+ except RemoteRFProtocolError as exc:
673
+ if not exc.details.get("current"):
674
+ raise
675
+ from .dynamic_device import install_driver_if_stale
676
+
677
+ device_type = schema.get("device_type", "usrp")
678
+ if install_driver_if_stale(token=self.token, current_hash=schema["schema_hash"]):
694
679
  raise RemoteRFProtocolError(
695
- f"client requires UHD {required_uhd}; "
696
- f"server reports {actual_uhd or 'unknown'}"
697
- )
680
+ f"the server's {device_type} driver changed and the local copy "
681
+ f"was updated; reimport remoteRF.drivers.{device_type} and retry",
682
+ details=dict(exc.details),
683
+ ) from exc
684
+ raise
698
685
  self._session_id = opened["session_id"]
699
686
  self._handle = opened["device_handle"]
700
687
  self._generation = 0
@@ -833,6 +820,7 @@ def build_uhd_bindings(schema: dict, *, transport_factory=DynamicV2Transport):
833
820
  Range=uhd_v2.Range,
834
821
  MetaRange=uhd_v2.MetaRange,
835
822
  TuneRequest=uhd_v2.TuneRequest,
823
+ TuneRequestPolicy=uhd_v2.TuneRequestPolicy,
836
824
  TuneResult=uhd_v2.TuneResult,
837
825
  StreamCMD=uhd_v2.StreamCMD,
838
826
  StreamMode=uhd_v2.StreamMode,
@@ -864,6 +852,7 @@ def build_uhd_bindings(schema: dict, *, transport_factory=DynamicV2Transport):
864
852
  time_spec=uhd_v2.TimeSpec,
865
853
  device_addr=uhd_v2.DeviceAddr,
866
854
  tune_request=uhd_v2.TuneRequest,
855
+ tune_request_policy=uhd_v2.TuneRequestPolicy,
867
856
  tune_result=uhd_v2.TuneResult,
868
857
  )
869
858
  )
@@ -230,6 +230,10 @@ class TuneRequestPolicy(str, Enum):
230
230
  NONE = "NONE"
231
231
  AUTO = "AUTO"
232
232
  MANUAL = "MANUAL"
233
+ # UHD's own spellings: uhd.types.TuneRequestPolicy.manual
234
+ none = "NONE"
235
+ auto = "AUTO"
236
+ manual = "MANUAL"
233
237
 
234
238
 
235
239
  class TuneRequest(_ValueObject):
@@ -246,6 +250,11 @@ class TuneRequest(_ValueObject):
246
250
  self.dsp_freq = 0.0
247
251
  self.rf_freq_policy = TuneRequestPolicy.AUTO
248
252
  self.dsp_freq_policy = TuneRequestPolicy.AUTO
253
+ if self.lo_offset is not None:
254
+ # As UHD's tune_request_t(target, lo_off): the RF LO sits manually
255
+ # at target + offset and the DSP takes up the difference.
256
+ self.rf_freq_policy = TuneRequestPolicy.MANUAL
257
+ self.rf_freq = self.target_freq + self.lo_offset
249
258
  self.args = DeviceAddr()
250
259
 
251
260
  def as_payload(self):
remoteRF/remoterf_cli.py CHANGED
@@ -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)
@@ -158,6 +158,7 @@ def print_help() -> None:
158
158
  printf("Config:", (Sty.BOLD, Sty.MAGENTA))
159
159
  printf(" remoterf -c | --config [options]", Sty.CYAN)
160
160
  printf(" -a, --addr, -addr <host:port>", Sty.CYAN, " Set target server", Sty.DEFAULT)
161
+ printf(" --cert-port <port>", Sty.CYAN, " Cert server port (default: grpc port + 1)", Sty.DEFAULT)
161
162
  printf(" -w, --wipe, -wipe", Sty.CYAN, " Delete all local config", Sty.DEFAULT)
162
163
  printf(" -y, --yes, -yes", Sty.CYAN, " Skip wipe confirmation", Sty.DEFAULT)
163
164
  printf(" -r, --register", Sty.CYAN, " Register right after configuring", Sty.DEFAULT)
@@ -172,7 +173,7 @@ def print_help() -> None:
172
173
  printf(" remoterf --login global", Sty.GREEN)
173
174
  printf(" remoterf homes", Sty.GREEN)
174
175
  printf(" remoterf --version", Sty.GREEN)
175
- printf(" remoterf --config --addr 123.45.654.321:12321", Sty.GREEN)
176
+ printf(" remoterf --config --addr 203.0.113.10:12321", Sty.GREEN)
176
177
  printf(" remoterf --config --addr global.remoterf.net --register", Sty.GREEN)
177
178
  printf(" remoterf --config --wipe", Sty.GREEN)
178
179
  printf(" remoterf --config --wipe --yes", Sty.GREEN)
@@ -460,6 +461,7 @@ def main() -> int:
460
461
  yes = False
461
462
  register = False
462
463
  account_transport = None
464
+ cert_port_arg = None
463
465
 
464
466
  i = 1
465
467
  while i < len(argv):
@@ -481,6 +483,18 @@ def main() -> int:
481
483
  i += 2
482
484
  continue
483
485
 
486
+ if tok in ("--cert-port", "-cert-port"):
487
+ if i + 1 >= len(argv):
488
+ print("ERROR: missing required argument after --cert-port")
489
+ return 2
490
+ try:
491
+ cert_port_arg = int(argv[i + 1].strip())
492
+ except Exception:
493
+ print("ERROR: --cert-port must be an integer")
494
+ return 2
495
+ i += 2
496
+ continue
497
+
484
498
  if tok in ("--wipe", "-w", "-wipe"):
485
499
  wipe = True
486
500
  i += 1
@@ -549,7 +563,7 @@ def main() -> int:
549
563
  return 2
550
564
 
551
565
  # configure returns the proper exit code
552
- cert_port = port + 1
566
+ cert_port = cert_port_arg if cert_port_arg is not None else port + 1
553
567
  result = configure(host, port, cert_port)
554
568
  if result not in (None, 0):
555
569
  return int(result)
remoteRF.py ADDED
@@ -0,0 +1,39 @@
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
+ """Rescue for an upgrade from remoterf 2.0.7-2.0.13 on Windows and macOS.
17
+
18
+ Those releases installed the package in a lowercase ``remoterf`` folder. When
19
+ that folder survives an upgrade (it holds the generated drivers pip does not
20
+ know about), the new files land inside it, and on a case-insensitive disk
21
+ ``import remoteRF`` no longer finds the package. Python only reaches this
22
+ module then: a correctly named ``remoteRF`` folder always takes precedence.
23
+ """
24
+
25
+ import importlib.util
26
+ import os
27
+ import sys
28
+
29
+ _FOLDER = os.path.join(os.path.dirname(os.path.abspath(__file__)), "remoterf")
30
+ _SPEC = importlib.util.spec_from_file_location(
31
+ __name__, os.path.join(_FOLDER, "__init__.py"), submodule_search_locations=[_FOLDER]
32
+ )
33
+ if _SPEC is None or not os.path.isfile(os.path.join(_FOLDER, "__init__.py")):
34
+ raise ImportError("the remoteRF package folder is missing; reinstall with: pip install --force-reinstall remoterf")
35
+ _MODULE = importlib.util.module_from_spec(_SPEC)
36
+ # The import system returns whatever this name maps to once this module has
37
+ # run: the package itself, so ``remoteRF.<submodule>`` resolves inside it.
38
+ sys.modules[__name__] = _MODULE
39
+ _SPEC.loader.exec_module(_MODULE)
@@ -1,39 +1,27 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remoterf
3
- Version: 2.1.0.dev7
3
+ Version: 2.1.2
4
4
  Summary: A python API to remotely access signal centric hardware. Client-side only! Courtesy of Wireless Lab @ UCLA & Prof. Ian Roberts.
5
- Author: Ethan Ge
6
- Author-email: ethoGalaxy@gmail.com
7
- License: GPL-3.0-or-later
5
+ Author-email: Ethan Ge <ethoGalaxy@gmail.com>
6
+ License-Expression: GPL-3.0-or-later
8
7
  Classifier: Programming Language :: Python :: 3
9
- Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
10
8
  Classifier: Operating System :: OS Independent
11
9
  Requires-Python: >=3.10
12
10
  Description-Content-Type: text/markdown
13
11
  License-File: LICENSE
14
- Requires-Dist: remoterf-federation-core==0.13.0
12
+ Requires-Dist: remoterf-federation-core==0.16.0
15
13
  Requires-Dist: grpcio<2.0.0,>=1.78.1
16
14
  Requires-Dist: protobuf<7.0.0,>=6.31.1
17
15
  Requires-Dist: numpy
18
16
  Requires-Dist: prompt_toolkit
19
17
  Requires-Dist: python-dotenv
20
- Requires-Dist: prompt-toolkit
21
18
  Requires-Dist: httpx<1.0,>=0.27
22
19
  Requires-Dist: certifi
23
20
  Requires-Dist: pydantic<3.0,>=2.6
24
21
  Requires-Dist: keyring<26,>=24
25
22
  Requires-Dist: aioice<0.11,>=0.10
26
23
  Requires-Dist: aioquic<2.0,>=1.2
27
- Dynamic: author
28
- Dynamic: author-email
29
- Dynamic: classifier
30
- Dynamic: description
31
- Dynamic: description-content-type
32
- Dynamic: license
33
24
  Dynamic: license-file
34
- Dynamic: requires-dist
35
- Dynamic: requires-python
36
- Dynamic: summary
37
25
 
38
26
  # Remote RF
39
27
 
@@ -1,5 +1,6 @@
1
+ remoteRF.py,sha256=ANpEl1Hg7HpGji6UZP6SzHORqE1v4S7SzsZzWFiG6Es,1853
1
2
  remoteRF/__init__.py,sha256=-1h_xQjiE66oiWRO-vnLGJ4smpgUNhFuV-ZreW9_tD4,667
2
- remoteRF/remoterf_cli.py,sha256=ShiYgQfrjYxoegQD432XVJ9MrZrTn7iq9yLGVW-uPbE,20749
3
+ remoteRF/remoterf_cli.py,sha256=c09oAUXQp5N65d0riN35eUfkX6Lg-TlP2N2MZZ6Iywk,21403
3
4
  remoteRF/version.py,sha256=NWzcPOAdd567IvC8ei1yyPdeS7w0kt99oKwL6oWAQSc,850
4
5
  remoteRF/common/__init__.py,sha256=ey1IjkAaXFL1sZBnBwuWVlHhA_n0bu_uxgkDV_KOxYg,708
5
6
  remoteRF/common/tos_notice.txt,sha256=fUHXV6U9uhZDvVs8BcRi4kWbU1PeU9mkxML0n5DDQp8,334
@@ -10,7 +11,7 @@ remoteRF/common/grpc/deployment_capabilities_pb2_grpc.py,sha256=Vy_7zGsMNy_UwbGR
10
11
  remoteRF/common/grpc/grpc.proto,sha256=X85kGh3DgypPsb_sDjtARCkdb91XXNOr6GiTr2axEcY,5853
11
12
  remoteRF/common/grpc/grpc_pb2.py,sha256=8aVMR9vsLGWtdpvK1iAMspgGQN1_Jn7EdHzdz_YcJ3M,12736
12
13
  remoteRF/common/grpc/grpc_pb2_grpc.py,sha256=1vbqOxzDXt0bi8mjTJLAS742CsSUQcaZTw0N4cxRVyE,17731
13
- remoteRF/common/grpc/v2_codec.py,sha256=a9_xpxAnJAvAssfkElbGaDitkUpd1ntoK6a9h-xx7Og,5179
14
+ remoteRF/common/grpc/v2_codec.py,sha256=kgj_WXBOKw--J7ZgE7TBfrUbF5AmjMg_VNMx9tDNHTQ,5191
14
15
  remoteRF/common/utils/__init__.py,sha256=mRw79YhvGlA5m1wsejx6n4uNcGwusUizDquSkoU1nFY,1128
15
16
  remoteRF/common/utils/ansi_codes.py,sha256=jzSri_BqezN5yMBBLT9cjU2LoJZiQ4qBxhk5Y0KQPSI,5275
16
17
  remoteRF/common/utils/api_token.py,sha256=G0kratdJk1PUs4svF6qjRC0do8osV9mntj7UgqzRySI,1928
@@ -22,13 +23,13 @@ remoteRF/config/cert_fetcher.py,sha256=_m4CiYxJPTckDrdynwUjwnehD_gd6yZoly5etfT83
22
23
  remoteRF/config/config.py,sha256=jItSEI557nTor1BW2iOgebmMfl24TfIY7juRTjjICrc,9353
23
24
  remoteRF/core/__init__.py,sha256=1E9ARnxVICp4uCpGNQT3Fb-jD9TvUYbJmctuUqPy8hU,1206
24
25
  remoteRF/core/acc_login.py,sha256=Qct7vEsc0OIdVwmikOv2mVv9-ehH4CMVw7QWHe2yOsg,1010
25
- remoteRF/core/app.py,sha256=uh4UtLpN9Qx88EfeKMP4J9XU9mKS4HD942xh3zD1PJg,71823
26
+ remoteRF/core/app.py,sha256=kLDTUMChZfriuUuZOGo-uCzwwjtBNiRuuzZlR-iQ6pQ,72701
26
27
  remoteRF/core/banner_note.txt,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
27
- remoteRF/core/direct_path.py,sha256=O-DsMt198ywRuiszBxjINA_2sP3XHmj-ow9s6dSl9E0,28813
28
- remoteRF/core/dynamic_v2_transport.py,sha256=MLTi920dFeYeAxeOTuNZtw4mY-zZIq_ZYLDW992To3c,18029
29
- remoteRF/core/grpc_acc.py,sha256=-4eFiYcE8R1A6LN4KuqJG6QYlpEMoLcH6WmNH_VPwKI,7623
30
- remoteRF/core/grpc_client.py,sha256=34bNG5s65eatP91h0EYsLXrtRDCqykXDdRQbSI3Uw2M,18023
31
- remoteRF/core/secure_channel.py,sha256=ffPpc5o-3YAh7jMIT4izRbC-Iq9D-biSwpXwGBVlvUU,2283
28
+ remoteRF/core/direct_path.py,sha256=5-75osvC2vgEyfk4zopX-wYy8XJpFvE1EcYTVKjDygM,31789
29
+ remoteRF/core/dynamic_v2_transport.py,sha256=sOXrgvo020u_Mu8lGp1v0SI-MvMY3A43ezTbJRzY5aY,21998
30
+ remoteRF/core/grpc_acc.py,sha256=XzE4uTSQ6ZX_4jcgK9pbS96_9_7oDqWEqDTTi07Z6P0,7636
31
+ remoteRF/core/grpc_client.py,sha256=SAKdDQUm92zAYyzgG1N0gE9Lq53Z9CulRjv_49XILw8,17973
32
+ remoteRF/core/secure_channel.py,sha256=_avJBcvtLv0_9MgT6dpM-6bNH8DGph2Gls6DJZMkIXo,2859
32
33
  remoteRF/core/v2_errors.py,sha256=Xmuta24cfQEy4FGG7HZk7B9hD5g4pGT5Hz8Eguy42lU,2956
33
34
  remoteRF/deployment/__init__.py,sha256=aEl9z32KMUBfRRm6Ho0-fM-VUtiKIRq4bQBU2_u29-c,69
34
35
  remoteRF/deployment/backend.py,sha256=ULrHn31w-v8tN5BU4HktuPKG4vEGIteqPYo0hUWVjfI,16135
@@ -38,10 +39,10 @@ remoteRF/deployment/http.py,sha256=8Ar1-GOIyCJrPWURZyOl969e7FZqjdQuOkA5Km2IMGw,4
38
39
  remoteRF/deployment/state.py,sha256=rXHEMf-kJP1sYCJn-VD84ZNalkYUaNAiYaoaVu4uo3U,6541
39
40
  remoteRF/drivers/__init__.py,sha256=DXjon8E0R1X27fDQDy6IrwzqTFwrT6-suC5VSDXUP8Y,1250
40
41
  remoteRF/drivers/_virtual.py,sha256=J6uy0G1VCpr3AeTKWiPUZienNTlO9eBC3TVb2uBdcvc,4402
41
- remoteRF/drivers/dynamic_device.py,sha256=mGbRLh92QuWcktHNm_ZMej7AFWUvHM9ttJtsGDOGm9I,60504
42
- remoteRF/drivers/dynamic_v2.py,sha256=vF45cgH4rpKo8Gf67yJ4Hw6lKY1JhP-LGdyd68Fz-PI,33919
42
+ remoteRF/drivers/dynamic_device.py,sha256=lSdDgGl6022G2rUyo2WD0zMImwi9Q-uIzqX-T60MYeY,62180
43
+ remoteRF/drivers/dynamic_v2.py,sha256=CIJ6SW0jptT0Kp93Rly7WlYbeDoTdZVoP2CfHCaSiAo,33666
43
44
  remoteRF/drivers/adalm_pluto/__init__.py,sha256=QmFIGRC9MG9GMOc277Hkwd2CG-3wI59UmuV0k2XsvY8,829
44
- remoteRF/drivers/adalm_pluto/adalm_pluto_remote.py,sha256=uvaJmmd8IK7THqjKn6xjLwnXKIXNO_MNGoQz1KJCZf8,11669
45
+ remoteRF/drivers/adalm_pluto/adalm_pluto_remote.py,sha256=yIf_VkgMsDPsz5W54MK0emRJz22UujydMpWGFV41jtU,12186
45
46
  remoteRF/drivers/adalm_pluto/pluto_remote.py,sha256=6HRc94yZlM9FDt0_XcPIo_kVcG6ELgRahW0T25BIfb0,9134
46
47
  remoteRF/drivers/hackrf/__init__.py,sha256=BlpF0ZyAu8IcfpxyQlPXbI4PATdisFT1lR0cM8Gk6O0,821
47
48
  remoteRF/drivers/hackrf/hackrf_remote.py,sha256=UMwBsKGTUjq6lp0s3axwbdel9YtUUMoik0VtpsjGN-Y,8956
@@ -53,15 +54,15 @@ remoteRF/drivers/support/__init__.py,sha256=-1h_xQjiE66oiWRO-vnLGJ4smpgUNhFuV-Zr
53
54
  remoteRF/drivers/support/ti_mmwave.py,sha256=a7ThvnYjdGzoqJjoG1l7KOdOGAbhVWXBYFjW3UE2MDI,10085
54
55
  remoteRF/drivers/support/uhd.py,sha256=t0X2wG_xHSSVYedGhKSN-GIuvmkmd346bM7hx1KHm6s,11722
55
56
  remoteRF/drivers/support/uhd_dsp.py,sha256=s1_PsBYao16wv8Gq2mHBo8tTBfHzc_cyd1DmRATNhAg,3223
56
- remoteRF/drivers/support/uhd_v2.py,sha256=5Ho-ryyC86y-vitBsbFPouvzrVylNI0pdNzwXMANfXg,22600
57
+ remoteRF/drivers/support/uhd_v2.py,sha256=A66MK7FqfyT8AeNqS4pnCIwBxTTszMmT02YoZF2kqGk,23028
57
58
  remoteRF/drivers/ti_mmwave/__init__.py,sha256=-ZhvekQi9UCgtZUhezu17s-wUoxc_FZO1-5ATLRzBAI,1189
58
59
  remoteRF/drivers/ti_mmwave/ti_mmwave_remote.py,sha256=sQiRCstSoi73T7TkE2WXp1YYp3GhCIeJavq50qKsllU,5696
59
60
  remoteRF/drivers/usrp/__init__.py,sha256=y79nl50_4dV9-AI3cfDptOAWeR58fAaihHObqJI9HJM,780
60
61
  remoteRF/drivers/usrp/usrp_remote.py,sha256=hBh_CmGOAoEkkdQQ8uPFAsBA6LVeZsPSlpPGgo1c7sM,115018
61
62
  remoteRF/drivers/usrp/usrp_remote.pyi,sha256=MG7rMJdjyT0hLLoJmnS1emDQjgepWz695GbFED3v-T4,11810
62
- remoterf-2.1.0.dev7.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
63
- remoterf-2.1.0.dev7.dist-info/METADATA,sha256=p6qsKMi09w4yItYpIHhlSSylztT45QLMAzbFvKxV92c,13090
64
- remoterf-2.1.0.dev7.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
65
- remoterf-2.1.0.dev7.dist-info/entry_points.txt,sha256=La_TjAqCfqdcVTVjB2WrsJNn5rfgcuIYroe2v-CMwRQ,56
66
- remoterf-2.1.0.dev7.dist-info/top_level.txt,sha256=XQJoVTmAOsHV9qtPSP_QSh0ma4jKxsDkxn4IjM_ZuZk,9
67
- remoterf-2.1.0.dev7.dist-info/RECORD,,
63
+ remoterf-2.1.2.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
64
+ remoterf-2.1.2.dist-info/METADATA,sha256=xQLoKP0HEbmSxVt2I52TGq6bCcpxb9vmlj_Tmt436r0,12778
65
+ remoterf-2.1.2.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
66
+ remoterf-2.1.2.dist-info/entry_points.txt,sha256=La_TjAqCfqdcVTVjB2WrsJNn5rfgcuIYroe2v-CMwRQ,56
67
+ remoterf-2.1.2.dist-info/top_level.txt,sha256=XQJoVTmAOsHV9qtPSP_QSh0ma4jKxsDkxn4IjM_ZuZk,9
68
+ remoterf-2.1.2.dist-info/RECORD,,