remoteRF-server 0.1.95__tar.gz → 0.1.97__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 (42) hide show
  1. {remoterf_server-0.1.95 → remoterf_server-0.1.97}/PKG-INFO +2 -2
  2. {remoterf_server-0.1.95 → remoterf_server-0.1.97}/README.md +1 -1
  3. {remoterf_server-0.1.95 → remoterf_server-0.1.97}/pyproject.toml +1 -1
  4. {remoterf_server-0.1.95 → remoterf_server-0.1.97}/src/remoteRF_server/server/device_manager.py +286 -100
  5. {remoterf_server-0.1.95 → remoterf_server-0.1.97}/src/remoteRF_server/server/grpc_server.py +4 -4
  6. {remoterf_server-0.1.95 → remoterf_server-0.1.97}/src/remoteRF_server.egg-info/PKG-INFO +2 -2
  7. {remoterf_server-0.1.95 → remoterf_server-0.1.97}/setup.cfg +0 -0
  8. {remoterf_server-0.1.95 → remoterf_server-0.1.97}/src/remoteRF_server/__init__.py +0 -0
  9. {remoterf_server-0.1.95 → remoterf_server-0.1.97}/src/remoteRF_server/common/__init__.py +0 -0
  10. {remoterf_server-0.1.95 → remoterf_server-0.1.97}/src/remoteRF_server/common/grpc/__init__.py +0 -0
  11. {remoterf_server-0.1.95 → remoterf_server-0.1.97}/src/remoteRF_server/common/grpc/grpc_host_pb2.py +0 -0
  12. {remoterf_server-0.1.95 → remoterf_server-0.1.97}/src/remoteRF_server/common/grpc/grpc_host_pb2_grpc.py +0 -0
  13. {remoterf_server-0.1.95 → remoterf_server-0.1.97}/src/remoteRF_server/common/grpc/grpc_pb2.py +0 -0
  14. {remoterf_server-0.1.95 → remoterf_server-0.1.97}/src/remoteRF_server/common/grpc/grpc_pb2_grpc.py +0 -0
  15. {remoterf_server-0.1.95 → remoterf_server-0.1.97}/src/remoteRF_server/common/utils/__init__.py +0 -0
  16. {remoterf_server-0.1.95 → remoterf_server-0.1.97}/src/remoteRF_server/common/utils/ansi_codes.py +0 -0
  17. {remoterf_server-0.1.95 → remoterf_server-0.1.97}/src/remoteRF_server/common/utils/api_token.py +0 -0
  18. {remoterf_server-0.1.95 → remoterf_server-0.1.97}/src/remoteRF_server/common/utils/db_connection.py +0 -0
  19. {remoterf_server-0.1.95 → remoterf_server-0.1.97}/src/remoteRF_server/common/utils/db_location.py +0 -0
  20. {remoterf_server-0.1.95 → remoterf_server-0.1.97}/src/remoteRF_server/common/utils/list_string.py +0 -0
  21. {remoterf_server-0.1.95 → remoterf_server-0.1.97}/src/remoteRF_server/common/utils/process_arg.py +0 -0
  22. {remoterf_server-0.1.95 → remoterf_server-0.1.97}/src/remoteRF_server/drivers/__init__.py +0 -0
  23. {remoterf_server-0.1.95 → remoterf_server-0.1.97}/src/remoteRF_server/drivers/adalm_pluto/__init__.py +0 -0
  24. {remoterf_server-0.1.95 → remoterf_server-0.1.97}/src/remoteRF_server/drivers/adalm_pluto/pluto_remote_server.py +0 -0
  25. {remoterf_server-0.1.95 → remoterf_server-0.1.97}/src/remoteRF_server/host/__init__.py +0 -0
  26. {remoterf_server-0.1.95 → remoterf_server-0.1.97}/src/remoteRF_server/host/host_directory_store.py +0 -0
  27. {remoterf_server-0.1.95 → remoterf_server-0.1.97}/src/remoteRF_server/host/host_tunnel_server.py +0 -0
  28. {remoterf_server-0.1.95 → remoterf_server-0.1.97}/src/remoteRF_server/server/__init__.py +0 -0
  29. {remoterf_server-0.1.95 → remoterf_server-0.1.97}/src/remoteRF_server/server/acc_perms.py +0 -0
  30. {remoterf_server-0.1.95 → remoterf_server-0.1.97}/src/remoteRF_server/server/cert_provider.py +0 -0
  31. {remoterf_server-0.1.95 → remoterf_server-0.1.97}/src/remoteRF_server/server/reservation.py +0 -0
  32. {remoterf_server-0.1.95 → remoterf_server-0.1.97}/src/remoteRF_server/server/rpc_manager.py +0 -0
  33. {remoterf_server-0.1.95 → remoterf_server-0.1.97}/src/remoteRF_server/server/user_group_cli.py +0 -0
  34. {remoterf_server-0.1.95 → remoterf_server-0.1.97}/src/remoteRF_server/server/user_group_handler.py +0 -0
  35. {remoterf_server-0.1.95 → remoterf_server-0.1.97}/src/remoteRF_server/serverrf_cli.py +0 -0
  36. {remoterf_server-0.1.95 → remoterf_server-0.1.97}/src/remoteRF_server/tools/__init__.py +0 -0
  37. {remoterf_server-0.1.95 → remoterf_server-0.1.97}/src/remoteRF_server/tools/gen_certs.py +0 -0
  38. {remoterf_server-0.1.95 → remoterf_server-0.1.97}/src/remoteRF_server.egg-info/SOURCES.txt +0 -0
  39. {remoterf_server-0.1.95 → remoterf_server-0.1.97}/src/remoteRF_server.egg-info/dependency_links.txt +0 -0
  40. {remoterf_server-0.1.95 → remoterf_server-0.1.97}/src/remoteRF_server.egg-info/entry_points.txt +0 -0
  41. {remoterf_server-0.1.95 → remoterf_server-0.1.97}/src/remoteRF_server.egg-info/requires.txt +0 -0
  42. {remoterf_server-0.1.95 → remoterf_server-0.1.97}/src/remoteRF_server.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remoteRF-server
3
- Version: 0.1.95
3
+ Version: 0.1.97
4
4
  Description-Content-Type: text/markdown
5
5
  Requires-Dist: grpcio
6
6
  Requires-Dist: protobuf
@@ -350,7 +350,7 @@ todo:
350
350
 
351
351
  <!-- Host ID (NEEDS TO BE UNIQUE AND TELL THE HOST THAT) (or if device also does it as well.) -->
352
352
 
353
- ServerRF display meta data as part of devices list / reservation
353
+ <!-- ServerRF display meta data as part of devices list / reservation -->
354
354
 
355
355
  + online/activity status (appended into device name) + (plus host side warning if the device id is NOT globally unique!)
356
356
 
@@ -339,7 +339,7 @@ todo:
339
339
 
340
340
  <!-- Host ID (NEEDS TO BE UNIQUE AND TELL THE HOST THAT) (or if device also does it as well.) -->
341
341
 
342
- ServerRF display meta data as part of devices list / reservation
342
+ <!-- ServerRF display meta data as part of devices list / reservation -->
343
343
 
344
344
  + online/activity status (appended into device name) + (plus host side warning if the device id is NOT globally unique!)
345
345
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "remoteRF-server"
7
- version = "0.1.95"
7
+ version = "0.1.97"
8
8
  dependencies = [
9
9
  "grpcio",
10
10
  "protobuf",
@@ -2,47 +2,188 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import hashlib
6
- from typing import Dict, Tuple, Optional, Any
7
-
8
5
  import os
9
6
  import re
7
+ import time
8
+ import adi
10
9
  import subprocess
11
10
  import threading
12
- from dataclasses import dataclass
11
+ from dataclasses import dataclass, field
13
12
  from pathlib import Path
14
- from typing import Dict, Optional, Tuple, List, Iterator
13
+ from typing import Dict, Tuple, Optional, Any, List, Iterator
15
14
  from contextlib import contextmanager
16
15
 
17
- import adi
18
-
19
16
  from ..common.utils import validate_token # your salt/hash validator
20
17
  from ..host import host_tunnel_server as hts # gives access to hts.TUNNEL_REGISTRY
21
18
 
22
- import time # add near imports
23
-
24
19
  _state_lock = threading.RLock()
25
20
 
21
+
22
+ # ============================================================
23
+ # Virtual device (server-side proxy for host-managed hardware)
24
+ # ============================================================
25
+
26
+ @dataclass(frozen=True)
27
+ class VirtualDevice:
28
+ """
29
+ Server-side proxy for a host-managed device.
30
+
31
+ IMPORTANT:
32
+ - This is NOT an adi device.
33
+ - It only carries routing metadata so your pipeline can forward later.
34
+ """
35
+ gid: int
36
+ host_id: str
37
+ device_id: str # whatever key the host directory uses (stringified)
38
+ label: str = ""
39
+ serial: str = ""
40
+ kind: str = ""
41
+
42
+ def __repr__(self) -> str:
43
+ return f"<VirtualDevice gid={self.gid} host={self.host_id} device_id={self.device_id}>"
44
+
45
+
46
+ def is_virtual(dev: object) -> bool:
47
+ return isinstance(dev, VirtualDevice)
48
+
49
+
26
50
  @dataclass
27
51
  class _DeviceState:
28
- dev: object
52
+ dev: object | None
53
+ origin: str # "local" or "host"
54
+ online: bool # for host devices; for local can mirror (dev is not None)
29
55
  salt: str
30
56
  hsh: str
31
57
  name: str
32
58
  ident: str
33
59
  dtype: str
34
- io_lock: threading.RLock
60
+ io_lock: threading.RLock = field(default_factory=threading.RLock)
61
+
35
62
 
36
63
  # gid -> state
37
64
  _devices: Dict[int, _DeviceState] = {}
38
65
 
39
- # -----------------------------
66
+
67
+ def _is_connected(st: _DeviceState) -> bool:
68
+ if st.origin == "local":
69
+ return st.dev is not None
70
+ # host
71
+ return bool(st.online)
72
+
73
+
74
+ # ============================================================
40
75
  # Host device pulling/grabbing
41
- # -----------------------------
76
+ # ============================================================
42
77
 
43
78
  def _get_host_registry():
44
79
  return hts.get_tunnel_registry()
45
80
 
81
+
82
+ _host_sync_lock = threading.RLock()
83
+ _host_sync_last_ms = 0
84
+ _HOST_SYNC_TTL_MS = 500 # tune later
85
+
86
+
87
+ def _sync_host_devices(*, force: bool = False) -> None:
88
+ """
89
+ Pull host directory and upsert host devices into _devices using their GID.
90
+
91
+ - Does NOT touch local devices (origin="local")
92
+ - Preserves salt/hsh/io_lock for existing entries
93
+ - Marks missing host devices offline (keeps their proxy for reservations)
94
+ - Updates devices_info/device_serialization for host devices (best-effort)
95
+ """
96
+ global _host_sync_last_ms
97
+
98
+ now_ms = int(time.time() * 1000)
99
+ with _host_sync_lock:
100
+ if not force and (now_ms - _host_sync_last_ms) < _HOST_SYNC_TTL_MS:
101
+ return
102
+ _host_sync_last_ms = now_ms
103
+
104
+ reg = _get_host_registry()
105
+ if reg is None:
106
+ return
107
+
108
+ # Do registry calls without holding _state_lock
109
+ snap = reg.device_directory_cached(ttl_ms=0) # device_id -> (host_id, info_obj, is_active)
110
+
111
+ updates: Dict[int, Dict[str, object]] = {}
112
+ for device_id, (host_id, info_obj, is_active) in snap.items():
113
+ # You said device_id is assumed to be the GID
114
+ try:
115
+ gid = int(str(device_id))
116
+ except Exception:
117
+ continue
118
+
119
+ label = str(getattr(info_obj, "label", "") or "").strip()
120
+ serial = str(getattr(info_obj, "serial", "") or "").strip()
121
+ kind = str(getattr(info_obj, "kind", "") or "").strip()
122
+
123
+ updates[gid] = {
124
+ "host_id": str(host_id),
125
+ "device_id": str(device_id),
126
+ "label": label,
127
+ "serial": serial,
128
+ "kind": kind,
129
+ "online": bool(is_active),
130
+ }
131
+
132
+ with _state_lock:
133
+ # Upsert / refresh host entries
134
+ for gid, u in updates.items():
135
+ prev = _devices.get(int(gid))
136
+
137
+ # Collision policy: do not overwrite local devices
138
+ if prev is not None and prev.origin == "local":
139
+ # collision shouldn't happen; keep local stable
140
+ continue
141
+
142
+ salt = prev.salt if prev else ""
143
+ hsh = prev.hsh if prev else ""
144
+ lock = prev.io_lock if prev else threading.RLock()
145
+
146
+ label = str(u["label"] or "")
147
+ serial = str(u["serial"] or "")
148
+ kind = str(u["kind"] or "")
149
+ host_id = str(u["host_id"] or "")
150
+ device_id = str(u["device_id"] or "")
151
+
152
+ name = label or (prev.name if prev else f"host-device-{gid}")
153
+ dtype = (kind or (prev.dtype if prev else "")).strip().lower()
154
+ ident = serial or (prev.ident if prev else device_id)
155
+
156
+ _devices[int(gid)] = _DeviceState(
157
+ dev=VirtualDevice(
158
+ gid=int(gid),
159
+ host_id=host_id,
160
+ device_id=device_id,
161
+ label=label,
162
+ serial=serial,
163
+ kind=kind,
164
+ ),
165
+ origin="host",
166
+ online=bool(u["online"]),
167
+ salt=salt,
168
+ hsh=hsh,
169
+ name=name,
170
+ ident=ident,
171
+ dtype=dtype,
172
+ io_lock=lock,
173
+ )
174
+
175
+ # Keep legacy maps coherent for host devices too (best-effort)
176
+ devices_info[int(gid)] = name
177
+ if ident:
178
+ device_serialization[int(gid)] = ident
179
+
180
+ # Mark host entries not present in current snap as offline (but keep proxy + auth)
181
+ present = set(updates.keys())
182
+ for gid, st in _devices.items():
183
+ if st.origin == "host" and gid not in present:
184
+ st.online = False
185
+
186
+
46
187
  def _local_devices_str_snapshot() -> Dict[int, str]:
47
188
  """
48
189
  Local devices from devices.env (whether connected or not).
@@ -50,8 +191,11 @@ def _local_devices_str_snapshot() -> Dict[int, str]:
50
191
  out: Dict[int, str] = {}
51
192
  with _state_lock:
52
193
  for gid, st in _devices.items():
194
+ if st.origin != "local":
195
+ continue
196
+
53
197
  name = (st.name or f"device-{gid}").strip()
54
- status = "online" if (st.dev is not None) else "offline"
198
+ status = "online" if _is_connected(st) else "offline"
55
199
 
56
200
  dtype = (st.dtype or "").strip()
57
201
  ident = (st.ident or "").strip()
@@ -62,7 +206,6 @@ def _local_devices_str_snapshot() -> Dict[int, str]:
62
206
  if ident:
63
207
  meta_parts.append(f"ident={ident}")
64
208
 
65
- # optional: show reservation state
66
209
  if st.salt or st.hsh:
67
210
  status = status + ", reserved"
68
211
 
@@ -70,6 +213,7 @@ def _local_devices_str_snapshot() -> Dict[int, str]:
70
213
  return out
71
214
 
72
215
  def _host_devices_str_snapshot() -> Dict[int, str]:
216
+ _sync_host_devices() # ensure host devices are up-to-date before snapshot
73
217
  reg = _get_host_registry()
74
218
  if reg is None:
75
219
  return {}
@@ -117,34 +261,36 @@ def _host_devices_str_snapshot() -> Dict[int, str]:
117
261
 
118
262
  out[gid] = (
119
263
  f"{name} "
120
- # f"[host={host_id}, local_id={local_id}"
121
- # f"{', kind=' + kind if kind else ''}"
122
- # f"{', serial=' + serial if serial else ''}"
123
- # f"{last_seen}"
124
- # f"] ({status})"
125
-
126
- f"({status})"
264
+ f"[host={host_id}, local_id={local_id}"
265
+ f"{', kind=' + kind if kind else ''}"
266
+ f"{', serial=' + serial if serial else ''}"
267
+ f"{last_seen}"
268
+ f"] ({status})"
127
269
  )
128
270
 
129
271
  return out
130
272
 
131
- # -----------------------------
273
+
274
+ # ============================================================
132
275
  # Config paths (server-side)
133
- # -----------------------------
276
+ # ============================================================
134
277
 
135
278
  def _xdg_config_home() -> Path:
136
279
  return Path(os.getenv("XDG_CONFIG_HOME", Path.home() / ".config"))
137
280
 
281
+
138
282
  def _cfg_dir() -> Path:
139
283
  # default: ~/.config/remoterf
140
284
  return Path(os.getenv("REMOTERF_CONFIG_DIR", _xdg_config_home() / "remoterf"))
141
285
 
286
+
142
287
  def _devices_env_path() -> Path:
143
288
  return _cfg_dir() / "devices.env"
144
289
 
145
- # -----------------------------
290
+
291
+ # ============================================================
146
292
  # Pluto helpers
147
- # -----------------------------
293
+ # ============================================================
148
294
 
149
295
  def connect_pluto(*, ip: str = "", usb: str = ""):
150
296
  try:
@@ -158,7 +304,8 @@ def connect_pluto(*, ip: str = "", usb: str = ""):
158
304
  except Exception as e:
159
305
  print(f"Pluto {ip}: {e}")
160
306
  return None
161
-
307
+
308
+
162
309
  def get_usb_port_from_serial(serial: str) -> str | None:
163
310
  serial = (serial or "").strip()
164
311
  if not serial:
@@ -170,7 +317,6 @@ def get_usb_port_from_serial(serial: str) -> str | None:
170
317
  print(f"Error running iio_info: {e}")
171
318
  return None
172
319
 
173
- # match either serial= or hw_serial= and extract usb context anywhere on the line
174
320
  for line in out.splitlines():
175
321
  if (f"serial={serial}" in line) or (f"hw_serial={serial}" in line):
176
322
  m = re.search(r"\[usb:([^\]]+)\]", line)
@@ -181,19 +327,21 @@ def get_usb_port_from_serial(serial: str) -> str | None:
181
327
  return None
182
328
 
183
329
 
184
- # -----------------------------
330
+ # ============================================================
185
331
  # Parse devices.env
186
- # -----------------------------
332
+ # ============================================================
187
333
 
188
334
  _ENV_LINE = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$")
189
335
  _DEVICE_KEY_RE = re.compile(r"^DEVICE_(\d+)_(.+)$", re.IGNORECASE)
190
336
 
337
+
191
338
  def _strip_quotes(v: str) -> str:
192
339
  v = (v or "").strip()
193
340
  if len(v) >= 2 and ((v[0] == v[-1] == '"') or (v[0] == v[-1] == "'")):
194
341
  return v[1:-1]
195
342
  return v
196
343
 
344
+
197
345
  def _read_env_file(path: Path) -> Dict[str, str]:
198
346
  out: Dict[str, str] = {}
199
347
  if not path.exists():
@@ -215,6 +363,7 @@ def _read_env_file(path: Path) -> Dict[str, str]:
215
363
  out[k] = v
216
364
  return out
217
365
 
366
+
218
367
  def _load_device_records() -> Dict[int, Dict[str, str]]:
219
368
  """
220
369
  Returns:
@@ -253,6 +402,7 @@ def _load_device_records() -> Dict[int, Dict[str, str]]:
253
402
 
254
403
  return recs
255
404
 
405
+
256
406
  def _connect_from_record(rec: Dict[str, str]):
257
407
  """
258
408
  Returns (device_obj, ident, dtype).
@@ -285,9 +435,9 @@ def _connect_from_record(rec: Dict[str, str]):
285
435
  return (None, ident, dtype)
286
436
 
287
437
 
288
- # -----------------------------
438
+ # ============================================================
289
439
  # Thread-safe runtime state
290
- # -----------------------------
440
+ # ============================================================
291
441
 
292
442
  # legacy maps (kept for your existing server calls/UI)
293
443
  devices_info: Dict[int, str] = {}
@@ -318,6 +468,8 @@ def _init_from_env() -> None:
318
468
 
319
469
  tmp_devices[int(gid)] = _DeviceState(
320
470
  dev=dev,
471
+ origin="local",
472
+ online=(dev is not None),
321
473
  salt="",
322
474
  hsh="",
323
475
  name=name,
@@ -337,13 +489,14 @@ def _init_from_env() -> None:
337
489
  device_serialization.clear()
338
490
  device_serialization.update(tmp_ser)
339
491
 
492
+
340
493
  # initialize on import
341
494
  _init_from_env()
342
495
 
343
496
 
344
- # -----------------------------
497
+ # ============================================================
345
498
  # Legacy reservation helpers (master token parsing)
346
- # -----------------------------
499
+ # ============================================================
347
500
 
348
501
  def parse_mastertoken(token: str):
349
502
  """
@@ -366,9 +519,9 @@ def parse_mastertoken(token: str):
366
519
  return (device_id, force)
367
520
 
368
521
 
369
- # -----------------------------
522
+ # ============================================================
370
523
  # Transmitter stubs (legacy)
371
- # -----------------------------
524
+ # ============================================================
372
525
 
373
526
  _transmitter = None
374
527
 
@@ -382,57 +535,52 @@ def get_transmitter_state() -> bool:
382
535
  return False
383
536
 
384
537
 
385
- # -----------------------------
538
+ # ============================================================
386
539
  # Legacy API (state-safe)
387
- # -----------------------------
540
+ # ============================================================
388
541
 
389
542
  def get_all_devices() -> Dict[int, Tuple[object, str, str]]:
390
- # snapshot: connected devices only
543
+ _sync_host_devices()
544
+
391
545
  with _state_lock:
392
546
  out: Dict[int, Tuple[object, str, str]] = {}
393
547
  for gid, st in _devices.items():
394
- if st.dev is not None:
395
- out[gid] = (st.dev, st.salt, st.hsh)
548
+ if _is_connected(st):
549
+ out[gid] = (st.dev, st.salt, st.hsh) # dev may be VirtualDevice for host
396
550
  return out
397
551
 
552
+
398
553
  def get_all_devices_str() -> Dict[int, str]:
399
- # local snapshot (includes offline local definitions)
400
- out = _local_devices_str_snapshot()
554
+ _sync_host_devices()
401
555
 
402
- # host snapshot (includes online + offline-but-known host devices)
556
+ out = _local_devices_str_snapshot()
403
557
  host_out = _host_devices_str_snapshot()
404
558
 
405
- # ---- DEBUG: print full host_out ----
406
- # try:
407
- # from ..host import host_tunnel_server as hts # adjust if your canonical import differs
408
- # reg = hts.get_tunnel_registry(create=False) if hasattr(hts, "get_tunnel_registry") else None
409
- # print("\n[device_manager] host_out dump")
410
- # print(f" hts module: {getattr(hts, '__name__', '<?>')}")
411
- # print(f" registry id: {id(reg) if reg is not None else None}")
412
- # print(f" host_out count: {len(host_out)}")
413
-
414
- # if not host_out:
415
- # print(" (empty)")
416
- # else:
417
- # for gid, s in sorted(host_out.items(), key=lambda kv: int(kv[0])):
418
- # print(f" {gid}: {s}")
419
- # print()
420
- # except Exception as e:
421
- # print(f"[device_manager] host_out dump failed: {e}")
422
-
423
- # merge with collision handling
559
+ # merge with collision handling (shouldn't happen; keeps UI stable if it does)
424
560
  for gid, s in host_out.items():
425
561
  if gid in out:
426
- # preserve both entries in the UI list; avoid overwriting
427
- alt = -int(gid) - 1 # deterministic "shadow key"
562
+ alt = -int(gid) - 1
428
563
  out[alt] = s + " [gid-collision]"
429
564
  else:
430
565
  out[gid] = s
431
566
 
432
567
  return out
433
568
 
569
+
434
570
  def set_device(device_id: int, salt: str, hash: str):
435
- # update ONLY reservation/auth state
571
+ # ensure host devices are upserted so reservations can apply to them too
572
+ _sync_host_devices()
573
+
574
+ with _state_lock:
575
+ st = _devices.get(int(device_id))
576
+
577
+ if not st:
578
+ _sync_host_devices(force=True)
579
+ with _state_lock:
580
+ st = _devices.get(int(device_id))
581
+ if not st:
582
+ return
583
+
436
584
  with _state_lock:
437
585
  st = _devices.get(int(device_id))
438
586
  if not st:
@@ -440,39 +588,48 @@ def set_device(device_id: int, salt: str, hash: str):
440
588
  st.salt = str(salt or "")
441
589
  st.hsh = str(hash or "")
442
590
 
591
+
443
592
  def device_exists(device_id: int) -> bool:
444
593
  did = int(device_id)
445
594
 
446
- # local
595
+ _sync_host_devices()
596
+
597
+ # local: connected only (unchanged semantics)
447
598
  with _state_lock:
448
599
  st = _devices.get(did)
449
- if st and st.dev is not None:
600
+ if st and st.origin == "local" and st.dev is not None:
450
601
  return True
451
602
 
452
- # host
603
+ # host: existence as known by registry (unchanged)
453
604
  reg = _get_host_registry()
454
605
  if reg is None:
455
606
  return False
456
607
  return bool(reg.is_host_device(str(did)))
457
608
 
609
+
458
610
  def device_is_available(device_id: int) -> bool:
611
+ _sync_host_devices()
612
+
459
613
  with _state_lock:
460
614
  st = _devices.get(int(device_id))
461
- if not st or st.dev is None:
615
+ if not st or not _is_connected(st):
462
616
  return False
463
617
  return (st.salt == "") and (st.hsh == "")
464
618
 
619
+
465
620
  def get_device_by_id(device_id: int):
466
- # legacy: returns (dev, salt, hash) tuple
621
+ _sync_host_devices()
622
+
467
623
  with _state_lock:
468
624
  st = _devices.get(int(device_id))
469
625
  if not st:
470
626
  return None
471
627
  return (st.dev, st.salt, st.hsh)
472
628
 
629
+
473
630
  def get_device(*, api_token: str):
474
631
  """
475
- Legacy behavior:
632
+ Legacy behavior preserved:
476
633
  - master_token returns first available device object
477
634
  - master_token_<id>[_force] selects a specific device
478
635
  - otherwise validate against per-device salt/hash (set via reservations)
@@ -483,17 +640,24 @@ def get_device(*, api_token: str):
483
640
  if not api_token:
484
641
  return None
485
642
 
486
- # Take a snapshot of state for validation without holding the global lock
643
+ _sync_host_devices()
644
+
487
645
  with _state_lock:
488
- snapshot: List[Tuple[int, object, str, str]] = [
489
- (gid, st.dev, st.salt, st.hsh)
646
+ snapshot: List[Tuple[int, object, str, str, str]] = [
647
+ (gid, st.dev, st.salt, st.hsh, st.origin)
490
648
  for gid, st in _devices.items()
491
- if st.dev is not None
649
+ if _is_connected(st)
492
650
  ]
493
651
 
652
+ # Preserve "master prefers local" behavior
494
653
  if api_token == master_token:
495
- for _, dev, salt, hsh in snapshot:
496
- if dev is not None and salt == "" and hsh == "":
654
+ # local first
655
+ for _, dev, salt, hsh, origin in snapshot:
656
+ if origin == "local" and dev is not None and salt == "" and hsh == "":
657
+ return dev
658
+ # then host
659
+ for _, dev, salt, hsh, origin in snapshot:
660
+ if origin == "host" and dev is not None and salt == "" and hsh == "":
497
661
  return dev
498
662
  return None
499
663
 
@@ -502,13 +666,13 @@ def get_device(*, api_token: str):
502
666
  device_id, force = parsed
503
667
  with _state_lock:
504
668
  st = _devices.get(int(device_id))
505
- if not st or st.dev is None:
669
+ if not st or not _is_connected(st):
506
670
  return None
507
671
  if force:
508
672
  return st.dev
509
673
  return st.dev if (st.salt == "" and st.hsh == "") else None
510
674
 
511
- for _, dev, salt, hsh in snapshot:
675
+ for _, dev, salt, hsh, _origin in snapshot:
512
676
  if dev is None:
513
677
  continue
514
678
  if salt and hsh and validate_token(salt, hsh, api_token):
@@ -517,47 +681,55 @@ def get_device(*, api_token: str):
517
681
  return None
518
682
 
519
683
 
520
- # -----------------------------
521
- # NEW: Per-device I/O locking (true concurrency support)
522
- # -----------------------------
684
+ # ============================================================
685
+ # Per-device I/O locking (true concurrency support)
686
+ # ============================================================
523
687
 
524
688
  @contextmanager
525
689
  def acquire_device(api_token: str) -> Iterator[Tuple[int, object]]:
526
690
  """
527
691
  Thread-safe way to use a device:
528
692
  with acquire_device(token) as (gid, dev):
529
- # safe: only one thread at a time per device
530
693
  ...
531
694
 
532
695
  - Selects the device using the SAME logic as get_device()
533
696
  - Then acquires that device's io_lock
534
697
  - Yields (gid, dev)
698
+
699
+ NOTE:
700
+ dev can be an adi device (local) OR a VirtualDevice (host).
535
701
  """
536
702
  if not api_token:
537
703
  raise RuntimeError("missing api_token")
538
704
 
539
- # resolve gid first (snapshot-based)
705
+ _sync_host_devices()
706
+
540
707
  gid: Optional[int] = None
541
708
 
542
709
  if api_token == master_token:
543
710
  with _state_lock:
711
+ # prefer local first
544
712
  for k, st in _devices.items():
545
- if st.dev is not None and st.salt == "" and st.hsh == "":
713
+ if st.origin == "local" and _is_connected(st) and st.salt == "" and st.hsh == "":
546
714
  gid = k
547
715
  break
716
+ if gid is None:
717
+ for k, st in _devices.items():
718
+ if st.origin == "host" and _is_connected(st) and st.salt == "" and st.hsh == "":
719
+ gid = k
720
+ break
548
721
  else:
549
722
  parsed = parse_mastertoken(api_token)
550
723
  if parsed:
551
724
  cand, force = parsed
552
725
  with _state_lock:
553
726
  st = _devices.get(int(cand))
554
- if st and st.dev is not None:
727
+ if st and _is_connected(st):
555
728
  if force or (st.salt == "" and st.hsh == ""):
556
729
  gid = int(cand)
557
730
  else:
558
- # validate token against per-device salt/hash
559
731
  with _state_lock:
560
- snapshot = [(k, st.dev, st.salt, st.hsh) for k, st in _devices.items() if st.dev is not None]
732
+ snapshot = [(k, st.dev, st.salt, st.hsh) for k, st in _devices.items() if _is_connected(st)]
561
733
  for k, dev, salt, hsh in snapshot:
562
734
  if salt and hsh and validate_token(salt, hsh, api_token):
563
735
  gid = k
@@ -566,10 +738,9 @@ def acquire_device(api_token: str) -> Iterator[Tuple[int, object]]:
566
738
  if gid is None:
567
739
  raise RuntimeError("no device available / invalid token")
568
740
 
569
- # lock the selected device for I/O
570
741
  with _state_lock:
571
742
  st = _devices.get(int(gid))
572
- if not st or st.dev is None:
743
+ if not st or not _is_connected(st) or st.dev is None:
573
744
  raise RuntimeError("device disappeared / not connected")
574
745
  lock = st.io_lock
575
746
  dev = st.dev
@@ -581,22 +752,25 @@ def acquire_device(api_token: str) -> Iterator[Tuple[int, object]]:
581
752
  lock.release()
582
753
 
583
754
 
584
- # -----------------------------
755
+ # ============================================================
585
756
  # Optional: reload device definitions (atomic swap)
586
- # -----------------------------
757
+ # ============================================================
587
758
 
588
759
  def reload_devices() -> None:
589
760
  """
590
- Reload devices.env and reconnect devices.
591
- Preserves existing salt/hash for devices that remain (same gid).
761
+ Reload devices.env and reconnect local devices.
762
+ Preserves existing salt/hash for devices that remain (same gid),
763
+ and preserves host virtual devices + their auth state.
592
764
  """
593
765
  records = _load_device_records()
594
766
 
595
- # snapshot current salt/hash so we can carry it over
767
+ _sync_host_devices() # ensure host entries exist before snapshot
768
+
596
769
  with _state_lock:
597
770
  prev_auth = {gid: (st.salt, st.hsh) for gid, st in _devices.items()}
771
+ prev_host = {gid: st for gid, st in _devices.items() if st.origin == "host"}
598
772
 
599
- tmp_devices: Dict[int, _DeviceState] = {}
773
+ tmp_local: Dict[int, _DeviceState] = {}
600
774
  tmp_info: Dict[int, str] = {}
601
775
  tmp_ser: Dict[int, str] = {}
602
776
 
@@ -611,8 +785,10 @@ def reload_devices() -> None:
611
785
  tmp_ser[int(gid)] = ident
612
786
 
613
787
  salt, hsh = prev_auth.get(int(gid), ("", ""))
614
- tmp_devices[int(gid)] = _DeviceState(
788
+ tmp_local[int(gid)] = _DeviceState(
615
789
  dev=dev,
790
+ origin="local",
791
+ online=(dev is not None),
616
792
  salt=salt,
617
793
  hsh=hsh,
618
794
  name=name,
@@ -623,7 +799,14 @@ def reload_devices() -> None:
623
799
 
624
800
  with _state_lock:
625
801
  _devices.clear()
626
- _devices.update(tmp_devices)
802
+
803
+ # restore host entries (but do not overwrite locals if collision)
804
+ for gid, st in prev_host.items():
805
+ if gid not in tmp_local:
806
+ _devices[gid] = st
807
+
808
+ # add locals
809
+ _devices.update(tmp_local)
627
810
 
628
811
  devices_info.clear()
629
812
  devices_info.update(tmp_info)
@@ -631,7 +814,10 @@ def reload_devices() -> None:
631
814
  device_serialization.clear()
632
815
  device_serialization.update(tmp_ser)
633
816
 
817
+ # refresh host online/offline status after reload (best-effort)
818
+ _sync_host_devices(force=True)
819
+
634
820
 
635
821
  def set_pluto(ip: str = "192.168.2.1"):
636
822
  # kept for compatibility (no-op)
637
- pass
823
+ pass
@@ -391,10 +391,10 @@ def _cmd_hosts_status() -> None:
391
391
  reg = hts.get_tunnel_registry()
392
392
  assert reg is not None
393
393
 
394
- print("\n[hosts status] registry debug")
395
- print(f" hts module: {hts.__name__}")
396
- reg = hts.get_tunnel_registry(create=False)
397
- print(f" registry id: {id(reg) if reg else None}\n")
394
+ # print("\n[hosts status] registry debug")
395
+ # print(f" hts module: {hts.__name__}")
396
+ # reg = hts.get_tunnel_registry(create=False)
397
+ # print(f" registry id: {id(reg) if reg else None}\n")
398
398
 
399
399
  hosts = reg.list_hosts() # host_id -> HostStatus
400
400
  devices = reg.list_devices() # device_id -> DeviceStatus
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remoteRF-server
3
- Version: 0.1.95
3
+ Version: 0.1.97
4
4
  Description-Content-Type: text/markdown
5
5
  Requires-Dist: grpcio
6
6
  Requires-Dist: protobuf
@@ -350,7 +350,7 @@ todo:
350
350
 
351
351
  <!-- Host ID (NEEDS TO BE UNIQUE AND TELL THE HOST THAT) (or if device also does it as well.) -->
352
352
 
353
- ServerRF display meta data as part of devices list / reservation
353
+ <!-- ServerRF display meta data as part of devices list / reservation -->
354
354
 
355
355
  + online/activity status (appended into device name) + (plus host side warning if the device id is NOT globally unique!)
356
356