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.
- unifi_network_maps/__init__.py +1 -1
- unifi_network_maps/adapters/unifi.py +83 -101
- unifi_network_maps/assets/icons/modern/ap.svg +9 -0
- unifi_network_maps/assets/icons/modern/camera.svg +9 -0
- unifi_network_maps/assets/icons/modern/client.svg +9 -0
- unifi_network_maps/assets/icons/modern/game_console.svg +10 -0
- unifi_network_maps/assets/icons/modern/gateway.svg +17 -0
- unifi_network_maps/assets/icons/modern/iot.svg +9 -0
- unifi_network_maps/assets/icons/modern/nas.svg +9 -0
- unifi_network_maps/assets/icons/modern/other.svg +10 -0
- unifi_network_maps/assets/icons/modern/phone.svg +10 -0
- unifi_network_maps/assets/icons/modern/printer.svg +9 -0
- unifi_network_maps/assets/icons/modern/speaker.svg +10 -0
- unifi_network_maps/assets/icons/modern/switch.svg +10 -0
- unifi_network_maps/assets/icons/modern/tv.svg +10 -0
- unifi_network_maps/assets/icons/modern-flat/ap.svg +5 -0
- unifi_network_maps/assets/icons/modern-flat/camera.svg +5 -0
- unifi_network_maps/assets/icons/modern-flat/client.svg +5 -0
- unifi_network_maps/assets/icons/modern-flat/game_console.svg +6 -0
- unifi_network_maps/assets/icons/modern-flat/gateway.svg +13 -0
- unifi_network_maps/assets/icons/modern-flat/iot.svg +5 -0
- unifi_network_maps/assets/icons/modern-flat/nas.svg +5 -0
- unifi_network_maps/assets/icons/modern-flat/other.svg +6 -0
- unifi_network_maps/assets/icons/modern-flat/phone.svg +6 -0
- unifi_network_maps/assets/icons/modern-flat/printer.svg +5 -0
- unifi_network_maps/assets/icons/modern-flat/speaker.svg +6 -0
- unifi_network_maps/assets/icons/modern-flat/switch.svg +6 -0
- unifi_network_maps/assets/icons/modern-flat/tv.svg +6 -0
- unifi_network_maps/assets/themes/dark.yaml +53 -10
- unifi_network_maps/assets/themes/default.yaml +34 -0
- unifi_network_maps/assets/themes/minimal-dark.yaml +98 -0
- unifi_network_maps/assets/themes/minimal.yaml +92 -0
- unifi_network_maps/assets/themes/unifi-dark.yaml +97 -0
- unifi_network_maps/assets/themes/unifi.yaml +92 -0
- unifi_network_maps/cli/args.py +54 -0
- unifi_network_maps/cli/main.py +18 -7
- unifi_network_maps/cli/render.py +79 -27
- unifi_network_maps/cli/runtime.py +29 -15
- unifi_network_maps/io/debug.py +2 -1
- unifi_network_maps/io/export.py +19 -13
- unifi_network_maps/io/mock_data.py +5 -3
- unifi_network_maps/io/paths.py +5 -3
- unifi_network_maps/model/classify.py +199 -0
- unifi_network_maps/model/clients.py +271 -0
- unifi_network_maps/model/connection.py +37 -0
- unifi_network_maps/model/diff.py +544 -0
- unifi_network_maps/model/edges.py +558 -0
- unifi_network_maps/model/helpers.py +64 -0
- unifi_network_maps/model/lldp.py +20 -25
- unifi_network_maps/model/mock.py +110 -23
- unifi_network_maps/model/snapshot.py +294 -0
- unifi_network_maps/model/topology.py +143 -931
- unifi_network_maps/model/topology_coerce.py +339 -0
- unifi_network_maps/model/vlans.py +32 -46
- unifi_network_maps/model/wan.py +132 -0
- unifi_network_maps/render/device_ports_md.py +39 -97
- unifi_network_maps/render/device_summary.py +53 -0
- unifi_network_maps/render/lldp_md.py +29 -219
- unifi_network_maps/render/markdown_tables.py +8 -0
- unifi_network_maps/render/mermaid.py +11 -2
- unifi_network_maps/render/mkdocs.py +2 -1
- unifi_network_maps/render/svg.py +566 -908
- unifi_network_maps/render/svg_icons.py +231 -0
- unifi_network_maps/render/svg_isometric.py +1196 -0
- unifi_network_maps/render/svg_labels.py +184 -0
- unifi_network_maps/render/svg_theme.py +166 -32
- unifi_network_maps/render/theme.py +86 -1
- {unifi_network_maps-1.4.14.dist-info → unifi_network_maps-1.5.0.dist-info}/METADATA +107 -31
- {unifi_network_maps-1.4.14.dist-info → unifi_network_maps-1.5.0.dist-info}/RECORD +73 -31
- unifi_network_maps/assets/icons/isometric/printer.svg +0 -122
- {unifi_network_maps-1.4.14.dist-info → unifi_network_maps-1.5.0.dist-info}/WHEEL +0 -0
- {unifi_network_maps-1.4.14.dist-info → unifi_network_maps-1.5.0.dist-info}/entry_points.txt +0 -0
- {unifi_network_maps-1.4.14.dist-info → unifi_network_maps-1.5.0.dist-info}/licenses/LICENSE +0 -0
- {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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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[
|
|
128
|
+
index[normalize_mac(device.mac)] = device.name
|
|
453
129
|
return index
|
|
454
130
|
|
|
455
131
|
|
|
456
|
-
|
|
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
|
-
|
|
930
|
-
|
|
931
|
-
|
|
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
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
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
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
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
|
+
]
|