unifi-network-maps 1.4.14__py3-none-any.whl → 1.5.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 (74) hide show
  1. unifi_network_maps/__init__.py +1 -1
  2. unifi_network_maps/adapters/unifi.py +83 -101
  3. unifi_network_maps/assets/icons/modern/ap.svg +9 -0
  4. unifi_network_maps/assets/icons/modern/camera.svg +9 -0
  5. unifi_network_maps/assets/icons/modern/client.svg +9 -0
  6. unifi_network_maps/assets/icons/modern/game_console.svg +10 -0
  7. unifi_network_maps/assets/icons/modern/gateway.svg +17 -0
  8. unifi_network_maps/assets/icons/modern/iot.svg +9 -0
  9. unifi_network_maps/assets/icons/modern/nas.svg +9 -0
  10. unifi_network_maps/assets/icons/modern/other.svg +10 -0
  11. unifi_network_maps/assets/icons/modern/phone.svg +10 -0
  12. unifi_network_maps/assets/icons/modern/printer.svg +9 -0
  13. unifi_network_maps/assets/icons/modern/speaker.svg +10 -0
  14. unifi_network_maps/assets/icons/modern/switch.svg +10 -0
  15. unifi_network_maps/assets/icons/modern/tv.svg +10 -0
  16. unifi_network_maps/assets/icons/modern-flat/ap.svg +5 -0
  17. unifi_network_maps/assets/icons/modern-flat/camera.svg +5 -0
  18. unifi_network_maps/assets/icons/modern-flat/client.svg +5 -0
  19. unifi_network_maps/assets/icons/modern-flat/game_console.svg +6 -0
  20. unifi_network_maps/assets/icons/modern-flat/gateway.svg +13 -0
  21. unifi_network_maps/assets/icons/modern-flat/iot.svg +5 -0
  22. unifi_network_maps/assets/icons/modern-flat/nas.svg +5 -0
  23. unifi_network_maps/assets/icons/modern-flat/other.svg +6 -0
  24. unifi_network_maps/assets/icons/modern-flat/phone.svg +6 -0
  25. unifi_network_maps/assets/icons/modern-flat/printer.svg +5 -0
  26. unifi_network_maps/assets/icons/modern-flat/speaker.svg +6 -0
  27. unifi_network_maps/assets/icons/modern-flat/switch.svg +6 -0
  28. unifi_network_maps/assets/icons/modern-flat/tv.svg +6 -0
  29. unifi_network_maps/assets/themes/dark.yaml +53 -10
  30. unifi_network_maps/assets/themes/default.yaml +34 -0
  31. unifi_network_maps/assets/themes/minimal-dark.yaml +98 -0
  32. unifi_network_maps/assets/themes/minimal.yaml +92 -0
  33. unifi_network_maps/assets/themes/unifi-dark.yaml +97 -0
  34. unifi_network_maps/assets/themes/unifi.yaml +92 -0
  35. unifi_network_maps/cli/args.py +54 -0
  36. unifi_network_maps/cli/main.py +18 -7
  37. unifi_network_maps/cli/render.py +79 -27
  38. unifi_network_maps/cli/runtime.py +29 -15
  39. unifi_network_maps/io/debug.py +2 -1
  40. unifi_network_maps/io/export.py +19 -13
  41. unifi_network_maps/io/mock_data.py +5 -3
  42. unifi_network_maps/io/paths.py +5 -3
  43. unifi_network_maps/model/classify.py +199 -0
  44. unifi_network_maps/model/clients.py +271 -0
  45. unifi_network_maps/model/connection.py +37 -0
  46. unifi_network_maps/model/diff.py +544 -0
  47. unifi_network_maps/model/edges.py +558 -0
  48. unifi_network_maps/model/helpers.py +64 -0
  49. unifi_network_maps/model/lldp.py +20 -25
  50. unifi_network_maps/model/mock.py +110 -23
  51. unifi_network_maps/model/snapshot.py +294 -0
  52. unifi_network_maps/model/topology.py +143 -931
  53. unifi_network_maps/model/topology_coerce.py +339 -0
  54. unifi_network_maps/model/vlans.py +32 -46
  55. unifi_network_maps/model/wan.py +132 -0
  56. unifi_network_maps/render/device_ports_md.py +39 -97
  57. unifi_network_maps/render/device_summary.py +53 -0
  58. unifi_network_maps/render/lldp_md.py +29 -219
  59. unifi_network_maps/render/markdown_tables.py +8 -0
  60. unifi_network_maps/render/mermaid.py +11 -2
  61. unifi_network_maps/render/mkdocs.py +2 -1
  62. unifi_network_maps/render/svg.py +566 -908
  63. unifi_network_maps/render/svg_icons.py +231 -0
  64. unifi_network_maps/render/svg_isometric.py +1196 -0
  65. unifi_network_maps/render/svg_labels.py +184 -0
  66. unifi_network_maps/render/svg_theme.py +166 -32
  67. unifi_network_maps/render/theme.py +86 -1
  68. {unifi_network_maps-1.4.14.dist-info → unifi_network_maps-1.5.0.dist-info}/METADATA +107 -31
  69. {unifi_network_maps-1.4.14.dist-info → unifi_network_maps-1.5.0.dist-info}/RECORD +73 -31
  70. unifi_network_maps/assets/icons/isometric/printer.svg +0 -122
  71. {unifi_network_maps-1.4.14.dist-info → unifi_network_maps-1.5.0.dist-info}/WHEEL +0 -0
  72. {unifi_network_maps-1.4.14.dist-info → unifi_network_maps-1.5.0.dist-info}/entry_points.txt +0 -0
  73. {unifi_network_maps-1.4.14.dist-info → unifi_network_maps-1.5.0.dist-info}/licenses/LICENSE +0 -0
  74. {unifi_network_maps-1.4.14.dist-info → unifi_network_maps-1.5.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,558 @@
1
+ """Edge building and topology construction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from collections import deque
7
+ from collections.abc import Iterable
8
+
9
+ from .classify import classify_device_type
10
+ from .helpers import normalize_mac
11
+ from .labels import compose_port_label, order_edge_names
12
+ from .lldp import LLDPEntry, local_port_label
13
+ from .ports import extract_port_number
14
+ from .topology import (
15
+ Device,
16
+ Edge,
17
+ PoeMap,
18
+ PortInfo,
19
+ PortMap,
20
+ SpeedMap,
21
+ TopologyResult,
22
+ UplinkInfo,
23
+ VlanMap,
24
+ )
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ def build_device_index(devices: Iterable[Device]) -> dict[str, str]:
30
+ """Build MAC to name index for devices."""
31
+ index: dict[str, str] = {}
32
+ for device in devices:
33
+ index[normalize_mac(device.mac)] = device.name
34
+ return index
35
+
36
+
37
+ def _lldp_candidates(entry: LLDPEntry) -> list[str]:
38
+ """Get candidate port identifiers from LLDP entry."""
39
+ candidates: list[str] = []
40
+ if entry.local_port_name:
41
+ candidates.append(entry.local_port_name)
42
+ if entry.port_id:
43
+ candidates.append(entry.port_id)
44
+ return candidates
45
+
46
+
47
+ def _match_port_by_name(candidates: list[str], port_table: list[PortInfo]) -> int | None:
48
+ """Match port by name/ifname."""
49
+ for candidate in candidates:
50
+ normalized = candidate.strip().lower()
51
+ for port in port_table:
52
+ if port.ifname and port.ifname.strip().lower() == normalized:
53
+ return port.port_idx
54
+ if port.name and port.name.strip().lower() == normalized:
55
+ return port.port_idx
56
+ return None
57
+
58
+
59
+ def _match_port_by_number(candidates: list[str], port_table: list[PortInfo]) -> int | None:
60
+ """Match port by extracted number."""
61
+ for candidate in candidates:
62
+ number = extract_port_number(candidate)
63
+ if number is None:
64
+ continue
65
+ for port in port_table:
66
+ if port.port_idx == number:
67
+ return port.port_idx
68
+ return None
69
+
70
+
71
+ def _resolve_port_idx_from_lldp(lldp_entry: LLDPEntry, port_table: list[PortInfo]) -> int | None:
72
+ """Resolve port index from LLDP entry."""
73
+ if lldp_entry.local_port_idx is not None:
74
+ return lldp_entry.local_port_idx
75
+ candidates = _lldp_candidates(lldp_entry)
76
+ matched = _match_port_by_name(candidates, port_table)
77
+ if matched is not None:
78
+ return matched
79
+ return _match_port_by_number(candidates, port_table)
80
+
81
+
82
+ def _find_port_by_idx(port_table: list[PortInfo], port_idx: int) -> PortInfo | None:
83
+ """Find port entry by index."""
84
+ for port in port_table:
85
+ if port.port_idx == port_idx:
86
+ return port
87
+ return None
88
+
89
+
90
+ def _port_speed_by_idx(port_table: list[PortInfo], port_idx: int) -> int | None:
91
+ """Get port speed by index."""
92
+ port = _find_port_by_idx(port_table, port_idx)
93
+ return port.speed if port else None
94
+
95
+
96
+ def _port_vlans_by_idx(port_table: list[PortInfo], port_idx: int) -> tuple[int, ...]:
97
+ """Get all VLANs configured on a port (native + tagged)."""
98
+ port = _find_port_by_idx(port_table, port_idx)
99
+ if not port:
100
+ return ()
101
+ vlans: list[int] = []
102
+ if port.native_vlan is not None:
103
+ vlans.append(port.native_vlan)
104
+ vlans.extend(port.tagged_vlans)
105
+ return tuple(sorted(set(vlans)))
106
+
107
+
108
+ def _populate_port_maps(
109
+ device_name: str,
110
+ peer_name: str,
111
+ port_idx: int,
112
+ poe_ports: dict[int, bool],
113
+ port_table: list[PortInfo],
114
+ poe_map: PoeMap,
115
+ speed_map: SpeedMap,
116
+ vlan_map: VlanMap,
117
+ ) -> None:
118
+ """Populate PoE, speed, and VLAN maps for an edge."""
119
+ if port_idx in poe_ports:
120
+ poe_map[(device_name, peer_name)] = poe_ports[port_idx]
121
+ port_speed = _port_speed_by_idx(port_table, port_idx)
122
+ if port_speed is not None:
123
+ speed_map[(device_name, peer_name)] = port_speed
124
+ port_vlans = _port_vlans_by_idx(port_table, port_idx)
125
+ if port_vlans:
126
+ vlan_map[(device_name, peer_name)] = port_vlans
127
+
128
+
129
+ def _collect_lldp_links(
130
+ devices: list[Device],
131
+ index: dict[str, str],
132
+ port_map: PortMap,
133
+ poe_map: PoeMap,
134
+ speed_map: SpeedMap,
135
+ vlan_map: VlanMap,
136
+ raw_links: list[tuple[str, str]],
137
+ seen: set[frozenset[str]],
138
+ *,
139
+ only_unifi: bool,
140
+ ) -> set[str]:
141
+ """Collect edges from LLDP data."""
142
+ devices_with_lldp_edges: set[str] = set()
143
+ for device in devices:
144
+ poe_ports = device.poe_ports
145
+ for lldp_entry in sorted(
146
+ device.lldp_info,
147
+ key=lambda item: (
148
+ normalize_mac(item.chassis_id),
149
+ str(item.port_id or ""),
150
+ str(item.port_desc or ""),
151
+ ),
152
+ ):
153
+ peer_mac = normalize_mac(lldp_entry.chassis_id)
154
+ peer_name = index.get(peer_mac)
155
+ if peer_name is None:
156
+ if only_unifi:
157
+ continue
158
+ peer_name = lldp_entry.chassis_id
159
+
160
+ resolved_port_idx = _resolve_port_idx_from_lldp(lldp_entry, device.port_table)
161
+ entry_for_label = (
162
+ LLDPEntry(
163
+ chassis_id=lldp_entry.chassis_id,
164
+ port_id=lldp_entry.port_id,
165
+ port_desc=lldp_entry.port_desc,
166
+ local_port_name=lldp_entry.local_port_name,
167
+ local_port_idx=resolved_port_idx,
168
+ )
169
+ if resolved_port_idx is not None
170
+ else lldp_entry
171
+ )
172
+ label = local_port_label(entry_for_label)
173
+ if label:
174
+ port_map[(device.name, peer_name)] = label
175
+ if resolved_port_idx is not None:
176
+ _populate_port_maps(
177
+ device.name,
178
+ peer_name,
179
+ resolved_port_idx,
180
+ poe_ports,
181
+ device.port_table,
182
+ poe_map,
183
+ speed_map,
184
+ vlan_map,
185
+ )
186
+
187
+ key = frozenset({device.name, peer_name})
188
+ if key in seen:
189
+ continue
190
+
191
+ raw_links.append((device.name, peer_name))
192
+ seen.add(key)
193
+ devices_with_lldp_edges.add(device.name)
194
+ return devices_with_lldp_edges
195
+
196
+
197
+ def _uplink_name(
198
+ uplink: UplinkInfo | None,
199
+ index: dict[str, str],
200
+ *,
201
+ only_unifi: bool,
202
+ ) -> str | None:
203
+ """Get upstream device name from uplink info."""
204
+ if not uplink:
205
+ return None
206
+ if uplink.mac:
207
+ resolved = index.get(normalize_mac(uplink.mac))
208
+ if resolved:
209
+ return resolved
210
+ if uplink.name:
211
+ return uplink.name
212
+ if not only_unifi and uplink.mac:
213
+ return uplink.mac
214
+ return None
215
+
216
+
217
+ def _maybe_add_uplink_link(
218
+ device: Device,
219
+ upstream_name: str,
220
+ *,
221
+ uplink: UplinkInfo | None,
222
+ port_map: PortMap,
223
+ raw_links: list[tuple[str, str]],
224
+ seen: set[frozenset[str]],
225
+ include_ports: bool,
226
+ ) -> None:
227
+ """Add uplink-based edge if not already seen."""
228
+ key = frozenset({device.name, upstream_name})
229
+ if key in seen:
230
+ return
231
+ if uplink and uplink.port is not None:
232
+ if include_ports:
233
+ port_map[(upstream_name, device.name)] = f"Port {uplink.port}"
234
+ raw_links.append((upstream_name, device.name))
235
+ seen.add(key)
236
+
237
+
238
+ def _collect_uplink_links(
239
+ devices: list[Device],
240
+ devices_with_lldp_edges: set[str],
241
+ index: dict[str, str],
242
+ device_by_name: dict[str, Device],
243
+ port_map: PortMap,
244
+ raw_links: list[tuple[str, str]],
245
+ seen: set[frozenset[str]],
246
+ *,
247
+ include_ports: bool,
248
+ only_unifi: bool,
249
+ ) -> None:
250
+ """Collect edges from uplink data (fallback for devices without LLDP)."""
251
+ for device in devices:
252
+ if device.name in devices_with_lldp_edges:
253
+ continue
254
+ uplink = device.uplink or device.last_uplink
255
+ upstream_name = _uplink_name(uplink, index, only_unifi=only_unifi)
256
+ if not upstream_name:
257
+ continue
258
+ if only_unifi and upstream_name not in device_by_name:
259
+ continue
260
+ _maybe_add_uplink_link(
261
+ device,
262
+ upstream_name,
263
+ uplink=uplink,
264
+ port_map=port_map,
265
+ raw_links=raw_links,
266
+ seen=seen,
267
+ include_ports=include_ports,
268
+ )
269
+
270
+
271
+ def _build_ordered_edges(
272
+ raw_links: list[tuple[str, str]],
273
+ port_map: PortMap,
274
+ poe_map: PoeMap,
275
+ speed_map: SpeedMap,
276
+ vlan_map: VlanMap,
277
+ device_by_name: dict[str, Device],
278
+ *,
279
+ include_ports: bool,
280
+ ) -> list[Edge]:
281
+ """Build ordered Edge objects from raw links."""
282
+ type_rank = {"gateway": 0, "switch": 1, "ap": 2, "other": 3}
283
+
284
+ def _rank_for_name(name: str) -> int:
285
+ device = device_by_name.get(name)
286
+ if not device:
287
+ return 3
288
+ return type_rank.get(classify_device_type(device), 3)
289
+
290
+ edges: list[Edge] = []
291
+ for source_name, target_name in raw_links:
292
+ left_name = source_name
293
+ right_name = target_name
294
+ if include_ports:
295
+ left_name, right_name = order_edge_names(
296
+ left_name,
297
+ right_name,
298
+ port_map,
299
+ _rank_for_name,
300
+ )
301
+ poe = poe_map.get((left_name, right_name), False) or poe_map.get(
302
+ (right_name, left_name), False
303
+ )
304
+ # Use None-aware lookup to handle speed=0 correctly
305
+ speed = speed_map.get((left_name, right_name))
306
+ if speed is None:
307
+ speed = speed_map.get((right_name, left_name))
308
+ label = compose_port_label(left_name, right_name, port_map) if include_ports else None
309
+ vlans_lr = vlan_map.get((left_name, right_name), ())
310
+ vlans_rl = vlan_map.get((right_name, left_name), ())
311
+ vlans = tuple(sorted(set(vlans_lr) | set(vlans_rl)))
312
+ is_trunk = len(vlans) > 1
313
+ edges.append(
314
+ Edge(
315
+ left=left_name,
316
+ right=right_name,
317
+ label=label,
318
+ poe=poe,
319
+ speed=speed,
320
+ vlans=vlans,
321
+ active_vlans=(),
322
+ is_trunk=is_trunk,
323
+ )
324
+ )
325
+ return edges
326
+
327
+
328
+ def build_edges(
329
+ devices: Iterable[Device],
330
+ *,
331
+ include_ports: bool = False,
332
+ only_unifi: bool = True,
333
+ ) -> list[Edge]:
334
+ """Build edges between devices from LLDP and uplink data."""
335
+ ordered_devices = sorted(devices, key=lambda item: (item.name.lower(), item.mac.lower()))
336
+ index = build_device_index(ordered_devices)
337
+ device_by_name = {device.name: device for device in ordered_devices}
338
+ raw_links: list[tuple[str, str]] = []
339
+ seen: set[frozenset[str]] = set()
340
+ port_map: PortMap = {}
341
+ poe_map: PoeMap = {}
342
+ speed_map: SpeedMap = {}
343
+ vlan_map: VlanMap = {}
344
+
345
+ devices_with_lldp_edges = _collect_lldp_links(
346
+ ordered_devices,
347
+ index,
348
+ port_map,
349
+ poe_map,
350
+ speed_map,
351
+ vlan_map,
352
+ raw_links,
353
+ seen,
354
+ only_unifi=only_unifi,
355
+ )
356
+ _collect_uplink_links(
357
+ ordered_devices,
358
+ devices_with_lldp_edges,
359
+ index,
360
+ device_by_name,
361
+ port_map,
362
+ raw_links,
363
+ seen,
364
+ include_ports=include_ports,
365
+ only_unifi=only_unifi,
366
+ )
367
+ edges = _build_ordered_edges(
368
+ raw_links,
369
+ port_map,
370
+ poe_map,
371
+ speed_map,
372
+ vlan_map,
373
+ device_by_name,
374
+ include_ports=include_ports,
375
+ )
376
+
377
+ poe_edges = sum(1 for edge in edges if edge.poe)
378
+ logger.debug("Built %d unique edges (%d PoE)", len(edges), poe_edges)
379
+ return edges
380
+
381
+
382
+ def build_port_map(devices: Iterable[Device], *, only_unifi: bool = True) -> PortMap:
383
+ """Build port label map from device data."""
384
+ ordered_devices = sorted(devices, key=lambda item: (item.name.lower(), item.mac.lower()))
385
+ index = build_device_index(ordered_devices)
386
+ device_by_name = {device.name: device for device in ordered_devices}
387
+ raw_links: list[tuple[str, str]] = []
388
+ seen: set[frozenset[str]] = set()
389
+ port_map: PortMap = {}
390
+ poe_map: PoeMap = {}
391
+ speed_map: SpeedMap = {}
392
+ vlan_map: VlanMap = {}
393
+
394
+ devices_with_lldp_edges = _collect_lldp_links(
395
+ ordered_devices,
396
+ index,
397
+ port_map,
398
+ poe_map,
399
+ speed_map,
400
+ vlan_map,
401
+ raw_links,
402
+ seen,
403
+ only_unifi=only_unifi,
404
+ )
405
+ _collect_uplink_links(
406
+ ordered_devices,
407
+ devices_with_lldp_edges,
408
+ index,
409
+ device_by_name,
410
+ port_map,
411
+ raw_links,
412
+ seen,
413
+ include_ports=True,
414
+ only_unifi=only_unifi,
415
+ )
416
+ return port_map
417
+
418
+
419
+ def _build_adjacency(edges: Iterable[Edge]) -> dict[str, set[str]]:
420
+ """Build adjacency list from edges."""
421
+ adjacency: dict[str, set[str]] = {}
422
+ for edge in edges:
423
+ adjacency.setdefault(edge.left, set()).add(edge.right)
424
+ adjacency.setdefault(edge.right, set()).add(edge.left)
425
+ return adjacency
426
+
427
+
428
+ def _build_edge_map(edges: Iterable[Edge]) -> dict[frozenset[str], Edge]:
429
+ """Build edge lookup map."""
430
+ return {frozenset({edge.left, edge.right}): edge for edge in edges}
431
+
432
+
433
+ def _tree_parents(adjacency: dict[str, set[str]], gateways: list[str]) -> dict[str, str]:
434
+ """BFS to find parent for each node in tree."""
435
+ visited: set[str] = set()
436
+ parent: dict[str, str] = {}
437
+ queue: deque[str] = deque()
438
+
439
+ for gateway in gateways:
440
+ if gateway in adjacency:
441
+ visited.add(gateway)
442
+ queue.append(gateway)
443
+
444
+ while queue:
445
+ current = queue.popleft()
446
+ for neighbor in sorted(adjacency.get(current, set())):
447
+ if neighbor in visited:
448
+ continue
449
+ visited.add(neighbor)
450
+ parent[neighbor] = current
451
+ queue.append(neighbor)
452
+ return parent
453
+
454
+
455
+ def _tree_edges_from_parent(
456
+ parent: dict[str, str], edge_map: dict[frozenset[str], Edge]
457
+ ) -> list[Edge]:
458
+ """Build tree edges from parent map."""
459
+ tree_edges: list[Edge] = []
460
+ for child in sorted(parent):
461
+ parent_name = parent[child]
462
+ original = edge_map.get(frozenset({child, parent_name}))
463
+ if original is None:
464
+ tree_edges.append(Edge(left=parent_name, right=child))
465
+ else:
466
+ tree_edges.append(
467
+ Edge(
468
+ left=parent_name,
469
+ right=child,
470
+ label=original.label,
471
+ poe=original.poe,
472
+ wireless=original.wireless,
473
+ speed=original.speed,
474
+ channel=original.channel,
475
+ vlans=original.vlans,
476
+ active_vlans=original.active_vlans,
477
+ is_trunk=original.is_trunk,
478
+ )
479
+ )
480
+ return tree_edges
481
+
482
+
483
+ def build_tree_edges_by_topology(edges: Iterable[Edge], gateways: list[str]) -> list[Edge]:
484
+ """Build tree edges rooted at gateways using BFS."""
485
+ if not gateways:
486
+ return []
487
+ adjacency = _build_adjacency(edges)
488
+ edge_map = _build_edge_map(edges)
489
+ parent = _tree_parents(adjacency, gateways)
490
+ return _tree_edges_from_parent(parent, edge_map)
491
+
492
+
493
+ def enrich_edges_with_active_vlans(
494
+ edges: list[Edge],
495
+ client_edges: list[Edge],
496
+ ) -> list[Edge]:
497
+ """Add active_vlans to edges based on client traffic."""
498
+ device_active_vlans: dict[str, set[int]] = {}
499
+ for client_edge in client_edges:
500
+ device_name = client_edge.left
501
+ for vlan in client_edge.active_vlans:
502
+ device_active_vlans.setdefault(device_name, set()).add(vlan)
503
+
504
+ enriched: list[Edge] = []
505
+ for edge in edges:
506
+ left_active = device_active_vlans.get(edge.left, set())
507
+ right_active = device_active_vlans.get(edge.right, set())
508
+ combined_active = left_active | right_active
509
+ active_vlans = tuple(sorted(set(edge.vlans) & combined_active))
510
+ enriched.append(
511
+ Edge(
512
+ left=edge.left,
513
+ right=edge.right,
514
+ label=edge.label,
515
+ poe=edge.poe,
516
+ wireless=edge.wireless,
517
+ speed=edge.speed,
518
+ channel=edge.channel,
519
+ vlans=edge.vlans,
520
+ active_vlans=active_vlans,
521
+ is_trunk=edge.is_trunk,
522
+ )
523
+ )
524
+ return enriched
525
+
526
+
527
+ def build_topology(
528
+ devices: Iterable[Device],
529
+ *,
530
+ include_ports: bool,
531
+ only_unifi: bool,
532
+ gateways: list[str],
533
+ ) -> TopologyResult:
534
+ """Build complete topology from devices."""
535
+ normalized_devices = list(devices)
536
+ lldp_entries = sum(len(device.lldp_info) for device in normalized_devices)
537
+ logger.debug(
538
+ "Normalized %d devices (%d LLDP entries)",
539
+ len(normalized_devices),
540
+ lldp_entries,
541
+ )
542
+ raw_edges = build_edges(normalized_devices, include_ports=include_ports, only_unifi=only_unifi)
543
+ tree_edges = build_tree_edges_by_topology(raw_edges, gateways)
544
+ logger.debug(
545
+ "Built %d hierarchy edges (gateways=%d)",
546
+ len(tree_edges),
547
+ len(gateways),
548
+ )
549
+ return TopologyResult(raw_edges=raw_edges, tree_edges=tree_edges)
550
+
551
+
552
+ def group_devices_by_type(devices: Iterable[Device]) -> dict[str, list[str]]:
553
+ """Group devices by their type."""
554
+ groups: dict[str, list[str]] = {"gateway": [], "switch": [], "ap": [], "other": []}
555
+ for device in devices:
556
+ group = classify_device_type(device)
557
+ groups[group].append(device.name)
558
+ return groups
@@ -0,0 +1,64 @@
1
+ """Shared low-level helpers for the model layer.
2
+
3
+ These tiny pure functions are used across multiple model modules.
4
+ Centralising them here avoids circular-import issues and duplication.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Iterable
10
+
11
+
12
+ def as_list(value: object | None) -> list[object]:
13
+ """Coerce *value* to a list, handling dicts, iterables, and None."""
14
+ if value is None:
15
+ return []
16
+ if isinstance(value, list):
17
+ return value
18
+ if isinstance(value, dict):
19
+ return [value]
20
+ if isinstance(value, str | bytes):
21
+ return []
22
+ if isinstance(value, Iterable):
23
+ return list(value)
24
+ return []
25
+
26
+
27
+ def as_bool(value: object | None) -> bool:
28
+ """Coerce *value* to a boolean."""
29
+ if isinstance(value, bool):
30
+ return value
31
+ if isinstance(value, int | float):
32
+ return value != 0
33
+ if isinstance(value, str):
34
+ return value.strip().lower() in {"1", "true", "yes", "y", "on"}
35
+ return False
36
+
37
+
38
+ def first_attr(obj: object, *names: str) -> object | None:
39
+ """Return the first non-None field value from *names*."""
40
+ for name in names:
41
+ value = get_field(obj, name)
42
+ if value is not None:
43
+ return value
44
+ return None
45
+
46
+
47
+ def first_string_field(obj: object, *keys: str) -> str | None:
48
+ """Return the first non-empty stripped string from *keys*."""
49
+ for key in keys:
50
+ value = get_field(obj, key)
51
+ if isinstance(value, str) and value.strip():
52
+ return value.strip()
53
+ return None
54
+
55
+
56
+ def normalize_mac(value: str) -> str:
57
+ return value.strip().lower()
58
+
59
+
60
+ def get_field(obj: object, name: str) -> object | None:
61
+ """Read a named field from a dict **or** an attribute-style object."""
62
+ if isinstance(obj, dict):
63
+ return obj.get(name)
64
+ return getattr(obj, name, None)
@@ -16,33 +16,28 @@ class LLDPEntry:
16
16
  local_port_idx: int | None = None
17
17
 
18
18
 
19
- def coerce_lldp(entry: object) -> LLDPEntry:
19
+ def _get_field(entry: object, *names: str) -> str | int | None:
20
+ """Get a field by trying multiple names (snake_case and camelCase variants)."""
20
21
  if isinstance(entry, dict):
21
- chassis_id = entry.get("chassis_id") or entry.get("chassisId")
22
- port_id = entry.get("port_id") or entry.get("portId")
23
- port_desc = (
24
- entry.get("port_desc")
25
- or entry.get("portDesc")
26
- or entry.get("port_descr")
27
- or entry.get("portDescr")
28
- )
29
- local_port_name = entry.get("local_port_name") or entry.get("localPortName")
30
- local_port_idx = entry.get("local_port_idx") or entry.get("localPortIdx")
22
+ for name in names:
23
+ val = entry.get(name)
24
+ if val is not None:
25
+ return val # type: ignore[return-value]
31
26
  else:
32
- chassis_id = getattr(entry, "chassis_id", None) or getattr(entry, "chassisId", None)
33
- port_id = getattr(entry, "port_id", None) or getattr(entry, "portId", None)
34
- port_desc = (
35
- getattr(entry, "port_desc", None)
36
- or getattr(entry, "portDesc", None)
37
- or getattr(entry, "port_descr", None)
38
- or getattr(entry, "portDescr", None)
39
- )
40
- local_port_name = getattr(entry, "local_port_name", None) or getattr(
41
- entry, "localPortName", None
42
- )
43
- local_port_idx = getattr(entry, "local_port_idx", None) or getattr(
44
- entry, "localPortIdx", None
45
- )
27
+ for name in names:
28
+ val = getattr(entry, name, None)
29
+ if val is not None:
30
+ return val # type: ignore[return-value]
31
+ return None
32
+
33
+
34
+ def coerce_lldp(entry: object) -> LLDPEntry:
35
+ chassis_id = _get_field(entry, "chassis_id", "chassisId")
36
+ port_id = _get_field(entry, "port_id", "portId")
37
+ port_desc = _get_field(entry, "port_desc", "portDesc", "port_descr", "portDescr")
38
+ local_port_name = _get_field(entry, "local_port_name", "localPortName")
39
+ local_port_idx = _get_field(entry, "local_port_idx", "localPortIdx")
40
+
46
41
  if not chassis_id or not port_id:
47
42
  raise ValueError("LLDP entry missing chassis_id or port_id")
48
43
  return LLDPEntry(