unifi-network-maps 1.4.11__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 (99) hide show
  1. unifi_network_maps/__init__.py +1 -0
  2. unifi_network_maps/__main__.py +8 -0
  3. unifi_network_maps/adapters/__init__.py +1 -0
  4. unifi_network_maps/adapters/config.py +49 -0
  5. unifi_network_maps/adapters/unifi.py +457 -0
  6. unifi_network_maps/assets/__init__.py +0 -0
  7. unifi_network_maps/assets/icons/__init__.py +0 -0
  8. unifi_network_maps/assets/icons/access-point.svg +1 -0
  9. unifi_network_maps/assets/icons/isometric/ISOPACKS_LICENSE +7 -0
  10. unifi_network_maps/assets/icons/isometric/block.svg +23 -0
  11. unifi_network_maps/assets/icons/isometric/cache.svg +48 -0
  12. unifi_network_maps/assets/icons/isometric/cardterminal.svg +316 -0
  13. unifi_network_maps/assets/icons/isometric/cloud.svg +89 -0
  14. unifi_network_maps/assets/icons/isometric/cronjob.svg +409 -0
  15. unifi_network_maps/assets/icons/isometric/cube.svg +24 -0
  16. unifi_network_maps/assets/icons/isometric/desktop.svg +107 -0
  17. unifi_network_maps/assets/icons/isometric/diamond.svg +23 -0
  18. unifi_network_maps/assets/icons/isometric/dns.svg +46 -0
  19. unifi_network_maps/assets/icons/isometric/document.svg +62 -0
  20. unifi_network_maps/assets/icons/isometric/firewall.svg +200 -0
  21. unifi_network_maps/assets/icons/isometric/function-module.svg +215 -0
  22. unifi_network_maps/assets/icons/isometric/image.svg +65 -0
  23. unifi_network_maps/assets/icons/isometric/laptop.svg +37 -0
  24. unifi_network_maps/assets/icons/isometric/loadbalancer.svg +65 -0
  25. unifi_network_maps/assets/icons/isometric/lock.svg +155 -0
  26. unifi_network_maps/assets/icons/isometric/mail.svg +35 -0
  27. unifi_network_maps/assets/icons/isometric/mailmultiple.svg +91 -0
  28. unifi_network_maps/assets/icons/isometric/mobiledevice.svg +66 -0
  29. unifi_network_maps/assets/icons/isometric/office.svg +136 -0
  30. unifi_network_maps/assets/icons/isometric/package-module.svg +39 -0
  31. unifi_network_maps/assets/icons/isometric/paymentcard.svg +92 -0
  32. unifi_network_maps/assets/icons/isometric/plane.svg +1 -0
  33. unifi_network_maps/assets/icons/isometric/printer.svg +122 -0
  34. unifi_network_maps/assets/icons/isometric/pyramid.svg +28 -0
  35. unifi_network_maps/assets/icons/isometric/queue.svg +38 -0
  36. unifi_network_maps/assets/icons/isometric/router.svg +39 -0
  37. unifi_network_maps/assets/icons/isometric/server.svg +112 -0
  38. unifi_network_maps/assets/icons/isometric/speech.svg +70 -0
  39. unifi_network_maps/assets/icons/isometric/sphere.svg +15 -0
  40. unifi_network_maps/assets/icons/isometric/storage.svg +92 -0
  41. unifi_network_maps/assets/icons/isometric/switch-module.svg +45 -0
  42. unifi_network_maps/assets/icons/isometric/tower.svg +50 -0
  43. unifi_network_maps/assets/icons/isometric/truck-2.svg +1 -0
  44. unifi_network_maps/assets/icons/isometric/truck.svg +1 -0
  45. unifi_network_maps/assets/icons/isometric/user.svg +231 -0
  46. unifi_network_maps/assets/icons/isometric/vm.svg +50 -0
  47. unifi_network_maps/assets/icons/laptop.svg +1 -0
  48. unifi_network_maps/assets/icons/router-network.svg +1 -0
  49. unifi_network_maps/assets/icons/server-network.svg +1 -0
  50. unifi_network_maps/assets/icons/server.svg +1 -0
  51. unifi_network_maps/assets/themes/dark.yaml +50 -0
  52. unifi_network_maps/assets/themes/default.yaml +47 -0
  53. unifi_network_maps/cli/__init__.py +5 -0
  54. unifi_network_maps/cli/__main__.py +8 -0
  55. unifi_network_maps/cli/args.py +166 -0
  56. unifi_network_maps/cli/main.py +134 -0
  57. unifi_network_maps/cli/render.py +255 -0
  58. unifi_network_maps/cli/runtime.py +157 -0
  59. unifi_network_maps/io/__init__.py +1 -0
  60. unifi_network_maps/io/debug.py +60 -0
  61. unifi_network_maps/io/export.py +32 -0
  62. unifi_network_maps/io/mkdocs_assets.py +21 -0
  63. unifi_network_maps/io/mock_data.py +23 -0
  64. unifi_network_maps/io/mock_generate.py +7 -0
  65. unifi_network_maps/model/__init__.py +1 -0
  66. unifi_network_maps/model/labels.py +35 -0
  67. unifi_network_maps/model/lldp.py +99 -0
  68. unifi_network_maps/model/mock.py +307 -0
  69. unifi_network_maps/model/ports.py +23 -0
  70. unifi_network_maps/model/topology.py +909 -0
  71. unifi_network_maps/render/__init__.py +1 -0
  72. unifi_network_maps/render/device_ports_md.py +492 -0
  73. unifi_network_maps/render/legend.py +30 -0
  74. unifi_network_maps/render/lldp_md.py +352 -0
  75. unifi_network_maps/render/markdown_tables.py +21 -0
  76. unifi_network_maps/render/mermaid.py +273 -0
  77. unifi_network_maps/render/mermaid_theme.py +56 -0
  78. unifi_network_maps/render/mkdocs.py +167 -0
  79. unifi_network_maps/render/svg.py +1235 -0
  80. unifi_network_maps/render/svg_theme.py +64 -0
  81. unifi_network_maps/render/templates/device_port_block.md.j2 +5 -0
  82. unifi_network_maps/render/templates/legend_compact.html.j2 +14 -0
  83. unifi_network_maps/render/templates/lldp_device_section.md.j2 +15 -0
  84. unifi_network_maps/render/templates/markdown_section.md.j2 +3 -0
  85. unifi_network_maps/render/templates/mermaid_legend.mmd.j2 +30 -0
  86. unifi_network_maps/render/templates/mkdocs_document.md.j2 +23 -0
  87. unifi_network_maps/render/templates/mkdocs_dual_theme_style.html.j2 +8 -0
  88. unifi_network_maps/render/templates/mkdocs_html_block.html.j2 +2 -0
  89. unifi_network_maps/render/templates/mkdocs_legend.css.j2 +29 -0
  90. unifi_network_maps/render/templates/mkdocs_legend.js.j2 +18 -0
  91. unifi_network_maps/render/templates/mkdocs_mermaid_block.md.j2 +4 -0
  92. unifi_network_maps/render/templating.py +19 -0
  93. unifi_network_maps/render/theme.py +109 -0
  94. unifi_network_maps-1.4.11.dist-info/METADATA +290 -0
  95. unifi_network_maps-1.4.11.dist-info/RECORD +99 -0
  96. unifi_network_maps-1.4.11.dist-info/WHEEL +5 -0
  97. unifi_network_maps-1.4.11.dist-info/entry_points.txt +2 -0
  98. unifi_network_maps-1.4.11.dist-info/licenses/LICENSE +21 -0
  99. unifi_network_maps-1.4.11.dist-info/top_level.txt +1 -0
@@ -0,0 +1 @@
1
+ __version__ = "1.4.11"
@@ -0,0 +1,8 @@
1
+ """Module entrypoint for python -m unifi_network_maps."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .cli.main import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ """Package module."""
@@ -0,0 +1,49 @@
1
+ """Configuration loading from environment variables."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+
8
+
9
+ def _parse_bool(value: str | None, default: bool = True) -> bool:
10
+ if value is None:
11
+ return default
12
+ normalized = value.strip().lower()
13
+ if normalized in {"1", "true", "yes", "y", "on"}:
14
+ return True
15
+ if normalized in {"0", "false", "no", "n", "off"}:
16
+ return False
17
+ return default
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Config:
22
+ url: str
23
+ site: str
24
+ user: str
25
+ password: str
26
+ verify_ssl: bool
27
+
28
+ @classmethod
29
+ def from_env(cls, *, env_file: str | None = None) -> Config:
30
+ if env_file:
31
+ try:
32
+ from dotenv import load_dotenv
33
+ except ImportError:
34
+ raise ValueError("python-dotenv required for --env-file") from None
35
+ load_dotenv(dotenv_path=env_file)
36
+ url = os.environ.get("UNIFI_URL", "").strip()
37
+ site = os.environ.get("UNIFI_SITE", "default").strip()
38
+ user = os.environ.get("UNIFI_USER", "").strip()
39
+ password = os.environ.get("UNIFI_PASS", "").strip()
40
+ verify_ssl = _parse_bool(os.environ.get("UNIFI_VERIFY_SSL"), default=True)
41
+
42
+ if not url:
43
+ raise ValueError("UNIFI_URL is required")
44
+ if not user:
45
+ raise ValueError("UNIFI_USER is required")
46
+ if not password:
47
+ raise ValueError("UNIFI_PASS is required")
48
+
49
+ return cls(url=url, site=site, user=user, password=password, verify_ssl=verify_ssl)
@@ -0,0 +1,457 @@
1
+ """UniFi API integration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import logging
8
+ import os
9
+ import stat
10
+ import time
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
15
+ from pathlib import Path
16
+ from typing import IO, TYPE_CHECKING
17
+
18
+ from .config import Config
19
+
20
+ if TYPE_CHECKING:
21
+ from unifi_controller_api import UnifiController
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ def _cache_dir() -> Path:
27
+ return Path(os.environ.get("UNIFI_CACHE_DIR", ".cache/unifi_network_maps"))
28
+
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
+
218
+ def _cache_ttl_seconds() -> int:
219
+ value = os.environ.get("UNIFI_CACHE_TTL_SECONDS", "").strip()
220
+ if not value:
221
+ return 3600
222
+ if value.isdigit():
223
+ return int(value)
224
+ logger.warning("Invalid UNIFI_CACHE_TTL_SECONDS value: %s", value)
225
+ return 3600
226
+
227
+
228
+ def _cache_key(*parts: str) -> str:
229
+ digest = hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest()
230
+ return digest[:24]
231
+
232
+
233
+ def _load_cache(path: Path, ttl_seconds: int) -> Sequence[object] | None:
234
+ data, age = _load_cache_with_age(path)
235
+ if data is None:
236
+ return None
237
+ if ttl_seconds <= 0:
238
+ return None
239
+ if age is None or age > ttl_seconds:
240
+ return None
241
+ return data
242
+
243
+
244
+ def _load_cache_with_age(path: Path) -> tuple[Sequence[object] | None, float | None]:
245
+ if not path.exists():
246
+ return None, None
247
+ try:
248
+ with _cache_lock(path):
249
+ payload = json.loads(path.read_text(encoding="utf-8"))
250
+ except Exception as exc:
251
+ logger.debug("Failed to read cache %s: %s", path, exc)
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
256
+ timestamp = payload.get("timestamp")
257
+ if not isinstance(timestamp, int | float):
258
+ return None, None
259
+ data = payload.get("data")
260
+ if not isinstance(data, list):
261
+ logger.debug("Cached payload at %s is not a list", path)
262
+ return None, None
263
+ return data, time.time() - timestamp
264
+
265
+
266
+ def _save_cache(path: Path, data: Sequence[object]) -> None:
267
+ try:
268
+ path.parent.mkdir(parents=True, exist_ok=True)
269
+ if not _is_cache_dir_safe(path.parent):
270
+ return
271
+ payload = {"timestamp": time.time(), "data": data}
272
+ tmp_path = path.with_suffix(path.suffix + ".tmp")
273
+ with _cache_lock(path):
274
+ tmp_path.write_text(json.dumps(payload, ensure_ascii=True), encoding="utf-8")
275
+ tmp_path.replace(path)
276
+ except Exception as exc:
277
+ logger.debug("Failed to write cache %s: %s", path, exc)
278
+
279
+
280
+ def _retry_attempts() -> int:
281
+ value = os.environ.get("UNIFI_RETRY_ATTEMPTS", "").strip()
282
+ if not value:
283
+ return 2
284
+ if value.isdigit():
285
+ return max(1, int(value))
286
+ logger.warning("Invalid UNIFI_RETRY_ATTEMPTS value: %s", value)
287
+ return 2
288
+
289
+
290
+ def _retry_backoff_seconds() -> float:
291
+ value = os.environ.get("UNIFI_RETRY_BACKOFF_SECONDS", "").strip()
292
+ if not value:
293
+ return 0.5
294
+ try:
295
+ return max(0.0, float(value))
296
+ except ValueError:
297
+ logger.warning("Invalid UNIFI_RETRY_BACKOFF_SECONDS value: %s", value)
298
+ return 0.5
299
+
300
+
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:
325
+ attempts = _retry_attempts()
326
+ backoff = _retry_backoff_seconds()
327
+ timeout = _request_timeout_seconds()
328
+ last_exc: Exception | None = None
329
+ for attempt in range(1, attempts + 1):
330
+ try:
331
+ return _call_with_timeout(operation, func, timeout)
332
+ except Exception as exc: # noqa: BLE001 - surface full error after retries
333
+ last_exc = exc
334
+ logger.warning("Failed %s attempt %d/%d: %s", operation, attempt, attempts, exc)
335
+ if attempt < attempts and backoff > 0:
336
+ time.sleep(backoff * attempt)
337
+ if last_exc:
338
+ raise last_exc
339
+ raise RuntimeError(f"Failed {operation}")
340
+
341
+
342
+ def _init_controller(config: Config, *, is_udm_pro: bool) -> UnifiController:
343
+ from unifi_controller_api import UnifiController
344
+
345
+ return UnifiController(
346
+ controller_url=config.url,
347
+ username=config.user,
348
+ password=config.password,
349
+ is_udm_pro=is_udm_pro,
350
+ verify_ssl=config.verify_ssl,
351
+ )
352
+
353
+
354
+ def fetch_devices(
355
+ config: Config,
356
+ *,
357
+ site: str | None = None,
358
+ detailed: bool = True,
359
+ use_cache: bool = True,
360
+ ) -> Sequence[object]:
361
+ """Fetch devices from UniFi Controller.
362
+
363
+ Uses `unifi-controller-api` to authenticate and return device objects.
364
+ """
365
+ try:
366
+ from unifi_controller_api import UnifiAuthenticationError
367
+ except ImportError as exc:
368
+ raise RuntimeError("Missing dependency: unifi-controller-api") from exc
369
+
370
+ site_name = site or config.site
371
+ ttl_seconds = _cache_ttl_seconds()
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
379
+ if cached is not None:
380
+ logger.info("Using cached devices (%d)", len(cached))
381
+ return cached
382
+
383
+ try:
384
+ controller = _init_controller(config, is_udm_pro=True)
385
+ except UnifiAuthenticationError:
386
+ logger.info("UDM Pro authentication failed, retrying legacy auth")
387
+ controller = _init_controller(config, is_udm_pro=False)
388
+
389
+ def _fetch() -> Sequence[object]:
390
+ return controller.get_unifi_site_device(site_name=site_name, detailed=detailed, raw=False)
391
+
392
+ try:
393
+ devices = _call_with_retries("device fetch", _fetch)
394
+ except Exception as exc: # noqa: BLE001 - fallback to cache
395
+ if stale_cached is not None:
396
+ logger.warning(
397
+ "Device fetch failed; using stale cache (%ds old): %s",
398
+ int(cache_age or 0),
399
+ exc,
400
+ )
401
+ return stale_cached
402
+ raise
403
+ if use_cache:
404
+ _save_cache(cache_path, _serialize_devices_for_cache(devices))
405
+ logger.info("Fetched %d devices", len(devices))
406
+ return devices
407
+
408
+
409
+ def fetch_clients(
410
+ config: Config,
411
+ *,
412
+ site: str | None = None,
413
+ use_cache: bool = True,
414
+ ) -> Sequence[object]:
415
+ """Fetch active clients from UniFi Controller."""
416
+ try:
417
+ from unifi_controller_api import UnifiAuthenticationError
418
+ except ImportError as exc:
419
+ raise RuntimeError("Missing dependency: unifi-controller-api") from exc
420
+
421
+ site_name = site or config.site
422
+ ttl_seconds = _cache_ttl_seconds()
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
430
+ if cached is not None:
431
+ logger.info("Using cached clients (%d)", len(cached))
432
+ return cached
433
+
434
+ try:
435
+ controller = _init_controller(config, is_udm_pro=True)
436
+ except UnifiAuthenticationError:
437
+ logger.info("UDM Pro authentication failed, retrying legacy auth")
438
+ controller = _init_controller(config, is_udm_pro=False)
439
+
440
+ def _fetch() -> Sequence[object]:
441
+ return controller.get_unifi_site_client(site_name=site_name, raw=True)
442
+
443
+ try:
444
+ clients = _call_with_retries("client fetch", _fetch)
445
+ except Exception as exc: # noqa: BLE001 - fallback to cache
446
+ if stale_cached is not None:
447
+ logger.warning(
448
+ "Client fetch failed; using stale cache (%ds old): %s",
449
+ int(cache_age or 0),
450
+ exc,
451
+ )
452
+ return stale_cached
453
+ raise
454
+ if use_cache:
455
+ _save_cache(cache_path, clients)
456
+ logger.info("Fetched %d clients", len(clients))
457
+ return clients
File without changes
File without changes
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" id="mdi-access-point" viewBox="0 0 24 24"><path d="M4.93,4.93C3.12,6.74 2,9.24 2,12C2,14.76 3.12,17.26 4.93,19.07L6.34,17.66C4.89,16.22 4,14.22 4,12C4,9.79 4.89,7.78 6.34,6.34L4.93,4.93M19.07,4.93L17.66,6.34C19.11,7.78 20,9.79 20,12C20,14.22 19.11,16.22 17.66,17.66L19.07,19.07C20.88,17.26 22,14.76 22,12C22,9.24 20.88,6.74 19.07,4.93M7.76,7.76C6.67,8.85 6,10.35 6,12C6,13.65 6.67,15.15 7.76,16.24L9.17,14.83C8.45,14.11 8,13.11 8,12C8,10.89 8.45,9.89 9.17,9.17L7.76,7.76M16.24,7.76L14.83,9.17C15.55,9.89 16,10.89 16,12C16,13.11 15.55,14.11 14.83,14.83L16.24,16.24C17.33,15.15 18,13.65 18,12C18,10.35 17.33,8.85 16.24,7.76M12,10A2,2 0 0,0 10,12A2,2 0 0,0 12,14A2,2 0 0,0 14,12A2,2 0 0,0 12,10Z" /></svg>
@@ -0,0 +1,7 @@
1
+ Copyright 2023 Mark Mankarious
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,23 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
3
+ width="551.59998px" height="343.79999px" viewBox="0 0 551.59998 343.79999"
4
+ style="enable-background:new 0 0 551.59998 343.79999;" xml:space="preserve">
5
+ <style type="text/css">
6
+ .st0{opacity:0.4;enable-background:new ;}
7
+ .st1{fill:#CDD9EE;}
8
+ .st2{fill:#B5C5DC;}
9
+ .st3{fill:#FFFFFF;}
10
+ .st4{fill:#6885A9;}
11
+ .st5{fill:#231F20;}
12
+ </style>
13
+ <g>
14
+ <polygon class="st0" points="309.29999,322.89999 274.60001,316.10001 274,16.2 551.59998,180 "/>
15
+ <polygon class="st1" points="274.60001,228.8 92.2,119.2 274,9.7 457,119.2 "/>
16
+ <polygon class="st2" points="90.2,195 274.60001,305.79999 274.60001,305.79999 274.60001,231.3 90.2,120.4 "/>
17
+ <polygon class="st3" points="288.29999,220.60001 135.7,127.7 303,27 274,9.7 92.2,119.2 274.60001,228.8 "/>
18
+ <polygon class="st4" points="459,195 274.60001,305.79999 274.60001,305.79999 274.60001,231.3 459,120.4 "/>
19
+ <path class="st5" d="M467.20001,115.6L467.20001,115.6L274,0L83.00002,115.1l-1,0.6v83.7l192.59998,115.80002L466.20001,200
20
+ l1-0.60001V115.6z M274,9.7l183,109.5L274.60001,228.8L92.20001,119.2L274,9.7z M457,124.1v69.69999L276.70001,302.20001V232.5
21
+ L457,124.1z M272.60001,232.39999v69.69998L92.30001,193.8v-69.7L272.60001,232.39999z"/>
22
+ </g>
23
+ </svg>
@@ -0,0 +1,48 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <!-- Generator: Adobe Illustrator 25.3.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
3
+ <svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
4
+ width="55.1px" height="57.3px" viewBox="0 0 55.1 57.3" enable-background="new 0 0 55.1 57.3" xml:space="preserve">
5
+ <g id="Layer_3">
6
+ </g>
7
+ <g id="Layer_4">
8
+ </g>
9
+ <g id="Layer_10">
10
+ <g opacity="0.4" fill="#000000">
11
+ <polygon points="35.6,46 38.3,48.1 25.6,52.1 21.2,53 32,38.9 41.4,44.8 "/>
12
+ </g>
13
+ </g>
14
+ <g id="Layer_7">
15
+ </g>
16
+ <g id="Layer_6">
17
+ <polygon fill="#F2C256" points="31.6,19 18.4,11.1 18.4,33.9 21.2,35.5 21.2,52.1 31.6,31.5 26,28.1 "/>
18
+ <polygon fill="#FFE4AE" points="18.4,33.9 18.5,33.9 25.6,15.4 18.4,11.1 "/>
19
+ <polygon fill="none" stroke="#010202" stroke-width="0.5" stroke-linejoin="round" stroke-miterlimit="10" points="31.6,19
20
+ 18.4,11.1 18.4,33.9 21.2,35.5 21.2,52.1 31.6,31.5 26,28.1 "/>
21
+ </g>
22
+ <g id="Layer_5">
23
+ <polygon fill="#F7C56B" stroke="#010202" stroke-width="0.4" stroke-linejoin="round" stroke-miterlimit="10" points="18.4,11.1
24
+ 24.5,7.4 37.6,15.3 32,24.3 37.6,27.7 27.2,48.3 21.2,52.1 31.6,31.5 26,28.1 31.6,19 "/>
25
+ <polygon fill="#754F0C" stroke="#010202" stroke-width="0.4" stroke-miterlimit="10" points="37.6,15.3 31.6,19 26,28.1 31.6,31.5
26
+ 21.2,52.1 27.2,48.3 37.6,27.7 32,24.3 "/>
27
+ <g id="Layer_12">
28
+ </g>
29
+ <polygon fill="none" stroke="#010202" stroke-width="0.4" stroke-linejoin="round" stroke-miterlimit="10" points="18.4,11.1
30
+ 24.5,7.4 37.6,15.3 32,24.3 37.6,27.7 27.2,48.3 21.2,52.1 31.6,31.5 26,28.1 31.6,19 "/>
31
+ </g>
32
+ <g id="Layer_8">
33
+ <polygon fill="#AF7A2E" stroke="#010202" stroke-width="0.4" stroke-linejoin="round" stroke-miterlimit="10" points="31.6,31.5
34
+ 37.6,27.7 32,24.3 26,28.1 "/>
35
+ </g>
36
+ <g id="Layer_11">
37
+ <polyline fill="none" stroke="#010202" stroke-width="0.4" stroke-linejoin="round" stroke-miterlimit="10" points="37.6,15.3
38
+ 31.6,19 26,28.1 32,24.3 37.6,15.3 "/>
39
+ </g>
40
+ <g id="Layer_9">
41
+ <g>
42
+ <path fill="#010202" d="M24.5,7.4l13.2,7.9l-5.7,9l5.6,3.4L27.2,48.3l-6,3.7V35.5l-2.7-1.6V11.1L24.5,7.4 M24.5,6.6
43
+ c-0.1,0-0.3,0-0.4,0.1l-6,3.7c-0.2,0.1-0.4,0.4-0.4,0.7v22.8c0,0.3,0.1,0.5,0.4,0.7l2.3,1.4v16.1c0,0.3,0.2,0.6,0.4,0.7
44
+ c0.1,0.1,0.3,0.1,0.4,0.1s0.3,0,0.4-0.1l6-3.7c0.1-0.1,0.2-0.2,0.3-0.3l10.4-20.6c0.2-0.4,0.1-0.8-0.3-1l-4.9-3l5.2-8.4
45
+ c0.1-0.2,0.1-0.4,0.1-0.6c-0.1-0.2-0.2-0.4-0.4-0.5l-13.1-8C24.7,6.6,24.6,6.6,24.5,6.6L24.5,6.6z"/>
46
+ </g>
47
+ </g>
48
+ </svg>