RemoteRF 2.3.1__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.
- {remoterf-2.3.1/src/remoterf.egg-info → remoterf-2.5.0}/PKG-INFO +1 -1
- {remoterf-2.3.1 → remoterf-2.5.0}/pyproject.toml +1 -1
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/common/utils/process_arg.py +8 -6
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/core/app.py +21 -1
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/core/direct_path.py +162 -25
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/core/grpc_client.py +247 -23
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/deployment/backend.py +82 -41
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/deployment/http.py +35 -1
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/drivers/adalm_pluto/adalm_pluto_remote.py +4 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/drivers/dynamic_device.py +23 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/drivers/hackrf/hackrf_remote.py +4 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/drivers/rtl_sdr/rtl_sdr_remote.py +4 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/drivers/sim_sdr/sim_sdr_remote.py +4 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/drivers/ti_mmwave/ti_mmwave_remote.py +4 -0
- {remoterf-2.3.1 → remoterf-2.5.0/src/remoterf.egg-info}/PKG-INFO +1 -1
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf.egg-info/SOURCES.txt +3 -0
- remoterf-2.5.0/tests/test_batch.py +187 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/tests/test_device_routes.py +32 -0
- remoterf-2.5.0/tests/test_direct_path_global.py +139 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/tests/test_direct_path_partner.py +6 -5
- {remoterf-2.3.1 → remoterf-2.5.0}/tests/test_dynamic_device_codegen.py +24 -1
- remoterf-2.5.0/tests/test_federated_devices.py +289 -0
- remoterf-2.5.0/tests/test_fetch_idl_fallback.py +98 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/tests/test_gate_i_direct_path.py +119 -7
- {remoterf-2.3.1 → remoterf-2.5.0}/tests/test_process_arg.py +9 -0
- remoterf-2.5.0/tests/test_rx_readahead.py +158 -0
- remoterf-2.3.1/tests/test_federated_devices.py +0 -115
- remoterf-2.3.1/tests/test_fetch_idl_fallback.py +0 -45
- {remoterf-2.3.1 → remoterf-2.5.0}/LICENSE +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/README.md +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/setup.cfg +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/__init__.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/common/__init__.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/common/grpc/__init__.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/common/grpc/deployment_capabilities.proto +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/common/grpc/deployment_capabilities_pb2.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/common/grpc/deployment_capabilities_pb2_grpc.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/common/grpc/grpc.proto +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/common/grpc/grpc_pb2.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/common/grpc/grpc_pb2_grpc.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/common/grpc/v2_codec.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/common/tos_notice.txt +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/common/utils/__init__.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/common/utils/ansi_codes.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/common/utils/api_token.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/common/utils/banner.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/common/utils/list_string.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/config/__init__.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/config/cert_fetcher.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/config/config.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/core/__init__.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/core/acc_login.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/core/banner_note.txt +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/core/dynamic_v2_transport.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/core/grpc_acc.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/core/secure_channel.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/core/v2_errors.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/deployment/__init__.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/deployment/direct.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/deployment/homes.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/deployment/state.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/drivers/__init__.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/drivers/_virtual.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/drivers/adalm_pluto/__init__.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/drivers/dynamic_v2.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/drivers/hackrf/__init__.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/drivers/rtl_sdr/__init__.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/drivers/sim_sdr/__init__.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/drivers/support/__init__.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/drivers/support/ti_mmwave.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/drivers/support/uhd.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/drivers/support/uhd_dsp.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/drivers/support/uhd_v2.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/drivers/ti_mmwave/__init__.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/drivers/usrp/__init__.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/drivers/usrp/usrp_remote.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/drivers/usrp/usrp_remote.pyi +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/remoterf_cli.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf/version.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf.egg-info/dependency_links.txt +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf.egg-info/entry_points.txt +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf.egg-info/requires.txt +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf.egg-info/top_level.txt +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/src/remoterf.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/tests/test_banner.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/tests/test_cli_login_fresh.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/tests/test_config_address.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/tests/test_deployment_accounts.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/tests/test_direct_path_fixed.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/tests/test_direct_path_race.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/tests/test_dynamic_v2.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/tests/test_finish_prompt.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/tests/test_grpc_client_target.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/tests/test_homes.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/tests/test_native_account_credentials.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/tests/test_native_devices.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/tests/test_partner_dynamic_v2.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/tests/test_pluto_virtual.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/tests/test_remoterf_cli.py +0 -0
- {remoterf-2.3.1 → remoterf-2.5.0}/tests/test_ti_mmwave.py +0 -0
- {remoterf-2.3.1 → 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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
210
|
-
|
|
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
|
-
|
|
214
|
-
|
|
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
|
-
|
|
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):
|
|
@@ -19,9 +19,11 @@ On a home reached over its relay, ICE (aioice) punches one UDP pair to the
|
|
|
19
19
|
Server and QUIC (aioquic) runs over it; device RPCs then travel as one stream
|
|
20
20
|
per call (4-byte big-endian length + GenericRPCRequest, reply likewise)
|
|
21
21
|
instead of through the relay. Control RPCs (ACC:*) stay on gRPC. When the
|
|
22
|
-
punch fails or the path dies, calls fall back to gRPC
|
|
23
|
-
|
|
24
|
-
has. See
|
|
22
|
+
punch fails or the path dies, calls fall back to gRPC; a path that died is
|
|
23
|
+
retried after 30 s, a failed attempt only once a device call wants it.
|
|
24
|
+
Signaling is ACC:direct_offer over the channel the client already has. See
|
|
25
|
+
RemoteRF-Server docs/gate-i-direct-path.md. A Global device ref or rfg_
|
|
26
|
+
token signals through Global instead (global_path).
|
|
25
27
|
|
|
26
28
|
A partner lab's device (one the home lists on a federation agreement) gets a
|
|
27
29
|
path of its own, punched to that lab: the offer goes to the home with the
|
|
@@ -42,6 +44,7 @@ import asyncio
|
|
|
42
44
|
import atexit
|
|
43
45
|
import collections
|
|
44
46
|
import contextlib
|
|
47
|
+
import hashlib
|
|
45
48
|
import json
|
|
46
49
|
import os
|
|
47
50
|
import secrets
|
|
@@ -74,6 +77,8 @@ FIXED_SECONDS = 2.0 # a reachable fixed port answers in one round trip; a filte
|
|
|
74
77
|
SETTLE_SECONDS = 20.0 # gathering, the offer RPC and both budgets: one attempt, worst case
|
|
75
78
|
WAIT_SECONDS = 3.0 # how long a login or a first device call waits for that attempt
|
|
76
79
|
RETRY_SECONDS = 30.0
|
|
80
|
+
MEMO_SECONDS = 600 # a route that failed here is not signaled again for this long, by any process
|
|
81
|
+
MEMO_FILE = "direct-v1.json"
|
|
77
82
|
IDLE_SECONDS = 600
|
|
78
83
|
SLEEP_CHECK_SECONDS = 5.0 # how often the path looks for a system sleep it slept through
|
|
79
84
|
async def close_ice(ice) -> None:
|
|
@@ -202,6 +207,53 @@ class PathError(Exception):
|
|
|
202
207
|
"""The direct path could not carry this call and is down now."""
|
|
203
208
|
|
|
204
209
|
|
|
210
|
+
class PathUnavailable(PathError):
|
|
211
|
+
"""This route can never carry a direct path: stop trying."""
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# ---- what the last attempt per (route, network) came to, across processes ----
|
|
215
|
+
#
|
|
216
|
+
# Keys are hashes: no ref, token or address is stored in clear.
|
|
217
|
+
|
|
218
|
+
def _memo_key(scope: str) -> str:
|
|
219
|
+
return hashlib.sha256(f"{scope}|{default_route_address()}".encode()).hexdigest()[:32]
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _memo_entries() -> dict:
|
|
223
|
+
from ..deployment.state import root
|
|
224
|
+
|
|
225
|
+
memo = json.loads((root() / MEMO_FILE).read_text())
|
|
226
|
+
now = time.time()
|
|
227
|
+
return {key: [str(value[0]), int(value[1])] for key, value in memo.items() if now - value[1] < 86400}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _memo_outcome(scope: Optional[str]) -> Optional[tuple[str, int]]:
|
|
231
|
+
"""("ready" | "failed" | "unavailable", when) for ``scope`` on this network, if known."""
|
|
232
|
+
if scope is None:
|
|
233
|
+
return None
|
|
234
|
+
try:
|
|
235
|
+
outcome, at = _memo_entries()[_memo_key(scope)]
|
|
236
|
+
return outcome, at
|
|
237
|
+
except Exception: # noqa: BLE001 - ponytail: a lost memo costs one extra attempt
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _remember(scope: Optional[str], outcome: str) -> None:
|
|
242
|
+
if scope is None:
|
|
243
|
+
return
|
|
244
|
+
try:
|
|
245
|
+
from ..deployment.state import private_write, root
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
memo = _memo_entries()
|
|
249
|
+
except Exception: # noqa: BLE001 - none yet, or unreadable: start over
|
|
250
|
+
memo = {}
|
|
251
|
+
memo[_memo_key(scope)] = [outcome, int(time.time())]
|
|
252
|
+
private_write(root() / MEMO_FILE, memo)
|
|
253
|
+
except Exception: # noqa: BLE001 - ponytail: a lost memo costs one extra attempt
|
|
254
|
+
pass
|
|
255
|
+
|
|
256
|
+
|
|
205
257
|
def _why(exc: BaseException) -> str:
|
|
206
258
|
return "timed out" if isinstance(exc, asyncio.TimeoutError) else f"{type(exc).__name__}: {exc}"
|
|
207
259
|
|
|
@@ -257,14 +309,18 @@ class DirectPath:
|
|
|
257
309
|
``offer`` sends the ICE offer dict and returns the answer dict (it runs
|
|
258
310
|
the ACC:direct_offer RPC, blocking). ``server_name`` is the hostname the
|
|
259
311
|
Server's certificate must carry; ``ca_pem`` the CA pinned for this home.
|
|
312
|
+
``memo`` names the route in the cross-process record of how the last
|
|
313
|
+
attempt on this network went (None: no record, and calls always wait).
|
|
260
314
|
"""
|
|
261
315
|
|
|
262
316
|
def __init__(self, *, offer: Callable[[dict], dict], server_name: str, ca_pem: bytes,
|
|
263
|
-
stun: Optional[tuple[str, int]] = STUN):
|
|
317
|
+
stun: Optional[tuple[str, int]] = STUN, memo: Optional[str] = None):
|
|
264
318
|
self._offer = offer
|
|
265
319
|
self._server_name = server_name
|
|
266
320
|
self._ca_pem = ca_pem
|
|
267
321
|
self._stun = stun
|
|
322
|
+
self._memo = memo
|
|
323
|
+
self._answered = False # this attempt's offer was answered: a failure now is the network's
|
|
268
324
|
self._id = secrets.token_hex(8) # tells this process's path from the session's others
|
|
269
325
|
self._loop = asyncio.new_event_loop()
|
|
270
326
|
quiet_aioice_errors(self._loop)
|
|
@@ -300,11 +356,20 @@ class DirectPath:
|
|
|
300
356
|
"""Block until the first attempt has settled, or ``timeout``; True when ready.
|
|
301
357
|
|
|
302
358
|
A punch that works takes well under a second; one that cannot work
|
|
303
|
-
spends the whole ICE budget, and nobody should wait for that.
|
|
359
|
+
spends the whole ICE budget, and nobody should wait for that. Nor
|
|
360
|
+
for a path that has not worked on this network before.
|
|
304
361
|
"""
|
|
305
|
-
self.
|
|
362
|
+
if self._worth_waiting():
|
|
363
|
+
self._settled.wait(timeout)
|
|
306
364
|
return self.ready
|
|
307
365
|
|
|
366
|
+
def _worth_waiting(self) -> bool:
|
|
367
|
+
"""Only where the last attempt on this network reached ready."""
|
|
368
|
+
if self._memo is None:
|
|
369
|
+
return True
|
|
370
|
+
outcome = _memo_outcome(self._memo)
|
|
371
|
+
return outcome is not None and outcome[0] == "ready"
|
|
372
|
+
|
|
308
373
|
@property
|
|
309
374
|
def ready(self) -> bool:
|
|
310
375
|
return self.state == "ready"
|
|
@@ -342,6 +407,8 @@ class DirectPath:
|
|
|
342
407
|
"""A device call wants the idle path back; with ``wait`` it gives the
|
|
343
408
|
re-punch that long before settling for the relay."""
|
|
344
409
|
self._loop.call_soon_threadsafe(lambda: self._wanted and self._wanted.set())
|
|
410
|
+
if wait and self.state in ("idle", "connecting") and not self._worth_waiting():
|
|
411
|
+
wait = 0.0
|
|
345
412
|
deadline = time.monotonic() + wait
|
|
346
413
|
while wait and not self.ready and self.state in ("idle", "connecting") and time.monotonic() < deadline:
|
|
347
414
|
time.sleep(0.05)
|
|
@@ -368,16 +435,35 @@ class DirectPath:
|
|
|
368
435
|
try:
|
|
369
436
|
while not self._stopping:
|
|
370
437
|
self._dead = asyncio.Event()
|
|
438
|
+
memo = _memo_outcome(self._memo)
|
|
439
|
+
remaining = MEMO_SECONDS - (time.time() - memo[1]) if memo and memo[0] != "ready" else 0
|
|
440
|
+
if remaining > 0:
|
|
441
|
+
# At most one signaling attempt per MEMO_SECONDS on a network
|
|
442
|
+
# that cannot carry the path, whichever process makes it.
|
|
443
|
+
self.state, self.reason = "down", f"the last attempt on this network: {memo[0]}"
|
|
444
|
+
self._settled.set()
|
|
445
|
+
await self._on_demand(remaining)
|
|
446
|
+
continue
|
|
371
447
|
try:
|
|
372
448
|
await asyncio.wait_for(self._establish(), SETTLE_SECONDS)
|
|
373
449
|
except Exception as exc: # noqa: BLE001 - a failed punch is expected on some networks
|
|
374
450
|
self.reason = str(exc) if isinstance(exc, PathError) else _why(exc)
|
|
375
|
-
|
|
376
|
-
if "No matching server function" in self.reason:
|
|
451
|
+
if isinstance(exc, PathUnavailable) or "No matching server function" in self.reason:
|
|
377
452
|
self.state = "unavailable"
|
|
378
|
-
|
|
453
|
+
if not isinstance(exc, PathUnavailable):
|
|
454
|
+
self.reason = "this Server has no direct path"
|
|
455
|
+
_remember(self._memo, "unavailable")
|
|
379
456
|
return
|
|
457
|
+
if self._answered:
|
|
458
|
+
_remember(self._memo, "failed")
|
|
459
|
+
self.state = "down"
|
|
460
|
+
self._settled.set()
|
|
461
|
+
await self._teardown()
|
|
462
|
+
# No polling: the next attempt waits for a device call to want it.
|
|
463
|
+
await self._on_demand(RETRY_SECONDS)
|
|
464
|
+
continue
|
|
380
465
|
else:
|
|
466
|
+
_remember(self._memo, "ready")
|
|
381
467
|
self.state = "ready"
|
|
382
468
|
self._settled.set()
|
|
383
469
|
await self._dead.wait()
|
|
@@ -398,14 +484,19 @@ class DirectPath:
|
|
|
398
484
|
self._settled.set()
|
|
399
485
|
await self._teardown()
|
|
400
486
|
|
|
487
|
+
async def _on_demand(self, delay: float) -> None:
|
|
488
|
+
"""Until a device call wants the path, and no sooner than ``delay``."""
|
|
489
|
+
self._wanted = asyncio.Event()
|
|
490
|
+
await asyncio.sleep(delay)
|
|
491
|
+
await self._wanted.wait()
|
|
492
|
+
|
|
401
493
|
async def _establish(self) -> None:
|
|
402
494
|
loop = asyncio.get_running_loop()
|
|
495
|
+
self._answered = False
|
|
403
496
|
ice = self._ice = Ice(ice_controlling=True, stun_server=self._stun)
|
|
497
|
+
# A silent STUN server still leaves the host candidate: a lab on this
|
|
498
|
+
# machine or LAN, or its fixed port, needs nothing more.
|
|
404
499
|
await ice.gather_candidates()
|
|
405
|
-
if self._stun and not any(c.type == "srflx" for c in ice.local_candidates):
|
|
406
|
-
# Without a reflexive address there is nothing to punch with, and a
|
|
407
|
-
# network that drops STUN drops the checks too: skip the 5 s of them.
|
|
408
|
-
raise PathError("no STUN answer (UDP blocked?)")
|
|
409
500
|
offer = {
|
|
410
501
|
"id": self._id,
|
|
411
502
|
"ufrag": ice.local_username,
|
|
@@ -417,6 +508,7 @@ class DirectPath:
|
|
|
417
508
|
"race": True,
|
|
418
509
|
}
|
|
419
510
|
answer = await loop.run_in_executor(None, self._offer, offer)
|
|
511
|
+
self._answered = True
|
|
420
512
|
|
|
421
513
|
# A partner lab's answer names the lab whose certificate to pin.
|
|
422
514
|
server_name = str(answer.get("server_name") or self._server_name)
|
|
@@ -633,7 +725,7 @@ def start(*, username: str, secret: str, route: dict) -> Optional[DirectPath]:
|
|
|
633
725
|
|
|
634
726
|
_path = DirectPath(
|
|
635
727
|
offer=_offer_rpc(username, secret), server_name=route["host"],
|
|
636
|
-
ca_pem=_current_profile().ca_path.read_bytes(),
|
|
728
|
+
ca_pem=_current_profile().ca_path.read_bytes(), memo=route["host"],
|
|
637
729
|
)
|
|
638
730
|
_path.start()
|
|
639
731
|
return _path
|
|
@@ -675,7 +767,7 @@ def _from_stored_login() -> Optional[DirectPath]:
|
|
|
675
767
|
return None
|
|
676
768
|
return DirectPath(
|
|
677
769
|
offer=_offer_rpc(login["username"], login["secret"]), server_name=route["host"],
|
|
678
|
-
ca_pem=profile.ca_path.read_bytes(),
|
|
770
|
+
ca_pem=profile.ca_path.read_bytes(), memo=route["host"],
|
|
679
771
|
)
|
|
680
772
|
|
|
681
773
|
|
|
@@ -698,25 +790,70 @@ def partner_of(token: Optional[str]) -> Optional[str]:
|
|
|
698
790
|
return _partner_owners.get(token) if token else None
|
|
699
791
|
|
|
700
792
|
|
|
701
|
-
def
|
|
702
|
-
"""The path
|
|
793
|
+
def _side_path(key: str, make: Callable[[], Optional[DirectPath]]) -> Optional[DirectPath]:
|
|
794
|
+
"""The path kept for ``key`` (``make`` builds it on first use; None: no
|
|
795
|
+
path), when it is ready for this call."""
|
|
703
796
|
with _lock:
|
|
704
|
-
path = _partners.get(
|
|
797
|
+
path = _partners.get(key)
|
|
705
798
|
if path is None:
|
|
706
|
-
|
|
707
|
-
if
|
|
799
|
+
path = make()
|
|
800
|
+
if path is None:
|
|
708
801
|
return None
|
|
709
|
-
|
|
710
|
-
offer=_offer_rpc(*credentials, token=token), server_name="", ca_pem=b"",
|
|
711
|
-
)
|
|
802
|
+
_partners[key] = path
|
|
712
803
|
path.start()
|
|
713
|
-
if path.state
|
|
804
|
+
if path.state in ("idle", "down"):
|
|
714
805
|
path.wake(WAIT_SECONDS) # a punch takes well under a second; a 2 MB buffer will not fit the relay
|
|
715
806
|
elif path.state == "connecting":
|
|
716
807
|
path.wait() # the first call waits briefly, as a script's first capture does
|
|
717
808
|
return path if path.ready else None
|
|
718
809
|
|
|
719
810
|
|
|
811
|
+
def _partner(token: str) -> Optional[DirectPath]:
|
|
812
|
+
"""The path to the lab owning the device behind ``token``, punched on first use."""
|
|
813
|
+
def make() -> Optional[DirectPath]:
|
|
814
|
+
credentials = _credentials()
|
|
815
|
+
if credentials is None:
|
|
816
|
+
return None
|
|
817
|
+
return DirectPath(
|
|
818
|
+
offer=_offer_rpc(*credentials, token=token), server_name="", ca_pem=b"",
|
|
819
|
+
memo=_partner_owners.get(token),
|
|
820
|
+
)
|
|
821
|
+
|
|
822
|
+
return _side_path(token, make)
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def _global_offer(ref: str, deployment_id: Optional[str]) -> Callable[[dict], dict]:
|
|
826
|
+
"""The offer through Global: an ``rfg_`` token is its own credential, a ref takes the login."""
|
|
827
|
+
def send(offer: dict) -> dict:
|
|
828
|
+
from ..deployment.http import AccountBackendError
|
|
829
|
+
from . import grpc_client
|
|
830
|
+
|
|
831
|
+
backend = grpc_client._token_backend() if ref.startswith("rfg_") else grpc_client.federated_backend()
|
|
832
|
+
try:
|
|
833
|
+
answer = json.loads(backend.device_direct(ref, deployment_id, json.dumps(offer)))
|
|
834
|
+
except AccountBackendError as exc:
|
|
835
|
+
# not_found: a Global without the route; the others: a lab without it.
|
|
836
|
+
if exc.code in {"not_found", "action_unavailable", "direct_path_unavailable"}:
|
|
837
|
+
raise PathUnavailable(f"no direct path: {exc.code}") from None
|
|
838
|
+
raise PathError(str(exc)) from None
|
|
839
|
+
if not (answer.get("server_name") and answer.get("ca_pem")):
|
|
840
|
+
raise PathUnavailable("the lab's Server predates direct paths for Global users")
|
|
841
|
+
return answer
|
|
842
|
+
|
|
843
|
+
return send
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def global_path(ref: str, deployment_id: Optional[str] = None) -> Optional[DirectPath]:
|
|
847
|
+
"""The path to the lab behind a Global device ref or ``rfg_`` token,
|
|
848
|
+
signaled through Global, when it is ready for this call."""
|
|
849
|
+
if not enabled():
|
|
850
|
+
return None
|
|
851
|
+
return _side_path(ref, lambda: DirectPath(
|
|
852
|
+
offer=_global_offer(ref, deployment_id), server_name="", ca_pem=b"",
|
|
853
|
+
memo=deployment_id or ref.rsplit(":", 1)[0],
|
|
854
|
+
))
|
|
855
|
+
|
|
856
|
+
|
|
720
857
|
def current(token: Optional[str] = None) -> Optional[DirectPath]:
|
|
721
858
|
"""The path a call with ``token`` should take when it is ready, else None.
|
|
722
859
|
|
|
@@ -742,7 +879,7 @@ def current(token: Optional[str] = None) -> Optional[DirectPath]:
|
|
|
742
879
|
_path.start()
|
|
743
880
|
_path.wait()
|
|
744
881
|
path = _path
|
|
745
|
-
if path is not None and path.state
|
|
882
|
+
if path is not None and path.state in ("idle", "down"):
|
|
746
883
|
path.wake(WAIT_SECONDS)
|
|
747
884
|
return path if path is not None and path.ready else None
|
|
748
885
|
|