unifi-network-maps 1.4.15__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 +80 -96
  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 -951
  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.15.dist-info → unifi_network_maps-1.5.0.dist-info}/METADATA +107 -31
  69. {unifi_network_maps-1.4.15.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.15.dist-info → unifi_network_maps-1.5.0.dist-info}/WHEEL +0 -0
  72. {unifi_network_maps-1.4.15.dist-info → unifi_network_maps-1.5.0.dist-info}/entry_points.txt +0 -0
  73. {unifi_network_maps-1.4.15.dist-info → unifi_network_maps-1.5.0.dist-info}/licenses/LICENSE +0 -0
  74. {unifi_network_maps-1.4.15.dist-info → unifi_network_maps-1.5.0.dist-info}/top_level.txt +0 -0
@@ -1,21 +1,58 @@
1
- """Topology normalization and edge construction."""
1
+ """Topology data classes and type definitions.
2
+
3
+ This module contains the core data structures for representing network topology.
4
+ For functions, see:
5
+ - classify: Device/client type classification
6
+ - edges: Edge building and topology construction
7
+ - clients: Client handling
8
+ - wan: WAN interface extraction
9
+ """
2
10
 
3
11
  from __future__ import annotations
4
12
 
5
- import logging
6
- from collections import deque
7
13
  from collections.abc import Iterable
8
14
  from dataclasses import dataclass, field
15
+ from typing import TYPE_CHECKING
16
+
17
+ from .helpers import normalize_mac
18
+ from .lldp import LLDPEntry
19
+
20
+ if TYPE_CHECKING:
21
+ from .connection import ConnectionInfo
22
+ from .diff import TopologyDiff
9
23
 
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
24
 
14
- logger = logging.getLogger(__name__)
25
+ @dataclass(frozen=True)
26
+ class UplinkInfo:
27
+ """Information about a device's uplink connection."""
28
+
29
+ mac: str | None
30
+ name: str | None
31
+ port: int | None
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class PortInfo:
36
+ """Information about a switch/gateway port."""
37
+
38
+ port_idx: int | None
39
+ name: str | None
40
+ ifname: str | None
41
+ speed: int | None
42
+ aggregation_group: str | None
43
+ port_poe: bool
44
+ poe_enable: bool
45
+ poe_good: bool
46
+ poe_power: float | None
47
+ native_vlan: int | None = None
48
+ tagged_vlans: tuple[int, ...] = ()
49
+ wan_networkconf_id: str | None = None
15
50
 
16
51
 
17
52
  @dataclass(frozen=True)
18
53
  class Device:
54
+ """A network device (gateway, switch, or access point)."""
55
+
19
56
  name: str
20
57
  model_name: str
21
58
  model: str
@@ -32,6 +69,8 @@ class Device:
32
69
 
33
70
  @dataclass(frozen=True)
34
71
  class Edge:
72
+ """A connection between two nodes in the topology."""
73
+
35
74
  left: str
36
75
  right: str
37
76
  label: str | None = None
@@ -39,975 +78,128 @@ class Edge:
39
78
  wireless: bool = False
40
79
  speed: int | None = None
41
80
  channel: int | None = None
81
+ vlans: tuple[int, ...] = ()
82
+ active_vlans: tuple[int, ...] = ()
83
+ is_trunk: bool = False
84
+ connection: ConnectionInfo | None = None
42
85
 
43
86
 
44
- type DeviceSource = object
87
+ @dataclass(frozen=True)
88
+ class WanInterface:
89
+ """Information about a WAN interface on a gateway."""
90
+
91
+ port_idx: int
92
+ link_speed: int | None
93
+ ip_address: str | None
94
+ enabled: bool
95
+ label: str | None = None
96
+ isp_speed: str | None = None
45
97
 
46
98
 
47
99
  @dataclass(frozen=True)
48
- class UplinkInfo:
49
- mac: str | None
50
- name: str | None
51
- port: int | None
100
+ class WanInfo:
101
+ """WAN interface information for a gateway device."""
102
+
103
+ wan1: WanInterface | None = None
104
+ wan2: WanInterface | None = None
52
105
 
53
106
 
54
107
  @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
108
+ class TopologyResult:
109
+ """Result of building a topology."""
110
+
111
+ raw_edges: list[Edge]
112
+ tree_edges: list[Edge]
65
113
 
66
114
 
115
+ # Type aliases for maps used in edge building
116
+ type DeviceSource = object
67
117
  type PortMap = dict[tuple[str, str], str]
68
118
  type PoeMap = dict[tuple[str, str], bool]
69
119
  type SpeedMap = dict[tuple[str, str], int]
70
120
  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 _get_model_display_name(device: DeviceSource) -> str | None:
306
- """Extract the human-readable model name from device data.
307
-
308
- UniFi stores the friendly model name (e.g., 'USW Flex 2.5G 8 PoE') in various
309
- fields depending on controller version. This function checks multiple candidates
310
- and returns the first non-empty value found.
311
- """
312
- candidates = (
313
- "model_in_lts",
314
- "model_in_eol",
315
- "shortname",
316
- "model_name",
317
- )
318
- for key in candidates:
319
- value = _get_attr(device, key)
320
- if isinstance(value, str) and value.strip():
321
- return value.strip()
322
- return None
323
-
324
-
325
- def coerce_device(device: DeviceSource) -> Device:
326
- name = _get_attr(device, "name")
327
- model_name = _get_model_display_name(device) or _get_attr(device, "model")
328
- model = _get_attr(device, "model")
329
- mac = _get_attr(device, "mac")
330
- ip = _get_attr(device, "ip") or _get_attr(device, "ip_address")
331
- dev_type = _get_attr(device, "type") or _get_attr(device, "device_type")
332
- version = _get_attr(device, "displayable_version") or _get_attr(device, "version")
333
- lldp_info = _get_attr(device, "lldp_info")
334
- if lldp_info is None:
335
- lldp_info = _get_attr(device, "lldp")
336
- if lldp_info is None:
337
- lldp_info = _get_attr(device, "lldp_table")
338
-
339
- if not name or not mac:
340
- raise ValueError("Device missing name or mac")
341
- uplink, last_uplink = _uplink_info(device)
342
- if lldp_info is None:
343
- if uplink or last_uplink:
344
- logger.warning("Device %s missing LLDP info; using uplink fallback", name)
345
- lldp_info = []
346
- else:
347
- raise ValueError(f"Device {name} missing LLDP info")
348
-
349
- lldp_entries = _as_list(lldp_info)
350
- coerced_lldp = [coerce_lldp(lldp_entry) for lldp_entry in lldp_entries]
351
- port_table = _coerce_port_table(device)
352
- poe_ports = _poe_ports_from_device(device)
353
-
354
- return Device(
355
- name=str(name),
356
- model_name=str(model_name or ""),
357
- model=str(model or ""),
358
- mac=str(mac),
359
- ip=str(ip or ""),
360
- type=str(dev_type or ""),
361
- lldp_info=coerced_lldp,
362
- port_table=port_table,
363
- poe_ports=poe_ports,
364
- uplink=uplink,
365
- last_uplink=last_uplink,
366
- version=str(version or ""),
367
- )
368
-
369
-
370
- def normalize_devices(devices: Iterable[DeviceSource]) -> list[Device]:
371
- return [coerce_device(device) for device in devices]
372
-
373
-
374
- def classify_device_type(device: object) -> str:
375
- raw_type = _device_field(device, "type")
376
- raw_name = _device_field(device, "name")
377
- value = raw_type.strip().lower() if isinstance(raw_type, str) else ""
378
- if not value:
379
- name = raw_name.strip().lower() if isinstance(raw_name, str) else ""
380
- if "gateway" in name or name.startswith("gw"):
381
- return "gateway"
382
- if "switch" in name:
383
- return "switch"
384
- if "ap" in name:
385
- return "ap"
386
- if value in {"gateway", "ugw", "usg", "ux", "udm", "udr"}:
387
- return "gateway"
388
- if value in {"switch", "usw"}:
389
- return "switch"
390
- if value in {"uap", "ap"} or "ap" in value:
391
- return "ap"
392
- return "other"
393
-
394
-
395
- def group_devices_by_type(devices: Iterable[Device]) -> dict[str, list[str]]:
396
- groups: dict[str, list[str]] = {"gateway": [], "switch": [], "ap": [], "other": []}
397
- for device in devices:
398
- group = classify_device_type(device)
399
- groups[group].append(device.name)
400
- return groups
401
-
402
-
403
- def _build_adjacency(edges: Iterable[Edge]) -> dict[str, set[str]]:
404
- adjacency: dict[str, set[str]] = {}
405
- for edge in edges:
406
- adjacency.setdefault(edge.left, set()).add(edge.right)
407
- adjacency.setdefault(edge.right, set()).add(edge.left)
408
- return adjacency
409
-
410
-
411
- def _build_edge_map(edges: Iterable[Edge]) -> dict[frozenset[str], Edge]:
412
- return {frozenset({edge.left, edge.right}): edge for edge in edges}
413
-
414
-
415
- def _tree_parents(adjacency: dict[str, set[str]], gateways: list[str]) -> dict[str, str]:
416
- visited: set[str] = set()
417
- parent: dict[str, str] = {}
418
- queue: deque[str] = deque()
419
-
420
- for gateway in gateways:
421
- if gateway in adjacency:
422
- visited.add(gateway)
423
- queue.append(gateway)
424
-
425
- while queue:
426
- current = queue.popleft()
427
- for neighbor in sorted(adjacency.get(current, set())):
428
- if neighbor in visited:
429
- continue
430
- visited.add(neighbor)
431
- parent[neighbor] = current
432
- queue.append(neighbor)
433
- return parent
434
-
435
-
436
- def _tree_edges_from_parent(
437
- parent: dict[str, str], edge_map: dict[frozenset[str], Edge]
438
- ) -> list[Edge]:
439
- tree_edges: list[Edge] = []
440
- for child in sorted(parent):
441
- parent_name = parent[child]
442
- original = edge_map.get(frozenset({child, parent_name}))
443
- if original is None:
444
- tree_edges.append(Edge(left=parent_name, right=child))
445
- else:
446
- tree_edges.append(
447
- Edge(
448
- left=parent_name,
449
- right=child,
450
- label=original.label,
451
- poe=original.poe,
452
- wireless=original.wireless,
453
- speed=original.speed,
454
- channel=original.channel,
455
- )
456
- )
457
- return tree_edges
458
-
459
-
460
- def build_tree_edges_by_topology(edges: Iterable[Edge], gateways: list[str]) -> list[Edge]:
461
- if not gateways:
462
- return []
463
- adjacency = _build_adjacency(edges)
464
- edge_map = _build_edge_map(edges)
465
- parent = _tree_parents(adjacency, gateways)
466
- return _tree_edges_from_parent(parent, edge_map)
121
+ type VlanMap = dict[tuple[str, str], tuple[int, ...]]
467
122
 
468
123
 
469
124
  def build_device_index(devices: Iterable[Device]) -> dict[str, str]:
125
+ """Build MAC to name index for devices."""
470
126
  index: dict[str, str] = {}
471
127
  for device in devices:
472
- index[_normalize_mac(device.mac)] = device.name
128
+ index[normalize_mac(device.mac)] = device.name
473
129
  return index
474
130
 
475
131
 
476
- def _client_field(client: object, name: str) -> object | None:
477
- if isinstance(client, dict):
478
- return client.get(name)
479
- return getattr(client, name, None)
480
-
481
-
482
- def _client_display_name(client: object) -> str | None:
483
- raw_name = _client_field(client, "name")
484
- if isinstance(raw_name, str) and raw_name.strip():
485
- return raw_name.strip()
486
- preferred = _client_ucore_display_name(client)
487
- if preferred:
488
- return preferred
489
- for key in ("hostname", "mac"):
490
- value = _client_field(client, key)
491
- if isinstance(value, str) and value.strip():
492
- return value.strip()
493
- return None
494
-
495
-
496
- def _client_uplink_mac(client: object) -> str | None:
497
- for key in ("ap_mac", "sw_mac", "uplink_mac", "uplink_device_mac", "last_uplink_mac"):
498
- value = _client_field(client, key)
499
- if isinstance(value, str) and value.strip():
500
- return value.strip()
501
- for key in ("uplink", "last_uplink"):
502
- nested = _client_field(client, key)
503
- if isinstance(nested, dict):
504
- value = nested.get("uplink_mac") or nested.get("uplink_device_mac")
505
- if isinstance(value, str) and value.strip():
506
- return value.strip()
507
- return None
508
-
509
-
510
- def _client_uplink_port(client: object) -> int | None:
511
- for value in _client_port_values(client):
512
- parsed = _parse_port_value(value)
513
- if parsed is not None:
514
- return parsed
515
- return None
516
-
517
-
518
- def _client_port_values(client: object) -> Iterable[object | None]:
519
- for key in ("uplink_remote_port", "sw_port", "ap_port", "port_idx"):
520
- yield _client_field(client, key)
521
- for key in ("uplink", "last_uplink"):
522
- nested = _client_field(client, key)
523
- if isinstance(nested, dict):
524
- for nested_key in ("uplink_remote_port", "port_idx"):
525
- yield nested.get(nested_key)
526
-
527
-
528
- def _parse_port_value(value: object | None) -> int | None:
529
- if isinstance(value, int):
530
- return value
531
- if isinstance(value, str):
532
- stripped = value.strip()
533
- if stripped.isdigit():
534
- return int(stripped)
535
- return extract_port_number(stripped)
536
- return None
537
-
538
-
539
- def _client_is_wired(client: object) -> bool:
540
- return bool(_client_field(client, "is_wired"))
541
-
542
-
543
- def _client_unifi_flag(client: object) -> bool | None:
544
- for key in ("is_unifi", "is_unifi_device", "is_ubnt", "is_uap", "is_managed"):
545
- value = _client_field(client, key)
546
- if isinstance(value, bool):
547
- return value
548
- if isinstance(value, int):
549
- return value != 0
550
- return None
551
-
552
-
553
- def _client_vendor(client: object) -> str | None:
554
- for key in ("oui", "vendor", "vendor_name", "manufacturer", "manufacturer_name"):
555
- value = _client_field(client, key)
556
- if isinstance(value, str) and value.strip():
557
- return value.strip()
558
- return None
559
-
560
-
561
- def _client_ucore_info(client: object) -> dict[str, object] | None:
562
- info = _client_field(client, "unifi_device_info_from_ucore")
563
- if isinstance(info, dict):
564
- return info
565
- return None
566
-
567
-
568
- def _client_ucore_display_name(client: object) -> str | None:
569
- ucore = _client_ucore_info(client)
570
- if not ucore:
571
- return None
572
- for key in ("name", "computed_model", "product_model", "product_shortname"):
573
- value = ucore.get(key)
574
- if isinstance(value, str) and value.strip():
575
- return value.strip()
576
- return None
577
-
578
-
579
- def _client_hostname_source(client: object) -> str | None:
580
- value = _client_field(client, "hostname_source")
581
- if isinstance(value, str) and value.strip():
582
- return value.strip()
583
- return None
584
-
585
-
586
- def _client_is_unifi(client: object) -> bool:
587
- flag = _client_unifi_flag(client)
588
- if flag is not None:
589
- return flag
590
- ucore = _client_ucore_info(client)
591
- if ucore:
592
- managed = ucore.get("managed")
593
- if isinstance(managed, bool) and managed:
594
- return True
595
- if isinstance(ucore.get("product_line"), str) and ucore.get("product_line"):
596
- return True
597
- if isinstance(ucore.get("product_shortname"), str) and ucore.get("product_shortname"):
598
- return True
599
- for key in ("name", "computed_model", "product_model"):
600
- value = ucore.get(key)
601
- if isinstance(value, str) and value.strip():
602
- return True
603
- vendor = _client_vendor(client)
604
- if not vendor:
605
- return False
606
- normalized = vendor.lower()
607
- return "ubiquiti" in normalized or "unifi" in normalized
608
-
609
-
610
- def _client_channel(client: object) -> int | None:
611
- for key in ("channel", "radio_channel", "wifi_channel"):
612
- value = _client_field(client, key)
613
- if isinstance(value, int):
614
- return value
615
- if isinstance(value, str) and value.isdigit():
616
- return int(value)
617
- return None
618
-
619
-
620
- def _client_matches_mode(client: object, mode: str) -> bool:
621
- wired = _client_is_wired(client)
622
- if mode == "all":
623
- return True
624
- if mode == "wireless":
625
- return not wired
626
- return wired
627
-
628
-
629
- def _client_matches_filters(client: object, *, client_mode: str, only_unifi: bool) -> bool:
630
- if not _client_matches_mode(client, client_mode):
631
- return False
632
- if only_unifi and not _client_is_unifi(client):
633
- return False
634
- return True
635
-
636
-
637
- def build_client_edges(
638
- clients: Iterable[object],
639
- device_index: dict[str, str],
640
- *,
641
- include_ports: bool = False,
642
- client_mode: str = "wired",
643
- only_unifi: bool = False,
644
- ) -> list[Edge]:
645
- edges: list[Edge] = []
646
- seen: set[tuple[str, str]] = set()
647
- for client in clients:
648
- if not _client_matches_filters(client, client_mode=client_mode, only_unifi=only_unifi):
649
- continue
650
- name = _client_display_name(client)
651
- uplink_mac = _client_uplink_mac(client)
652
- if not name or not uplink_mac:
653
- continue
654
- device_name = device_index.get(_normalize_mac(uplink_mac))
655
- if not device_name:
656
- continue
657
- label = None
658
- if include_ports:
659
- uplink_port = _client_uplink_port(client)
660
- if uplink_port is not None:
661
- label = f"{device_name}: Port {uplink_port} <-> {name}"
662
- key = (device_name, name)
663
- if key in seen:
664
- continue
665
- is_wireless = not _client_is_wired(client)
666
- channel = _client_channel(client) if is_wireless else None
667
- edges.append(
668
- Edge(
669
- left=device_name,
670
- right=name,
671
- label=label,
672
- wireless=is_wireless,
673
- channel=channel,
674
- )
675
- )
676
- seen.add(key)
677
- return edges
678
-
679
-
680
- def build_node_type_map(
681
- devices: Iterable[Device],
682
- clients: Iterable[object] | None = None,
683
- *,
684
- client_mode: str = "wired",
685
- only_unifi: bool = False,
686
- ) -> dict[str, str]:
687
- node_types: dict[str, str] = {}
688
- for device in devices:
689
- node_types[device.name] = classify_device_type(device)
690
- if clients:
691
- for client in clients:
692
- if not _client_matches_filters(client, client_mode=client_mode, only_unifi=only_unifi):
693
- continue
694
- name = _client_display_name(client)
695
- if name:
696
- node_types[name] = "client"
697
- return node_types
698
-
699
-
700
- def build_edges(
701
- devices: Iterable[Device],
702
- *,
703
- include_ports: bool = False,
704
- only_unifi: bool = True,
705
- ) -> list[Edge]:
706
- ordered_devices = sorted(devices, key=lambda item: (item.name.lower(), item.mac.lower()))
707
- index = build_device_index(ordered_devices)
708
- device_by_name = {device.name: device for device in ordered_devices}
709
- raw_links: list[tuple[str, str]] = []
710
- seen: set[frozenset[str]] = set()
711
- port_map: PortMap = {}
712
- poe_map: PoeMap = {}
713
- speed_map: SpeedMap = {}
714
-
715
- devices_with_lldp_edges = _collect_lldp_links(
716
- ordered_devices,
717
- index,
718
- port_map,
719
- poe_map,
720
- speed_map,
721
- raw_links,
722
- seen,
723
- only_unifi=only_unifi,
724
- )
725
- _collect_uplink_links(
726
- ordered_devices,
727
- devices_with_lldp_edges,
728
- index,
729
- device_by_name,
730
- port_map,
731
- raw_links,
732
- seen,
733
- include_ports=include_ports,
734
- only_unifi=only_unifi,
735
- )
736
- edges = _build_ordered_edges(
737
- raw_links,
738
- port_map,
739
- poe_map,
740
- speed_map,
741
- device_by_name,
742
- include_ports=include_ports,
743
- )
744
-
745
- poe_edges = sum(1 for edge in edges if edge.poe)
746
- logger.debug("Built %d unique edges (%d PoE)", len(edges), poe_edges)
747
- return edges
748
-
749
-
750
- def build_port_map(devices: Iterable[Device], *, only_unifi: bool = True) -> PortMap:
751
- ordered_devices = sorted(devices, key=lambda item: (item.name.lower(), item.mac.lower()))
752
- index = build_device_index(ordered_devices)
753
- device_by_name = {device.name: device for device in ordered_devices}
754
- raw_links: list[tuple[str, str]] = []
755
- seen: set[frozenset[str]] = set()
756
- port_map: PortMap = {}
757
- poe_map: PoeMap = {}
758
- speed_map: SpeedMap = {}
759
-
760
- devices_with_lldp_edges = _collect_lldp_links(
761
- ordered_devices,
762
- index,
763
- port_map,
764
- poe_map,
765
- speed_map,
766
- raw_links,
767
- seen,
768
- only_unifi=only_unifi,
769
- )
770
- _collect_uplink_links(
771
- ordered_devices,
772
- devices_with_lldp_edges,
773
- index,
774
- device_by_name,
775
- port_map,
776
- raw_links,
777
- seen,
778
- include_ports=True,
779
- only_unifi=only_unifi,
780
- )
781
- return port_map
782
-
783
-
784
- def build_client_port_map(
785
- devices: Iterable[Device],
786
- clients: Iterable[object],
787
- *,
788
- client_mode: str,
789
- only_unifi: bool = False,
790
- ) -> ClientPortMap:
791
- device_index = build_device_index(devices)
792
- port_map: ClientPortMap = {}
793
- for client in clients:
794
- if not _client_matches_filters(client, client_mode=client_mode, only_unifi=only_unifi):
795
- continue
796
- name = _client_display_name(client)
797
- uplink_mac = _client_uplink_mac(client)
798
- uplink_port = _client_uplink_port(client)
799
- if not name or not uplink_mac or uplink_port is None:
800
- continue
801
- device_name = device_index.get(_normalize_mac(uplink_mac))
802
- if not device_name:
803
- continue
804
- port_map.setdefault(device_name, []).append((uplink_port, name))
805
- return port_map
806
-
807
-
808
- def _port_speed_by_idx(port_table: list[PortInfo], port_idx: int) -> int | None:
809
- for port in port_table:
810
- if port.port_idx == port_idx:
811
- return port.speed
812
- return None
813
-
814
-
815
- def _collect_lldp_links(
816
- devices: list[Device],
817
- index: dict[str, str],
818
- port_map: PortMap,
819
- poe_map: PoeMap,
820
- speed_map: SpeedMap,
821
- raw_links: list[tuple[str, str]],
822
- seen: set[frozenset[str]],
823
- *,
824
- only_unifi: bool,
825
- ) -> set[str]:
826
- devices_with_lldp_edges: set[str] = set()
827
- for device in devices:
828
- poe_ports = device.poe_ports
829
- for lldp_entry in sorted(
830
- device.lldp_info,
831
- key=lambda item: (
832
- _normalize_mac(item.chassis_id),
833
- str(item.port_id or ""),
834
- str(item.port_desc or ""),
835
- ),
836
- ):
837
- peer_mac = _normalize_mac(lldp_entry.chassis_id)
838
- peer_name = index.get(peer_mac)
839
- if peer_name is None:
840
- if only_unifi:
841
- continue
842
- peer_name = lldp_entry.chassis_id
843
-
844
- resolved_port_idx = _resolve_port_idx_from_lldp(lldp_entry, device.port_table)
845
- entry_for_label = (
846
- LLDPEntry(
847
- chassis_id=lldp_entry.chassis_id,
848
- port_id=lldp_entry.port_id,
849
- port_desc=lldp_entry.port_desc,
850
- local_port_name=lldp_entry.local_port_name,
851
- local_port_idx=resolved_port_idx,
852
- )
853
- if resolved_port_idx is not None
854
- else lldp_entry
855
- )
856
- label = local_port_label(entry_for_label)
857
- if label:
858
- port_map[(device.name, peer_name)] = label
859
- if resolved_port_idx is not None:
860
- if resolved_port_idx in poe_ports:
861
- poe_map[(device.name, peer_name)] = poe_ports[resolved_port_idx]
862
- port_speed = _port_speed_by_idx(device.port_table, resolved_port_idx)
863
- if port_speed is not None:
864
- speed_map[(device.name, peer_name)] = port_speed
865
-
866
- key = frozenset({device.name, peer_name})
867
- if key in seen:
868
- continue
869
-
870
- raw_links.append((device.name, peer_name))
871
- seen.add(key)
872
- devices_with_lldp_edges.add(device.name)
873
- return devices_with_lldp_edges
874
-
875
-
876
- def _uplink_name(
877
- uplink: UplinkInfo | None,
878
- index: dict[str, str],
879
- *,
880
- only_unifi: bool,
881
- ) -> str | None:
882
- if not uplink:
883
- return None
884
- if uplink.mac:
885
- resolved = index.get(_normalize_mac(uplink.mac))
886
- if resolved:
887
- return resolved
888
- if uplink.name:
889
- return uplink.name
890
- if not only_unifi and uplink.mac:
891
- return uplink.mac
892
- return None
893
-
894
-
895
- def _maybe_add_uplink_link(
896
- device: Device,
897
- upstream_name: str,
898
- *,
899
- uplink: UplinkInfo | None,
900
- device_by_name: dict[str, Device],
901
- port_map: PortMap,
902
- raw_links: list[tuple[str, str]],
903
- seen: set[frozenset[str]],
904
- include_ports: bool,
905
- ) -> None:
906
- key = frozenset({device.name, upstream_name})
907
- if key in seen:
908
- return
909
- if uplink and uplink.port is not None:
910
- if include_ports:
911
- port_map[(upstream_name, device.name)] = f"Port {uplink.port}"
912
- raw_links.append((upstream_name, device.name))
913
- seen.add(key)
914
-
915
-
916
- def _collect_uplink_links(
917
- devices: list[Device],
918
- devices_with_lldp_edges: set[str],
919
- index: dict[str, str],
920
- device_by_name: dict[str, Device],
921
- port_map: PortMap,
922
- raw_links: list[tuple[str, str]],
923
- seen: set[frozenset[str]],
924
- *,
925
- include_ports: bool,
926
- only_unifi: bool,
927
- ) -> None:
928
- for device in devices:
929
- if device.name in devices_with_lldp_edges:
930
- continue
931
- uplink = device.uplink or device.last_uplink
932
- upstream_name = _uplink_name(uplink, index, only_unifi=only_unifi)
933
- if not upstream_name:
934
- continue
935
- if only_unifi and upstream_name not in device_by_name:
936
- continue
937
- _maybe_add_uplink_link(
938
- device,
939
- upstream_name,
940
- uplink=uplink,
941
- device_by_name=device_by_name,
942
- port_map=port_map,
943
- raw_links=raw_links,
944
- seen=seen,
945
- include_ports=include_ports,
946
- )
132
+ # --- Topology class for serialization and diff ---
947
133
 
948
134
 
949
- def _build_ordered_edges(
950
- raw_links: list[tuple[str, str]],
951
- port_map: PortMap,
952
- poe_map: PoeMap,
953
- speed_map: SpeedMap,
954
- device_by_name: dict[str, Device],
955
- *,
956
- include_ports: bool,
957
- ) -> list[Edge]:
958
- type_rank = {"gateway": 0, "switch": 1, "ap": 2, "other": 3}
959
-
960
- def _rank_for_name(name: str) -> int:
961
- device = device_by_name.get(name)
962
- if not device:
963
- return 3
964
- return type_rank.get(classify_device_type(device), 3)
965
-
966
- edges: list[Edge] = []
967
- for source_name, target_name in raw_links:
968
- left_name = source_name
969
- right_name = target_name
970
- if include_ports:
971
- left_name, right_name = order_edge_names(
972
- left_name,
973
- right_name,
974
- port_map,
975
- _rank_for_name,
976
- )
977
- poe = poe_map.get((left_name, right_name), False) or poe_map.get(
978
- (right_name, left_name), False
979
- )
980
- speed = speed_map.get((left_name, right_name)) or speed_map.get((right_name, left_name))
981
- label = compose_port_label(left_name, right_name, port_map) if include_ports else None
982
- edges.append(Edge(left=left_name, right=right_name, label=label, poe=poe, speed=speed))
983
- return edges
135
+ @dataclass
136
+ class Topology:
137
+ """A complete network topology snapshot for serialization and comparison."""
984
138
 
139
+ devices: list[Device] = field(default_factory=list)
140
+ clients: list[dict[str, object]] = field(default_factory=list)
141
+ edges: list[Edge] = field(default_factory=list)
142
+ timestamp: str | None = None
985
143
 
986
- @dataclass(frozen=True)
987
- class TopologyResult:
988
- raw_edges: list[Edge]
989
- tree_edges: list[Edge]
144
+ def to_dict(self) -> dict[str, object]:
145
+ """Serialize topology to a JSON-compatible dictionary."""
146
+ from .snapshot import client_to_dict, device_to_dict, edge_to_dict
147
+
148
+ return {
149
+ "version": 1,
150
+ "timestamp": self.timestamp,
151
+ "devices": [device_to_dict(d) for d in self.devices],
152
+ "clients": [client_to_dict(c) for c in self.clients], # type: ignore[arg-type]
153
+ "edges": [edge_to_dict(e) for e in self.edges],
154
+ }
155
+
156
+ @classmethod
157
+ def from_dict(cls, data: dict[str, object]) -> Topology:
158
+ """Deserialize topology from a dictionary."""
159
+ from .snapshot import client_from_dict, device_from_dict, edge_from_dict
160
+
161
+ devices_data = data.get("devices", [])
162
+ clients_data = data.get("clients", [])
163
+ edges_data = data.get("edges", [])
164
+
165
+ devices = [device_from_dict(d) for d in devices_data] # type: ignore[arg-type]
166
+ clients = [client_from_dict(c) for c in clients_data] # type: ignore[arg-type]
167
+ edges = [edge_from_dict(e) for e in edges_data] # type: ignore[arg-type]
168
+
169
+ timestamp = data.get("timestamp")
170
+ return cls(
171
+ devices=devices,
172
+ clients=clients,
173
+ edges=edges,
174
+ timestamp=timestamp if isinstance(timestamp, str) else None,
175
+ )
176
+
177
+ def diff(self, other: Topology) -> TopologyDiff:
178
+ """Compare this topology with another and return differences."""
179
+ from .diff import compare_topologies
180
+
181
+ return compare_topologies(
182
+ old_devices=self.devices,
183
+ new_devices=other.devices,
184
+ old_clients=self.clients, # type: ignore[arg-type]
185
+ new_clients=other.clients, # type: ignore[arg-type]
186
+ old_edges=self.edges,
187
+ new_edges=other.edges,
188
+ old_timestamp=self.timestamp,
189
+ new_timestamp=other.timestamp,
190
+ )
990
191
 
991
192
 
992
- def build_topology(
993
- devices: Iterable[Device],
994
- *,
995
- include_ports: bool,
996
- only_unifi: bool,
997
- gateways: list[str],
998
- ) -> TopologyResult:
999
- normalized_devices = list(devices)
1000
- lldp_entries = sum(len(device.lldp_info) for device in normalized_devices)
1001
- logger.debug(
1002
- "Normalized %d devices (%d LLDP entries)",
1003
- len(normalized_devices),
1004
- lldp_entries,
1005
- )
1006
- raw_edges = build_edges(normalized_devices, include_ports=include_ports, only_unifi=only_unifi)
1007
- tree_edges = build_tree_edges_by_topology(raw_edges, gateways)
1008
- logger.debug(
1009
- "Built %d hierarchy edges (gateways=%d)",
1010
- len(tree_edges),
1011
- len(gateways),
1012
- )
1013
- return TopologyResult(raw_edges=raw_edges, tree_edges=tree_edges)
193
+ __all__ = [
194
+ # Data classes
195
+ "Device",
196
+ "Edge",
197
+ "PortInfo",
198
+ "TopologyResult",
199
+ "Topology",
200
+ "UplinkInfo",
201
+ "WanInfo",
202
+ "WanInterface",
203
+ # Functions
204
+ "build_device_index",
205
+ ]