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
@@ -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,955 +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 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)
121
+ type VlanMap = dict[tuple[str, str], tuple[int, ...]]
447
122
 
448
123
 
449
124
  def build_device_index(devices: Iterable[Device]) -> dict[str, str]:
125
+ """Build MAC to name index for devices."""
450
126
  index: dict[str, str] = {}
451
127
  for device in devices:
452
- index[_normalize_mac(device.mac)] = device.name
128
+ index[normalize_mac(device.mac)] = device.name
453
129
  return index
454
130
 
455
131
 
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
- raw_name = _client_field(client, "name")
464
- if isinstance(raw_name, str) and raw_name.strip():
465
- return raw_name.strip()
466
- preferred = _client_ucore_display_name(client)
467
- if preferred:
468
- return preferred
469
- for key in ("hostname", "mac"):
470
- value = _client_field(client, key)
471
- if isinstance(value, str) and value.strip():
472
- return value.strip()
473
- return None
474
-
475
-
476
- def _client_uplink_mac(client: object) -> str | None:
477
- for key in ("ap_mac", "sw_mac", "uplink_mac", "uplink_device_mac", "last_uplink_mac"):
478
- value = _client_field(client, key)
479
- if isinstance(value, str) and value.strip():
480
- return value.strip()
481
- for key in ("uplink", "last_uplink"):
482
- nested = _client_field(client, key)
483
- if isinstance(nested, dict):
484
- value = nested.get("uplink_mac") or nested.get("uplink_device_mac")
485
- if isinstance(value, str) and value.strip():
486
- return value.strip()
487
- return None
488
-
489
-
490
- def _client_uplink_port(client: object) -> int | None:
491
- for value in _client_port_values(client):
492
- parsed = _parse_port_value(value)
493
- if parsed is not None:
494
- return parsed
495
- return None
496
-
497
-
498
- def _client_port_values(client: object) -> Iterable[object | None]:
499
- for key in ("uplink_remote_port", "sw_port", "ap_port", "port_idx"):
500
- yield _client_field(client, key)
501
- for key in ("uplink", "last_uplink"):
502
- nested = _client_field(client, key)
503
- if isinstance(nested, dict):
504
- for nested_key in ("uplink_remote_port", "port_idx"):
505
- yield nested.get(nested_key)
506
-
507
-
508
- def _parse_port_value(value: object | None) -> int | None:
509
- if isinstance(value, int):
510
- return value
511
- if isinstance(value, str):
512
- stripped = value.strip()
513
- if stripped.isdigit():
514
- return int(stripped)
515
- return extract_port_number(stripped)
516
- return None
517
-
518
-
519
- def _client_is_wired(client: object) -> bool:
520
- return bool(_client_field(client, "is_wired"))
521
-
522
-
523
- def _client_unifi_flag(client: object) -> bool | None:
524
- for key in ("is_unifi", "is_unifi_device", "is_ubnt", "is_uap", "is_managed"):
525
- value = _client_field(client, key)
526
- if isinstance(value, bool):
527
- return value
528
- if isinstance(value, int):
529
- return value != 0
530
- return None
531
-
532
-
533
- def _client_vendor(client: object) -> str | None:
534
- for key in ("oui", "vendor", "vendor_name", "manufacturer", "manufacturer_name"):
535
- value = _client_field(client, key)
536
- if isinstance(value, str) and value.strip():
537
- return value.strip()
538
- return None
539
-
540
-
541
- def _client_ucore_info(client: object) -> dict[str, object] | None:
542
- info = _client_field(client, "unifi_device_info_from_ucore")
543
- if isinstance(info, dict):
544
- return info
545
- return None
546
-
547
-
548
- def _client_ucore_display_name(client: object) -> str | None:
549
- ucore = _client_ucore_info(client)
550
- if not ucore:
551
- return None
552
- for key in ("name", "computed_model", "product_model", "product_shortname"):
553
- value = ucore.get(key)
554
- if isinstance(value, str) and value.strip():
555
- return value.strip()
556
- return None
557
-
558
-
559
- def _client_hostname_source(client: object) -> str | None:
560
- value = _client_field(client, "hostname_source")
561
- if isinstance(value, str) and value.strip():
562
- return value.strip()
563
- return None
564
-
565
-
566
- def _client_is_unifi(client: object) -> bool:
567
- flag = _client_unifi_flag(client)
568
- if flag is not None:
569
- return flag
570
- ucore = _client_ucore_info(client)
571
- if ucore:
572
- managed = ucore.get("managed")
573
- if isinstance(managed, bool) and managed:
574
- return True
575
- if isinstance(ucore.get("product_line"), str) and ucore.get("product_line"):
576
- return True
577
- if isinstance(ucore.get("product_shortname"), str) and ucore.get("product_shortname"):
578
- return True
579
- for key in ("name", "computed_model", "product_model"):
580
- value = ucore.get(key)
581
- if isinstance(value, str) and value.strip():
582
- return True
583
- vendor = _client_vendor(client)
584
- if not vendor:
585
- return False
586
- normalized = vendor.lower()
587
- return "ubiquiti" in normalized or "unifi" in normalized
588
-
589
-
590
- def _client_channel(client: object) -> int | None:
591
- for key in ("channel", "radio_channel", "wifi_channel"):
592
- value = _client_field(client, key)
593
- if isinstance(value, int):
594
- return value
595
- if isinstance(value, str) and value.isdigit():
596
- return int(value)
597
- return None
598
-
599
-
600
- def _client_matches_mode(client: object, mode: str) -> bool:
601
- wired = _client_is_wired(client)
602
- if mode == "all":
603
- return True
604
- if mode == "wireless":
605
- return not wired
606
- return wired
607
-
608
-
609
- def _client_matches_filters(client: object, *, client_mode: str, only_unifi: bool) -> bool:
610
- if not _client_matches_mode(client, client_mode):
611
- return False
612
- if only_unifi and not _client_is_unifi(client):
613
- return False
614
- return True
615
-
616
-
617
- def build_client_edges(
618
- clients: Iterable[object],
619
- device_index: dict[str, str],
620
- *,
621
- include_ports: bool = False,
622
- client_mode: str = "wired",
623
- only_unifi: bool = False,
624
- ) -> list[Edge]:
625
- edges: list[Edge] = []
626
- seen: set[tuple[str, str]] = set()
627
- for client in clients:
628
- if not _client_matches_filters(client, client_mode=client_mode, only_unifi=only_unifi):
629
- continue
630
- name = _client_display_name(client)
631
- uplink_mac = _client_uplink_mac(client)
632
- if not name or not uplink_mac:
633
- continue
634
- device_name = device_index.get(_normalize_mac(uplink_mac))
635
- if not device_name:
636
- continue
637
- label = None
638
- if include_ports:
639
- uplink_port = _client_uplink_port(client)
640
- if uplink_port is not None:
641
- label = f"{device_name}: Port {uplink_port} <-> {name}"
642
- key = (device_name, name)
643
- if key in seen:
644
- continue
645
- is_wireless = not _client_is_wired(client)
646
- channel = _client_channel(client) if is_wireless else None
647
- edges.append(
648
- Edge(
649
- left=device_name,
650
- right=name,
651
- label=label,
652
- wireless=is_wireless,
653
- channel=channel,
654
- )
655
- )
656
- seen.add(key)
657
- return edges
658
-
659
-
660
- def build_node_type_map(
661
- devices: Iterable[Device],
662
- clients: Iterable[object] | None = None,
663
- *,
664
- client_mode: str = "wired",
665
- only_unifi: bool = False,
666
- ) -> dict[str, str]:
667
- node_types: dict[str, str] = {}
668
- for device in devices:
669
- node_types[device.name] = classify_device_type(device)
670
- if clients:
671
- for client in clients:
672
- if not _client_matches_filters(client, client_mode=client_mode, only_unifi=only_unifi):
673
- continue
674
- name = _client_display_name(client)
675
- if name:
676
- node_types[name] = "client"
677
- return node_types
678
-
679
-
680
- def build_edges(
681
- devices: Iterable[Device],
682
- *,
683
- include_ports: bool = False,
684
- only_unifi: bool = True,
685
- ) -> list[Edge]:
686
- ordered_devices = sorted(devices, key=lambda item: (item.name.lower(), item.mac.lower()))
687
- index = build_device_index(ordered_devices)
688
- device_by_name = {device.name: device for device in ordered_devices}
689
- raw_links: list[tuple[str, str]] = []
690
- seen: set[frozenset[str]] = set()
691
- port_map: PortMap = {}
692
- poe_map: PoeMap = {}
693
- speed_map: SpeedMap = {}
694
-
695
- devices_with_lldp_edges = _collect_lldp_links(
696
- ordered_devices,
697
- index,
698
- port_map,
699
- poe_map,
700
- speed_map,
701
- raw_links,
702
- seen,
703
- only_unifi=only_unifi,
704
- )
705
- _collect_uplink_links(
706
- ordered_devices,
707
- devices_with_lldp_edges,
708
- index,
709
- device_by_name,
710
- port_map,
711
- raw_links,
712
- seen,
713
- include_ports=include_ports,
714
- only_unifi=only_unifi,
715
- )
716
- edges = _build_ordered_edges(
717
- raw_links,
718
- port_map,
719
- poe_map,
720
- speed_map,
721
- device_by_name,
722
- include_ports=include_ports,
723
- )
724
-
725
- poe_edges = sum(1 for edge in edges if edge.poe)
726
- logger.debug("Built %d unique edges (%d PoE)", len(edges), poe_edges)
727
- return edges
728
-
729
-
730
- def build_port_map(devices: Iterable[Device], *, only_unifi: bool = True) -> PortMap:
731
- ordered_devices = sorted(devices, key=lambda item: (item.name.lower(), item.mac.lower()))
732
- index = build_device_index(ordered_devices)
733
- device_by_name = {device.name: device for device in ordered_devices}
734
- raw_links: list[tuple[str, str]] = []
735
- seen: set[frozenset[str]] = set()
736
- port_map: PortMap = {}
737
- poe_map: PoeMap = {}
738
- speed_map: SpeedMap = {}
739
-
740
- devices_with_lldp_edges = _collect_lldp_links(
741
- ordered_devices,
742
- index,
743
- port_map,
744
- poe_map,
745
- speed_map,
746
- raw_links,
747
- seen,
748
- only_unifi=only_unifi,
749
- )
750
- _collect_uplink_links(
751
- ordered_devices,
752
- devices_with_lldp_edges,
753
- index,
754
- device_by_name,
755
- port_map,
756
- raw_links,
757
- seen,
758
- include_ports=True,
759
- only_unifi=only_unifi,
760
- )
761
- return port_map
762
-
763
-
764
- def build_client_port_map(
765
- devices: Iterable[Device],
766
- clients: Iterable[object],
767
- *,
768
- client_mode: str,
769
- only_unifi: bool = False,
770
- ) -> ClientPortMap:
771
- device_index = build_device_index(devices)
772
- port_map: ClientPortMap = {}
773
- for client in clients:
774
- if not _client_matches_filters(client, client_mode=client_mode, only_unifi=only_unifi):
775
- continue
776
- name = _client_display_name(client)
777
- uplink_mac = _client_uplink_mac(client)
778
- uplink_port = _client_uplink_port(client)
779
- if not name or not uplink_mac or uplink_port is None:
780
- continue
781
- device_name = device_index.get(_normalize_mac(uplink_mac))
782
- if not device_name:
783
- continue
784
- port_map.setdefault(device_name, []).append((uplink_port, name))
785
- return port_map
786
-
787
-
788
- def _port_speed_by_idx(port_table: list[PortInfo], port_idx: int) -> int | None:
789
- for port in port_table:
790
- if port.port_idx == port_idx:
791
- return port.speed
792
- return None
793
-
794
-
795
- def _collect_lldp_links(
796
- devices: list[Device],
797
- index: dict[str, str],
798
- port_map: PortMap,
799
- poe_map: PoeMap,
800
- speed_map: SpeedMap,
801
- raw_links: list[tuple[str, str]],
802
- seen: set[frozenset[str]],
803
- *,
804
- only_unifi: bool,
805
- ) -> set[str]:
806
- devices_with_lldp_edges: set[str] = set()
807
- for device in devices:
808
- poe_ports = device.poe_ports
809
- for lldp_entry in sorted(
810
- device.lldp_info,
811
- key=lambda item: (
812
- _normalize_mac(item.chassis_id),
813
- str(item.port_id or ""),
814
- str(item.port_desc or ""),
815
- ),
816
- ):
817
- peer_mac = _normalize_mac(lldp_entry.chassis_id)
818
- peer_name = index.get(peer_mac)
819
- if peer_name is None:
820
- if only_unifi:
821
- continue
822
- peer_name = lldp_entry.chassis_id
823
-
824
- resolved_port_idx = _resolve_port_idx_from_lldp(lldp_entry, device.port_table)
825
- entry_for_label = (
826
- LLDPEntry(
827
- chassis_id=lldp_entry.chassis_id,
828
- port_id=lldp_entry.port_id,
829
- port_desc=lldp_entry.port_desc,
830
- local_port_name=lldp_entry.local_port_name,
831
- local_port_idx=resolved_port_idx,
832
- )
833
- if resolved_port_idx is not None
834
- else lldp_entry
835
- )
836
- label = local_port_label(entry_for_label)
837
- if label:
838
- port_map[(device.name, peer_name)] = label
839
- if resolved_port_idx is not None:
840
- if resolved_port_idx in poe_ports:
841
- poe_map[(device.name, peer_name)] = poe_ports[resolved_port_idx]
842
- port_speed = _port_speed_by_idx(device.port_table, resolved_port_idx)
843
- if port_speed is not None:
844
- speed_map[(device.name, peer_name)] = port_speed
845
-
846
- key = frozenset({device.name, peer_name})
847
- if key in seen:
848
- continue
849
-
850
- raw_links.append((device.name, peer_name))
851
- seen.add(key)
852
- devices_with_lldp_edges.add(device.name)
853
- return devices_with_lldp_edges
854
-
855
-
856
- def _uplink_name(
857
- uplink: UplinkInfo | None,
858
- index: dict[str, str],
859
- *,
860
- only_unifi: bool,
861
- ) -> str | None:
862
- if not uplink:
863
- return None
864
- if uplink.mac:
865
- resolved = index.get(_normalize_mac(uplink.mac))
866
- if resolved:
867
- return resolved
868
- if uplink.name:
869
- return uplink.name
870
- if not only_unifi and uplink.mac:
871
- return uplink.mac
872
- return None
873
-
874
-
875
- def _maybe_add_uplink_link(
876
- device: Device,
877
- upstream_name: str,
878
- *,
879
- uplink: UplinkInfo | None,
880
- device_by_name: dict[str, Device],
881
- port_map: PortMap,
882
- raw_links: list[tuple[str, str]],
883
- seen: set[frozenset[str]],
884
- include_ports: bool,
885
- ) -> None:
886
- key = frozenset({device.name, upstream_name})
887
- if key in seen:
888
- return
889
- if uplink and uplink.port is not None:
890
- if include_ports:
891
- port_map[(upstream_name, device.name)] = f"Port {uplink.port}"
892
- raw_links.append((upstream_name, device.name))
893
- seen.add(key)
894
-
895
-
896
- def _collect_uplink_links(
897
- devices: list[Device],
898
- devices_with_lldp_edges: set[str],
899
- index: dict[str, str],
900
- device_by_name: dict[str, Device],
901
- port_map: PortMap,
902
- raw_links: list[tuple[str, str]],
903
- seen: set[frozenset[str]],
904
- *,
905
- include_ports: bool,
906
- only_unifi: bool,
907
- ) -> None:
908
- for device in devices:
909
- if device.name in devices_with_lldp_edges:
910
- continue
911
- uplink = device.uplink or device.last_uplink
912
- upstream_name = _uplink_name(uplink, index, only_unifi=only_unifi)
913
- if not upstream_name:
914
- continue
915
- if only_unifi and upstream_name not in device_by_name:
916
- continue
917
- _maybe_add_uplink_link(
918
- device,
919
- upstream_name,
920
- uplink=uplink,
921
- device_by_name=device_by_name,
922
- port_map=port_map,
923
- raw_links=raw_links,
924
- seen=seen,
925
- include_ports=include_ports,
926
- )
132
+ # --- Topology class for serialization and diff ---
927
133
 
928
134
 
929
- def _build_ordered_edges(
930
- raw_links: list[tuple[str, str]],
931
- port_map: PortMap,
932
- poe_map: PoeMap,
933
- speed_map: SpeedMap,
934
- device_by_name: dict[str, Device],
935
- *,
936
- include_ports: bool,
937
- ) -> list[Edge]:
938
- type_rank = {"gateway": 0, "switch": 1, "ap": 2, "other": 3}
939
-
940
- def _rank_for_name(name: str) -> int:
941
- device = device_by_name.get(name)
942
- if not device:
943
- return 3
944
- return type_rank.get(classify_device_type(device), 3)
945
-
946
- edges: list[Edge] = []
947
- for source_name, target_name in raw_links:
948
- left_name = source_name
949
- right_name = target_name
950
- if include_ports:
951
- left_name, right_name = order_edge_names(
952
- left_name,
953
- right_name,
954
- port_map,
955
- _rank_for_name,
956
- )
957
- poe = poe_map.get((left_name, right_name), False) or poe_map.get(
958
- (right_name, left_name), False
959
- )
960
- speed = speed_map.get((left_name, right_name)) or speed_map.get((right_name, left_name))
961
- label = compose_port_label(left_name, right_name, port_map) if include_ports else None
962
- edges.append(Edge(left=left_name, right=right_name, label=label, poe=poe, speed=speed))
963
- return edges
135
+ @dataclass
136
+ class Topology:
137
+ """A complete network topology snapshot for serialization and comparison."""
964
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
965
143
 
966
- @dataclass(frozen=True)
967
- class TopologyResult:
968
- raw_edges: list[Edge]
969
- 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
+ )
970
191
 
971
192
 
972
- def build_topology(
973
- devices: Iterable[Device],
974
- *,
975
- include_ports: bool,
976
- only_unifi: bool,
977
- gateways: list[str],
978
- ) -> TopologyResult:
979
- normalized_devices = list(devices)
980
- lldp_entries = sum(len(device.lldp_info) for device in normalized_devices)
981
- logger.debug(
982
- "Normalized %d devices (%d LLDP entries)",
983
- len(normalized_devices),
984
- lldp_entries,
985
- )
986
- raw_edges = build_edges(normalized_devices, include_ports=include_ports, only_unifi=only_unifi)
987
- tree_edges = build_tree_edges_by_topology(raw_edges, gateways)
988
- logger.debug(
989
- "Built %d hierarchy edges (gateways=%d)",
990
- len(tree_edges),
991
- len(gateways),
992
- )
993
- 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
+ ]