unifi-network-maps 1.4.1__py3-none-any.whl → 1.4.2__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 +266 -24
- unifi_network_maps/cli/main.py +352 -107
- unifi_network_maps/io/debug.py +15 -5
- unifi_network_maps/io/export.py +20 -1
- unifi_network_maps/model/topology.py +125 -71
- unifi_network_maps/render/device_ports_md.py +31 -18
- unifi_network_maps/render/lldp_md.py +87 -43
- unifi_network_maps/render/mermaid.py +96 -49
- unifi_network_maps/render/svg.py +614 -318
- {unifi_network_maps-1.4.1.dist-info → unifi_network_maps-1.4.2.dist-info}/METADATA +57 -82
- {unifi_network_maps-1.4.1.dist-info → unifi_network_maps-1.4.2.dist-info}/RECORD +16 -16
- {unifi_network_maps-1.4.1.dist-info → unifi_network_maps-1.4.2.dist-info}/WHEEL +0 -0
- {unifi_network_maps-1.4.1.dist-info → unifi_network_maps-1.4.2.dist-info}/entry_points.txt +0 -0
- {unifi_network_maps-1.4.1.dist-info → unifi_network_maps-1.4.2.dist-info}/licenses/LICENSE +0 -0
- {unifi_network_maps-1.4.1.dist-info → unifi_network_maps-1.4.2.dist-info}/top_level.txt +0 -0
|
@@ -6,7 +6,6 @@ import logging
|
|
|
6
6
|
from collections import deque
|
|
7
7
|
from collections.abc import Iterable
|
|
8
8
|
from dataclasses import dataclass, field
|
|
9
|
-
from typing import Protocol
|
|
10
9
|
|
|
11
10
|
from .labels import compose_port_label, order_edge_names
|
|
12
11
|
from .lldp import LLDPEntry, coerce_lldp, local_port_label
|
|
@@ -40,27 +39,7 @@ class Edge:
|
|
|
40
39
|
wireless: bool = False
|
|
41
40
|
|
|
42
41
|
|
|
43
|
-
|
|
44
|
-
name: str | None
|
|
45
|
-
model_name: str | None
|
|
46
|
-
model: str | None
|
|
47
|
-
mac: str | None
|
|
48
|
-
ip: str | None
|
|
49
|
-
ip_address: str | None
|
|
50
|
-
type: str | None
|
|
51
|
-
device_type: str | None
|
|
52
|
-
lldp_info: object | None
|
|
53
|
-
lldp: object | None
|
|
54
|
-
port_table: object | None
|
|
55
|
-
uplink: object | None
|
|
56
|
-
last_uplink: object | None
|
|
57
|
-
uplink_mac: object | None
|
|
58
|
-
uplink_device_mac: object | None
|
|
59
|
-
last_uplink_mac: object | None
|
|
60
|
-
uplink_device_name: object | None
|
|
61
|
-
uplink_remote_port: object | None
|
|
62
|
-
version: object | None
|
|
63
|
-
displayable_version: object | None
|
|
42
|
+
type DeviceSource = object
|
|
64
43
|
|
|
65
44
|
|
|
66
45
|
@dataclass(frozen=True)
|
|
@@ -94,6 +73,20 @@ def _get_attr(obj: object, name: str) -> object | None:
|
|
|
94
73
|
return getattr(obj, name, None)
|
|
95
74
|
|
|
96
75
|
|
|
76
|
+
def _as_list(value: object | None) -> list[object]:
|
|
77
|
+
if value is None:
|
|
78
|
+
return []
|
|
79
|
+
if isinstance(value, list):
|
|
80
|
+
return value
|
|
81
|
+
if isinstance(value, dict):
|
|
82
|
+
return [value]
|
|
83
|
+
if isinstance(value, str | bytes):
|
|
84
|
+
return []
|
|
85
|
+
if isinstance(value, Iterable):
|
|
86
|
+
return list(value)
|
|
87
|
+
return []
|
|
88
|
+
|
|
89
|
+
|
|
97
90
|
def _normalize_mac(value: str) -> str:
|
|
98
91
|
return value.strip().lower()
|
|
99
92
|
|
|
@@ -167,14 +160,16 @@ def _aggregation_group(port_entry: object) -> object | None:
|
|
|
167
160
|
return None
|
|
168
161
|
|
|
169
162
|
|
|
170
|
-
def
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
if
|
|
175
|
-
candidates.append(
|
|
176
|
-
|
|
177
|
-
|
|
163
|
+
def _lldp_candidates(entry: LLDPEntry) -> list[str]:
|
|
164
|
+
candidates: list[str] = []
|
|
165
|
+
if entry.local_port_name:
|
|
166
|
+
candidates.append(entry.local_port_name)
|
|
167
|
+
if entry.port_id:
|
|
168
|
+
candidates.append(entry.port_id)
|
|
169
|
+
return candidates
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _match_port_by_name(candidates: list[str], port_table: list[PortInfo]) -> int | None:
|
|
178
173
|
for candidate in candidates:
|
|
179
174
|
normalized = candidate.strip().lower()
|
|
180
175
|
for port in port_table:
|
|
@@ -182,6 +177,10 @@ def _resolve_port_idx_from_lldp(lldp_entry: LLDPEntry, port_table: list[PortInfo
|
|
|
182
177
|
return port.port_idx
|
|
183
178
|
if port.name and port.name.strip().lower() == normalized:
|
|
184
179
|
return port.port_idx
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _match_port_by_number(candidates: list[str], port_table: list[PortInfo]) -> int | None:
|
|
185
184
|
for candidate in candidates:
|
|
186
185
|
number = extract_port_number(candidate)
|
|
187
186
|
if number is None:
|
|
@@ -192,6 +191,16 @@ def _resolve_port_idx_from_lldp(lldp_entry: LLDPEntry, port_table: list[PortInfo
|
|
|
192
191
|
return None
|
|
193
192
|
|
|
194
193
|
|
|
194
|
+
def _resolve_port_idx_from_lldp(lldp_entry: LLDPEntry, port_table: list[PortInfo]) -> int | None:
|
|
195
|
+
if lldp_entry.local_port_idx is not None:
|
|
196
|
+
return lldp_entry.local_port_idx
|
|
197
|
+
candidates = _lldp_candidates(lldp_entry)
|
|
198
|
+
matched = _match_port_by_name(candidates, port_table)
|
|
199
|
+
if matched is not None:
|
|
200
|
+
return matched
|
|
201
|
+
return _match_port_by_number(candidates, port_table)
|
|
202
|
+
|
|
203
|
+
|
|
195
204
|
def _port_info_from_entry(port_entry: object) -> PortInfo:
|
|
196
205
|
if isinstance(port_entry, dict):
|
|
197
206
|
port_idx = port_entry.get("port_idx") or port_entry.get("portIdx")
|
|
@@ -226,12 +235,12 @@ def _port_info_from_entry(port_entry: object) -> PortInfo:
|
|
|
226
235
|
)
|
|
227
236
|
|
|
228
237
|
|
|
229
|
-
def _coerce_port_table(device:
|
|
230
|
-
port_table = _get_attr(device, "port_table")
|
|
238
|
+
def _coerce_port_table(device: DeviceSource) -> list[PortInfo]:
|
|
239
|
+
port_table = _as_list(_get_attr(device, "port_table"))
|
|
231
240
|
return [_port_info_from_entry(port_entry) for port_entry in port_table]
|
|
232
241
|
|
|
233
242
|
|
|
234
|
-
def _poe_ports_from_device(device:
|
|
243
|
+
def _poe_ports_from_device(device: DeviceSource) -> dict[int, bool]:
|
|
235
244
|
port_table = _coerce_port_table(device)
|
|
236
245
|
poe_ports: dict[int, bool] = {}
|
|
237
246
|
for port_entry in port_table:
|
|
@@ -271,7 +280,7 @@ def _parse_uplink(value: object | None) -> UplinkInfo | None:
|
|
|
271
280
|
return UplinkInfo(mac=mac_value, name=name_value, port=port)
|
|
272
281
|
|
|
273
282
|
|
|
274
|
-
def _uplink_info(device:
|
|
283
|
+
def _uplink_info(device: DeviceSource) -> tuple[UplinkInfo | None, UplinkInfo | None]:
|
|
275
284
|
uplink = _parse_uplink(_device_field(device, "uplink"))
|
|
276
285
|
last_uplink = _parse_uplink(_device_field(device, "last_uplink"))
|
|
277
286
|
|
|
@@ -290,7 +299,7 @@ def _uplink_info(device: DeviceLike) -> tuple[UplinkInfo | None, UplinkInfo | No
|
|
|
290
299
|
return uplink, last_uplink
|
|
291
300
|
|
|
292
301
|
|
|
293
|
-
def coerce_device(device:
|
|
302
|
+
def coerce_device(device: DeviceSource) -> Device:
|
|
294
303
|
name = _get_attr(device, "name")
|
|
295
304
|
model_name = _get_attr(device, "model_name") or _get_attr(device, "model")
|
|
296
305
|
model = _get_attr(device, "model")
|
|
@@ -301,6 +310,8 @@ def coerce_device(device: DeviceLike) -> Device:
|
|
|
301
310
|
lldp_info = _get_attr(device, "lldp_info")
|
|
302
311
|
if lldp_info is None:
|
|
303
312
|
lldp_info = _get_attr(device, "lldp")
|
|
313
|
+
if lldp_info is None:
|
|
314
|
+
lldp_info = _get_attr(device, "lldp_table")
|
|
304
315
|
|
|
305
316
|
if not name or not mac:
|
|
306
317
|
raise ValueError("Device missing name or mac")
|
|
@@ -312,7 +323,8 @@ def coerce_device(device: DeviceLike) -> Device:
|
|
|
312
323
|
else:
|
|
313
324
|
raise ValueError(f"Device {name} missing LLDP info")
|
|
314
325
|
|
|
315
|
-
|
|
326
|
+
lldp_entries = _as_list(lldp_info)
|
|
327
|
+
coerced_lldp = [coerce_lldp(lldp_entry) for lldp_entry in lldp_entries]
|
|
316
328
|
port_table = _coerce_port_table(device)
|
|
317
329
|
poe_ports = _poe_ports_from_device(device)
|
|
318
330
|
|
|
@@ -332,14 +344,16 @@ def coerce_device(device: DeviceLike) -> Device:
|
|
|
332
344
|
)
|
|
333
345
|
|
|
334
346
|
|
|
335
|
-
def normalize_devices(devices: Iterable[
|
|
347
|
+
def normalize_devices(devices: Iterable[DeviceSource]) -> list[Device]:
|
|
336
348
|
return [coerce_device(device) for device in devices]
|
|
337
349
|
|
|
338
350
|
|
|
339
|
-
def classify_device_type(device:
|
|
340
|
-
|
|
351
|
+
def classify_device_type(device: object) -> str:
|
|
352
|
+
raw_type = _device_field(device, "type")
|
|
353
|
+
raw_name = _device_field(device, "name")
|
|
354
|
+
value = raw_type.strip().lower() if isinstance(raw_type, str) else ""
|
|
341
355
|
if not value:
|
|
342
|
-
name =
|
|
356
|
+
name = raw_name.strip().lower() if isinstance(raw_name, str) else ""
|
|
343
357
|
if "gateway" in name or name.startswith("gw"):
|
|
344
358
|
return "gateway"
|
|
345
359
|
if "switch" in name:
|
|
@@ -363,19 +377,19 @@ def group_devices_by_type(devices: Iterable[Device]) -> dict[str, list[str]]:
|
|
|
363
377
|
return groups
|
|
364
378
|
|
|
365
379
|
|
|
366
|
-
def
|
|
380
|
+
def _build_adjacency(edges: Iterable[Edge]) -> dict[str, set[str]]:
|
|
367
381
|
adjacency: dict[str, set[str]] = {}
|
|
368
382
|
for edge in edges:
|
|
369
383
|
adjacency.setdefault(edge.left, set()).add(edge.right)
|
|
370
384
|
adjacency.setdefault(edge.right, set()).add(edge.left)
|
|
385
|
+
return adjacency
|
|
371
386
|
|
|
372
|
-
if not gateways:
|
|
373
|
-
return []
|
|
374
387
|
|
|
375
|
-
|
|
376
|
-
for edge in edges
|
|
377
|
-
edge_map[frozenset({edge.left, edge.right})] = edge
|
|
388
|
+
def _build_edge_map(edges: Iterable[Edge]) -> dict[frozenset[str], Edge]:
|
|
389
|
+
return {frozenset({edge.left, edge.right}): edge for edge in edges}
|
|
378
390
|
|
|
391
|
+
|
|
392
|
+
def _tree_parents(adjacency: dict[str, set[str]], gateways: list[str]) -> dict[str, str]:
|
|
379
393
|
visited: set[str] = set()
|
|
380
394
|
parent: dict[str, str] = {}
|
|
381
395
|
queue: deque[str] = deque()
|
|
@@ -393,7 +407,12 @@ def build_tree_edges_by_topology(edges: Iterable[Edge], gateways: list[str]) ->
|
|
|
393
407
|
visited.add(neighbor)
|
|
394
408
|
parent[neighbor] = current
|
|
395
409
|
queue.append(neighbor)
|
|
410
|
+
return parent
|
|
411
|
+
|
|
396
412
|
|
|
413
|
+
def _tree_edges_from_parent(
|
|
414
|
+
parent: dict[str, str], edge_map: dict[frozenset[str], Edge]
|
|
415
|
+
) -> list[Edge]:
|
|
397
416
|
tree_edges: list[Edge] = []
|
|
398
417
|
for child in sorted(parent):
|
|
399
418
|
parent_name = parent[child]
|
|
@@ -404,10 +423,18 @@ def build_tree_edges_by_topology(edges: Iterable[Edge], gateways: list[str]) ->
|
|
|
404
423
|
tree_edges.append(
|
|
405
424
|
Edge(left=parent_name, right=child, label=original.label, poe=original.poe)
|
|
406
425
|
)
|
|
407
|
-
|
|
408
426
|
return tree_edges
|
|
409
427
|
|
|
410
428
|
|
|
429
|
+
def build_tree_edges_by_topology(edges: Iterable[Edge], gateways: list[str]) -> list[Edge]:
|
|
430
|
+
if not gateways:
|
|
431
|
+
return []
|
|
432
|
+
adjacency = _build_adjacency(edges)
|
|
433
|
+
edge_map = _build_edge_map(edges)
|
|
434
|
+
parent = _tree_parents(adjacency, gateways)
|
|
435
|
+
return _tree_edges_from_parent(parent, edge_map)
|
|
436
|
+
|
|
437
|
+
|
|
411
438
|
def build_device_index(devices: Iterable[Device]) -> dict[str, str]:
|
|
412
439
|
index: dict[str, str] = {}
|
|
413
440
|
for device in devices:
|
|
@@ -561,7 +588,6 @@ def build_edges(
|
|
|
561
588
|
index,
|
|
562
589
|
device_by_name,
|
|
563
590
|
port_map,
|
|
564
|
-
poe_map,
|
|
565
591
|
raw_links,
|
|
566
592
|
seen,
|
|
567
593
|
include_ports=include_ports,
|
|
@@ -604,7 +630,6 @@ def build_port_map(devices: Iterable[Device], *, only_unifi: bool = True) -> Por
|
|
|
604
630
|
index,
|
|
605
631
|
device_by_name,
|
|
606
632
|
port_map,
|
|
607
|
-
poe_map,
|
|
608
633
|
raw_links,
|
|
609
634
|
seen,
|
|
610
635
|
include_ports=True,
|
|
@@ -692,13 +717,52 @@ def _collect_lldp_links(
|
|
|
692
717
|
return devices_with_lldp_edges
|
|
693
718
|
|
|
694
719
|
|
|
720
|
+
def _uplink_name(
|
|
721
|
+
uplink: UplinkInfo | None,
|
|
722
|
+
index: dict[str, str],
|
|
723
|
+
*,
|
|
724
|
+
only_unifi: bool,
|
|
725
|
+
) -> str | None:
|
|
726
|
+
if not uplink:
|
|
727
|
+
return None
|
|
728
|
+
if uplink.mac:
|
|
729
|
+
resolved = index.get(_normalize_mac(uplink.mac))
|
|
730
|
+
if resolved:
|
|
731
|
+
return resolved
|
|
732
|
+
if uplink.name:
|
|
733
|
+
return uplink.name
|
|
734
|
+
if not only_unifi and uplink.mac:
|
|
735
|
+
return uplink.mac
|
|
736
|
+
return None
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _maybe_add_uplink_link(
|
|
740
|
+
device: Device,
|
|
741
|
+
upstream_name: str,
|
|
742
|
+
*,
|
|
743
|
+
uplink: UplinkInfo | None,
|
|
744
|
+
device_by_name: dict[str, Device],
|
|
745
|
+
port_map: PortMap,
|
|
746
|
+
raw_links: list[tuple[str, str]],
|
|
747
|
+
seen: set[frozenset[str]],
|
|
748
|
+
include_ports: bool,
|
|
749
|
+
) -> None:
|
|
750
|
+
key = frozenset({device.name, upstream_name})
|
|
751
|
+
if key in seen:
|
|
752
|
+
return
|
|
753
|
+
if uplink and uplink.port is not None:
|
|
754
|
+
if include_ports:
|
|
755
|
+
port_map[(upstream_name, device.name)] = f"Port {uplink.port}"
|
|
756
|
+
raw_links.append((upstream_name, device.name))
|
|
757
|
+
seen.add(key)
|
|
758
|
+
|
|
759
|
+
|
|
695
760
|
def _collect_uplink_links(
|
|
696
761
|
devices: list[Device],
|
|
697
762
|
devices_with_lldp_edges: set[str],
|
|
698
763
|
index: dict[str, str],
|
|
699
764
|
device_by_name: dict[str, Device],
|
|
700
765
|
port_map: PortMap,
|
|
701
|
-
poe_map: PoeMap,
|
|
702
766
|
raw_links: list[tuple[str, str]],
|
|
703
767
|
seen: set[frozenset[str]],
|
|
704
768
|
*,
|
|
@@ -709,31 +773,21 @@ def _collect_uplink_links(
|
|
|
709
773
|
if device.name in devices_with_lldp_edges:
|
|
710
774
|
continue
|
|
711
775
|
uplink = device.uplink or device.last_uplink
|
|
712
|
-
upstream_name =
|
|
713
|
-
if uplink and uplink.mac:
|
|
714
|
-
upstream_name = index.get(_normalize_mac(uplink.mac))
|
|
715
|
-
if not upstream_name and uplink and uplink.name:
|
|
716
|
-
upstream_name = uplink.name
|
|
717
|
-
if not upstream_name and not only_unifi and uplink and uplink.mac:
|
|
718
|
-
upstream_name = uplink.mac
|
|
776
|
+
upstream_name = _uplink_name(uplink, index, only_unifi=only_unifi)
|
|
719
777
|
if not upstream_name:
|
|
720
778
|
continue
|
|
721
779
|
if only_unifi and upstream_name not in device_by_name:
|
|
722
780
|
continue
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
raw_links.append((upstream_name, device.name))
|
|
734
|
-
seen.add(key)
|
|
735
|
-
if poe:
|
|
736
|
-
poe_map[(upstream_name, device.name)] = poe
|
|
781
|
+
_maybe_add_uplink_link(
|
|
782
|
+
device,
|
|
783
|
+
upstream_name,
|
|
784
|
+
uplink=uplink,
|
|
785
|
+
device_by_name=device_by_name,
|
|
786
|
+
port_map=port_map,
|
|
787
|
+
raw_links=raw_links,
|
|
788
|
+
seen=seen,
|
|
789
|
+
include_ports=include_ports,
|
|
790
|
+
)
|
|
737
791
|
|
|
738
792
|
|
|
739
793
|
def _build_ordered_edges(
|
|
@@ -6,7 +6,7 @@ from collections import defaultdict
|
|
|
6
6
|
from html import escape as _escape_html
|
|
7
7
|
|
|
8
8
|
from ..model.ports import extract_port_number
|
|
9
|
-
from ..model.topology import ClientPortMap, Device, PortMap, classify_device_type
|
|
9
|
+
from ..model.topology import ClientPortMap, Device, PortInfo, PortMap, classify_device_type
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
def render_device_port_overview(
|
|
@@ -355,8 +355,8 @@ def _format_client_connections(clients: list[str]) -> str:
|
|
|
355
355
|
return f'<ul class="unifi-port-clients">{items}</ul>'
|
|
356
356
|
|
|
357
357
|
|
|
358
|
-
def
|
|
359
|
-
groups: dict[str, list[
|
|
358
|
+
def _aggregate_base_groups(port_table: list[PortInfo]) -> dict[str, list[PortInfo]]:
|
|
359
|
+
groups: dict[str, list[PortInfo]] = defaultdict(list)
|
|
360
360
|
for port in port_table:
|
|
361
361
|
group = getattr(port, "aggregation_group", None)
|
|
362
362
|
if group:
|
|
@@ -366,10 +366,17 @@ def _aggregate_ports(port_table: list[object]) -> dict[str, list[object]]:
|
|
|
366
366
|
port_idx = getattr(port, "port_idx", None)
|
|
367
367
|
if port_idx is not None:
|
|
368
368
|
groups[f"lag-{port_idx}"].append(port)
|
|
369
|
+
return groups
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _extend_singleton_groups(
|
|
373
|
+
groups: dict[str, list[PortInfo]],
|
|
374
|
+
port_table: list[PortInfo],
|
|
375
|
+
) -> None:
|
|
369
376
|
if not groups:
|
|
370
|
-
return
|
|
371
|
-
port_by_idx = {
|
|
372
|
-
|
|
377
|
+
return
|
|
378
|
+
port_by_idx: dict[int, PortInfo] = {
|
|
379
|
+
port.port_idx: port for port in port_table if port.port_idx is not None
|
|
373
380
|
}
|
|
374
381
|
for group_id, group_ports in list(groups.items()):
|
|
375
382
|
if len(group_ports) > 1:
|
|
@@ -377,27 +384,33 @@ def _aggregate_ports(port_table: list[object]) -> dict[str, list[object]]:
|
|
|
377
384
|
lone_port = group_ports[0]
|
|
378
385
|
if not _looks_like_lag(lone_port):
|
|
379
386
|
continue
|
|
380
|
-
|
|
387
|
+
port_idx = lone_port.port_idx
|
|
388
|
+
if port_idx is None:
|
|
381
389
|
continue
|
|
382
|
-
candidates = []
|
|
383
|
-
for neighbor in (
|
|
390
|
+
candidates: list[PortInfo] = []
|
|
391
|
+
for neighbor in (port_idx - 1, port_idx + 1):
|
|
384
392
|
port = port_by_idx.get(neighbor)
|
|
385
393
|
if port and not getattr(port, "aggregation_group", None):
|
|
386
394
|
if getattr(port, "speed", None) == getattr(lone_port, "speed", None):
|
|
387
395
|
candidates.append(port)
|
|
388
396
|
if candidates:
|
|
389
397
|
groups[group_id].extend(candidates)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _aggregate_ports(port_table: list[PortInfo]) -> dict[str, list[PortInfo]]:
|
|
401
|
+
groups = _aggregate_base_groups(port_table)
|
|
402
|
+
_extend_singleton_groups(groups, port_table)
|
|
390
403
|
return groups
|
|
391
404
|
|
|
392
405
|
|
|
393
|
-
def _looks_like_lag(port:
|
|
406
|
+
def _looks_like_lag(port: PortInfo) -> bool:
|
|
394
407
|
name = (getattr(port, "name", "") or "").lower()
|
|
395
408
|
ifname = (getattr(port, "ifname", "") or "").lower()
|
|
396
409
|
return "lag" in name or "lag" in ifname or "aggregate" in name
|
|
397
410
|
|
|
398
411
|
|
|
399
|
-
def _format_aggregate_label(group_ports: list[
|
|
400
|
-
ports = sorted([p.port_idx for p in group_ports if
|
|
412
|
+
def _format_aggregate_label(group_ports: list[PortInfo]) -> str:
|
|
413
|
+
ports = sorted([int(p.port_idx) for p in group_ports if p.port_idx is not None])
|
|
401
414
|
if ports:
|
|
402
415
|
if len(ports) == 1:
|
|
403
416
|
return f"Port {ports[0]} (LAG)"
|
|
@@ -407,14 +420,14 @@ def _format_aggregate_label(group_ports: list[object]) -> str:
|
|
|
407
420
|
return "Aggregated ports"
|
|
408
421
|
|
|
409
422
|
|
|
410
|
-
def _aggregate_sort_key(group_ports: list[
|
|
411
|
-
ports = sorted([p.port_idx for p in group_ports if
|
|
423
|
+
def _aggregate_sort_key(group_ports: list[PortInfo]) -> int:
|
|
424
|
+
ports = sorted([int(p.port_idx) for p in group_ports if p.port_idx is not None])
|
|
412
425
|
return ports[0] if ports else 10_000
|
|
413
426
|
|
|
414
427
|
|
|
415
428
|
def _format_aggregate_connections(
|
|
416
429
|
device_name: str,
|
|
417
|
-
group_ports: list[
|
|
430
|
+
group_ports: list[PortInfo],
|
|
418
431
|
connections: dict[int, list[str]],
|
|
419
432
|
client_connections: dict[int, list[str]],
|
|
420
433
|
port_map: PortMap,
|
|
@@ -436,7 +449,7 @@ def _format_aggregate_connections(
|
|
|
436
449
|
return ", ".join([item for item in rendered if item])
|
|
437
450
|
|
|
438
451
|
|
|
439
|
-
def _format_aggregate_speed(group_ports: list[
|
|
452
|
+
def _format_aggregate_speed(group_ports: list[PortInfo]) -> str:
|
|
440
453
|
speeds = {getattr(port, "speed", None) for port in group_ports}
|
|
441
454
|
speeds.discard(None)
|
|
442
455
|
if not speeds:
|
|
@@ -446,7 +459,7 @@ def _format_aggregate_speed(group_ports: list[object]) -> str:
|
|
|
446
459
|
return "mixed"
|
|
447
460
|
|
|
448
461
|
|
|
449
|
-
def _format_aggregate_poe_state(group_ports: list[
|
|
462
|
+
def _format_aggregate_poe_state(group_ports: list[PortInfo]) -> str:
|
|
450
463
|
states = {_format_poe_state(port) for port in group_ports}
|
|
451
464
|
if "active" in states:
|
|
452
465
|
return "active"
|
|
@@ -457,6 +470,6 @@ def _format_aggregate_poe_state(group_ports: list[object]) -> str:
|
|
|
457
470
|
return "-"
|
|
458
471
|
|
|
459
472
|
|
|
460
|
-
def _format_aggregate_power(group_ports: list[
|
|
473
|
+
def _format_aggregate_power(group_ports: list[PortInfo]) -> str:
|
|
461
474
|
total = sum(getattr(port, "poe_power", 0.0) or 0.0 for port in group_ports)
|
|
462
475
|
return _format_poe_power(total)
|
|
@@ -213,63 +213,107 @@ def _client_rows(
|
|
|
213
213
|
return rows_by_device
|
|
214
214
|
|
|
215
215
|
|
|
216
|
-
def
|
|
216
|
+
def _prepare_lldp_maps(
|
|
217
217
|
devices: list[Device],
|
|
218
218
|
*,
|
|
219
|
-
clients: Iterable[object] | None
|
|
220
|
-
include_ports: bool
|
|
221
|
-
show_clients: bool
|
|
222
|
-
client_mode: str
|
|
223
|
-
) ->
|
|
219
|
+
clients: Iterable[object] | None,
|
|
220
|
+
include_ports: bool,
|
|
221
|
+
show_clients: bool,
|
|
222
|
+
client_mode: str,
|
|
223
|
+
) -> tuple[
|
|
224
|
+
dict[tuple[str, str], str],
|
|
225
|
+
dict[str, list[tuple[int, str]]] | None,
|
|
226
|
+
dict[str, list[tuple[str, str | None]]],
|
|
227
|
+
]:
|
|
224
228
|
device_index = build_device_index(devices)
|
|
225
|
-
port_map = {}
|
|
226
|
-
client_port_map = None
|
|
227
229
|
client_rows = (
|
|
228
230
|
_client_rows(clients, device_index, include_ports=include_ports, client_mode=client_mode)
|
|
229
231
|
if clients
|
|
230
232
|
else {}
|
|
231
233
|
)
|
|
234
|
+
port_map: dict[tuple[str, str], str] = {}
|
|
235
|
+
client_port_map = None
|
|
232
236
|
if include_ports:
|
|
233
237
|
port_map = build_port_map(devices, only_unifi=False)
|
|
234
238
|
if clients and show_clients:
|
|
235
239
|
client_port_map = build_client_port_map(devices, clients, client_mode=client_mode)
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
240
|
+
return port_map, client_port_map, client_rows
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _render_device_lldp_section(
|
|
244
|
+
lines: list[str],
|
|
245
|
+
device: Device,
|
|
246
|
+
*,
|
|
247
|
+
device_index: dict[str, str],
|
|
248
|
+
port_map: dict[tuple[str, str], str],
|
|
249
|
+
client_port_map: dict[str, list[tuple[int, str]]] | None,
|
|
250
|
+
client_rows: dict[str, list[tuple[str, str | None]]],
|
|
251
|
+
include_ports: bool,
|
|
252
|
+
show_clients: bool,
|
|
253
|
+
client_mode: str,
|
|
254
|
+
) -> None:
|
|
255
|
+
lines.extend(_device_header_lines(device))
|
|
256
|
+
lines.append("")
|
|
257
|
+
lines.extend(_details_table_lines(device, client_rows, client_mode))
|
|
258
|
+
if include_ports:
|
|
259
|
+
lines.append("### Ports")
|
|
260
|
+
lines.append("")
|
|
261
|
+
lines.append(
|
|
262
|
+
render_device_port_details(device, port_map, client_ports=client_port_map).strip()
|
|
263
|
+
)
|
|
264
|
+
if device.lldp_info:
|
|
265
|
+
lines.append("")
|
|
266
|
+
lines.append("| Local Port | Neighbor | Neighbor Port | Chassis ID | Port Description |")
|
|
267
|
+
lines.append("| --- | --- | --- | --- | --- |")
|
|
268
|
+
for row in _lldp_rows(device.lldp_info, device_index):
|
|
269
|
+
lines.append("| " + " | ".join(_escape_cell(cell) for cell in row) + " |")
|
|
239
270
|
lines.append("")
|
|
240
|
-
|
|
271
|
+
else:
|
|
272
|
+
lines.append("_No LLDP neighbors._")
|
|
273
|
+
lines.append("")
|
|
274
|
+
rows = client_rows.get(device.name)
|
|
275
|
+
if rows and show_clients:
|
|
276
|
+
lines.append("")
|
|
277
|
+
lines.append("### Clients")
|
|
241
278
|
if include_ports:
|
|
242
|
-
lines.append("### Ports")
|
|
243
|
-
lines.append("")
|
|
244
|
-
lines.append(
|
|
245
|
-
render_device_port_details(device, port_map, client_ports=client_port_map).strip()
|
|
246
|
-
)
|
|
247
|
-
if device.lldp_info:
|
|
248
|
-
lines.append("")
|
|
249
|
-
lines.append(
|
|
250
|
-
"| Local Port | Neighbor | Neighbor Port | Chassis ID | Port Description |"
|
|
251
|
-
)
|
|
252
|
-
lines.append("| --- | --- | --- | --- | --- |")
|
|
253
|
-
for row in _lldp_rows(device.lldp_info, device_index):
|
|
254
|
-
lines.append("| " + " | ".join(_escape_cell(cell) for cell in row) + " |")
|
|
255
279
|
lines.append("")
|
|
280
|
+
lines.append("| Client | Port |")
|
|
281
|
+
lines.append("| --- | --- |")
|
|
282
|
+
for client_name, port_label in rows:
|
|
283
|
+
lines.append(f"| {_escape_cell(client_name)} | {_escape_cell(port_label or '-')} |")
|
|
256
284
|
else:
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
285
|
+
for client_name, _port_label in rows:
|
|
286
|
+
lines.append(f"- {_escape_cell(client_name)}")
|
|
287
|
+
lines.append("")
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def render_lldp_md(
|
|
291
|
+
devices: list[Device],
|
|
292
|
+
*,
|
|
293
|
+
clients: Iterable[object] | None = None,
|
|
294
|
+
include_ports: bool = False,
|
|
295
|
+
show_clients: bool = False,
|
|
296
|
+
client_mode: str = "wired",
|
|
297
|
+
) -> str:
|
|
298
|
+
device_index = build_device_index(devices)
|
|
299
|
+
port_map, client_port_map, client_rows = _prepare_lldp_maps(
|
|
300
|
+
devices,
|
|
301
|
+
clients=clients,
|
|
302
|
+
include_ports=include_ports,
|
|
303
|
+
show_clients=show_clients,
|
|
304
|
+
client_mode=client_mode,
|
|
305
|
+
)
|
|
306
|
+
lines: list[str] = ["# LLDP Neighbors", ""]
|
|
307
|
+
for device in sorted(devices, key=lambda item: item.name.lower()):
|
|
308
|
+
_render_device_lldp_section(
|
|
309
|
+
lines,
|
|
310
|
+
device,
|
|
311
|
+
device_index=device_index,
|
|
312
|
+
port_map=port_map,
|
|
313
|
+
client_port_map=client_port_map,
|
|
314
|
+
client_rows=client_rows,
|
|
315
|
+
include_ports=include_ports,
|
|
316
|
+
show_clients=show_clients,
|
|
317
|
+
client_mode=client_mode,
|
|
318
|
+
)
|
|
275
319
|
return "\n".join(lines).rstrip() + "\n"
|