unifi-network-maps 1.4.1__py3-none-any.whl → 1.4.2__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.
- unifi_network_maps/__init__.py +1 -1
- unifi_network_maps/adapters/unifi.py +266 -24
- unifi_network_maps/cli/main.py +352 -107
- unifi_network_maps/io/debug.py +15 -5
- unifi_network_maps/io/export.py +20 -1
- unifi_network_maps/model/topology.py +125 -71
- unifi_network_maps/render/device_ports_md.py +31 -18
- unifi_network_maps/render/lldp_md.py +87 -43
- unifi_network_maps/render/mermaid.py +96 -49
- unifi_network_maps/render/svg.py +614 -318
- {unifi_network_maps-1.4.1.dist-info → unifi_network_maps-1.4.2.dist-info}/METADATA +57 -82
- {unifi_network_maps-1.4.1.dist-info → unifi_network_maps-1.4.2.dist-info}/RECORD +16 -16
- {unifi_network_maps-1.4.1.dist-info → unifi_network_maps-1.4.2.dist-info}/WHEEL +0 -0
- {unifi_network_maps-1.4.1.dist-info → unifi_network_maps-1.4.2.dist-info}/entry_points.txt +0 -0
- {unifi_network_maps-1.4.1.dist-info → unifi_network_maps-1.4.2.dist-info}/licenses/LICENSE +0 -0
- {unifi_network_maps-1.4.1.dist-info → unifi_network_maps-1.4.2.dist-info}/top_level.txt +0 -0
unifi_network_maps/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "1.4.
|
|
1
|
+
__version__ = "1.4.2"
|
|
@@ -3,13 +3,17 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import hashlib
|
|
6
|
+
import json
|
|
6
7
|
import logging
|
|
7
8
|
import os
|
|
8
|
-
import
|
|
9
|
+
import stat
|
|
9
10
|
import time
|
|
10
|
-
from collections.abc import
|
|
11
|
+
from collections.abc import Callable, Iterator, Sequence
|
|
12
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
13
|
+
from concurrent.futures import TimeoutError as FutureTimeoutError
|
|
14
|
+
from contextlib import contextmanager
|
|
11
15
|
from pathlib import Path
|
|
12
|
-
from typing import TYPE_CHECKING
|
|
16
|
+
from typing import IO, TYPE_CHECKING
|
|
13
17
|
|
|
14
18
|
from .config import Config
|
|
15
19
|
|
|
@@ -23,6 +27,194 @@ def _cache_dir() -> Path:
|
|
|
23
27
|
return Path(os.environ.get("UNIFI_CACHE_DIR", ".cache/unifi_network_maps"))
|
|
24
28
|
|
|
25
29
|
|
|
30
|
+
def _device_attr(device: object, name: str) -> object | None:
|
|
31
|
+
if isinstance(device, dict):
|
|
32
|
+
return device.get(name)
|
|
33
|
+
return getattr(device, name, None)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _first_attr(device: object, *names: str) -> object | None:
|
|
37
|
+
for name in names:
|
|
38
|
+
value = _device_attr(device, name)
|
|
39
|
+
if value is not None:
|
|
40
|
+
return value
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _as_list(value: object | None) -> list[object]:
|
|
45
|
+
if value is None:
|
|
46
|
+
return []
|
|
47
|
+
if isinstance(value, list):
|
|
48
|
+
return value
|
|
49
|
+
if isinstance(value, dict):
|
|
50
|
+
return [value]
|
|
51
|
+
if isinstance(value, str | bytes):
|
|
52
|
+
return []
|
|
53
|
+
try:
|
|
54
|
+
return list(value) # type: ignore[arg-type]
|
|
55
|
+
except TypeError:
|
|
56
|
+
return []
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _serialize_lldp_entry(entry: object) -> dict[str, object]:
|
|
60
|
+
return {
|
|
61
|
+
"chassis_id": _first_attr(entry, "chassis_id", "chassisId"),
|
|
62
|
+
"port_id": _first_attr(entry, "port_id", "portId"),
|
|
63
|
+
"port_desc": _first_attr(entry, "port_desc", "portDesc", "port_descr", "portDescr"),
|
|
64
|
+
"local_port_name": _first_attr(entry, "local_port_name", "localPortName"),
|
|
65
|
+
"local_port_idx": _first_attr(entry, "local_port_idx", "localPortIdx"),
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _serialize_lldp_entries(value: object | None) -> list[dict[str, object]]:
|
|
70
|
+
entries = _as_list(value)
|
|
71
|
+
serialized: list[dict[str, object]] = []
|
|
72
|
+
for entry in entries:
|
|
73
|
+
data = _serialize_lldp_entry(entry)
|
|
74
|
+
if data.get("chassis_id") and data.get("port_id"):
|
|
75
|
+
serialized.append(data)
|
|
76
|
+
return serialized
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _serialize_port_entry(entry: object) -> dict[str, object]:
|
|
80
|
+
aggregation_group = _first_attr(
|
|
81
|
+
entry,
|
|
82
|
+
"aggregation_group",
|
|
83
|
+
"aggregation_id",
|
|
84
|
+
"aggregate_id",
|
|
85
|
+
"agg_id",
|
|
86
|
+
"lag_id",
|
|
87
|
+
"lag_group",
|
|
88
|
+
"link_aggregation_group",
|
|
89
|
+
"link_aggregation_id",
|
|
90
|
+
"aggregate",
|
|
91
|
+
"aggregated_by",
|
|
92
|
+
)
|
|
93
|
+
return {
|
|
94
|
+
"port_idx": _first_attr(entry, "port_idx", "portIdx"),
|
|
95
|
+
"name": _first_attr(entry, "name"),
|
|
96
|
+
"ifname": _first_attr(entry, "ifname"),
|
|
97
|
+
"speed": _first_attr(entry, "speed"),
|
|
98
|
+
"aggregation_group": aggregation_group,
|
|
99
|
+
"port_poe": _first_attr(entry, "port_poe"),
|
|
100
|
+
"poe_enable": _first_attr(entry, "poe_enable"),
|
|
101
|
+
"poe_good": _first_attr(entry, "poe_good"),
|
|
102
|
+
"poe_power": _first_attr(entry, "poe_power"),
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _serialize_port_table(value: object | None) -> list[dict[str, object]]:
|
|
107
|
+
return [_serialize_port_entry(entry) for entry in _as_list(value)]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _serialize_uplink(value: object | None) -> dict[str, object] | None:
|
|
111
|
+
if value is None:
|
|
112
|
+
return None
|
|
113
|
+
data = {
|
|
114
|
+
"uplink_mac": _first_attr(value, "uplink_mac", "uplink_device_mac"),
|
|
115
|
+
"uplink_device_name": _first_attr(value, "uplink_device_name", "uplink_name"),
|
|
116
|
+
"uplink_remote_port": _first_attr(value, "uplink_remote_port", "port_idx"),
|
|
117
|
+
}
|
|
118
|
+
if any(item is not None for item in data.values()):
|
|
119
|
+
return data
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _device_lldp_value(device: object) -> object | None:
|
|
124
|
+
lldp_info = _device_attr(device, "lldp_info")
|
|
125
|
+
if lldp_info is None:
|
|
126
|
+
lldp_info = _device_attr(device, "lldp")
|
|
127
|
+
if lldp_info is None:
|
|
128
|
+
lldp_info = _device_attr(device, "lldp_table")
|
|
129
|
+
return lldp_info
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _device_uplink_fields(device: object) -> dict[str, object | None]:
|
|
133
|
+
return {
|
|
134
|
+
"uplink": _serialize_uplink(_device_attr(device, "uplink")),
|
|
135
|
+
"last_uplink": _serialize_uplink(_device_attr(device, "last_uplink")),
|
|
136
|
+
"uplink_mac": _first_attr(device, "uplink_mac", "uplink_device_mac"),
|
|
137
|
+
"uplink_device_name": _device_attr(device, "uplink_device_name"),
|
|
138
|
+
"uplink_remote_port": _device_attr(device, "uplink_remote_port"),
|
|
139
|
+
"last_uplink_mac": _device_attr(device, "last_uplink_mac"),
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _serialize_device_for_cache(device: object) -> dict[str, object]:
|
|
144
|
+
payload = {
|
|
145
|
+
"name": _device_attr(device, "name"),
|
|
146
|
+
"model_name": _device_attr(device, "model_name"),
|
|
147
|
+
"model": _device_attr(device, "model"),
|
|
148
|
+
"mac": _device_attr(device, "mac"),
|
|
149
|
+
"ip": _first_attr(device, "ip", "ip_address"),
|
|
150
|
+
"type": _first_attr(device, "type", "device_type"),
|
|
151
|
+
"displayable_version": _first_attr(device, "displayable_version", "version"),
|
|
152
|
+
"lldp_info": _serialize_lldp_entries(_device_lldp_value(device)),
|
|
153
|
+
"port_table": _serialize_port_table(_device_attr(device, "port_table")),
|
|
154
|
+
}
|
|
155
|
+
payload.update(_device_uplink_fields(device))
|
|
156
|
+
return payload
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _serialize_devices_for_cache(devices: Sequence[object]) -> list[dict[str, object]]:
|
|
160
|
+
return [_serialize_device_for_cache(device) for device in devices]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _cache_lock_path(path: Path) -> Path:
|
|
164
|
+
return path.with_suffix(path.suffix + ".lock")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _acquire_cache_lock(lock_file: IO[str]) -> None:
|
|
168
|
+
if os.name == "nt":
|
|
169
|
+
import msvcrt
|
|
170
|
+
|
|
171
|
+
msvcrt.locking(lock_file.fileno(), msvcrt.LK_LOCK, 1)
|
|
172
|
+
else:
|
|
173
|
+
import fcntl
|
|
174
|
+
|
|
175
|
+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _release_cache_lock(lock_file: IO[str]) -> None:
|
|
179
|
+
if os.name == "nt":
|
|
180
|
+
import msvcrt
|
|
181
|
+
|
|
182
|
+
msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
|
|
183
|
+
else:
|
|
184
|
+
import fcntl
|
|
185
|
+
|
|
186
|
+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@contextmanager
|
|
190
|
+
def _cache_lock(path: Path) -> Iterator[None]:
|
|
191
|
+
lock_path = _cache_lock_path(path)
|
|
192
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
193
|
+
with lock_path.open("a+", encoding="utf-8") as lock_file:
|
|
194
|
+
try:
|
|
195
|
+
_acquire_cache_lock(lock_file)
|
|
196
|
+
yield
|
|
197
|
+
finally:
|
|
198
|
+
try:
|
|
199
|
+
_release_cache_lock(lock_file)
|
|
200
|
+
except OSError:
|
|
201
|
+
logger.debug("Failed to release cache lock %s", lock_path)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _is_cache_dir_safe(path: Path) -> bool:
|
|
205
|
+
if not path.exists():
|
|
206
|
+
return True
|
|
207
|
+
try:
|
|
208
|
+
mode = stat.S_IMODE(path.stat().st_mode)
|
|
209
|
+
except OSError as exc:
|
|
210
|
+
logger.warning("Failed to stat cache dir %s: %s", path, exc)
|
|
211
|
+
return False
|
|
212
|
+
if mode & (stat.S_IWGRP | stat.S_IWOTH):
|
|
213
|
+
logger.warning("Cache dir %s is group/world-writable; skipping cache", path)
|
|
214
|
+
return False
|
|
215
|
+
return True
|
|
216
|
+
|
|
217
|
+
|
|
26
218
|
def _cache_ttl_seconds() -> int:
|
|
27
219
|
value = os.environ.get("UNIFI_CACHE_TTL_SECONDS", "").strip()
|
|
28
220
|
if not value:
|
|
@@ -38,7 +230,7 @@ def _cache_key(*parts: str) -> str:
|
|
|
38
230
|
return digest[:24]
|
|
39
231
|
|
|
40
232
|
|
|
41
|
-
def _load_cache(path: Path, ttl_seconds: int) -> object | None:
|
|
233
|
+
def _load_cache(path: Path, ttl_seconds: int) -> Sequence[object] | None:
|
|
42
234
|
data, age = _load_cache_with_age(path)
|
|
43
235
|
if data is None:
|
|
44
236
|
return None
|
|
@@ -49,14 +241,18 @@ def _load_cache(path: Path, ttl_seconds: int) -> object | None:
|
|
|
49
241
|
return data
|
|
50
242
|
|
|
51
243
|
|
|
52
|
-
def _load_cache_with_age(path: Path) -> tuple[object | None, float | None]:
|
|
244
|
+
def _load_cache_with_age(path: Path) -> tuple[Sequence[object] | None, float | None]:
|
|
53
245
|
if not path.exists():
|
|
54
246
|
return None, None
|
|
55
247
|
try:
|
|
56
|
-
|
|
248
|
+
with _cache_lock(path):
|
|
249
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
57
250
|
except Exception as exc:
|
|
58
251
|
logger.debug("Failed to read cache %s: %s", path, exc)
|
|
59
252
|
return None, None
|
|
253
|
+
if not isinstance(payload, dict):
|
|
254
|
+
logger.debug("Cached payload at %s is not a dict", path)
|
|
255
|
+
return None, None
|
|
60
256
|
timestamp = payload.get("timestamp")
|
|
61
257
|
if not isinstance(timestamp, int | float):
|
|
62
258
|
return None, None
|
|
@@ -67,13 +263,16 @@ def _load_cache_with_age(path: Path) -> tuple[object | None, float | None]:
|
|
|
67
263
|
return data, time.time() - timestamp
|
|
68
264
|
|
|
69
265
|
|
|
70
|
-
def _save_cache(path: Path, data: object) -> None:
|
|
266
|
+
def _save_cache(path: Path, data: Sequence[object]) -> None:
|
|
71
267
|
try:
|
|
72
268
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
269
|
+
if not _is_cache_dir_safe(path.parent):
|
|
270
|
+
return
|
|
73
271
|
payload = {"timestamp": time.time(), "data": data}
|
|
74
272
|
tmp_path = path.with_suffix(path.suffix + ".tmp")
|
|
75
|
-
|
|
76
|
-
|
|
273
|
+
with _cache_lock(path):
|
|
274
|
+
tmp_path.write_text(json.dumps(payload, ensure_ascii=True), encoding="utf-8")
|
|
275
|
+
tmp_path.replace(path)
|
|
77
276
|
except Exception as exc:
|
|
78
277
|
logger.debug("Failed to write cache %s: %s", path, exc)
|
|
79
278
|
|
|
@@ -99,13 +298,37 @@ def _retry_backoff_seconds() -> float:
|
|
|
99
298
|
return 0.5
|
|
100
299
|
|
|
101
300
|
|
|
102
|
-
def
|
|
301
|
+
def _request_timeout_seconds() -> float | None:
|
|
302
|
+
value = os.environ.get("UNIFI_REQUEST_TIMEOUT_SECONDS", "").strip()
|
|
303
|
+
if not value:
|
|
304
|
+
return None
|
|
305
|
+
try:
|
|
306
|
+
return max(0.0, float(value))
|
|
307
|
+
except ValueError:
|
|
308
|
+
logger.warning("Invalid UNIFI_REQUEST_TIMEOUT_SECONDS value: %s", value)
|
|
309
|
+
return None
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _call_with_timeout[T](operation: str, func: Callable[[], T], timeout: float | None) -> T:
|
|
313
|
+
if timeout is None or timeout <= 0:
|
|
314
|
+
return func()
|
|
315
|
+
with ThreadPoolExecutor(max_workers=1) as executor:
|
|
316
|
+
future = executor.submit(func)
|
|
317
|
+
try:
|
|
318
|
+
return future.result(timeout=timeout)
|
|
319
|
+
except FutureTimeoutError as exc:
|
|
320
|
+
future.cancel()
|
|
321
|
+
raise TimeoutError(f"{operation} timed out after {timeout:.2f}s") from exc
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _call_with_retries[T](operation: str, func: Callable[[], T]) -> T:
|
|
103
325
|
attempts = _retry_attempts()
|
|
104
326
|
backoff = _retry_backoff_seconds()
|
|
327
|
+
timeout = _request_timeout_seconds()
|
|
105
328
|
last_exc: Exception | None = None
|
|
106
329
|
for attempt in range(1, attempts + 1):
|
|
107
330
|
try:
|
|
108
|
-
return func
|
|
331
|
+
return _call_with_timeout(operation, func, timeout)
|
|
109
332
|
except Exception as exc: # noqa: BLE001 - surface full error after retries
|
|
110
333
|
last_exc = exc
|
|
111
334
|
logger.warning("Failed %s attempt %d/%d: %s", operation, attempt, attempts, exc)
|
|
@@ -129,8 +352,12 @@ def _init_controller(config: Config, *, is_udm_pro: bool) -> UnifiController:
|
|
|
129
352
|
|
|
130
353
|
|
|
131
354
|
def fetch_devices(
|
|
132
|
-
config: Config,
|
|
133
|
-
|
|
355
|
+
config: Config,
|
|
356
|
+
*,
|
|
357
|
+
site: str | None = None,
|
|
358
|
+
detailed: bool = True,
|
|
359
|
+
use_cache: bool = True,
|
|
360
|
+
) -> Sequence[object]:
|
|
134
361
|
"""Fetch devices from UniFi Controller.
|
|
135
362
|
|
|
136
363
|
Uses `unifi-controller-api` to authenticate and return device objects.
|
|
@@ -142,9 +369,13 @@ def fetch_devices(
|
|
|
142
369
|
|
|
143
370
|
site_name = site or config.site
|
|
144
371
|
ttl_seconds = _cache_ttl_seconds()
|
|
145
|
-
cache_path = _cache_dir() / f"devices_{_cache_key(config.url, site_name, str(detailed))}.
|
|
146
|
-
|
|
147
|
-
|
|
372
|
+
cache_path = _cache_dir() / f"devices_{_cache_key(config.url, site_name, str(detailed))}.json"
|
|
373
|
+
if use_cache and _is_cache_dir_safe(cache_path.parent):
|
|
374
|
+
cached = _load_cache(cache_path, ttl_seconds)
|
|
375
|
+
stale_cached, cache_age = _load_cache_with_age(cache_path)
|
|
376
|
+
else:
|
|
377
|
+
cached = None
|
|
378
|
+
stale_cached, cache_age = None, None
|
|
148
379
|
if cached is not None:
|
|
149
380
|
logger.info("Using cached devices (%d)", len(cached))
|
|
150
381
|
return cached
|
|
@@ -155,7 +386,7 @@ def fetch_devices(
|
|
|
155
386
|
logger.info("UDM Pro authentication failed, retrying legacy auth")
|
|
156
387
|
controller = _init_controller(config, is_udm_pro=False)
|
|
157
388
|
|
|
158
|
-
def _fetch() ->
|
|
389
|
+
def _fetch() -> Sequence[object]:
|
|
159
390
|
return controller.get_unifi_site_device(site_name=site_name, detailed=detailed, raw=False)
|
|
160
391
|
|
|
161
392
|
try:
|
|
@@ -169,12 +400,18 @@ def fetch_devices(
|
|
|
169
400
|
)
|
|
170
401
|
return stale_cached
|
|
171
402
|
raise
|
|
172
|
-
|
|
403
|
+
if use_cache:
|
|
404
|
+
_save_cache(cache_path, _serialize_devices_for_cache(devices))
|
|
173
405
|
logger.info("Fetched %d devices", len(devices))
|
|
174
406
|
return devices
|
|
175
407
|
|
|
176
408
|
|
|
177
|
-
def fetch_clients(
|
|
409
|
+
def fetch_clients(
|
|
410
|
+
config: Config,
|
|
411
|
+
*,
|
|
412
|
+
site: str | None = None,
|
|
413
|
+
use_cache: bool = True,
|
|
414
|
+
) -> Sequence[object]:
|
|
178
415
|
"""Fetch active clients from UniFi Controller."""
|
|
179
416
|
try:
|
|
180
417
|
from unifi_controller_api import UnifiAuthenticationError
|
|
@@ -183,9 +420,13 @@ def fetch_clients(config: Config, *, site: str | None = None) -> Iterable[object
|
|
|
183
420
|
|
|
184
421
|
site_name = site or config.site
|
|
185
422
|
ttl_seconds = _cache_ttl_seconds()
|
|
186
|
-
cache_path = _cache_dir() / f"clients_{_cache_key(config.url, site_name)}.
|
|
187
|
-
|
|
188
|
-
|
|
423
|
+
cache_path = _cache_dir() / f"clients_{_cache_key(config.url, site_name)}.json"
|
|
424
|
+
if use_cache and _is_cache_dir_safe(cache_path.parent):
|
|
425
|
+
cached = _load_cache(cache_path, ttl_seconds)
|
|
426
|
+
stale_cached, cache_age = _load_cache_with_age(cache_path)
|
|
427
|
+
else:
|
|
428
|
+
cached = None
|
|
429
|
+
stale_cached, cache_age = None, None
|
|
189
430
|
if cached is not None:
|
|
190
431
|
logger.info("Using cached clients (%d)", len(cached))
|
|
191
432
|
return cached
|
|
@@ -196,7 +437,7 @@ def fetch_clients(config: Config, *, site: str | None = None) -> Iterable[object
|
|
|
196
437
|
logger.info("UDM Pro authentication failed, retrying legacy auth")
|
|
197
438
|
controller = _init_controller(config, is_udm_pro=False)
|
|
198
439
|
|
|
199
|
-
def _fetch() ->
|
|
440
|
+
def _fetch() -> Sequence[object]:
|
|
200
441
|
return controller.get_unifi_site_client(site_name=site_name, raw=True)
|
|
201
442
|
|
|
202
443
|
try:
|
|
@@ -210,6 +451,7 @@ def fetch_clients(config: Config, *, site: str | None = None) -> Iterable[object
|
|
|
210
451
|
)
|
|
211
452
|
return stale_cached
|
|
212
453
|
raise
|
|
213
|
-
|
|
454
|
+
if use_cache:
|
|
455
|
+
_save_cache(cache_path, clients)
|
|
214
456
|
logger.info("Fetched %d clients", len(clients))
|
|
215
457
|
return clients
|