RemoteRF 2.0.15__py3-none-any.whl → 2.1.0.dev7__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.
Files changed (35) hide show
  1. remoteRF/common/grpc/__init__.py +1 -1
  2. remoteRF/common/grpc/deployment_capabilities.proto +15 -0
  3. remoteRF/common/grpc/deployment_capabilities_pb2.py +41 -0
  4. remoteRF/common/grpc/deployment_capabilities_pb2_grpc.py +100 -0
  5. remoteRF/common/grpc/v2_codec.py +1 -1
  6. remoteRF/common/utils/banner.py +13 -10
  7. remoteRF/common/utils/process_arg.py +31 -2
  8. remoteRF/config/cert_fetcher.py +30 -5
  9. remoteRF/config/config.py +84 -7
  10. remoteRF/core/acc_login.py +11 -4
  11. remoteRF/core/app.py +930 -228
  12. remoteRF/core/direct_path.py +707 -0
  13. remoteRF/core/grpc_acc.py +96 -16
  14. remoteRF/core/grpc_client.py +200 -50
  15. remoteRF/core/secure_channel.py +63 -0
  16. remoteRF/deployment/__init__.py +1 -0
  17. remoteRF/deployment/backend.py +416 -0
  18. remoteRF/deployment/direct.py +38 -0
  19. remoteRF/deployment/homes.py +383 -0
  20. remoteRF/deployment/http.py +121 -0
  21. remoteRF/deployment/state.py +187 -0
  22. remoteRF/drivers/adalm_pluto/__init__.py +6 -1
  23. remoteRF/drivers/adalm_pluto/adalm_pluto_remote.py +388 -0
  24. remoteRF/drivers/dynamic_device.py +50 -46
  25. remoteRF/drivers/dynamic_v2.py +38 -25
  26. remoteRF/drivers/sim_sdr/__init__.py +21 -0
  27. remoteRF/drivers/sim_sdr/sim_sdr_remote.py +183 -0
  28. remoteRF/remoterf_cli.py +328 -26
  29. {remoterf-2.0.15.dist-info → remoterf-2.1.0.dev7.dist-info}/METADATA +84 -3
  30. remoterf-2.1.0.dev7.dist-info/RECORD +67 -0
  31. remoterf-2.0.15.dist-info/RECORD +0 -53
  32. {remoterf-2.0.15.dist-info → remoterf-2.1.0.dev7.dist-info}/WHEEL +0 -0
  33. {remoterf-2.0.15.dist-info → remoterf-2.1.0.dev7.dist-info}/entry_points.txt +0 -0
  34. {remoterf-2.0.15.dist-info → remoterf-2.1.0.dev7.dist-info}/licenses/LICENSE +0 -0
  35. {remoterf-2.0.15.dist-info → remoterf-2.1.0.dev7.dist-info}/top_level.txt +0 -0
@@ -13,4 +13,4 @@
13
13
  # You should have received a copy of the GNU General Public License
14
14
  # along with this program. If not, see <https://www.gnu.org/licenses/>.
15
15
 
16
- from . import grpc_pb2, grpc_pb2_grpc
16
+ from . import grpc_pb2, grpc_pb2_grpc
@@ -0,0 +1,15 @@
1
+ syntax = "proto3";
2
+ package remote_rf;
3
+ import "grpc.proto";
4
+
5
+ // Optional discovery. Existing GenericRPC/IDL/device protocols are unchanged.
6
+ service DeploymentCapabilitiesV1 {
7
+ rpc GetCapabilities (DeploymentCapabilitiesRequest) returns (DeploymentCapabilitiesResponse);
8
+ }
9
+ message DeploymentCapabilitiesRequest {
10
+ string protocol_version = 1;
11
+ }
12
+ message DeploymentCapabilitiesResponse {
13
+ string capabilities_json = 1;
14
+ ErrorEnvelope error = 2;
15
+ }
@@ -0,0 +1,41 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Generated by the protocol buffer compiler. DO NOT EDIT!
3
+ # NO CHECKED-IN PROTOBUF GENCODE
4
+ # source: deployment_capabilities.proto
5
+ # Protobuf Python Version: 6.31.1
6
+ """Generated protocol buffer code."""
7
+ from google.protobuf import descriptor as _descriptor
8
+ from google.protobuf import descriptor_pool as _descriptor_pool
9
+ from google.protobuf import runtime_version as _runtime_version
10
+ from google.protobuf import symbol_database as _symbol_database
11
+ from google.protobuf.internal import builder as _builder
12
+ _runtime_version.ValidateProtobufRuntimeVersion(
13
+ _runtime_version.Domain.PUBLIC,
14
+ 6,
15
+ 31,
16
+ 1,
17
+ '',
18
+ 'deployment_capabilities.proto'
19
+ )
20
+ # @@protoc_insertion_point(imports)
21
+
22
+ _sym_db = _symbol_database.Default()
23
+
24
+
25
+ from . import grpc_pb2 as grpc__pb2
26
+
27
+
28
+ DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1d\x64\x65ployment_capabilities.proto\x12\tremote_rf\x1a\ngrpc.proto\"9\n\x1d\x44\x65ploymentCapabilitiesRequest\x12\x18\n\x10protocol_version\x18\x01 \x01(\t\"d\n\x1e\x44\x65ploymentCapabilitiesResponse\x12\x19\n\x11\x63\x61pabilities_json\x18\x01 \x01(\t\x12\'\n\x05\x65rror\x18\x02 \x01(\x0b\x32\x18.remote_rf.ErrorEnvelope2\x82\x01\n\x18\x44\x65ploymentCapabilitiesV1\x12\x66\n\x0fGetCapabilities\x12(.remote_rf.DeploymentCapabilitiesRequest\x1a).remote_rf.DeploymentCapabilitiesResponseb\x06proto3')
29
+
30
+ _globals = globals()
31
+ _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
32
+ _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'deployment_capabilities_pb2', _globals)
33
+ if not _descriptor._USE_C_DESCRIPTORS:
34
+ DESCRIPTOR._loaded_options = None
35
+ _globals['_DEPLOYMENTCAPABILITIESREQUEST']._serialized_start=56
36
+ _globals['_DEPLOYMENTCAPABILITIESREQUEST']._serialized_end=113
37
+ _globals['_DEPLOYMENTCAPABILITIESRESPONSE']._serialized_start=115
38
+ _globals['_DEPLOYMENTCAPABILITIESRESPONSE']._serialized_end=215
39
+ _globals['_DEPLOYMENTCAPABILITIESV1']._serialized_start=218
40
+ _globals['_DEPLOYMENTCAPABILITIESV1']._serialized_end=348
41
+ # @@protoc_insertion_point(module_scope)
@@ -0,0 +1,100 @@
1
+ # Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
2
+ """Client and server classes corresponding to protobuf-defined services."""
3
+ import grpc
4
+ import warnings
5
+
6
+ from . import deployment_capabilities_pb2 as deployment__capabilities__pb2
7
+
8
+ GRPC_GENERATED_VERSION = '1.74.0'
9
+ GRPC_VERSION = grpc.__version__
10
+ _version_not_supported = False
11
+
12
+ try:
13
+ from grpc._utilities import first_version_is_lower
14
+ _version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION)
15
+ except ImportError:
16
+ _version_not_supported = True
17
+
18
+ if _version_not_supported:
19
+ raise RuntimeError(
20
+ f'The grpc package installed is at version {GRPC_VERSION},'
21
+ + f' but the generated code in deployment_capabilities_pb2_grpc.py depends on'
22
+ + f' grpcio>={GRPC_GENERATED_VERSION}.'
23
+ + f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}'
24
+ + f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.'
25
+ )
26
+
27
+
28
+ class DeploymentCapabilitiesV1Stub(object):
29
+ """Optional discovery. Existing GenericRPC/IDL/device protocols are unchanged.
30
+ """
31
+
32
+ def __init__(self, channel):
33
+ """Constructor.
34
+
35
+ Args:
36
+ channel: A grpc.Channel.
37
+ """
38
+ self.GetCapabilities = channel.unary_unary(
39
+ '/remote_rf.DeploymentCapabilitiesV1/GetCapabilities',
40
+ request_serializer=deployment__capabilities__pb2.DeploymentCapabilitiesRequest.SerializeToString,
41
+ response_deserializer=deployment__capabilities__pb2.DeploymentCapabilitiesResponse.FromString,
42
+ _registered_method=True)
43
+
44
+
45
+ class DeploymentCapabilitiesV1Servicer(object):
46
+ """Optional discovery. Existing GenericRPC/IDL/device protocols are unchanged.
47
+ """
48
+
49
+ def GetCapabilities(self, request, context):
50
+ """Missing associated documentation comment in .proto file."""
51
+ context.set_code(grpc.StatusCode.UNIMPLEMENTED)
52
+ context.set_details('Method not implemented!')
53
+ raise NotImplementedError('Method not implemented!')
54
+
55
+
56
+ def add_DeploymentCapabilitiesV1Servicer_to_server(servicer, server):
57
+ rpc_method_handlers = {
58
+ 'GetCapabilities': grpc.unary_unary_rpc_method_handler(
59
+ servicer.GetCapabilities,
60
+ request_deserializer=deployment__capabilities__pb2.DeploymentCapabilitiesRequest.FromString,
61
+ response_serializer=deployment__capabilities__pb2.DeploymentCapabilitiesResponse.SerializeToString,
62
+ ),
63
+ }
64
+ generic_handler = grpc.method_handlers_generic_handler(
65
+ 'remote_rf.DeploymentCapabilitiesV1', rpc_method_handlers)
66
+ server.add_generic_rpc_handlers((generic_handler,))
67
+ server.add_registered_method_handlers('remote_rf.DeploymentCapabilitiesV1', rpc_method_handlers)
68
+
69
+
70
+ # This class is part of an EXPERIMENTAL API.
71
+ class DeploymentCapabilitiesV1(object):
72
+ """Optional discovery. Existing GenericRPC/IDL/device protocols are unchanged.
73
+ """
74
+
75
+ @staticmethod
76
+ def GetCapabilities(request,
77
+ target,
78
+ options=(),
79
+ channel_credentials=None,
80
+ call_credentials=None,
81
+ insecure=False,
82
+ compression=None,
83
+ wait_for_ready=None,
84
+ timeout=None,
85
+ metadata=None):
86
+ return grpc.experimental.unary_unary(
87
+ request,
88
+ target,
89
+ '/remote_rf.DeploymentCapabilitiesV1/GetCapabilities',
90
+ deployment__capabilities__pb2.DeploymentCapabilitiesRequest.SerializeToString,
91
+ deployment__capabilities__pb2.DeploymentCapabilitiesResponse.FromString,
92
+ options,
93
+ channel_credentials,
94
+ insecure,
95
+ call_credentials,
96
+ compression,
97
+ wait_for_ready,
98
+ timeout,
99
+ metadata,
100
+ _registered_method=True)
@@ -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 _json_safe(value.item())
51
+ return value.item()
52
52
  if isinstance(value, complex):
53
53
  return {
54
54
  _JSON_TYPE_KEY: "complex",
@@ -47,7 +47,7 @@ COMPACT_BANNER = "RemoteRF"
47
47
  ASCII_WORDMARK_WIDTH = max(len(line) for line in ASCII_BANNER.splitlines())
48
48
  ASCII_BANNER_WIDTH = max(len(line) for line in BRANDED_ASCII_BANNER.splitlines())
49
49
  ASCII_BANNER_MIN_COLUMNS = ASCII_BANNER_WIDTH + 1
50
- PANEL_CONTENT_WIDTH = 35
50
+ PANEL_CONTENT_WIDTH = 39
51
51
  PANEL_INNER_WIDTH = PANEL_CONTENT_WIDTH + 2
52
52
  PANEL_WIDTH = PANEL_INNER_WIDTH + 2
53
53
  PANEL_GAP = 1
@@ -111,12 +111,15 @@ def _panel_lines(
111
111
  marker = "◆" if unicode_supported else "*"
112
112
  label = f"{horizontal} REMOTERF CLIENT "
113
113
  top = f"{top_left}{label}{horizontal * (PANEL_INNER_WIDTH - len(label))}{top_right}"
114
- content = (
114
+ content = [
115
115
  f"{marker} www.remoterf.net",
116
- "Created by the Wireless Lab at UCLA",
116
+ "Created by https://wireless.ee.ucla.edu",
117
117
  f"VERSION {version}",
118
- f"SERVER {server or 'NA'}",
119
- )
118
+ ]
119
+ # Before a target is chosen there is no server to name; an "NA" row would
120
+ # only say so twice.
121
+ if server:
122
+ content.append(f"SERVER {server}")
120
123
  body = [
121
124
  f"{vertical} {_fit_panel_text(line):<{PANEL_CONTENT_WIDTH}} {vertical}"
122
125
  for line in content
@@ -169,12 +172,12 @@ def _compact_lines(
169
172
  heading = COMPACT_BANNER[:width]
170
173
 
171
174
  short_version = str(version).split(maxsplit=1)[0]
172
- server = str(server or "NA")
175
+ server = str(server or "")
173
176
  detail_options = (
174
- f"v{version} {separator} {server}",
175
- f"v{short_version} {separator} {server}",
176
- f"v{short_version}",
177
- )
177
+ (f"v{version} {separator} {server}", f"v{short_version} {separator} {server}")
178
+ if server
179
+ else (f"v{version}",)
180
+ ) + (f"v{short_version}",)
178
181
  details = next((line for line in detail_options if len(line) <= width), "")
179
182
  if not details:
180
183
  details = detail_options[-1][:width]
@@ -30,6 +30,31 @@ _MAX_NDARRAY_DIMENSIONS = 8
30
30
  _SUPPORTED_NDARRAY_KINDS = {"b", "i", "u", "f", "c"}
31
31
 
32
32
 
33
+ # Gate I slice 3: complex samples as interleaved int16 IQ, a quarter of the
34
+ # complex128 bytes, decoded to complex64. Only exact for integer-valued data
35
+ # within int16 range (a 12-bit ADC's, say); map_iq16 refuses anything else.
36
+ IQ16 = "ci16"
37
+
38
+
39
+ def fits_iq16(array: np.ndarray) -> bool:
40
+ if not (isinstance(array, np.ndarray) and np.iscomplexobj(array)):
41
+ return False
42
+ parts = np.stack((array.real, array.imag))
43
+ return bool(np.all(np.abs(parts) <= 32767) and np.array_equal(parts, np.round(parts)))
44
+
45
+
46
+ def map_iq16(array: np.ndarray) -> grpc_pb2.Argument:
47
+ if not fits_iq16(array):
48
+ raise ValueError("samples are not integer-valued int16 IQ")
49
+ iq = np.empty(array.shape + (2,), dtype=np.int16)
50
+ iq[..., 0], iq[..., 1] = array.real, array.imag
51
+ arg = grpc_pb2.Argument()
52
+ arg.ndarray_value.dtype = IQ16
53
+ arg.ndarray_value.shape.extend(array.shape)
54
+ arg.ndarray_value.data = np.ascontiguousarray(iq).tobytes(order="C")
55
+ return arg
56
+
57
+
33
58
  def _validate_ndarray(dtype: np.dtype, shape) -> int:
34
59
  """Return the expected payload length for a safe numeric ndarray."""
35
60
  if dtype.hasobject or dtype.kind not in _SUPPORTED_NDARRAY_KINDS:
@@ -49,18 +74,22 @@ def _validate_ndarray(dtype: np.dtype, shape) -> int:
49
74
 
50
75
 
51
76
  def _decode_ndarray(descriptor: grpc_pb2.NDArrayValue) -> np.ndarray:
77
+ iq16 = descriptor.dtype == IQ16
52
78
  try:
53
- dtype = np.dtype(descriptor.dtype)
79
+ dtype = np.dtype(np.int16) if iq16 else np.dtype(descriptor.dtype)
54
80
  except (TypeError, ValueError) as exc:
55
81
  raise ValueError(f"invalid ndarray dtype: {descriptor.dtype!r}") from exc
56
82
 
57
83
  shape = tuple(int(item) for item in descriptor.shape)
58
- expected = _validate_ndarray(dtype, shape)
84
+ expected = _validate_ndarray(dtype, shape + (2,) if iq16 else shape)
59
85
  actual = len(descriptor.data)
60
86
  if actual != expected:
61
87
  raise ValueError(
62
88
  f"ndarray byte length mismatch: expected {expected}, got {actual}"
63
89
  )
90
+ if iq16:
91
+ iq = np.frombuffer(descriptor.data, dtype=dtype).reshape(shape + (2,))
92
+ return (iq[..., 0].astype(np.float32) + 1j * iq[..., 1].astype(np.float32)).astype(np.complex64)
64
93
  return np.frombuffer(descriptor.data, dtype=dtype).reshape(shape).copy()
65
94
 
66
95
 
@@ -20,6 +20,7 @@ from __future__ import annotations
20
20
  import hashlib
21
21
  import os
22
22
  import socket
23
+ import urllib.error
23
24
  import urllib.request
24
25
  from pathlib import Path
25
26
  from typing import Optional
@@ -38,6 +39,26 @@ def _ensure_parent_dir(p: Path) -> None:
38
39
  def _looks_like_pem_cert(data: bytes) -> bool:
39
40
  return b"BEGIN CERTIFICATE" in data and b"END CERTIFICATE" in data
40
41
 
42
+
43
+ def looks_like_pem_cert(data: bytes) -> bool:
44
+ """Public alias of `_looks_like_pem_cert` for generic callers."""
45
+ return _looks_like_pem_cert(data)
46
+
47
+
48
+ def fetch_ca_bytes(host: str, port: int, *, timeout_sec: float = 3.0) -> bytes:
49
+ """Fetch raw CA certificate bytes (HTTP first, then raw-TCP fallback),
50
+ without writing anything to disk. Shared by direct-mode's
51
+ `fetch_and_save_ca_cert` below.
52
+ """
53
+ try:
54
+ return _fetch_http(host, port, timeout_sec)
55
+ except urllib.error.URLError as exc:
56
+ if isinstance(exc.reason, (OSError, socket.timeout)):
57
+ raise # nothing answered: a raw retry would only wait the timeout again
58
+ return _fetch_raw_tcp(host, port, timeout_sec)
59
+ except Exception:
60
+ return _fetch_raw_tcp(host, port, timeout_sec)
61
+
41
62
  def sha256_fingerprint_pem(pem_bytes: bytes) -> str:
42
63
  h = hashlib.sha256(pem_bytes).hexdigest()
43
64
  return ":".join(h[i:i+2] for i in range(0, len(h), 2))
@@ -48,9 +69,16 @@ def _fetch_http(host: str, port: int, timeout_sec: float) -> bytes:
48
69
  with urllib.request.urlopen(req, timeout=timeout_sec) as resp:
49
70
  return resp.read()
50
71
 
72
+ def _bare_host(host: str) -> str:
73
+ """Strip IPv6 literal brackets (e.g. '[::1]' -> '::1') for raw-socket use."""
74
+ host = host.strip()
75
+ if host.startswith("[") and host.endswith("]"):
76
+ return host[1:-1]
77
+ return host
78
+
51
79
  def _fetch_raw_tcp(host: str, port: int, timeout_sec: float) -> bytes:
52
80
  chunks: list[bytes] = []
53
- with socket.create_connection((host, port), timeout=timeout_sec) as s:
81
+ with socket.create_connection((_bare_host(host), port), timeout=timeout_sec) as s:
54
82
  s.settimeout(timeout_sec)
55
83
  while True:
56
84
  try:
@@ -164,10 +192,7 @@ def fetch_and_save_ca_cert(
164
192
  return True
165
193
 
166
194
  # Fetch (HTTP first, then raw TCP fallback)
167
- try:
168
- data = _fetch_http(host, port, timeout_sec)
169
- except Exception:
170
- data = _fetch_raw_tcp(host, port, timeout_sec)
195
+ data = fetch_ca_bytes(host, port, timeout_sec=timeout_sec)
171
196
 
172
197
  if not data or not _looks_like_pem_cert(data):
173
198
  return False
remoteRF/config/config.py CHANGED
@@ -18,6 +18,7 @@
18
18
  from __future__ import annotations
19
19
 
20
20
  import os
21
+ import socket
21
22
  import sys
22
23
  import shutil
23
24
  from pathlib import Path
@@ -68,6 +69,27 @@ def _parse_hostport(s: str) -> Tuple[str, int]:
68
69
  raise ValueError("Port out of range")
69
70
  return host, port
70
71
 
72
+
73
+ def _bare_host(host: str) -> str:
74
+ """Strip IPv6 literal brackets (e.g. '[::1]' -> '::1') for name/address
75
+ resolution and raw-socket use. IPv4 addresses and DNS hostnames
76
+ (including plain multi-label names such as ucla.global.remoterf.net)
77
+ are returned unchanged.
78
+ """
79
+ host = host.strip()
80
+ if host.startswith("[") and host.endswith("]"):
81
+ return host[1:-1]
82
+ return host
83
+
84
+
85
+ def _host_resolves(host: str) -> bool:
86
+ """Return True if `host` (an IPv4/IPv6 literal or DNS hostname) resolves."""
87
+ try:
88
+ socket.getaddrinfo(_bare_host(host), None)
89
+ return True
90
+ except socket.gaierror:
91
+ return False
92
+
71
93
  def _write_env_kv(path: Path, kv: dict[str, str]) -> None:
72
94
  path.parent.mkdir(parents=True, exist_ok=True)
73
95
  lines: list[str] = []
@@ -151,21 +173,57 @@ def _print_config_summary(host: str, grpc_port: int, cert_port: int, ca_out: Pat
151
173
  _print_separator()
152
174
 
153
175
 
154
- def _confirm_tos() -> bool:
176
+ def _confirm_tos(question: str = "Continue with configuration? [y/N]: ",
177
+ cancelled: str = "Configuration cancelled.") -> bool:
155
178
  _print_separator()
156
179
  _print_tos_notice()
157
180
  _print_separator()
158
181
  try:
159
- reply = input("Continue with configuration? [y/N]: ").strip().lower()
160
- except KeyboardInterrupt:
161
- print("\nConfiguration cancelled.")
162
- return False
163
- except EOFError:
164
- print("\nConfiguration cancelled.")
182
+ reply = input(question).strip().lower()
183
+ except (KeyboardInterrupt, EOFError):
184
+ print(f"\n{cancelled}")
165
185
  return False
166
186
  print()
167
187
  return reply in {"y", "yes"}
168
188
 
189
+
190
+ # The one remembered agreement, for logins. A registration always asks anew
191
+ # and forgets this first, so a declined or abandoned `-r` means the next
192
+ # login asks again.
193
+ def _tos_agreement_path():
194
+ from ..deployment.state import root
195
+
196
+ return root() / "tos-agreed.json"
197
+
198
+
199
+ def tos_agreed() -> bool:
200
+ return _tos_agreement_path().exists()
201
+
202
+
203
+ def remember_tos_agreement() -> None:
204
+ from datetime import datetime, timezone
205
+
206
+ from ..deployment.state import private_write
207
+
208
+ private_write(_tos_agreement_path(), {"agreed_at": datetime.now(timezone.utc).isoformat()})
209
+
210
+
211
+ def forget_tos_agreement() -> None:
212
+ try:
213
+ _tos_agreement_path().unlink()
214
+ except FileNotFoundError:
215
+ pass
216
+
217
+
218
+ def require_tos_for_login() -> bool:
219
+ """Ask once, then remember -- until a registration clears it."""
220
+ if tos_agreed():
221
+ return True
222
+ if not _confirm_tos("Do you agree to the RemoteRF Terms of Service? [y/N]: ", "Login cancelled."):
223
+ return False
224
+ remember_tos_agreement()
225
+ return True
226
+
169
227
  def configure(host: str, port: int, cert_port: int) -> int:
170
228
  # Basic validation
171
229
  host = (host or "").strip()
@@ -176,6 +234,16 @@ def configure(host: str, port: int, cert_port: int) -> int:
176
234
  print("Error: port out of range", file=sys.stderr)
177
235
  return 2
178
236
 
237
+ if not _host_resolves(host):
238
+ print(
239
+ "Error: Could not resolve RemoteRF server hostname:\n"
240
+ f" {host}\n"
241
+ "Check the address and your network/DNS connection, then re-run:\n"
242
+ " remoterf --config --addr <host:port>",
243
+ file=sys.stderr,
244
+ )
245
+ return 1
246
+
179
247
  grpc_port = int(port)
180
248
  cert_port = int(cert_port)
181
249
 
@@ -208,6 +276,15 @@ def configure(host: str, port: int, cert_port: int) -> int:
208
276
  "REMOTERF_CA_CERT": str(ca_out),
209
277
  "REMOTERF_PROFILE": profile,
210
278
  })
279
+ # This profile is what the "default" home was adopted from. A new address
280
+ # or a re-fetched CA makes that adoption stale (its pin would now refuse
281
+ # this very server), so drop it and let the next login adopt this one.
282
+ try:
283
+ from ..deployment import homes
284
+
285
+ homes.forget_home(profile)
286
+ except Exception: # noqa: BLE001 - never fail configuration over bookkeeping
287
+ pass
211
288
 
212
289
  _print_config_summary(host, grpc_port, cert_port, ca_out, env_file)
213
290
 
@@ -13,7 +13,14 @@
13
13
  # You should have received a copy of the GNU General Public License
14
14
  # along with this program. If not, see <https://www.gnu.org/licenses/>.
15
15
 
16
- from . import app as client
17
-
18
- def main():
19
- pass
16
+ def main(*, register=False, enrollment_code=None, server_label=None, show_banner=True,
17
+ home=None, route=None):
18
+ from .app import run
19
+ return run(
20
+ register=register,
21
+ enrollment_code=enrollment_code,
22
+ server_label=server_label,
23
+ show_banner=show_banner,
24
+ home=home,
25
+ route=route,
26
+ )