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,909 @@
1
+ """Topology normalization and edge construction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from collections import deque
7
+ from collections.abc import Iterable
8
+ from dataclasses import dataclass, field
9
+
10
+ from .labels import compose_port_label, order_edge_names
11
+ from .lldp import LLDPEntry, coerce_lldp, local_port_label
12
+ from .ports import extract_port_number
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Device:
19
+ name: str
20
+ model_name: str
21
+ model: str
22
+ mac: str
23
+ ip: str
24
+ type: str
25
+ lldp_info: list[LLDPEntry]
26
+ port_table: list[PortInfo] = field(default_factory=list)
27
+ poe_ports: dict[int, bool] = field(default_factory=dict)
28
+ uplink: UplinkInfo | None = None
29
+ last_uplink: UplinkInfo | None = None
30
+ version: str = ""
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class Edge:
35
+ left: str
36
+ right: str
37
+ label: str | None = None
38
+ poe: bool = False
39
+ wireless: bool = False
40
+ speed: int | None = None
41
+ channel: int | None = None
42
+
43
+
44
+ type DeviceSource = object
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class UplinkInfo:
49
+ mac: str | None
50
+ name: str | None
51
+ port: int | None
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class PortInfo:
56
+ port_idx: int | None
57
+ name: str | None
58
+ ifname: str | None
59
+ speed: int | None
60
+ aggregation_group: str | None
61
+ port_poe: bool
62
+ poe_enable: bool
63
+ poe_good: bool
64
+ poe_power: float | None
65
+
66
+
67
+ type PortMap = dict[tuple[str, str], str]
68
+ type PoeMap = dict[tuple[str, str], bool]
69
+ type SpeedMap = dict[tuple[str, str], int]
70
+ type ClientPortMap = dict[str, list[tuple[int, str]]]
71
+
72
+
73
+ def _get_attr(obj: object, name: str) -> object | None:
74
+ if isinstance(obj, dict):
75
+ return obj.get(name)
76
+ return getattr(obj, name, None)
77
+
78
+
79
+ def _as_list(value: object | None) -> list[object]:
80
+ if value is None:
81
+ return []
82
+ if isinstance(value, list):
83
+ return value
84
+ if isinstance(value, dict):
85
+ return [value]
86
+ if isinstance(value, str | bytes):
87
+ return []
88
+ if isinstance(value, Iterable):
89
+ return list(value)
90
+ return []
91
+
92
+
93
+ def _normalize_mac(value: str) -> str:
94
+ return value.strip().lower()
95
+
96
+
97
+ def _as_bool(value: object | None) -> bool:
98
+ if isinstance(value, bool):
99
+ return value
100
+ if isinstance(value, int | float):
101
+ return value != 0
102
+ if isinstance(value, str):
103
+ return value.strip().lower() in {"1", "true", "yes", "y", "on"}
104
+ return False
105
+
106
+
107
+ def _as_float(value: object | None) -> float:
108
+ if value is None:
109
+ return 0.0
110
+ if isinstance(value, int | float):
111
+ return float(value)
112
+ if isinstance(value, str):
113
+ try:
114
+ return float(value)
115
+ except ValueError:
116
+ return 0.0
117
+ return 0.0
118
+
119
+
120
+ def _as_int(value: object | None) -> int | None:
121
+ if isinstance(value, int):
122
+ return value
123
+ if isinstance(value, str) and value.isdigit():
124
+ return int(value)
125
+ return None
126
+
127
+
128
+ def _as_group_id(value: object | None) -> str | None:
129
+ if value is None:
130
+ return None
131
+ if isinstance(value, bool):
132
+ return None
133
+ if isinstance(value, int):
134
+ return str(value)
135
+ if isinstance(value, str):
136
+ return value.strip() or None
137
+ return None
138
+
139
+
140
+ def _aggregation_group(port_entry: object) -> object | None:
141
+ keys = (
142
+ "aggregation_group",
143
+ "aggregation_id",
144
+ "aggregate_id",
145
+ "agg_id",
146
+ "lag_id",
147
+ "lag_group",
148
+ "link_aggregation_group",
149
+ "link_aggregation_id",
150
+ "aggregate",
151
+ "aggregated_by",
152
+ )
153
+ if isinstance(port_entry, dict):
154
+ for key in keys:
155
+ value = port_entry.get(key)
156
+ if value not in (None, "", False):
157
+ return value
158
+ return None
159
+ for key in keys:
160
+ value = _get_attr(port_entry, key)
161
+ if value not in (None, "", False):
162
+ return value
163
+ return None
164
+
165
+
166
+ def _lldp_candidates(entry: LLDPEntry) -> list[str]:
167
+ candidates: list[str] = []
168
+ if entry.local_port_name:
169
+ candidates.append(entry.local_port_name)
170
+ if entry.port_id:
171
+ candidates.append(entry.port_id)
172
+ return candidates
173
+
174
+
175
+ def _match_port_by_name(candidates: list[str], port_table: list[PortInfo]) -> int | None:
176
+ for candidate in candidates:
177
+ normalized = candidate.strip().lower()
178
+ for port in port_table:
179
+ if port.ifname and port.ifname.strip().lower() == normalized:
180
+ return port.port_idx
181
+ if port.name and port.name.strip().lower() == normalized:
182
+ return port.port_idx
183
+ return None
184
+
185
+
186
+ def _match_port_by_number(candidates: list[str], port_table: list[PortInfo]) -> int | None:
187
+ for candidate in candidates:
188
+ number = extract_port_number(candidate)
189
+ if number is None:
190
+ continue
191
+ for port in port_table:
192
+ if port.port_idx == number:
193
+ return port.port_idx
194
+ return None
195
+
196
+
197
+ def _resolve_port_idx_from_lldp(lldp_entry: LLDPEntry, port_table: list[PortInfo]) -> int | None:
198
+ if lldp_entry.local_port_idx is not None:
199
+ return lldp_entry.local_port_idx
200
+ candidates = _lldp_candidates(lldp_entry)
201
+ matched = _match_port_by_name(candidates, port_table)
202
+ if matched is not None:
203
+ return matched
204
+ return _match_port_by_number(candidates, port_table)
205
+
206
+
207
+ def _port_info_from_entry(port_entry: object) -> PortInfo:
208
+ if isinstance(port_entry, dict):
209
+ port_idx = port_entry.get("port_idx") or port_entry.get("portIdx")
210
+ name = port_entry.get("name")
211
+ ifname = port_entry.get("ifname")
212
+ speed = port_entry.get("speed")
213
+ aggregation_group = _aggregation_group(port_entry)
214
+ port_poe = _as_bool(port_entry.get("port_poe"))
215
+ poe_enable = _as_bool(port_entry.get("poe_enable"))
216
+ poe_good = _as_bool(port_entry.get("poe_good"))
217
+ poe_power = _as_float(port_entry.get("poe_power"))
218
+ else:
219
+ port_idx = _get_attr(port_entry, "port_idx") or _get_attr(port_entry, "portIdx")
220
+ name = _get_attr(port_entry, "name")
221
+ ifname = _get_attr(port_entry, "ifname")
222
+ speed = _get_attr(port_entry, "speed")
223
+ aggregation_group = _aggregation_group(port_entry)
224
+ port_poe = _as_bool(_get_attr(port_entry, "port_poe"))
225
+ poe_enable = _as_bool(_get_attr(port_entry, "poe_enable"))
226
+ poe_good = _as_bool(_get_attr(port_entry, "poe_good"))
227
+ poe_power = _as_float(_get_attr(port_entry, "poe_power"))
228
+ return PortInfo(
229
+ port_idx=_as_int(port_idx),
230
+ name=str(name) if isinstance(name, str) and name.strip() else None,
231
+ ifname=str(ifname) if isinstance(ifname, str) and ifname.strip() else None,
232
+ speed=_as_int(speed),
233
+ aggregation_group=_as_group_id(aggregation_group),
234
+ port_poe=port_poe,
235
+ poe_enable=poe_enable,
236
+ poe_good=poe_good,
237
+ poe_power=poe_power,
238
+ )
239
+
240
+
241
+ def _coerce_port_table(device: DeviceSource) -> list[PortInfo]:
242
+ port_table = _as_list(_get_attr(device, "port_table"))
243
+ return [_port_info_from_entry(port_entry) for port_entry in port_table]
244
+
245
+
246
+ def _poe_ports_from_device(device: DeviceSource) -> dict[int, bool]:
247
+ port_table = _coerce_port_table(device)
248
+ poe_ports: dict[int, bool] = {}
249
+ for port_entry in port_table:
250
+ if port_entry.port_idx is None:
251
+ continue
252
+ active = (
253
+ port_entry.poe_enable
254
+ or port_entry.port_poe
255
+ or port_entry.poe_good
256
+ or _as_float(port_entry.poe_power) > 0.0
257
+ )
258
+ poe_ports[int(port_entry.port_idx)] = active
259
+ return poe_ports
260
+
261
+
262
+ def _device_field(device: object, name: str) -> object | None:
263
+ if isinstance(device, dict):
264
+ return device.get(name)
265
+ return getattr(device, name, None)
266
+
267
+
268
+ def _parse_uplink(value: object | None) -> UplinkInfo | None:
269
+ if value is None:
270
+ return None
271
+ if isinstance(value, dict):
272
+ mac = value.get("uplink_mac") or value.get("uplink_device_mac")
273
+ name = value.get("uplink_device_name") or value.get("uplink_name")
274
+ port = _as_int(value.get("uplink_remote_port") or value.get("port_idx"))
275
+ else:
276
+ mac = _get_attr(value, "uplink_mac") or _get_attr(value, "uplink_device_mac")
277
+ name = _get_attr(value, "uplink_device_name") or _get_attr(value, "uplink_name")
278
+ port = _as_int(_get_attr(value, "uplink_remote_port") or _get_attr(value, "port_idx"))
279
+ mac_value = str(mac).strip() if isinstance(mac, str) and mac.strip() else None
280
+ name_value = str(name).strip() if isinstance(name, str) and name.strip() else None
281
+ if mac_value is None and name_value is None and port is None:
282
+ return None
283
+ return UplinkInfo(mac=mac_value, name=name_value, port=port)
284
+
285
+
286
+ def _uplink_info(device: DeviceSource) -> tuple[UplinkInfo | None, UplinkInfo | None]:
287
+ uplink = _parse_uplink(_device_field(device, "uplink"))
288
+ last_uplink = _parse_uplink(_device_field(device, "last_uplink"))
289
+
290
+ if uplink is None:
291
+ mac = _device_field(device, "uplink_mac") or _device_field(device, "uplink_device_mac")
292
+ name = _device_field(device, "uplink_device_name")
293
+ port = _as_int(_device_field(device, "uplink_remote_port"))
294
+ uplink = _parse_uplink(
295
+ {"uplink_mac": mac, "uplink_device_name": name, "uplink_remote_port": port}
296
+ )
297
+
298
+ if last_uplink is None:
299
+ mac = _device_field(device, "last_uplink_mac")
300
+ last_uplink = _parse_uplink({"uplink_mac": mac})
301
+
302
+ return uplink, last_uplink
303
+
304
+
305
+ def coerce_device(device: DeviceSource) -> Device:
306
+ name = _get_attr(device, "name")
307
+ model_name = _get_attr(device, "model_name") or _get_attr(device, "model")
308
+ model = _get_attr(device, "model")
309
+ mac = _get_attr(device, "mac")
310
+ ip = _get_attr(device, "ip") or _get_attr(device, "ip_address")
311
+ dev_type = _get_attr(device, "type") or _get_attr(device, "device_type")
312
+ version = _get_attr(device, "displayable_version") or _get_attr(device, "version")
313
+ lldp_info = _get_attr(device, "lldp_info")
314
+ if lldp_info is None:
315
+ lldp_info = _get_attr(device, "lldp")
316
+ if lldp_info is None:
317
+ lldp_info = _get_attr(device, "lldp_table")
318
+
319
+ if not name or not mac:
320
+ raise ValueError("Device missing name or mac")
321
+ uplink, last_uplink = _uplink_info(device)
322
+ if lldp_info is None:
323
+ if uplink or last_uplink:
324
+ logger.warning("Device %s missing LLDP info; using uplink fallback", name)
325
+ lldp_info = []
326
+ else:
327
+ raise ValueError(f"Device {name} missing LLDP info")
328
+
329
+ lldp_entries = _as_list(lldp_info)
330
+ coerced_lldp = [coerce_lldp(lldp_entry) for lldp_entry in lldp_entries]
331
+ port_table = _coerce_port_table(device)
332
+ poe_ports = _poe_ports_from_device(device)
333
+
334
+ return Device(
335
+ name=str(name),
336
+ model_name=str(model_name or ""),
337
+ model=str(model or ""),
338
+ mac=str(mac),
339
+ ip=str(ip or ""),
340
+ type=str(dev_type or ""),
341
+ lldp_info=coerced_lldp,
342
+ port_table=port_table,
343
+ poe_ports=poe_ports,
344
+ uplink=uplink,
345
+ last_uplink=last_uplink,
346
+ version=str(version or ""),
347
+ )
348
+
349
+
350
+ def normalize_devices(devices: Iterable[DeviceSource]) -> list[Device]:
351
+ return [coerce_device(device) for device in devices]
352
+
353
+
354
+ def classify_device_type(device: object) -> str:
355
+ raw_type = _device_field(device, "type")
356
+ raw_name = _device_field(device, "name")
357
+ value = raw_type.strip().lower() if isinstance(raw_type, str) else ""
358
+ if not value:
359
+ name = raw_name.strip().lower() if isinstance(raw_name, str) else ""
360
+ if "gateway" in name or name.startswith("gw"):
361
+ return "gateway"
362
+ if "switch" in name:
363
+ return "switch"
364
+ if "ap" in name:
365
+ return "ap"
366
+ if value in {"gateway", "ugw", "usg", "ux", "udm", "udr"}:
367
+ return "gateway"
368
+ if value in {"switch", "usw"}:
369
+ return "switch"
370
+ if value in {"uap", "ap"} or "ap" in value:
371
+ return "ap"
372
+ return "other"
373
+
374
+
375
+ def group_devices_by_type(devices: Iterable[Device]) -> dict[str, list[str]]:
376
+ groups: dict[str, list[str]] = {"gateway": [], "switch": [], "ap": [], "other": []}
377
+ for device in devices:
378
+ group = classify_device_type(device)
379
+ groups[group].append(device.name)
380
+ return groups
381
+
382
+
383
+ def _build_adjacency(edges: Iterable[Edge]) -> dict[str, set[str]]:
384
+ adjacency: dict[str, set[str]] = {}
385
+ for edge in edges:
386
+ adjacency.setdefault(edge.left, set()).add(edge.right)
387
+ adjacency.setdefault(edge.right, set()).add(edge.left)
388
+ return adjacency
389
+
390
+
391
+ def _build_edge_map(edges: Iterable[Edge]) -> dict[frozenset[str], Edge]:
392
+ return {frozenset({edge.left, edge.right}): edge for edge in edges}
393
+
394
+
395
+ def _tree_parents(adjacency: dict[str, set[str]], gateways: list[str]) -> dict[str, str]:
396
+ visited: set[str] = set()
397
+ parent: dict[str, str] = {}
398
+ queue: deque[str] = deque()
399
+
400
+ for gateway in gateways:
401
+ if gateway in adjacency:
402
+ visited.add(gateway)
403
+ queue.append(gateway)
404
+
405
+ while queue:
406
+ current = queue.popleft()
407
+ for neighbor in sorted(adjacency.get(current, set())):
408
+ if neighbor in visited:
409
+ continue
410
+ visited.add(neighbor)
411
+ parent[neighbor] = current
412
+ queue.append(neighbor)
413
+ return parent
414
+
415
+
416
+ def _tree_edges_from_parent(
417
+ parent: dict[str, str], edge_map: dict[frozenset[str], Edge]
418
+ ) -> list[Edge]:
419
+ tree_edges: list[Edge] = []
420
+ for child in sorted(parent):
421
+ parent_name = parent[child]
422
+ original = edge_map.get(frozenset({child, parent_name}))
423
+ if original is None:
424
+ tree_edges.append(Edge(left=parent_name, right=child))
425
+ else:
426
+ tree_edges.append(
427
+ Edge(
428
+ left=parent_name,
429
+ right=child,
430
+ label=original.label,
431
+ poe=original.poe,
432
+ wireless=original.wireless,
433
+ speed=original.speed,
434
+ channel=original.channel,
435
+ )
436
+ )
437
+ return tree_edges
438
+
439
+
440
+ def build_tree_edges_by_topology(edges: Iterable[Edge], gateways: list[str]) -> list[Edge]:
441
+ if not gateways:
442
+ return []
443
+ adjacency = _build_adjacency(edges)
444
+ edge_map = _build_edge_map(edges)
445
+ parent = _tree_parents(adjacency, gateways)
446
+ return _tree_edges_from_parent(parent, edge_map)
447
+
448
+
449
+ def build_device_index(devices: Iterable[Device]) -> dict[str, str]:
450
+ index: dict[str, str] = {}
451
+ for device in devices:
452
+ index[_normalize_mac(device.mac)] = device.name
453
+ return index
454
+
455
+
456
+ def _client_field(client: object, name: str) -> object | None:
457
+ if isinstance(client, dict):
458
+ return client.get(name)
459
+ return getattr(client, name, None)
460
+
461
+
462
+ def _client_display_name(client: object) -> str | None:
463
+ for key in ("name", "hostname", "mac"):
464
+ value = _client_field(client, key)
465
+ if isinstance(value, str) and value.strip():
466
+ return value.strip()
467
+ return None
468
+
469
+
470
+ def _client_uplink_mac(client: object) -> str | None:
471
+ for key in ("ap_mac", "sw_mac", "uplink_mac", "uplink_device_mac", "last_uplink_mac"):
472
+ value = _client_field(client, key)
473
+ if isinstance(value, str) and value.strip():
474
+ return value.strip()
475
+ for key in ("uplink", "last_uplink"):
476
+ nested = _client_field(client, key)
477
+ if isinstance(nested, dict):
478
+ value = nested.get("uplink_mac") or nested.get("uplink_device_mac")
479
+ if isinstance(value, str) and value.strip():
480
+ return value.strip()
481
+ return None
482
+
483
+
484
+ def _client_uplink_port(client: object) -> int | None:
485
+ for value in _client_port_values(client):
486
+ parsed = _parse_port_value(value)
487
+ if parsed is not None:
488
+ return parsed
489
+ return None
490
+
491
+
492
+ def _client_port_values(client: object) -> Iterable[object | None]:
493
+ for key in ("uplink_remote_port", "sw_port", "ap_port", "port_idx"):
494
+ yield _client_field(client, key)
495
+ for key in ("uplink", "last_uplink"):
496
+ nested = _client_field(client, key)
497
+ if isinstance(nested, dict):
498
+ for nested_key in ("uplink_remote_port", "port_idx"):
499
+ yield nested.get(nested_key)
500
+
501
+
502
+ def _parse_port_value(value: object | None) -> int | None:
503
+ if isinstance(value, int):
504
+ return value
505
+ if isinstance(value, str):
506
+ stripped = value.strip()
507
+ if stripped.isdigit():
508
+ return int(stripped)
509
+ return extract_port_number(stripped)
510
+ return None
511
+
512
+
513
+ def _client_is_wired(client: object) -> bool:
514
+ return bool(_client_field(client, "is_wired"))
515
+
516
+
517
+ def _client_channel(client: object) -> int | None:
518
+ for key in ("channel", "radio_channel", "wifi_channel"):
519
+ value = _client_field(client, key)
520
+ if isinstance(value, int):
521
+ return value
522
+ if isinstance(value, str) and value.isdigit():
523
+ return int(value)
524
+ return None
525
+
526
+
527
+ def _client_matches_mode(client: object, mode: str) -> bool:
528
+ wired = _client_is_wired(client)
529
+ if mode == "all":
530
+ return True
531
+ if mode == "wireless":
532
+ return not wired
533
+ return wired
534
+
535
+
536
+ def build_client_edges(
537
+ clients: Iterable[object],
538
+ device_index: dict[str, str],
539
+ *,
540
+ include_ports: bool = False,
541
+ client_mode: str = "wired",
542
+ ) -> list[Edge]:
543
+ edges: list[Edge] = []
544
+ seen: set[tuple[str, str]] = set()
545
+ for client in clients:
546
+ if not _client_matches_mode(client, client_mode):
547
+ continue
548
+ name = _client_display_name(client)
549
+ uplink_mac = _client_uplink_mac(client)
550
+ if not name or not uplink_mac:
551
+ continue
552
+ device_name = device_index.get(_normalize_mac(uplink_mac))
553
+ if not device_name:
554
+ continue
555
+ label = None
556
+ if include_ports:
557
+ uplink_port = _client_uplink_port(client)
558
+ if uplink_port is not None:
559
+ label = f"{device_name}: Port {uplink_port} <-> {name}"
560
+ key = (device_name, name)
561
+ if key in seen:
562
+ continue
563
+ is_wireless = not _client_is_wired(client)
564
+ channel = _client_channel(client) if is_wireless else None
565
+ edges.append(
566
+ Edge(
567
+ left=device_name,
568
+ right=name,
569
+ label=label,
570
+ wireless=is_wireless,
571
+ channel=channel,
572
+ )
573
+ )
574
+ seen.add(key)
575
+ return edges
576
+
577
+
578
+ def build_node_type_map(
579
+ devices: Iterable[Device],
580
+ clients: Iterable[object] | None = None,
581
+ *,
582
+ client_mode: str = "wired",
583
+ ) -> dict[str, str]:
584
+ node_types: dict[str, str] = {}
585
+ for device in devices:
586
+ node_types[device.name] = classify_device_type(device)
587
+ if clients:
588
+ for client in clients:
589
+ if not _client_matches_mode(client, client_mode):
590
+ continue
591
+ name = _client_display_name(client)
592
+ if name:
593
+ node_types[name] = "client"
594
+ return node_types
595
+
596
+
597
+ def build_edges(
598
+ devices: Iterable[Device],
599
+ *,
600
+ include_ports: bool = False,
601
+ only_unifi: bool = True,
602
+ ) -> list[Edge]:
603
+ ordered_devices = sorted(devices, key=lambda item: (item.name.lower(), item.mac.lower()))
604
+ index = build_device_index(ordered_devices)
605
+ device_by_name = {device.name: device for device in ordered_devices}
606
+ raw_links: list[tuple[str, str]] = []
607
+ seen: set[frozenset[str]] = set()
608
+ port_map: PortMap = {}
609
+ poe_map: PoeMap = {}
610
+ speed_map: SpeedMap = {}
611
+
612
+ devices_with_lldp_edges = _collect_lldp_links(
613
+ ordered_devices,
614
+ index,
615
+ port_map,
616
+ poe_map,
617
+ speed_map,
618
+ raw_links,
619
+ seen,
620
+ only_unifi=only_unifi,
621
+ )
622
+ _collect_uplink_links(
623
+ ordered_devices,
624
+ devices_with_lldp_edges,
625
+ index,
626
+ device_by_name,
627
+ port_map,
628
+ raw_links,
629
+ seen,
630
+ include_ports=include_ports,
631
+ only_unifi=only_unifi,
632
+ )
633
+ edges = _build_ordered_edges(
634
+ raw_links,
635
+ port_map,
636
+ poe_map,
637
+ speed_map,
638
+ device_by_name,
639
+ include_ports=include_ports,
640
+ )
641
+
642
+ poe_edges = sum(1 for edge in edges if edge.poe)
643
+ logger.info("Built %d unique edges (%d PoE)", len(edges), poe_edges)
644
+ return edges
645
+
646
+
647
+ def build_port_map(devices: Iterable[Device], *, only_unifi: bool = True) -> PortMap:
648
+ ordered_devices = sorted(devices, key=lambda item: (item.name.lower(), item.mac.lower()))
649
+ index = build_device_index(ordered_devices)
650
+ device_by_name = {device.name: device for device in ordered_devices}
651
+ raw_links: list[tuple[str, str]] = []
652
+ seen: set[frozenset[str]] = set()
653
+ port_map: PortMap = {}
654
+ poe_map: PoeMap = {}
655
+ speed_map: SpeedMap = {}
656
+
657
+ devices_with_lldp_edges = _collect_lldp_links(
658
+ ordered_devices,
659
+ index,
660
+ port_map,
661
+ poe_map,
662
+ speed_map,
663
+ raw_links,
664
+ seen,
665
+ only_unifi=only_unifi,
666
+ )
667
+ _collect_uplink_links(
668
+ ordered_devices,
669
+ devices_with_lldp_edges,
670
+ index,
671
+ device_by_name,
672
+ port_map,
673
+ raw_links,
674
+ seen,
675
+ include_ports=True,
676
+ only_unifi=only_unifi,
677
+ )
678
+ return port_map
679
+
680
+
681
+ def build_client_port_map(
682
+ devices: Iterable[Device],
683
+ clients: Iterable[object],
684
+ *,
685
+ client_mode: str,
686
+ ) -> ClientPortMap:
687
+ device_index = build_device_index(devices)
688
+ port_map: ClientPortMap = {}
689
+ for client in clients:
690
+ if not _client_matches_mode(client, client_mode):
691
+ continue
692
+ name = _client_display_name(client)
693
+ uplink_mac = _client_uplink_mac(client)
694
+ uplink_port = _client_uplink_port(client)
695
+ if not name or not uplink_mac or uplink_port is None:
696
+ continue
697
+ device_name = device_index.get(_normalize_mac(uplink_mac))
698
+ if not device_name:
699
+ continue
700
+ port_map.setdefault(device_name, []).append((uplink_port, name))
701
+ return port_map
702
+
703
+
704
+ def _port_speed_by_idx(port_table: list[PortInfo], port_idx: int) -> int | None:
705
+ for port in port_table:
706
+ if port.port_idx == port_idx:
707
+ return port.speed
708
+ return None
709
+
710
+
711
+ def _collect_lldp_links(
712
+ devices: list[Device],
713
+ index: dict[str, str],
714
+ port_map: PortMap,
715
+ poe_map: PoeMap,
716
+ speed_map: SpeedMap,
717
+ raw_links: list[tuple[str, str]],
718
+ seen: set[frozenset[str]],
719
+ *,
720
+ only_unifi: bool,
721
+ ) -> set[str]:
722
+ devices_with_lldp_edges: set[str] = set()
723
+ for device in devices:
724
+ poe_ports = device.poe_ports
725
+ for lldp_entry in sorted(
726
+ device.lldp_info,
727
+ key=lambda item: (
728
+ _normalize_mac(item.chassis_id),
729
+ str(item.port_id or ""),
730
+ str(item.port_desc or ""),
731
+ ),
732
+ ):
733
+ peer_mac = _normalize_mac(lldp_entry.chassis_id)
734
+ peer_name = index.get(peer_mac)
735
+ if peer_name is None:
736
+ if only_unifi:
737
+ continue
738
+ peer_name = lldp_entry.chassis_id
739
+
740
+ resolved_port_idx = _resolve_port_idx_from_lldp(lldp_entry, device.port_table)
741
+ entry_for_label = (
742
+ LLDPEntry(
743
+ chassis_id=lldp_entry.chassis_id,
744
+ port_id=lldp_entry.port_id,
745
+ port_desc=lldp_entry.port_desc,
746
+ local_port_name=lldp_entry.local_port_name,
747
+ local_port_idx=resolved_port_idx,
748
+ )
749
+ if resolved_port_idx is not None
750
+ else lldp_entry
751
+ )
752
+ label = local_port_label(entry_for_label)
753
+ if label:
754
+ port_map[(device.name, peer_name)] = label
755
+ if resolved_port_idx is not None:
756
+ if resolved_port_idx in poe_ports:
757
+ poe_map[(device.name, peer_name)] = poe_ports[resolved_port_idx]
758
+ port_speed = _port_speed_by_idx(device.port_table, resolved_port_idx)
759
+ if port_speed is not None:
760
+ speed_map[(device.name, peer_name)] = port_speed
761
+
762
+ key = frozenset({device.name, peer_name})
763
+ if key in seen:
764
+ continue
765
+
766
+ raw_links.append((device.name, peer_name))
767
+ seen.add(key)
768
+ devices_with_lldp_edges.add(device.name)
769
+ return devices_with_lldp_edges
770
+
771
+
772
+ def _uplink_name(
773
+ uplink: UplinkInfo | None,
774
+ index: dict[str, str],
775
+ *,
776
+ only_unifi: bool,
777
+ ) -> str | None:
778
+ if not uplink:
779
+ return None
780
+ if uplink.mac:
781
+ resolved = index.get(_normalize_mac(uplink.mac))
782
+ if resolved:
783
+ return resolved
784
+ if uplink.name:
785
+ return uplink.name
786
+ if not only_unifi and uplink.mac:
787
+ return uplink.mac
788
+ return None
789
+
790
+
791
+ def _maybe_add_uplink_link(
792
+ device: Device,
793
+ upstream_name: str,
794
+ *,
795
+ uplink: UplinkInfo | None,
796
+ device_by_name: dict[str, Device],
797
+ port_map: PortMap,
798
+ raw_links: list[tuple[str, str]],
799
+ seen: set[frozenset[str]],
800
+ include_ports: bool,
801
+ ) -> None:
802
+ key = frozenset({device.name, upstream_name})
803
+ if key in seen:
804
+ return
805
+ if uplink and uplink.port is not None:
806
+ if include_ports:
807
+ port_map[(upstream_name, device.name)] = f"Port {uplink.port}"
808
+ raw_links.append((upstream_name, device.name))
809
+ seen.add(key)
810
+
811
+
812
+ def _collect_uplink_links(
813
+ devices: list[Device],
814
+ devices_with_lldp_edges: set[str],
815
+ index: dict[str, str],
816
+ device_by_name: dict[str, Device],
817
+ port_map: PortMap,
818
+ raw_links: list[tuple[str, str]],
819
+ seen: set[frozenset[str]],
820
+ *,
821
+ include_ports: bool,
822
+ only_unifi: bool,
823
+ ) -> None:
824
+ for device in devices:
825
+ if device.name in devices_with_lldp_edges:
826
+ continue
827
+ uplink = device.uplink or device.last_uplink
828
+ upstream_name = _uplink_name(uplink, index, only_unifi=only_unifi)
829
+ if not upstream_name:
830
+ continue
831
+ if only_unifi and upstream_name not in device_by_name:
832
+ continue
833
+ _maybe_add_uplink_link(
834
+ device,
835
+ upstream_name,
836
+ uplink=uplink,
837
+ device_by_name=device_by_name,
838
+ port_map=port_map,
839
+ raw_links=raw_links,
840
+ seen=seen,
841
+ include_ports=include_ports,
842
+ )
843
+
844
+
845
+ def _build_ordered_edges(
846
+ raw_links: list[tuple[str, str]],
847
+ port_map: PortMap,
848
+ poe_map: PoeMap,
849
+ speed_map: SpeedMap,
850
+ device_by_name: dict[str, Device],
851
+ *,
852
+ include_ports: bool,
853
+ ) -> list[Edge]:
854
+ type_rank = {"gateway": 0, "switch": 1, "ap": 2, "other": 3}
855
+
856
+ def _rank_for_name(name: str) -> int:
857
+ device = device_by_name.get(name)
858
+ if not device:
859
+ return 3
860
+ return type_rank.get(classify_device_type(device), 3)
861
+
862
+ edges: list[Edge] = []
863
+ for source_name, target_name in raw_links:
864
+ left_name = source_name
865
+ right_name = target_name
866
+ if include_ports:
867
+ left_name, right_name = order_edge_names(
868
+ left_name,
869
+ right_name,
870
+ port_map,
871
+ _rank_for_name,
872
+ )
873
+ poe = poe_map.get((left_name, right_name), False) or poe_map.get(
874
+ (right_name, left_name), False
875
+ )
876
+ speed = speed_map.get((left_name, right_name)) or speed_map.get((right_name, left_name))
877
+ label = compose_port_label(left_name, right_name, port_map) if include_ports else None
878
+ edges.append(Edge(left=left_name, right=right_name, label=label, poe=poe, speed=speed))
879
+ return edges
880
+
881
+
882
+ @dataclass(frozen=True)
883
+ class TopologyResult:
884
+ raw_edges: list[Edge]
885
+ tree_edges: list[Edge]
886
+
887
+
888
+ def build_topology(
889
+ devices: Iterable[Device],
890
+ *,
891
+ include_ports: bool,
892
+ only_unifi: bool,
893
+ gateways: list[str],
894
+ ) -> TopologyResult:
895
+ normalized_devices = list(devices)
896
+ lldp_entries = sum(len(device.lldp_info) for device in normalized_devices)
897
+ logger.info(
898
+ "Normalized %d devices (%d LLDP entries)",
899
+ len(normalized_devices),
900
+ lldp_entries,
901
+ )
902
+ raw_edges = build_edges(normalized_devices, include_ports=include_ports, only_unifi=only_unifi)
903
+ tree_edges = build_tree_edges_by_topology(raw_edges, gateways)
904
+ logger.info(
905
+ "Built %d hierarchy edges (gateways=%d)",
906
+ len(tree_edges),
907
+ len(gateways),
908
+ )
909
+ return TopologyResult(raw_edges=raw_edges, tree_edges=tree_edges)