unifi-network-maps 1.4.11__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 -0
- unifi_network_maps/__main__.py +8 -0
- unifi_network_maps/adapters/__init__.py +1 -0
- unifi_network_maps/adapters/config.py +49 -0
- unifi_network_maps/adapters/unifi.py +457 -0
- unifi_network_maps/assets/__init__.py +0 -0
- unifi_network_maps/assets/icons/__init__.py +0 -0
- unifi_network_maps/assets/icons/access-point.svg +1 -0
- unifi_network_maps/assets/icons/isometric/ISOPACKS_LICENSE +7 -0
- unifi_network_maps/assets/icons/isometric/block.svg +23 -0
- unifi_network_maps/assets/icons/isometric/cache.svg +48 -0
- unifi_network_maps/assets/icons/isometric/cardterminal.svg +316 -0
- unifi_network_maps/assets/icons/isometric/cloud.svg +89 -0
- unifi_network_maps/assets/icons/isometric/cronjob.svg +409 -0
- unifi_network_maps/assets/icons/isometric/cube.svg +24 -0
- unifi_network_maps/assets/icons/isometric/desktop.svg +107 -0
- unifi_network_maps/assets/icons/isometric/diamond.svg +23 -0
- unifi_network_maps/assets/icons/isometric/dns.svg +46 -0
- unifi_network_maps/assets/icons/isometric/document.svg +62 -0
- unifi_network_maps/assets/icons/isometric/firewall.svg +200 -0
- unifi_network_maps/assets/icons/isometric/function-module.svg +215 -0
- unifi_network_maps/assets/icons/isometric/image.svg +65 -0
- unifi_network_maps/assets/icons/isometric/laptop.svg +37 -0
- unifi_network_maps/assets/icons/isometric/loadbalancer.svg +65 -0
- unifi_network_maps/assets/icons/isometric/lock.svg +155 -0
- unifi_network_maps/assets/icons/isometric/mail.svg +35 -0
- unifi_network_maps/assets/icons/isometric/mailmultiple.svg +91 -0
- unifi_network_maps/assets/icons/isometric/mobiledevice.svg +66 -0
- unifi_network_maps/assets/icons/isometric/office.svg +136 -0
- unifi_network_maps/assets/icons/isometric/package-module.svg +39 -0
- unifi_network_maps/assets/icons/isometric/paymentcard.svg +92 -0
- unifi_network_maps/assets/icons/isometric/plane.svg +1 -0
- unifi_network_maps/assets/icons/isometric/printer.svg +122 -0
- unifi_network_maps/assets/icons/isometric/pyramid.svg +28 -0
- unifi_network_maps/assets/icons/isometric/queue.svg +38 -0
- unifi_network_maps/assets/icons/isometric/router.svg +39 -0
- unifi_network_maps/assets/icons/isometric/server.svg +112 -0
- unifi_network_maps/assets/icons/isometric/speech.svg +70 -0
- unifi_network_maps/assets/icons/isometric/sphere.svg +15 -0
- unifi_network_maps/assets/icons/isometric/storage.svg +92 -0
- unifi_network_maps/assets/icons/isometric/switch-module.svg +45 -0
- unifi_network_maps/assets/icons/isometric/tower.svg +50 -0
- unifi_network_maps/assets/icons/isometric/truck-2.svg +1 -0
- unifi_network_maps/assets/icons/isometric/truck.svg +1 -0
- unifi_network_maps/assets/icons/isometric/user.svg +231 -0
- unifi_network_maps/assets/icons/isometric/vm.svg +50 -0
- unifi_network_maps/assets/icons/laptop.svg +1 -0
- unifi_network_maps/assets/icons/router-network.svg +1 -0
- unifi_network_maps/assets/icons/server-network.svg +1 -0
- unifi_network_maps/assets/icons/server.svg +1 -0
- unifi_network_maps/assets/themes/dark.yaml +50 -0
- unifi_network_maps/assets/themes/default.yaml +47 -0
- unifi_network_maps/cli/__init__.py +5 -0
- unifi_network_maps/cli/__main__.py +8 -0
- unifi_network_maps/cli/args.py +166 -0
- unifi_network_maps/cli/main.py +134 -0
- unifi_network_maps/cli/render.py +255 -0
- unifi_network_maps/cli/runtime.py +157 -0
- unifi_network_maps/io/__init__.py +1 -0
- unifi_network_maps/io/debug.py +60 -0
- unifi_network_maps/io/export.py +32 -0
- unifi_network_maps/io/mkdocs_assets.py +21 -0
- unifi_network_maps/io/mock_data.py +23 -0
- unifi_network_maps/io/mock_generate.py +7 -0
- unifi_network_maps/model/__init__.py +1 -0
- unifi_network_maps/model/labels.py +35 -0
- unifi_network_maps/model/lldp.py +99 -0
- unifi_network_maps/model/mock.py +307 -0
- unifi_network_maps/model/ports.py +23 -0
- unifi_network_maps/model/topology.py +909 -0
- unifi_network_maps/render/__init__.py +1 -0
- unifi_network_maps/render/device_ports_md.py +492 -0
- unifi_network_maps/render/legend.py +30 -0
- unifi_network_maps/render/lldp_md.py +352 -0
- unifi_network_maps/render/markdown_tables.py +21 -0
- unifi_network_maps/render/mermaid.py +273 -0
- unifi_network_maps/render/mermaid_theme.py +56 -0
- unifi_network_maps/render/mkdocs.py +167 -0
- unifi_network_maps/render/svg.py +1235 -0
- unifi_network_maps/render/svg_theme.py +64 -0
- unifi_network_maps/render/templates/device_port_block.md.j2 +5 -0
- unifi_network_maps/render/templates/legend_compact.html.j2 +14 -0
- unifi_network_maps/render/templates/lldp_device_section.md.j2 +15 -0
- unifi_network_maps/render/templates/markdown_section.md.j2 +3 -0
- unifi_network_maps/render/templates/mermaid_legend.mmd.j2 +30 -0
- unifi_network_maps/render/templates/mkdocs_document.md.j2 +23 -0
- unifi_network_maps/render/templates/mkdocs_dual_theme_style.html.j2 +8 -0
- unifi_network_maps/render/templates/mkdocs_html_block.html.j2 +2 -0
- unifi_network_maps/render/templates/mkdocs_legend.css.j2 +29 -0
- unifi_network_maps/render/templates/mkdocs_legend.js.j2 +18 -0
- unifi_network_maps/render/templates/mkdocs_mermaid_block.md.j2 +4 -0
- unifi_network_maps/render/templating.py +19 -0
- unifi_network_maps/render/theme.py +109 -0
- unifi_network_maps-1.4.11.dist-info/METADATA +290 -0
- unifi_network_maps-1.4.11.dist-info/RECORD +99 -0
- unifi_network_maps-1.4.11.dist-info/WHEEL +5 -0
- unifi_network_maps-1.4.11.dist-info/entry_points.txt +2 -0
- unifi_network_maps-1.4.11.dist-info/licenses/LICENSE +21 -0
- unifi_network_maps-1.4.11.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Runtime data preparation for CLI rendering."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import logging
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ..adapters.config import Config
|
|
10
|
+
from ..adapters.unifi import fetch_clients, fetch_devices
|
|
11
|
+
from ..io.debug import debug_dump_devices
|
|
12
|
+
from ..model.topology import (
|
|
13
|
+
ClientPortMap,
|
|
14
|
+
Device,
|
|
15
|
+
TopologyResult,
|
|
16
|
+
build_client_edges,
|
|
17
|
+
build_client_port_map,
|
|
18
|
+
build_device_index,
|
|
19
|
+
build_topology,
|
|
20
|
+
group_devices_by_type,
|
|
21
|
+
normalize_devices,
|
|
22
|
+
)
|
|
23
|
+
from ..render.mermaid_theme import MermaidTheme
|
|
24
|
+
from ..render.theme import load_theme
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_devices_data(
|
|
30
|
+
args: argparse.Namespace,
|
|
31
|
+
config: Config | None,
|
|
32
|
+
site: str,
|
|
33
|
+
*,
|
|
34
|
+
raw_devices_override: list[object] | None = None,
|
|
35
|
+
) -> tuple[list[object], list[Device]]:
|
|
36
|
+
if raw_devices_override is None:
|
|
37
|
+
if config is None:
|
|
38
|
+
raise ValueError("Config required to fetch devices")
|
|
39
|
+
raw_devices = list(
|
|
40
|
+
fetch_devices(config, site=site, detailed=True, use_cache=not args.no_cache)
|
|
41
|
+
)
|
|
42
|
+
else:
|
|
43
|
+
raw_devices = raw_devices_override
|
|
44
|
+
devices = normalize_devices(raw_devices)
|
|
45
|
+
if args.debug_dump:
|
|
46
|
+
debug_dump_devices(raw_devices, devices, sample_count=max(0, args.debug_sample))
|
|
47
|
+
return raw_devices, devices
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def build_topology_data(
|
|
51
|
+
args: argparse.Namespace,
|
|
52
|
+
config: Config | None,
|
|
53
|
+
site: str,
|
|
54
|
+
*,
|
|
55
|
+
include_ports: bool | None = None,
|
|
56
|
+
raw_devices_override: list[object] | None = None,
|
|
57
|
+
) -> tuple[list[Device], list[str], TopologyResult]:
|
|
58
|
+
_raw_devices, devices = load_devices_data(
|
|
59
|
+
args,
|
|
60
|
+
config,
|
|
61
|
+
site,
|
|
62
|
+
raw_devices_override=raw_devices_override,
|
|
63
|
+
)
|
|
64
|
+
groups_for_rank = group_devices_by_type(devices)
|
|
65
|
+
gateways = groups_for_rank.get("gateway", [])
|
|
66
|
+
topology = build_topology(
|
|
67
|
+
devices,
|
|
68
|
+
include_ports=include_ports if include_ports is not None else args.include_ports,
|
|
69
|
+
only_unifi=args.only_unifi,
|
|
70
|
+
gateways=gateways,
|
|
71
|
+
)
|
|
72
|
+
return devices, gateways, topology
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def build_edges_with_clients(
|
|
76
|
+
args: argparse.Namespace,
|
|
77
|
+
edges: list,
|
|
78
|
+
devices: list[Device],
|
|
79
|
+
config: Config | None,
|
|
80
|
+
site: str,
|
|
81
|
+
*,
|
|
82
|
+
clients_override: list[object] | None = None,
|
|
83
|
+
) -> tuple[list, list | None]:
|
|
84
|
+
clients = None
|
|
85
|
+
if args.include_clients:
|
|
86
|
+
if clients_override is None:
|
|
87
|
+
if config is None:
|
|
88
|
+
raise ValueError("Config required to fetch clients")
|
|
89
|
+
clients = list(fetch_clients(config, site=site, use_cache=not args.no_cache))
|
|
90
|
+
else:
|
|
91
|
+
clients = clients_override
|
|
92
|
+
device_index = build_device_index(devices)
|
|
93
|
+
edges = edges + build_client_edges(
|
|
94
|
+
clients,
|
|
95
|
+
device_index,
|
|
96
|
+
include_ports=args.include_ports,
|
|
97
|
+
client_mode=args.client_scope,
|
|
98
|
+
)
|
|
99
|
+
return edges, clients
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def select_edges(topology: TopologyResult) -> tuple[list, bool]:
|
|
103
|
+
if topology.tree_edges:
|
|
104
|
+
return topology.tree_edges, True
|
|
105
|
+
logging.warning("No gateway found for hierarchy; rendering raw edges.")
|
|
106
|
+
return topology.raw_edges, False
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def load_topology_for_render(
|
|
110
|
+
args: argparse.Namespace,
|
|
111
|
+
*,
|
|
112
|
+
config: Config | None,
|
|
113
|
+
site: str,
|
|
114
|
+
mock_devices: list[object] | None,
|
|
115
|
+
) -> tuple[list[Device], TopologyResult] | None:
|
|
116
|
+
try:
|
|
117
|
+
include_ports = True if args.format == "mkdocs" else None
|
|
118
|
+
devices, _gateways, topology = build_topology_data(
|
|
119
|
+
args,
|
|
120
|
+
config,
|
|
121
|
+
site,
|
|
122
|
+
include_ports=include_ports,
|
|
123
|
+
raw_devices_override=mock_devices,
|
|
124
|
+
)
|
|
125
|
+
except Exception as exc:
|
|
126
|
+
logging.error("Failed to build topology: %s", exc)
|
|
127
|
+
return None
|
|
128
|
+
return devices, topology
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def load_dark_mermaid_theme() -> MermaidTheme | None:
|
|
132
|
+
dark_theme_path = Path(__file__).resolve().parents[1] / "assets" / "themes" / "dark.yaml"
|
|
133
|
+
try:
|
|
134
|
+
dark_theme, _ = load_theme(dark_theme_path)
|
|
135
|
+
except Exception as exc: # noqa: BLE001
|
|
136
|
+
logger.warning("Failed to load dark theme: %s", exc)
|
|
137
|
+
return None
|
|
138
|
+
return dark_theme
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def resolve_mkdocs_client_ports(
|
|
142
|
+
args: argparse.Namespace,
|
|
143
|
+
devices: list[Device],
|
|
144
|
+
config: Config | None,
|
|
145
|
+
site: str,
|
|
146
|
+
mock_clients: list[object] | None,
|
|
147
|
+
) -> tuple[ClientPortMap | None, int | None]:
|
|
148
|
+
if not args.include_clients:
|
|
149
|
+
return None, None
|
|
150
|
+
if mock_clients is None:
|
|
151
|
+
if config is None:
|
|
152
|
+
return None, 2
|
|
153
|
+
clients = list(fetch_clients(config, site=site))
|
|
154
|
+
else:
|
|
155
|
+
clients = mock_clients
|
|
156
|
+
client_ports = build_client_port_map(devices, clients, client_mode=args.client_scope)
|
|
157
|
+
return client_ports, None
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Package module."""
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Debug helpers for dumping device data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from collections.abc import Iterable, Sequence
|
|
8
|
+
|
|
9
|
+
from ..model.topology import Device, group_devices_by_type
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def device_to_dict(device: object) -> dict[str, object]:
|
|
15
|
+
to_dict = getattr(device, "to_dict", None)
|
|
16
|
+
if callable(to_dict):
|
|
17
|
+
result = to_dict()
|
|
18
|
+
if isinstance(result, dict):
|
|
19
|
+
return result
|
|
20
|
+
return {"repr": repr(result)}
|
|
21
|
+
if hasattr(device, "__dict__"):
|
|
22
|
+
return dict(device.__dict__)
|
|
23
|
+
if isinstance(device, dict):
|
|
24
|
+
return dict(device)
|
|
25
|
+
return {"repr": repr(device)}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def debug_dump_devices(
|
|
29
|
+
raw_devices: Sequence[object],
|
|
30
|
+
normalized: Iterable[Device],
|
|
31
|
+
*,
|
|
32
|
+
sample_count: int,
|
|
33
|
+
) -> None:
|
|
34
|
+
name_to_device: dict[str, object] = {}
|
|
35
|
+
for device in raw_devices:
|
|
36
|
+
name = getattr(device, "name", None)
|
|
37
|
+
if name:
|
|
38
|
+
name_to_device[name] = device
|
|
39
|
+
|
|
40
|
+
groups = group_devices_by_type(normalized)
|
|
41
|
+
gateways = groups.get("gateway", [])
|
|
42
|
+
samples: list[str] = []
|
|
43
|
+
for group in ("switch", "ap", "other"):
|
|
44
|
+
for name in groups.get(group, []):
|
|
45
|
+
if name not in gateways:
|
|
46
|
+
samples.append(name)
|
|
47
|
+
if len(samples) >= sample_count:
|
|
48
|
+
break
|
|
49
|
+
if len(samples) >= sample_count:
|
|
50
|
+
break
|
|
51
|
+
|
|
52
|
+
selected_names = gateways[:1] + samples
|
|
53
|
+
payload = []
|
|
54
|
+
for name in selected_names:
|
|
55
|
+
device = name_to_device.get(name)
|
|
56
|
+
if device is None:
|
|
57
|
+
continue
|
|
58
|
+
payload.append({"name": name, "data": device_to_dict(device)})
|
|
59
|
+
|
|
60
|
+
logger.info("Debug dump devices: %s", json.dumps(payload, indent=2, sort_keys=True))
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Output helpers for files and stdout."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def write_output(content: str, *, output_path: str | None, stdout: bool) -> None:
|
|
12
|
+
if output_path:
|
|
13
|
+
_write_atomic(Path(output_path), content)
|
|
14
|
+
if stdout or not output_path:
|
|
15
|
+
sys.stdout.write(content)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _write_atomic(path: Path, content: str) -> None:
|
|
19
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
with tempfile.NamedTemporaryFile(
|
|
21
|
+
mode="w",
|
|
22
|
+
encoding="utf-8",
|
|
23
|
+
dir=str(path.parent),
|
|
24
|
+
prefix=f".{path.name}.",
|
|
25
|
+
suffix=".tmp",
|
|
26
|
+
delete=False,
|
|
27
|
+
) as temp_file:
|
|
28
|
+
temp_file.write(content)
|
|
29
|
+
temp_file.flush()
|
|
30
|
+
os.fsync(temp_file.fileno())
|
|
31
|
+
tmp_path = Path(temp_file.name)
|
|
32
|
+
tmp_path.replace(path)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""MkDocs asset output helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ..render.templating import render_template
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def write_mkdocs_sidebar_assets(output_path: str) -> None:
|
|
11
|
+
output_dir = Path(output_path).resolve().parent
|
|
12
|
+
assets_dir = output_dir / "assets"
|
|
13
|
+
assets_dir.mkdir(parents=True, exist_ok=True)
|
|
14
|
+
(assets_dir / "legend.js").write_text(
|
|
15
|
+
render_template("mkdocs_legend.js.j2"),
|
|
16
|
+
encoding="utf-8",
|
|
17
|
+
)
|
|
18
|
+
(assets_dir / "legend.css").write_text(
|
|
19
|
+
render_template("mkdocs_legend.css.j2"),
|
|
20
|
+
encoding="utf-8",
|
|
21
|
+
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Load mock UniFi data from JSON fixtures."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _as_list(value: object, name: str) -> list[object]:
|
|
10
|
+
if value is None:
|
|
11
|
+
return []
|
|
12
|
+
if isinstance(value, list):
|
|
13
|
+
return value
|
|
14
|
+
raise ValueError(f"Mock data field '{name}' must be a list")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_mock_data(path: str) -> tuple[list[object], list[object]]:
|
|
18
|
+
payload = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
19
|
+
if not isinstance(payload, dict):
|
|
20
|
+
raise ValueError("Mock data must be a JSON object")
|
|
21
|
+
devices = _as_list(payload.get("devices"), "devices")
|
|
22
|
+
clients = _as_list(payload.get("clients"), "clients")
|
|
23
|
+
return devices, clients
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Package module."""
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Edge label helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def compose_port_label(left: str, right: str, port_map: dict[tuple[str, str], str]) -> str | None:
|
|
9
|
+
left_label = port_map.get((left, right))
|
|
10
|
+
right_label = port_map.get((right, left))
|
|
11
|
+
if left_label and right_label:
|
|
12
|
+
return f"{left}: {left_label} <-> {right}: {right_label}"
|
|
13
|
+
if left_label:
|
|
14
|
+
return f"{left}: {left_label} <-> {right}: ?"
|
|
15
|
+
if right_label:
|
|
16
|
+
return f"{left}: ? <-> {right}: {right_label}"
|
|
17
|
+
return None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def order_edge_names(
|
|
21
|
+
left: str,
|
|
22
|
+
right: str,
|
|
23
|
+
port_map: dict[tuple[str, str], str],
|
|
24
|
+
rank_for_name: Callable[[str], int],
|
|
25
|
+
) -> tuple[str, str]:
|
|
26
|
+
left_label = port_map.get((left, right))
|
|
27
|
+
right_label = port_map.get((right, left))
|
|
28
|
+
if left_label is None and right_label is not None:
|
|
29
|
+
return (right, left)
|
|
30
|
+
if left_label and right_label:
|
|
31
|
+
left_rank = rank_for_name(left)
|
|
32
|
+
right_rank = rank_for_name(right)
|
|
33
|
+
if (left_rank, left.lower()) > (right_rank, right.lower()):
|
|
34
|
+
return (right, left)
|
|
35
|
+
return (left, right)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""LLDP parsing and port label helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from .ports import extract_port_number, normalize_port_label
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class LLDPEntry:
|
|
12
|
+
chassis_id: str
|
|
13
|
+
port_id: str
|
|
14
|
+
port_desc: str | None = None
|
|
15
|
+
local_port_name: str | None = None
|
|
16
|
+
local_port_idx: int | None = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def coerce_lldp(entry: object) -> LLDPEntry:
|
|
20
|
+
if isinstance(entry, dict):
|
|
21
|
+
chassis_id = entry.get("chassis_id") or entry.get("chassisId")
|
|
22
|
+
port_id = entry.get("port_id") or entry.get("portId")
|
|
23
|
+
port_desc = (
|
|
24
|
+
entry.get("port_desc")
|
|
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")
|
|
31
|
+
else:
|
|
32
|
+
chassis_id = getattr(entry, "chassis_id", None) or getattr(entry, "chassisId", None)
|
|
33
|
+
port_id = getattr(entry, "port_id", None) or getattr(entry, "portId", None)
|
|
34
|
+
port_desc = (
|
|
35
|
+
getattr(entry, "port_desc", None)
|
|
36
|
+
or getattr(entry, "portDesc", None)
|
|
37
|
+
or getattr(entry, "port_descr", None)
|
|
38
|
+
or getattr(entry, "portDescr", None)
|
|
39
|
+
)
|
|
40
|
+
local_port_name = getattr(entry, "local_port_name", None) or getattr(
|
|
41
|
+
entry, "localPortName", None
|
|
42
|
+
)
|
|
43
|
+
local_port_idx = getattr(entry, "local_port_idx", None) or getattr(
|
|
44
|
+
entry, "localPortIdx", None
|
|
45
|
+
)
|
|
46
|
+
if not chassis_id or not port_id:
|
|
47
|
+
raise ValueError("LLDP entry missing chassis_id or port_id")
|
|
48
|
+
return LLDPEntry(
|
|
49
|
+
chassis_id=str(chassis_id),
|
|
50
|
+
port_id=str(port_id),
|
|
51
|
+
port_desc=str(port_desc) if port_desc else None,
|
|
52
|
+
local_port_name=str(local_port_name) if local_port_name else None,
|
|
53
|
+
local_port_idx=int(local_port_idx) if local_port_idx is not None else None,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _looks_like_mac(value: str | None) -> bool:
|
|
58
|
+
if not value:
|
|
59
|
+
return False
|
|
60
|
+
cleaned = value.strip().lower()
|
|
61
|
+
if cleaned.count(":") == 5:
|
|
62
|
+
return all(
|
|
63
|
+
len(part) == 2 and all(ch in "0123456789abcdef" for ch in part)
|
|
64
|
+
for part in cleaned.split(":")
|
|
65
|
+
)
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _port_label_parts(entry: LLDPEntry) -> tuple[int | None, str | None, str | None]:
|
|
70
|
+
number = entry.local_port_idx
|
|
71
|
+
name = normalize_port_label(entry.local_port_name) if entry.local_port_name else None
|
|
72
|
+
desc = (
|
|
73
|
+
entry.port_desc.strip()
|
|
74
|
+
if entry.port_desc and not _looks_like_mac(entry.port_desc)
|
|
75
|
+
else None
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
if entry.port_id and not _looks_like_mac(entry.port_id) and name is None:
|
|
79
|
+
name = normalize_port_label(entry.port_id)
|
|
80
|
+
|
|
81
|
+
if number is None:
|
|
82
|
+
number = extract_port_number(name)
|
|
83
|
+
if number is None:
|
|
84
|
+
number = extract_port_number(desc)
|
|
85
|
+
|
|
86
|
+
return number, name, desc
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def local_port_label(entry: LLDPEntry) -> str | None:
|
|
90
|
+
number, name, desc = _port_label_parts(entry)
|
|
91
|
+
if number is not None and desc:
|
|
92
|
+
return f"Port {number} ({desc})"
|
|
93
|
+
if number is not None:
|
|
94
|
+
return f"Port {number}"
|
|
95
|
+
if name:
|
|
96
|
+
return name
|
|
97
|
+
if desc:
|
|
98
|
+
return desc
|
|
99
|
+
return None
|