remoteRF-server-testing 0.0.0__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 (44) hide show
  1. remoteRF_server/__init__.py +0 -0
  2. remoteRF_server/common/__init__.py +0 -0
  3. remoteRF_server/common/grpc/__init__.py +1 -0
  4. remoteRF_server/common/grpc/grpc_host_pb2.py +63 -0
  5. remoteRF_server/common/grpc/grpc_host_pb2_grpc.py +97 -0
  6. remoteRF_server/common/grpc/grpc_pb2.py +59 -0
  7. remoteRF_server/common/grpc/grpc_pb2_grpc.py +97 -0
  8. remoteRF_server/common/idl/__init__.py +1 -0
  9. remoteRF_server/common/idl/device_schema.py +39 -0
  10. remoteRF_server/common/idl/pluto_schema.py +174 -0
  11. remoteRF_server/common/idl/schema.py +358 -0
  12. remoteRF_server/common/utils/__init__.py +6 -0
  13. remoteRF_server/common/utils/ansi_codes.py +120 -0
  14. remoteRF_server/common/utils/api_token.py +21 -0
  15. remoteRF_server/common/utils/db_connection.py +35 -0
  16. remoteRF_server/common/utils/db_location.py +24 -0
  17. remoteRF_server/common/utils/list_string.py +5 -0
  18. remoteRF_server/common/utils/process_arg.py +80 -0
  19. remoteRF_server/drivers/__init__.py +0 -0
  20. remoteRF_server/drivers/adalm_pluto/__init__.py +0 -0
  21. remoteRF_server/drivers/adalm_pluto/pluto_remote_server.py +105 -0
  22. remoteRF_server/host/__init__.py +0 -0
  23. remoteRF_server/host/host_auth_token.py +292 -0
  24. remoteRF_server/host/host_directory_store.py +142 -0
  25. remoteRF_server/host/host_tunnel_server.py +1388 -0
  26. remoteRF_server/server/__init__.py +0 -0
  27. remoteRF_server/server/acc_perms.py +317 -0
  28. remoteRF_server/server/cert_provider.py +184 -0
  29. remoteRF_server/server/device_manager.py +688 -0
  30. remoteRF_server/server/grpc_server.py +1023 -0
  31. remoteRF_server/server/reservation.py +811 -0
  32. remoteRF_server/server/rpc_manager.py +104 -0
  33. remoteRF_server/server/user_group_cli.py +723 -0
  34. remoteRF_server/server/user_group_handler.py +1120 -0
  35. remoteRF_server/serverrf_cli.py +1377 -0
  36. remoteRF_server/tools/__init__.py +191 -0
  37. remoteRF_server/tools/gen_certs.py +274 -0
  38. remoteRF_server/tools/gist_status.py +139 -0
  39. remoteRF_server/tools/gist_status_testing.py +67 -0
  40. remoterf_server_testing-0.0.0.dist-info/METADATA +612 -0
  41. remoterf_server_testing-0.0.0.dist-info/RECORD +44 -0
  42. remoterf_server_testing-0.0.0.dist-info/WHEEL +5 -0
  43. remoterf_server_testing-0.0.0.dist-info/entry_points.txt +2 -0
  44. remoterf_server_testing-0.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,688 @@
1
+ # src/remoteRF_server/server/device_manager.py
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import time
8
+ import adi
9
+ import yaml
10
+ import subprocess
11
+ import threading
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Dict, Tuple, Optional, Any, List, Iterator
15
+ from contextlib import contextmanager
16
+
17
+ from ..common.utils import validate_token, get_remoterf_root
18
+ from ..host import host_tunnel_server as hts
19
+
20
+ _state_lock = threading.RLock()
21
+
22
+ @dataclass(frozen=True)
23
+ class VirtualDevice:
24
+ gid: int
25
+ host_id: str
26
+ device_id: str
27
+ label: str = ""
28
+ serial: str = ""
29
+ kind: str = ""
30
+
31
+ def __repr__(self) -> str:
32
+ return f"<VirtualDevice gid={self.gid} host={self.host_id} device_id={self.device_id}>"
33
+
34
+ def is_virtual(dev: object) -> bool:
35
+ return isinstance(dev, VirtualDevice)
36
+
37
+ @dataclass
38
+ class _DeviceState:
39
+ dev: object | None
40
+ origin: str # "local" or "host"
41
+ online: bool # for host devices; for local can mirror (dev is not None)
42
+ salt: str
43
+ hsh: str
44
+ name: str
45
+ ident: str
46
+ dtype: str
47
+ io_lock: threading.RLock = field(default_factory=threading.RLock)
48
+
49
+ # gid -> state
50
+ _devices: Dict[int, _DeviceState] = {}
51
+
52
+ def _is_connected(st: _DeviceState) -> bool:
53
+ if st.origin == "local":
54
+ return st.dev is not None
55
+ # host
56
+ return bool(st.online)
57
+
58
+ # Host device pulling/grabbing
59
+
60
+ def _get_host_registry():
61
+ return hts.get_tunnel_registry()
62
+
63
+ _host_sync_lock = threading.RLock()
64
+ _host_sync_last_ms = 0
65
+ _HOST_SYNC_TTL_MS = 500 # tune later
66
+
67
+ def _sync_host_devices(*, force: bool = False) -> None:
68
+ global _host_sync_last_ms
69
+
70
+ now_ms = int(time.time() * 1000)
71
+ with _host_sync_lock:
72
+ if not force and (now_ms - _host_sync_last_ms) < _HOST_SYNC_TTL_MS:
73
+ return
74
+ _host_sync_last_ms = now_ms
75
+
76
+ reg = _get_host_registry()
77
+ if reg is None:
78
+ return
79
+
80
+ # Do registry calls without holding _state_lock
81
+ snap = reg.device_directory_cached(ttl_ms=0) # device_id -> (host_id, info_obj, is_active)
82
+
83
+ updates: Dict[int, Dict[str, object]] = {}
84
+ for device_id, (host_id, info_obj, is_active) in snap.items():
85
+ # You said device_id is assumed to be the GID
86
+ try:
87
+ gid = int(str(device_id))
88
+ except Exception:
89
+ continue
90
+
91
+ label = str(getattr(info_obj, "label", "") or "").strip()
92
+ serial = str(getattr(info_obj, "serial", "") or "").strip()
93
+ kind = str(getattr(info_obj, "kind", "") or "").strip()
94
+
95
+ updates[gid] = {
96
+ "host_id": str(host_id),
97
+ "device_id": str(device_id),
98
+ "label": label,
99
+ "serial": serial,
100
+ "kind": kind,
101
+ "online": bool(is_active),
102
+ }
103
+
104
+ with _state_lock:
105
+ # Upsert / refresh host entries
106
+ for gid, u in updates.items():
107
+ prev = _devices.get(int(gid))
108
+
109
+ # Collision policy: do not overwrite local devices
110
+ if prev is not None and prev.origin == "local":
111
+ # collision shouldn't happen; keep local stable
112
+ continue
113
+
114
+ salt = prev.salt if prev else ""
115
+ hsh = prev.hsh if prev else ""
116
+ lock = prev.io_lock if prev else threading.RLock()
117
+
118
+ label = str(u["label"] or "")
119
+ serial = str(u["serial"] or "")
120
+ kind = str(u["kind"] or "")
121
+ host_id = str(u["host_id"] or "")
122
+ device_id = str(u["device_id"] or "")
123
+
124
+ name = label or (prev.name if prev else f"host-device-{gid}")
125
+ dtype = (kind or (prev.dtype if prev else "")).strip().lower()
126
+ ident = serial or (prev.ident if prev else device_id)
127
+
128
+ _devices[int(gid)] = _DeviceState(
129
+ dev=VirtualDevice(
130
+ gid=int(gid),
131
+ host_id=host_id,
132
+ device_id=device_id,
133
+ label=label,
134
+ serial=serial,
135
+ kind=kind,
136
+ ),
137
+ origin="host",
138
+ online=bool(u["online"]),
139
+ salt=salt,
140
+ hsh=hsh,
141
+ name=name,
142
+ ident=ident,
143
+ dtype=dtype,
144
+ io_lock=lock,
145
+ )
146
+
147
+ # Keep legacy maps coherent for host devices too (best-effort)
148
+ devices_info[int(gid)] = name
149
+ if ident:
150
+ device_serialization[int(gid)] = ident
151
+
152
+ # Mark host entries not present in current snap as offline (but keep proxy + auth)
153
+ present = set(updates.keys())
154
+ for gid, st in _devices.items():
155
+ if st.origin == "host" and gid not in present:
156
+ st.online = False
157
+
158
+ def _local_devices_str_snapshot() -> Dict[int, str]:
159
+ out: Dict[int, str] = {}
160
+ with _state_lock:
161
+ for gid, st in _devices.items():
162
+ if st.origin != "local":
163
+ continue
164
+
165
+ name = (st.name or f"device-{gid}").strip()
166
+
167
+ status = "online" if _is_connected(st) else "offline"
168
+ if st.salt or st.hsh:
169
+ status = f"{status}, reserved"
170
+
171
+ out[int(gid)] = f"{name} ({status})"
172
+ return out
173
+
174
+ def _host_devices_str_snapshot() -> Dict[int, str]:
175
+ _sync_host_devices() # ensure host devices are up-to-date before snapshot
176
+ reg = _get_host_registry()
177
+ if reg is None:
178
+ return {}
179
+
180
+ snap = reg.device_directory_cached(ttl_ms=0) # device_id -> (host_id, info_obj, is_active)
181
+
182
+ try:
183
+ status_map = reg.list_devices() # device_id -> DeviceStatus
184
+ except Exception:
185
+ status_map = {}
186
+
187
+ now_ms = int(time.time() * 1000)
188
+
189
+ out: Dict[int, str] = {}
190
+ for device_id, (host_id, info_obj, is_active) in snap.items():
191
+ # Prefer numeric device_id (old world), otherwise fall back to local_id (compat/UI key).
192
+ try:
193
+ gid = int(str(device_id))
194
+ except Exception:
195
+ try:
196
+ gid = int(getattr(info_obj, "local_id", 0) or 0)
197
+ except Exception:
198
+ continue
199
+ if gid <= 0:
200
+ continue
201
+
202
+ local_id = int(getattr(info_obj, "local_id", 0) or 0)
203
+ label = str(getattr(info_obj, "label", "") or "").strip()
204
+ serial = str(getattr(info_obj, "serial", "") or "").strip()
205
+ kind = str(getattr(info_obj, "kind", "") or "").strip()
206
+
207
+ status = "online" if bool(is_active) else "offline"
208
+ name = label or f"host-device-{gid}"
209
+
210
+ last_seen = ""
211
+ ds = status_map.get(str(device_id))
212
+ if ds is not None:
213
+ try:
214
+ ls = int(getattr(ds, "last_seen_ms", 0) or 0)
215
+ if ls > 0:
216
+ age_s = max(0, (now_ms - ls) // 1000)
217
+ last_seen = f", seen={age_s}s ago"
218
+ except Exception:
219
+ pass
220
+
221
+ out[gid] = (
222
+ f"{name} "
223
+ f"({status})"
224
+ )
225
+
226
+ return out
227
+
228
+ # Config paths (server-side)
229
+
230
+ def _cfg_dir() -> Path:
231
+ return Path(os.getenv("REMOTERF_CONFIG_DIR", get_remoterf_root()))
232
+
233
+ def _devices_yaml_path() -> Path:
234
+ p1 = _cfg_dir() / "devices.yml"
235
+ if p1.exists():
236
+ return p1
237
+ return _cfg_dir() / "devices.yaml"
238
+
239
+ def _load_device_records() -> Dict[int, Dict[str, str]]:
240
+ path = _devices_yaml_path()
241
+ if not path.exists():
242
+ return {}
243
+
244
+ try:
245
+ with path.open("r", encoding="utf-8") as f:
246
+ data = yaml.safe_load(f) or {}
247
+ except Exception as e:
248
+ print(f"Error reading YAML config {path}: {e}")
249
+ return {}
250
+
251
+ devices = data.get("devices") or []
252
+ if not isinstance(devices, list):
253
+ print(f"Invalid YAML config {path}: 'devices' must be a list")
254
+ return {}
255
+
256
+ recs: Dict[int, Dict[str, str]] = {}
257
+
258
+ for item in devices:
259
+ if not isinstance(item, dict):
260
+ continue
261
+
262
+ try:
263
+ gid = int(item["device_id"])
264
+ except Exception:
265
+ print(f"Skipping device entry with invalid/missing device_id: {item}")
266
+ continue
267
+
268
+ dtype = str(item.get("device_type") or "pluto").strip().lower()
269
+ if dtype != "pluto":
270
+ print(f"Skipping unsupported device_type={dtype!r} for gid={gid}")
271
+ continue
272
+
273
+ name = str(item.get("name") or f"device-{gid}").strip()
274
+ init = item.get("init") or {}
275
+ if not isinstance(init, dict):
276
+ init = {}
277
+
278
+ serial = str(init.get("serial") or "").strip()
279
+ if not serial:
280
+ print(f"Skipping Pluto gid={gid}: missing init.serial")
281
+ continue
282
+
283
+ recs[gid] = {
284
+ "TYPE": "pluto",
285
+ "NAME": name,
286
+ "IDENT_KIND": "iio_serial",
287
+ "IDENT": serial,
288
+ }
289
+
290
+ return recs
291
+
292
+ # Pluto helpers
293
+
294
+ def connect_pluto(*, ip: str = "", usb: str = ""):
295
+ try:
296
+ if ip == "":
297
+ dev = adi.Pluto(f"usb:{usb}")
298
+ print(f"Connected to Pluto usb:{usb}")
299
+ else:
300
+ dev = adi.Pluto(f"ip:{ip}")
301
+ print(f"Connected to Pluto ip:{ip}")
302
+ return dev
303
+ except Exception as e:
304
+ print(f"Pluto {ip}: {e}")
305
+ return None
306
+
307
+ def get_usb_port_from_serial(serial: str) -> str | None:
308
+ serial = (serial or "").strip()
309
+ if not serial:
310
+ return None
311
+
312
+ try:
313
+ out = subprocess.check_output(["iio_info", "-s"], text=True, stderr=subprocess.STDOUT)
314
+ except Exception as e:
315
+ print(f"Error running iio_info: {e}")
316
+ return None
317
+
318
+ for line in out.splitlines():
319
+ if (f"serial={serial}" in line) or (f"hw_serial={serial}" in line):
320
+ m = re.search(r"\[usb:([^\]]+)\]", line)
321
+ if m:
322
+ return m.group(1).strip()
323
+
324
+ print(f"No device found with serial {serial}")
325
+ return None
326
+
327
+ def _connect_from_record(rec: Dict[str, str]):
328
+ # Returns (device_obj, ident, dtype).
329
+
330
+ dtype = (rec.get("TYPE") or "").strip().lower()
331
+ ident_kind = (rec.get("IDENT_KIND") or "").strip().lower()
332
+ ident = (rec.get("IDENT") or "").strip()
333
+
334
+ if dtype != "pluto":
335
+ return (None, ident, dtype)
336
+
337
+ if ident_kind in ("iio_serial", "serial", "iio"):
338
+ if not ident:
339
+ return (None, ident, dtype)
340
+ usb_port = get_usb_port_from_serial(ident)
341
+ if not usb_port:
342
+ return (None, ident, dtype)
343
+ return (connect_pluto(usb=usb_port), ident, dtype)
344
+
345
+ if ident_kind == "usb":
346
+ if not ident:
347
+ return (None, ident, dtype)
348
+ return (connect_pluto(usb=ident), ident, dtype)
349
+
350
+ if ident_kind == "ip":
351
+ if not ident:
352
+ return (None, ident, dtype)
353
+ return (connect_pluto(ip=ident), ident, dtype)
354
+
355
+ return (None, ident, dtype)
356
+
357
+ # Thread-safe runtime state
358
+
359
+ # legacy maps (kept for your existing server calls/UI)
360
+ devices_info: Dict[int, str] = {}
361
+ device_serialization: Dict[int, str] = {}
362
+
363
+ # master token (overrideable)
364
+ master_token = os.getenv("REMOTERF_MASTER_TOKEN", "SuperCoolTokenForIan")
365
+
366
+ def _init_from_env() -> None:
367
+ records = _load_device_records()
368
+
369
+ tmp_devices: Dict[int, _DeviceState] = {}
370
+ tmp_info: Dict[int, str] = {}
371
+ tmp_ser: Dict[int, str] = {}
372
+
373
+ # Build + connect (do the expensive work without holding the global lock)
374
+ for gid, rec in sorted(records.items()):
375
+ name = (rec.get("NAME") or f"device-{gid}").strip()
376
+ ident = (rec.get("IDENT") or "").strip()
377
+ dtype = (rec.get("TYPE") or "").strip().lower()
378
+
379
+ dev, ident2, dtype2 = _connect_from_record(rec)
380
+
381
+ tmp_info[int(gid)] = name
382
+ if ident:
383
+ tmp_ser[int(gid)] = ident
384
+
385
+ tmp_devices[int(gid)] = _DeviceState(
386
+ dev=dev,
387
+ origin="local",
388
+ online=(dev is not None),
389
+ salt="",
390
+ hsh="",
391
+ name=name,
392
+ ident=ident2,
393
+ dtype=dtype2 or dtype,
394
+ io_lock=threading.RLock(),
395
+ )
396
+
397
+ # Swap atomically
398
+ with _state_lock:
399
+ _devices.clear()
400
+ _devices.update(tmp_devices)
401
+
402
+ devices_info.clear()
403
+ devices_info.update(tmp_info)
404
+
405
+ device_serialization.clear()
406
+ device_serialization.update(tmp_ser)
407
+
408
+
409
+ # initialize on import
410
+ _init_from_env()
411
+
412
+ # Legacy reservation helpers (master token parsing)
413
+
414
+ def parse_mastertoken(token: str):
415
+ if not token:
416
+ return None
417
+ prefix = re.escape(master_token)
418
+ pattern = re.compile(rf"^{prefix}[_-](\d+)(?:_force)?$")
419
+ m = pattern.match(token)
420
+ if not m:
421
+ return None
422
+ device_id = int(m.group(1))
423
+ force = token.endswith("_force")
424
+ return (device_id, force)
425
+
426
+ # Transmitter stubs (legacy)
427
+
428
+ _transmitter = None
429
+
430
+ def start_transmitter():
431
+ pass
432
+
433
+ def terminate_transmitter():
434
+ pass
435
+
436
+ def get_transmitter_state() -> bool:
437
+ return False
438
+
439
+ # Legacy API (state-safe)
440
+
441
+ def get_all_devices() -> Dict[int, Tuple[object, str, str]]:
442
+ _sync_host_devices()
443
+
444
+ with _state_lock:
445
+ out: Dict[int, Tuple[object, str, str]] = {}
446
+ for gid, st in _devices.items():
447
+ if _is_connected(st):
448
+ out[gid] = (st.dev, st.salt, st.hsh) # dev may be VirtualDevice for host
449
+ return out
450
+
451
+ def get_all_devices_str() -> Dict[int, str]:
452
+ _sync_host_devices()
453
+
454
+ out = _local_devices_str_snapshot()
455
+ host_out = _host_devices_str_snapshot()
456
+
457
+ # merge with collision handling (shouldn't happen; keeps UI stable if it does)
458
+ for gid, s in host_out.items():
459
+ if gid in out:
460
+ alt = -int(gid) - 1
461
+ out[alt] = s + " [gid-collision]"
462
+ else:
463
+ out[gid] = s
464
+
465
+ return dict(sorted(out.items(), key=lambda kv: int(kv[0])))
466
+
467
+ def set_device(device_id: int, salt: str, hash: str):
468
+ # ensure host devices are upserted so reservations can apply to them too
469
+ _sync_host_devices()
470
+
471
+ with _state_lock:
472
+ st = _devices.get(int(device_id))
473
+
474
+ if not st:
475
+ _sync_host_devices(force=True)
476
+ with _state_lock:
477
+ st = _devices.get(int(device_id))
478
+ if not st:
479
+ return
480
+
481
+ with _state_lock:
482
+ st = _devices.get(int(device_id))
483
+ if not st:
484
+ return
485
+ st.salt = str(salt or "")
486
+ st.hsh = str(hash or "")
487
+
488
+ def device_exists(device_id: int) -> bool:
489
+ did = int(device_id)
490
+
491
+ _sync_host_devices()
492
+
493
+ # local: connected only (unchanged semantics)
494
+ with _state_lock:
495
+ st = _devices.get(did)
496
+ if st and st.origin == "local" and st.dev is not None:
497
+ return True
498
+
499
+ # host: existence as known by registry (unchanged)
500
+ reg = _get_host_registry()
501
+ if reg is None:
502
+ return False
503
+ return bool(reg.is_host_device(str(did)))
504
+
505
+ def device_is_available(device_id: int) -> bool:
506
+ _sync_host_devices()
507
+
508
+ with _state_lock:
509
+ st = _devices.get(int(device_id))
510
+ if not st or not _is_connected(st):
511
+ return False
512
+ return (st.salt == "") and (st.hsh == "")
513
+
514
+ def get_device_by_id(device_id: int):
515
+ _sync_host_devices()
516
+
517
+ with _state_lock:
518
+ st = _devices.get(int(device_id))
519
+ if not st:
520
+ return None
521
+ return (st.dev, st.salt, st.hsh)
522
+
523
+ def get_device(*, api_token: str):
524
+ if not api_token:
525
+ return None
526
+
527
+ _sync_host_devices()
528
+
529
+ with _state_lock:
530
+ snapshot: List[Tuple[int, object, str, str, str]] = [
531
+ (gid, st.dev, st.salt, st.hsh, st.origin)
532
+ for gid, st in _devices.items()
533
+ if _is_connected(st)
534
+ ]
535
+
536
+ # Preserve "master prefers local" behavior
537
+ if api_token == master_token:
538
+ # local first
539
+ for _, dev, salt, hsh, origin in snapshot:
540
+ if origin == "local" and dev is not None and salt == "" and hsh == "":
541
+ return dev
542
+ # then host
543
+ for _, dev, salt, hsh, origin in snapshot:
544
+ if origin == "host" and dev is not None and salt == "" and hsh == "":
545
+ return dev
546
+ return None
547
+
548
+ parsed = parse_mastertoken(api_token)
549
+ if parsed:
550
+ device_id, force = parsed
551
+ with _state_lock:
552
+ st = _devices.get(int(device_id))
553
+ if not st or not _is_connected(st):
554
+ return None
555
+ if force:
556
+ return st.dev
557
+ return st.dev if (st.salt == "" and st.hsh == "") else None
558
+
559
+ for _, dev, salt, hsh, _origin in snapshot:
560
+ if dev is None:
561
+ continue
562
+ if salt and hsh and validate_token(salt, hsh, api_token):
563
+ return dev
564
+
565
+ return None
566
+
567
+ # Per-device I/O locking (true concurrency support)
568
+
569
+ @contextmanager
570
+ def acquire_device(api_token: str) -> Iterator[Tuple[int, object]]:
571
+ if not api_token:
572
+ raise RuntimeError("missing api_token")
573
+
574
+ _sync_host_devices()
575
+
576
+ gid: Optional[int] = None
577
+
578
+ if api_token == master_token:
579
+ with _state_lock:
580
+ # prefer local first
581
+ for k, st in _devices.items():
582
+ if st.origin == "local" and _is_connected(st) and st.salt == "" and st.hsh == "":
583
+ gid = k
584
+ break
585
+ if gid is None:
586
+ for k, st in _devices.items():
587
+ if st.origin == "host" and _is_connected(st) and st.salt == "" and st.hsh == "":
588
+ gid = k
589
+ break
590
+ else:
591
+ parsed = parse_mastertoken(api_token)
592
+ if parsed:
593
+ cand, force = parsed
594
+ with _state_lock:
595
+ st = _devices.get(int(cand))
596
+ if st and _is_connected(st):
597
+ if force or (st.salt == "" and st.hsh == ""):
598
+ gid = int(cand)
599
+ else:
600
+ with _state_lock:
601
+ snapshot = [(k, st.dev, st.salt, st.hsh) for k, st in _devices.items() if _is_connected(st)]
602
+ for k, dev, salt, hsh in snapshot:
603
+ if salt and hsh and validate_token(salt, hsh, api_token):
604
+ gid = k
605
+ break
606
+
607
+ if gid is None:
608
+ raise RuntimeError("no device available / invalid token")
609
+
610
+ with _state_lock:
611
+ st = _devices.get(int(gid))
612
+ if not st or not _is_connected(st) or st.dev is None:
613
+ raise RuntimeError("device disappeared / not connected")
614
+ lock = st.io_lock
615
+ dev = st.dev
616
+
617
+ lock.acquire()
618
+ try:
619
+ yield int(gid), dev
620
+ finally:
621
+ lock.release()
622
+
623
+ # Optional: reload device definitions (atomic swap)
624
+
625
+ def reload_devices() -> None:
626
+ """
627
+ Reload devices.env and reconnect local devices.
628
+ Preserves existing salt/hash for devices that remain (same gid),
629
+ and preserves host virtual devices + their auth state.
630
+ """
631
+ records = _load_device_records()
632
+
633
+ _sync_host_devices() # ensure host entries exist before snapshot
634
+
635
+ with _state_lock:
636
+ prev_auth = {gid: (st.salt, st.hsh) for gid, st in _devices.items()}
637
+ prev_host = {gid: st for gid, st in _devices.items() if st.origin == "host"}
638
+
639
+ tmp_local: Dict[int, _DeviceState] = {}
640
+ tmp_info: Dict[int, str] = {}
641
+ tmp_ser: Dict[int, str] = {}
642
+
643
+ for gid, rec in sorted(records.items()):
644
+ name = (rec.get("NAME") or f"device-{gid}").strip()
645
+ ident = (rec.get("IDENT") or "").strip()
646
+
647
+ dev, ident2, dtype2 = _connect_from_record(rec)
648
+
649
+ tmp_info[int(gid)] = name
650
+ if ident:
651
+ tmp_ser[int(gid)] = ident
652
+
653
+ salt, hsh = prev_auth.get(int(gid), ("", ""))
654
+ tmp_local[int(gid)] = _DeviceState(
655
+ dev=dev,
656
+ origin="local",
657
+ online=(dev is not None),
658
+ salt=salt,
659
+ hsh=hsh,
660
+ name=name,
661
+ ident=ident2,
662
+ dtype=dtype2,
663
+ io_lock=threading.RLock(),
664
+ )
665
+
666
+ with _state_lock:
667
+ _devices.clear()
668
+
669
+ # restore host entries (but do not overwrite locals if collision)
670
+ for gid, st in prev_host.items():
671
+ if gid not in tmp_local:
672
+ _devices[gid] = st
673
+
674
+ # add locals
675
+ _devices.update(tmp_local)
676
+
677
+ devices_info.clear()
678
+ devices_info.update(tmp_info)
679
+
680
+ device_serialization.clear()
681
+ device_serialization.update(tmp_ser)
682
+
683
+ # refresh host online/offline status after reload (best-effort)
684
+ _sync_host_devices(force=True)
685
+
686
+ def set_pluto(ip: str = "192.168.2.1"):
687
+ # kept for compatibility (no-op)
688
+ pass