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