RemoteRF 2.4.0__tar.gz → 2.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. {remoterf-2.4.0/src/remoterf.egg-info → remoterf-2.5.0}/PKG-INFO +1 -1
  2. {remoterf-2.4.0 → remoterf-2.5.0}/pyproject.toml +1 -1
  3. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/common/utils/process_arg.py +8 -6
  4. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/core/app.py +21 -1
  5. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/core/grpc_client.py +193 -15
  6. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/deployment/backend.py +33 -17
  7. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/deployment/http.py +32 -1
  8. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/drivers/adalm_pluto/adalm_pluto_remote.py +4 -0
  9. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/drivers/dynamic_device.py +7 -0
  10. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/drivers/hackrf/hackrf_remote.py +4 -0
  11. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/drivers/rtl_sdr/rtl_sdr_remote.py +4 -0
  12. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/drivers/sim_sdr/sim_sdr_remote.py +4 -0
  13. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/drivers/ti_mmwave/ti_mmwave_remote.py +4 -0
  14. {remoterf-2.4.0 → remoterf-2.5.0/src/remoterf.egg-info}/PKG-INFO +1 -1
  15. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf.egg-info/SOURCES.txt +2 -0
  16. remoterf-2.5.0/tests/test_batch.py +187 -0
  17. {remoterf-2.4.0 → remoterf-2.5.0}/tests/test_device_routes.py +31 -0
  18. {remoterf-2.4.0 → remoterf-2.5.0}/tests/test_dynamic_device_codegen.py +22 -0
  19. {remoterf-2.4.0 → remoterf-2.5.0}/tests/test_federated_devices.py +115 -1
  20. {remoterf-2.4.0 → remoterf-2.5.0}/tests/test_process_arg.py +9 -0
  21. remoterf-2.5.0/tests/test_rx_readahead.py +158 -0
  22. {remoterf-2.4.0 → remoterf-2.5.0}/LICENSE +0 -0
  23. {remoterf-2.4.0 → remoterf-2.5.0}/README.md +0 -0
  24. {remoterf-2.4.0 → remoterf-2.5.0}/setup.cfg +0 -0
  25. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/__init__.py +0 -0
  26. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/common/__init__.py +0 -0
  27. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/common/grpc/__init__.py +0 -0
  28. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/common/grpc/deployment_capabilities.proto +0 -0
  29. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/common/grpc/deployment_capabilities_pb2.py +0 -0
  30. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/common/grpc/deployment_capabilities_pb2_grpc.py +0 -0
  31. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/common/grpc/grpc.proto +0 -0
  32. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/common/grpc/grpc_pb2.py +0 -0
  33. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/common/grpc/grpc_pb2_grpc.py +0 -0
  34. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/common/grpc/v2_codec.py +0 -0
  35. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/common/tos_notice.txt +0 -0
  36. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/common/utils/__init__.py +0 -0
  37. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/common/utils/ansi_codes.py +0 -0
  38. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/common/utils/api_token.py +0 -0
  39. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/common/utils/banner.py +0 -0
  40. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/common/utils/list_string.py +0 -0
  41. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/config/__init__.py +0 -0
  42. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/config/cert_fetcher.py +0 -0
  43. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/config/config.py +0 -0
  44. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/core/__init__.py +0 -0
  45. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/core/acc_login.py +0 -0
  46. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/core/banner_note.txt +0 -0
  47. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/core/direct_path.py +0 -0
  48. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/core/dynamic_v2_transport.py +0 -0
  49. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/core/grpc_acc.py +0 -0
  50. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/core/secure_channel.py +0 -0
  51. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/core/v2_errors.py +0 -0
  52. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/deployment/__init__.py +0 -0
  53. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/deployment/direct.py +0 -0
  54. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/deployment/homes.py +0 -0
  55. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/deployment/state.py +0 -0
  56. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/drivers/__init__.py +0 -0
  57. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/drivers/_virtual.py +0 -0
  58. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/drivers/adalm_pluto/__init__.py +0 -0
  59. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/drivers/dynamic_v2.py +0 -0
  60. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/drivers/hackrf/__init__.py +0 -0
  61. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/drivers/rtl_sdr/__init__.py +0 -0
  62. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/drivers/sim_sdr/__init__.py +0 -0
  63. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/drivers/support/__init__.py +0 -0
  64. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/drivers/support/ti_mmwave.py +0 -0
  65. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/drivers/support/uhd.py +0 -0
  66. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/drivers/support/uhd_dsp.py +0 -0
  67. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/drivers/support/uhd_v2.py +0 -0
  68. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/drivers/ti_mmwave/__init__.py +0 -0
  69. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/drivers/usrp/__init__.py +0 -0
  70. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/drivers/usrp/usrp_remote.py +0 -0
  71. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/drivers/usrp/usrp_remote.pyi +0 -0
  72. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/remoterf_cli.py +0 -0
  73. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf/version.py +0 -0
  74. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf.egg-info/dependency_links.txt +0 -0
  75. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf.egg-info/entry_points.txt +0 -0
  76. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf.egg-info/requires.txt +0 -0
  77. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf.egg-info/top_level.txt +0 -0
  78. {remoterf-2.4.0 → remoterf-2.5.0}/src/remoterf.py +0 -0
  79. {remoterf-2.4.0 → remoterf-2.5.0}/tests/test_banner.py +0 -0
  80. {remoterf-2.4.0 → remoterf-2.5.0}/tests/test_cli_login_fresh.py +0 -0
  81. {remoterf-2.4.0 → remoterf-2.5.0}/tests/test_config_address.py +0 -0
  82. {remoterf-2.4.0 → remoterf-2.5.0}/tests/test_deployment_accounts.py +0 -0
  83. {remoterf-2.4.0 → remoterf-2.5.0}/tests/test_direct_path_fixed.py +0 -0
  84. {remoterf-2.4.0 → remoterf-2.5.0}/tests/test_direct_path_global.py +0 -0
  85. {remoterf-2.4.0 → remoterf-2.5.0}/tests/test_direct_path_partner.py +0 -0
  86. {remoterf-2.4.0 → remoterf-2.5.0}/tests/test_direct_path_race.py +0 -0
  87. {remoterf-2.4.0 → remoterf-2.5.0}/tests/test_dynamic_v2.py +0 -0
  88. {remoterf-2.4.0 → remoterf-2.5.0}/tests/test_fetch_idl_fallback.py +0 -0
  89. {remoterf-2.4.0 → remoterf-2.5.0}/tests/test_finish_prompt.py +0 -0
  90. {remoterf-2.4.0 → remoterf-2.5.0}/tests/test_gate_i_direct_path.py +0 -0
  91. {remoterf-2.4.0 → remoterf-2.5.0}/tests/test_grpc_client_target.py +0 -0
  92. {remoterf-2.4.0 → remoterf-2.5.0}/tests/test_homes.py +0 -0
  93. {remoterf-2.4.0 → remoterf-2.5.0}/tests/test_native_account_credentials.py +0 -0
  94. {remoterf-2.4.0 → remoterf-2.5.0}/tests/test_native_devices.py +0 -0
  95. {remoterf-2.4.0 → remoterf-2.5.0}/tests/test_partner_dynamic_v2.py +0 -0
  96. {remoterf-2.4.0 → remoterf-2.5.0}/tests/test_pluto_virtual.py +0 -0
  97. {remoterf-2.4.0 → remoterf-2.5.0}/tests/test_remoterf_cli.py +0 -0
  98. {remoterf-2.4.0 → remoterf-2.5.0}/tests/test_ti_mmwave.py +0 -0
  99. {remoterf-2.4.0 → remoterf-2.5.0}/tests/test_upgrade_rescue.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remoterf
3
- Version: 2.4.0
3
+ Version: 2.5.0
4
4
  Summary: A python API to remotely access signal centric hardware. Client-side only! Courtesy of Wireless Lab @ UCLA & Prof. Ian Roberts.
5
5
  Author-email: Ethan Ge <ethoGalaxy@gmail.com>
6
6
  License-Expression: GPL-3.0-or-later
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "remoterf"
7
- version = "2.4.0"
7
+ version = "2.5.0"
8
8
  description = "A python API to remotely access signal centric hardware. Client-side only! Courtesy of Wireless Lab @ UCLA & Prof. Ian Roberts."
9
9
  readme = "README.md"
10
10
  license = "GPL-3.0-or-later"
@@ -98,7 +98,7 @@ def _json_safe(value: Any):
98
98
  return _json_safe(value.as_payload())
99
99
  if isinstance(value, np.ndarray):
100
100
  if value.dtype == object:
101
- raise ValueError(f"Cannot JSON-map object-dtype array: {value!r}")
101
+ return _json_safe(value.tolist())
102
102
  array = np.asarray(value)
103
103
  if np.iscomplexobj(array):
104
104
  data = [[float(num.real), float(num.imag)] for num in array.ravel()]
@@ -206,13 +206,15 @@ def map_arg(value: Any) -> grpc_pb2.Argument:
206
206
  try:
207
207
  array = np.stack(value)
208
208
  except Exception:
209
- arg.json_value = json.dumps(_json_safe(value), separators=(",", ":"))
210
- return arg
209
+ pass
210
+ if array.dtype == object:
211
+ arg.json_value = json.dumps(_json_safe(value), separators=(",", ":"))
212
+ return arg
211
213
  return map_arg(array)
212
214
  elif isinstance(value, np.ndarray):
213
- if value.dtype == object:
214
- raise ValueError(f"Cannot map object-dtype array: {value!r}")
215
- if value.dtype.kind not in _SUPPORTED_NDARRAY_KINDS:
215
+ # Object arrays (a list of dicts, say) and non-numeric dtypes go as
216
+ # JSON, which the client decodes back to lists.
217
+ if value.dtype == object or value.dtype.kind not in _SUPPORTED_NDARRAY_KINDS:
216
218
  arg.json_value = json.dumps(
217
219
  _json_safe(value.tolist()), separators=(",", ":")
218
220
  )
@@ -177,11 +177,31 @@ def _print_reserved(where, when, token, handle, logged_in_to, install, no_handle
177
177
  printf(" Works anywhere, no login needed. Keep it safe: it can't be shown again.", Sty.DEFAULT)
178
178
  printf(f" ({no_handle}. Lost the token? Cancel and reserve again.)", Sty.DEFAULT)
179
179
  printf("", Sty.DEFAULT)
180
- call = DRIVER_CALLS.get(install())
180
+ device_type = install()
181
+ call = DRIVER_CALLS.get(device_type)
181
182
  if call:
182
183
  printf("Use either one as the token:" if handle else "Use it as the token:", Sty.DEFAULT)
183
184
  for text in (token, handle) if handle else (token,):
184
185
  printf(f' sdr = {call}(token="{text}")', Sty.CYAN)
186
+ elif device_type and (cls := _client_class(device_type)):
187
+ # A lab's own driver (bluetooth, gps, ...): its class, from its IDL.
188
+ printf("Use either one as the token:" if handle else "Use it as the token:", Sty.DEFAULT)
189
+ printf(" from remoterf.drivers import ensure_driver", Sty.CYAN)
190
+ printf(f' ensure_driver(token="{token}") # installs it on any other machine', Sty.CYAN)
191
+ printf(f" from remoterf.drivers.{device_type} import {cls}", Sty.CYAN)
192
+ for text in (token, handle) if handle else (token,):
193
+ printf(f' dev = {cls}(token="{text}")', Sty.CYAN)
194
+
195
+
196
+ def _client_class(device_type):
197
+ """The client class a generated driver package exports (beside its `adi` alias)."""
198
+ from importlib import import_module
199
+
200
+ try:
201
+ names = import_module(f"remoterf.drivers.{device_type}").__all__
202
+ except Exception: # noqa: BLE001 - no usage lines is all a bad package costs
203
+ return None
204
+ return next((n for n in names if n != "adi"), None)
185
205
 
186
206
 
187
207
  def _print_native_reserved(device_id, label, when, token):
@@ -17,6 +17,9 @@ import os
17
17
  import re
18
18
  import sys
19
19
  import threading
20
+ import time
21
+ from collections import deque
22
+ from contextlib import contextmanager
20
23
  from dataclasses import dataclass
21
24
  from pathlib import Path
22
25
  from typing import Any, Dict, Optional
@@ -226,20 +229,26 @@ def _federated_call(route, function_name, args):
226
229
  if not refused:
227
230
  return response
228
231
  request = grpc_pb2.GenericRPCRequest(function_name=function_name, args=args)
229
- try:
230
- if ref.startswith("rfg_"):
231
- raw = _token_backend().device_token_rpc(
232
- ref, getattr(route, "deployment_id", None), request.SerializeToString()
233
- )
234
- else:
235
- raw = federated_backend().device_rpc(ref, request.SerializeToString())
236
- except AccountBackendError as exc:
237
- if ref.startswith("rfg_") and str(exc) == "not_found":
238
- raise RuntimeError(
239
- "this RemoteRF Global does not accept device tokens yet; use the device reference"
240
- ) from None
241
- label = f" [{exc.provenance}]" if exc.provenance else ""
242
- raise RuntimeError(f"{exc}{label}") from None
232
+ for retry in (function_name.endswith(":GET"), False):
233
+ try:
234
+ if ref.startswith("rfg_"):
235
+ raw = _token_backend().device_token_rpc(
236
+ ref, getattr(route, "deployment_id", None), request.SerializeToString()
237
+ )
238
+ else:
239
+ raw = federated_backend().device_rpc(ref, request.SerializeToString())
240
+ break
241
+ except AccountBackendError as exc:
242
+ if retry and exc.code == "internal_error" and exc.provenance == "transport":
243
+ # An older lab drops ~0.1% of relayed results when a clock second
244
+ # ticks mid-call; a GET changes nothing, so it is sent once more.
245
+ continue
246
+ if ref.startswith("rfg_") and str(exc) == "not_found":
247
+ raise RuntimeError(
248
+ "this RemoteRF Global does not accept device tokens yet; use the device reference"
249
+ ) from None
250
+ label = f" [{exc.provenance}]" if exc.provenance else ""
251
+ raise RuntimeError(f"{exc}{label}") from None
243
252
  response = grpc_pb2.GenericRPCResponse()
244
253
  response.ParseFromString(raw)
245
254
  return response
@@ -476,12 +485,181 @@ def rpc_client(*, function_name, args, connection=None):
476
485
  token = _device_token(function_name, args)
477
486
  if function_name.endswith(":tx:CALL1") and "arg1" in args and token in _ci16_tokens:
478
487
  args = {**args, "arg1": _tx_wire(args["arg1"])}
479
- response = _dispatch(function_name, args, connection)
488
+ response = _driver_hooks(function_name, args, token) if connection is None else None
489
+ if response is None:
490
+ response = _dispatch(function_name, args, connection)
480
491
  if token is not None and function_name.endswith(":rx:CALL0") and _is_ci16(response.results.get(function_name.split(":")[1])):
481
492
  _ci16_tokens.add(token)
493
+ if token is not None and function_name.endswith(":rx:CALL0") and _readahead_k():
494
+ _rx_sizes[token] = response.ByteSize()
495
+ return response
496
+
497
+
498
+ def _driver_hooks(function_name, args, token):
499
+ """Batching and RX read-ahead for driver calls; None: send it as usual."""
500
+ parts = function_name.split(":")
501
+ driver_call = token is not None and len(parts) == 3
502
+ if driver_call and parts[2] != "GET" and parts[1:] != ["rx", "CALL0"]:
503
+ _rx_queues.clear()
504
+ pending = _batch_local.pending
505
+ same = driver_call and pending is not None and pending[:2] == (token, parts[0])
506
+ if same and parts[2] != "SET":
507
+ return _flush((function_name, args))
508
+ if pending is not None and not same:
509
+ _flush()
510
+ if driver_call and parts[2] == "SET" and parts[0] != "DynamicV2" and token in _batch_local.depth:
511
+ if _batch_local.pending is None:
512
+ _batch_local.pending = (token, parts[0], [])
513
+ _batch_local.pending[2].append((function_name, args))
514
+ if len(_batch_local.pending[2]) >= _BATCH_MAX_OPS:
515
+ _flush()
516
+ return grpc_pb2.GenericRPCResponse() # setters' results are never read
517
+ if driver_call and parts[1:] == ["rx", "CALL0"] and _readahead_k():
518
+ return _rx_readahead(parts[0], args, token)
519
+ return None
520
+
521
+
522
+ # ── config batching: with sdr.batch(): ───────────────────────────────
523
+ #
524
+ # A driver's property SETs queue up and go out as one <Prefix>:batch:CALLN
525
+ # RPC, which the lab runs in order under the device lock. The queue is sent
526
+ # when the block ends or at any other call; a call on the batching driver
527
+ # rides along as the last op and gets its own results back. A failed SET
528
+ # raises there, naming it; the SETs before it ran, the ones after did not.
529
+
530
+ _BATCH_MAX_OPS = 64
531
+ _batch_support: Dict[str, bool] = {} # token -> the lab runs batches (unknown until the first)
532
+
533
+
534
+ class _BatchState(threading.local):
535
+ pending = None # (token, prefix, [(function_name, args), ...])
536
+
537
+ def __init__(self):
538
+ self.depth = {} # token -> nesting of its open `with batch(token)`
539
+
540
+
541
+ _batch_local = _BatchState()
542
+
543
+
544
+ @contextmanager
545
+ def batch(token):
546
+ """Queue this token's property SETs until the block ends (re-entrant)."""
547
+ from ..drivers._virtual import is_virtual_token
548
+
549
+ if is_virtual_token(token):
550
+ yield
551
+ return
552
+ depth = _batch_local.depth
553
+ depth[token] = depth.get(token, 0) + 1
554
+ try:
555
+ yield
556
+ finally:
557
+ depth[token] -= 1
558
+ if not depth[token]:
559
+ del depth[token]
560
+ pending = _batch_local.pending
561
+ if pending is not None and pending[0] == token:
562
+ _flush()
563
+
564
+
565
+ def _flush(last=None):
566
+ token, prefix, ops = _batch_local.pending
567
+ _batch_local.pending = None
568
+ if last is not None:
569
+ ops.append(last)
570
+ if len(ops) == 1 or _batch_support.get(token) is False:
571
+ return _replay(ops)
572
+ args = {"a": ops[0][1]["a"]}
573
+ for i, (name, op_args) in enumerate(ops):
574
+ op = grpc_pb2.GenericRPCRequest(function_name=name, args={k: v for k, v in op_args.items() if k != "a"})
575
+ args[f"op{i}"] = grpc_pb2.Argument(bytes_value=op.SerializeToString())
576
+ try:
577
+ response = _dispatch(f"{prefix}:batch:CALLN", args, None)
578
+ except RuntimeError as exc:
579
+ # An older lab or Host refuses it before running anything: as before, one by one.
580
+ if not any(s in str(exc) for s in ("call_batch", "device_unavailable")):
581
+ raise
582
+ response = None
583
+ if response is None or "batch_done" not in response.results:
584
+ _batch_support[token] = False
585
+ return _replay(ops)
586
+ _batch_support[token] = True
587
+ if "batch_failed" in response.results:
588
+ failed = unmap_arg(response.results["batch_failed"])
589
+ name = ops[failed][0] if isinstance(failed, int) and 0 <= failed < len(ops) else f"{prefix}:batch:CALLN"
590
+ raise RuntimeError(f"{name}: {unmap_arg(response.results['batch_error'])}")
591
+ del response.results["batch_done"]
482
592
  return response
483
593
 
484
594
 
595
+ def _replay(ops):
596
+ for name, op_args in ops:
597
+ response = _dispatch(name, op_args, None)
598
+ return response
599
+
600
+
601
+ # ── RX read-ahead (opt-in): REMOTERF_RX_READAHEAD=<k> ────────────────
602
+ #
603
+ # One <Prefix>:rx_n:CALL1 RPC captures up to k buffers back to back; the
604
+ # next rx() calls are served from them. They were captured when the first
605
+ # rx() of the group ran, not when each later rx() is called, so use this only
606
+ # for continuous-capture loops. They are dropped after 1 s and on any SET or
607
+ # CALL (not GET) on any device of this process; changes made elsewhere
608
+ # (another program, another user's TX, the RF environment) are not seen.
609
+
610
+ # ponytail: fixed 1 s bound; make it configurable if a real loop needs another.
611
+ _RX_READAHEAD_MAX_AGE = 1.0
612
+ # The relay's response frame cap (core MAX_DEVICE_RPC_RESPONSE_FRAME_BYTES), less headroom.
613
+ _RX_N_BYTES = 786432 - 4096
614
+ _rx_queues: Dict[str, tuple] = {} # token -> (captured at, deque of rx Arguments)
615
+ _rx_sizes: Dict[str, int] = {} # token -> size of its last rx response
616
+ _rx_n_unsupported: set = set()
617
+
618
+
619
+ def _readahead_k():
620
+ try:
621
+ k = int(os.getenv("REMOTERF_RX_READAHEAD") or 0)
622
+ except ValueError:
623
+ return 0
624
+ return min(k, 64) if k >= 2 else 0
625
+
626
+
627
+ def _rx_readahead(prefix, args, token):
628
+ stamp, buffers = _rx_queues.get(token, (0.0, None))
629
+ if buffers and time.monotonic() - stamp <= _RX_READAHEAD_MAX_AGE:
630
+ try:
631
+ return grpc_pb2.GenericRPCResponse(results={"rx": buffers.popleft()})
632
+ except IndexError:
633
+ pass # another thread took the last one
634
+ size = _rx_sizes.get(token)
635
+ k = min(_readahead_k(), _RX_N_BYTES // size) if size and token not in _rx_n_unsupported else 0
636
+ if k < 2:
637
+ return None
638
+ stamp = time.monotonic()
639
+ try:
640
+ response = _dispatch(f"{prefix}:rx_n:CALL1", {**args, "arg1": map_arg(k)}, None)
641
+ except RuntimeError as exc:
642
+ # An older lab or Host refused it unrun, or it outgrew the relay: plain rx from now on.
643
+ if not any(s in str(exc) for s in ("call_rx_n", "device_unavailable", "response_too_large")):
644
+ raise
645
+ response = None
646
+ arg = response.results.get("rx_n") if response is not None else None
647
+ nd = arg.ndarray_value if arg is not None and arg.WhichOneof("value") == "ndarray_value" else None
648
+ m = nd.shape[0] if nd is not None and nd.shape else 0
649
+ if m < 1 or len(nd.data) % m:
650
+ _rx_n_unsupported.add(token)
651
+ return None
652
+ step = len(nd.data) // m
653
+ buffers = deque(
654
+ grpc_pb2.Argument(ndarray_value=grpc_pb2.NDArrayValue(
655
+ dtype=nd.dtype, shape=nd.shape[1:], data=nd.data[i * step:(i + 1) * step]))
656
+ for i in range(m)
657
+ )
658
+ first = buffers.popleft()
659
+ _rx_queues[token] = (stamp, buffers)
660
+ return grpc_pb2.GenericRPCResponse(results={"rx": first})
661
+
662
+
485
663
  # Device tokens whose Server answered an rx in ci16: it decodes ci16 too (the
486
664
  # same Gate I slice 3 change), so their tx may go out that way.
487
665
  _ci16_tokens: set = set()
@@ -102,6 +102,7 @@ class HttpsJsonAccountBackend(DeploymentAccountBackend):
102
102
  # One refresh at a time: a direct-path thread shares this session, and
103
103
  # Global revokes a session whose refresh token is presented twice.
104
104
  self._refresh_lock = threading.RLock()
105
+ self._framed = False # this deployment answered a device RPC with a raw frame
105
106
 
106
107
  def _operation(self, name):
107
108
  if name not in self.capabilities["supported_operations"]:
@@ -365,29 +366,40 @@ class HttpsJsonAccountBackend(DeploymentAccountBackend):
365
366
  """Relay one serialized GenericRPCRequest; returns the response bytes."""
366
367
 
367
368
  self._operation("DEV:rpc")
368
- value = self.transport.request(
369
- "POST", self.base + "/devices/rpc", access=self._access(),
370
- data={"device_id": self._ref(device_id, "device_id"),
371
- "request_b64": base64.b64encode(bytes(request_bytes)).decode("ascii")},
372
- overall_timeout=DEVICE_RPC_CLIENT_TIMEOUT_SECONDS,
373
- max_response_bytes=MAX_DEVICE_HTTP_RESPONSE_BYTES,
369
+ return self._device_call(
370
+ "/devices/rpc", {"device_id": self._ref(device_id, "device_id")}, request_bytes,
371
+ access=self._access(),
374
372
  )
375
- return self._device_frame(value)
376
373
 
377
374
  def device_token_rpc(self, token, deployment_id, request_bytes):
378
375
  """Relay one frame on a Global device API token (``rfg_``): the token
379
376
  is the credential, so there is no login and no bearer."""
380
377
 
381
- data = {"token": token,
382
- "request_b64": base64.b64encode(bytes(request_bytes)).decode("ascii")}
378
+ data = {"token": token}
383
379
  if deployment_id is not None:
384
380
  data["deployment_id"] = deployment_id
385
- value = self.transport.request(
386
- "POST", self.base + "/devices/token-rpc", data=data,
387
- overall_timeout=DEVICE_RPC_CLIENT_TIMEOUT_SECONDS,
388
- max_response_bytes=MAX_DEVICE_HTTP_RESPONSE_BYTES,
389
- )
390
- return self._device_frame(value)
381
+ return self._device_call("/devices/token-rpc", data, request_bytes)
382
+
383
+ def _device_call(self, path, data, request_bytes, **auth):
384
+ """Raw frames once this deployment has answered with one; base64 JSON before."""
385
+ request_bytes = bytes(request_bytes)
386
+ framed = self._framed
387
+ while True:
388
+ try:
389
+ value = self.transport.request(
390
+ "POST", self.base + path, **auth,
391
+ data=data if framed else {**data, "request_b64": base64.b64encode(request_bytes).decode("ascii")},
392
+ frame=request_bytes if framed else None, accept_framed=True,
393
+ overall_timeout=DEVICE_RPC_CLIENT_TIMEOUT_SECONDS,
394
+ max_response_bytes=MAX_DEVICE_HTTP_RESPONSE_BYTES,
395
+ )
396
+ except AccountBackendError as exc:
397
+ # A rolled-back deployment refuses the framed body before routing it.
398
+ if framed and exc.code == "invalid_request":
399
+ self._framed = framed = False
400
+ continue
401
+ raise
402
+ return self._device_frame(value)
391
403
 
392
404
  def device_direct(self, ref, deployment_id, ice):
393
405
  """Signal a direct path to the lab behind ``ref`` (a device ref under
@@ -408,8 +420,12 @@ class HttpsJsonAccountBackend(DeploymentAccountBackend):
408
420
  raise AccountBackendError("Invalid home device response")
409
421
  return value["ice"]
410
422
 
411
- @staticmethod
412
- def _device_frame(value):
423
+ def _device_frame(self, value):
424
+ if "frame" in value:
425
+ if value.get("provenance") != "destination" or type(value["frame"]) is not bytes:
426
+ raise AccountBackendError("Invalid home device response")
427
+ self._framed = True
428
+ return value["frame"]
413
429
  encoded = value.get("response_b64")
414
430
  # An empty frame is a valid setter response (no results).
415
431
  if value.get("provenance") != "destination" or type(encoded) is not str:
@@ -1,5 +1,6 @@
1
1
  """Verified same-origin JSON transport with bounded responses and no retries."""
2
2
 
3
+ import json
3
4
  import os
4
5
  import ssl
5
6
  import time
@@ -14,6 +15,10 @@ from remoterf_federation_core import (
14
15
  )
15
16
  from .state import origin
16
17
 
18
+ # Device RPC bodies: u32be len(envelope) || JSON envelope || raw frame. The
19
+ # core's FRAMED_CONTENT_TYPE, kept here while the client pins an older core.
20
+ FRAMED_CONTENT_TYPE = "application/x-remoterf-framed"
21
+
17
22
 
18
23
  class AccountBackendError(RuntimeError):
19
24
  def __init__(self, code, *, provenance=None):
@@ -44,6 +49,8 @@ class JsonTransport:
44
49
  access=None,
45
50
  overall_timeout=None,
46
51
  max_response_bytes=65536,
52
+ frame=None,
53
+ accept_framed=False,
47
54
  ):
48
55
  if (
49
56
  not path.startswith("/")
@@ -51,7 +58,12 @@ class JsonTransport:
51
58
  or any(c in path for c in ("?", "#", "\\", "%"))
52
59
  ):
53
60
  raise ValidationError("Invalid same-origin account path")
54
- headers = {"Accept": "application/json"}
61
+ headers = {"Accept": f"{FRAMED_CONTENT_TYPE}, application/json" if accept_framed else "application/json"}
62
+ content = None
63
+ if frame is not None:
64
+ envelope = json.dumps(data, separators=(",", ":")).encode()
65
+ content, data = len(envelope).to_bytes(4, "big") + envelope + bytes(frame), None
66
+ headers["Content-Type"] = FRAMED_CONTENT_TYPE
55
67
  if access:
56
68
  headers["Authorization"] = "Bearer " + access
57
69
  overall_timeout = (
@@ -71,6 +83,7 @@ class JsonTransport:
71
83
  method,
72
84
  self.origin + path,
73
85
  json=data,
86
+ content=content,
74
87
  headers=headers,
75
88
  follow_redirects=False,
76
89
  timeout=timeout,
@@ -86,6 +99,10 @@ class JsonTransport:
86
99
  )
87
100
  if 300 <= response.status_code < 400:
88
101
  raise AccountBackendError("Account redirects are not allowed")
102
+ if accept_framed and response.status_code < 300 and (
103
+ response.headers.get("content-type", "").split(";")[0].strip() == FRAMED_CONTENT_TYPE
104
+ ):
105
+ return _unframe(bytes(raw), max_response_bytes)
89
106
  value = strict_json(bytes(raw), max_bytes=max_response_bytes)
90
107
  if response.status_code >= 400:
91
108
  # Never print arbitrary server text (could contain secrets/terminal escapes).
@@ -123,3 +140,17 @@ class JsonTransport:
123
140
 
124
141
  def close(self):
125
142
  self.client.close()
143
+
144
+
145
+ def _unframe(raw, max_bytes):
146
+ """A framed body -> {**envelope, "frame": frame}."""
147
+ size = int.from_bytes(raw[:4], "big")
148
+ if len(raw) < 4 or 4 + size > len(raw):
149
+ raise AccountBackendError("Invalid home device response")
150
+ try:
151
+ value = strict_json(raw[4:4 + size], max_bytes=max_bytes)
152
+ except ValidationError:
153
+ raise AccountBackendError("Invalid home device response") from None
154
+ if type(value) is not dict:
155
+ raise AccountBackendError("Invalid home device response")
156
+ return {**value, "frame": raw[4 + size:]}
@@ -152,6 +152,10 @@ class Pluto:
152
152
  install_driver_if_stale(token=token, current_hash=_SCHEMA_HASH)
153
153
  _try_call("ip", token)
154
154
 
155
+ def batch(self):
156
+ from ...core.grpc_client import batch
157
+ return batch(self.token)
158
+
155
159
  @property
156
160
  def dds_enabled(self):
157
161
  return _try_get("dds_enabled", self.token)
@@ -1267,6 +1267,13 @@ def _codegen(schema: dict) -> str:
1267
1267
  if "ip" in call_map:
1268
1268
  lines.append(' _try_call("ip", token)')
1269
1269
  lines.append("")
1270
+ if "batch" not in getter_map and "batch" not in setter_map and "batch" not in call_map:
1271
+ lines += [
1272
+ " def batch(self):",
1273
+ " from ...core.grpc_client import batch",
1274
+ " return batch(self.token)",
1275
+ "",
1276
+ ]
1270
1277
 
1271
1278
  # ── Properties ────────────────────────────────────────────────────
1272
1279
  all_props = sorted(set(getter_map) | set(setter_map))
@@ -151,6 +151,10 @@ class HackRF:
151
151
  from ..dynamic_device import install_driver_if_stale
152
152
  install_driver_if_stale(token=token, current_hash=_SCHEMA_HASH)
153
153
 
154
+ def batch(self):
155
+ from ...core.grpc_client import batch
156
+ return batch(self.token)
157
+
154
158
  @property
155
159
  def amplifier_on(self):
156
160
  'Whether the 14 dB RF front-end amplifier is enabled.'
@@ -119,6 +119,10 @@ class RtlSdr:
119
119
  from ..dynamic_device import install_driver_if_stale
120
120
  install_driver_if_stale(token=token, current_hash=_SCHEMA_HASH)
121
121
 
122
+ def batch(self):
123
+ from ...core.grpc_client import batch
124
+ return batch(self.token)
125
+
122
126
  @property
123
127
  def agc_mode(self):
124
128
  return _try_get("agc_mode", self.token)
@@ -152,6 +152,10 @@ class SimSDR:
152
152
  install_driver_if_stale(token=token, current_hash=_SCHEMA_HASH)
153
153
  _try_call("ip", token)
154
154
 
155
+ def batch(self):
156
+ from ...core.grpc_client import batch
157
+ return batch(self.token)
158
+
155
159
  @property
156
160
  def rx_buffer_size(self):
157
161
  return _try_get("rx_buffer_size", self.token)
@@ -119,6 +119,10 @@ class TiMmWave:
119
119
  from ..dynamic_device import install_driver_if_stale
120
120
  install_driver_if_stale(token=token, current_hash=_SCHEMA_HASH)
121
121
 
122
+ def batch(self):
123
+ from ...core.grpc_client import batch
124
+ return batch(self.token)
125
+
122
126
  @property
123
127
  def device_info(self):
124
128
  'USB/UART identity, firmware profile, and current stream state.'
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remoterf
3
- Version: 2.4.0
3
+ Version: 2.5.0
4
4
  Summary: A python API to remotely access signal centric hardware. Client-side only! Courtesy of Wireless Lab @ UCLA & Prof. Ian Roberts.
5
5
  Author-email: Ethan Ge <ethoGalaxy@gmail.com>
6
6
  License-Expression: GPL-3.0-or-later
@@ -69,6 +69,7 @@ src/remoterf/drivers/usrp/__init__.py
69
69
  src/remoterf/drivers/usrp/usrp_remote.py
70
70
  src/remoterf/drivers/usrp/usrp_remote.pyi
71
71
  tests/test_banner.py
72
+ tests/test_batch.py
72
73
  tests/test_cli_login_fresh.py
73
74
  tests/test_config_address.py
74
75
  tests/test_deployment_accounts.py
@@ -91,5 +92,6 @@ tests/test_partner_dynamic_v2.py
91
92
  tests/test_pluto_virtual.py
92
93
  tests/test_process_arg.py
93
94
  tests/test_remoterf_cli.py
95
+ tests/test_rx_readahead.py
94
96
  tests/test_ti_mmwave.py
95
97
  tests/test_upgrade_rescue.py