unifi-network-maps 1.3.0__py3-none-any.whl → 1.4.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 +90 -9
- unifi_network_maps/cli/main.py +358 -25
- unifi_network_maps/io/mock_data.py +23 -0
- unifi_network_maps/io/mock_generate.py +299 -0
- unifi_network_maps/model/lldp.py +26 -12
- unifi_network_maps/model/topology.py +141 -7
- unifi_network_maps/render/device_ports_md.py +462 -0
- unifi_network_maps/render/lldp_md.py +275 -0
- unifi_network_maps/render/mermaid.py +67 -3
- unifi_network_maps/render/svg.py +18 -6
- {unifi_network_maps-1.3.0.dist-info → unifi_network_maps-1.4.0.dist-info}/METADATA +109 -18
- {unifi_network_maps-1.3.0.dist-info → unifi_network_maps-1.4.0.dist-info}/RECORD +17 -14
- unifi_network_maps-1.3.0.dist-info/licenses/LICENSES.md +0 -10
- {unifi_network_maps-1.3.0.dist-info → unifi_network_maps-1.4.0.dist-info}/WHEEL +0 -0
- {unifi_network_maps-1.3.0.dist-info → unifi_network_maps-1.4.0.dist-info}/entry_points.txt +0 -0
- {unifi_network_maps-1.3.0.dist-info → unifi_network_maps-1.4.0.dist-info}/licenses/LICENSE +0 -0
- {unifi_network_maps-1.3.0.dist-info → unifi_network_maps-1.4.0.dist-info}/top_level.txt +0 -0
|
@@ -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,299 @@
|
|
|
1
|
+
"""Generate mock UniFi data using Faker."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import random
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from faker import Faker
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class MockOptions:
|
|
15
|
+
seed: int = 1337
|
|
16
|
+
switch_count: int = 1
|
|
17
|
+
ap_count: int = 2
|
|
18
|
+
wired_client_count: int = 2
|
|
19
|
+
wireless_client_count: int = 2
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class _MockState:
|
|
24
|
+
fake: Faker
|
|
25
|
+
rng: random.Random
|
|
26
|
+
used_macs: set[str] = field(default_factory=set)
|
|
27
|
+
used_ips: set[str] = field(default_factory=set)
|
|
28
|
+
used_names: set[str] = field(default_factory=set)
|
|
29
|
+
used_rooms: set[str] = field(default_factory=set)
|
|
30
|
+
core_port_next: int = 2
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def generate_mock_payload(options: MockOptions) -> dict[str, list[dict[str, Any]]]:
|
|
34
|
+
state = _build_state(options.seed)
|
|
35
|
+
devices, core_switch, aps = _build_devices(options, state)
|
|
36
|
+
clients = _build_clients(options, state, core_switch, aps)
|
|
37
|
+
return {"devices": devices, "clients": clients}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def mock_payload_json(options: MockOptions) -> str:
|
|
41
|
+
payload = generate_mock_payload(options)
|
|
42
|
+
return json.dumps(payload, indent=2, sort_keys=True)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _build_state(seed: int) -> _MockState:
|
|
46
|
+
fake = Faker("en_US")
|
|
47
|
+
fake.seed_instance(seed)
|
|
48
|
+
rng = random.Random(seed)
|
|
49
|
+
return _MockState(fake=fake, rng=rng)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _build_devices(
|
|
53
|
+
options: MockOptions, state: _MockState
|
|
54
|
+
) -> tuple[list[dict[str, Any]], dict[str, Any], list[dict[str, Any]]]:
|
|
55
|
+
gateway = _build_gateway(state)
|
|
56
|
+
core_switch = _build_core_switch(state)
|
|
57
|
+
_link_gateway_to_switch(state, gateway, core_switch)
|
|
58
|
+
access_switches = _build_access_switches(options.switch_count - 1, state, core_switch)
|
|
59
|
+
aps = _build_aps(options.ap_count, state, core_switch)
|
|
60
|
+
devices = [gateway, core_switch] + access_switches + aps
|
|
61
|
+
return devices, core_switch, aps
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _build_gateway(state: _MockState) -> dict[str, Any]:
|
|
65
|
+
device = _device_base(state, "Cloud Gateway", "udm", "UniFi Dream Machine Pro", "UDM-Pro")
|
|
66
|
+
_add_port(device, 9, poe_enabled=False, rng=state.rng)
|
|
67
|
+
return device
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _build_core_switch(state: _MockState) -> dict[str, Any]:
|
|
71
|
+
return _device_base(state, "Core Switch", "usw", "UniFi Switch 24 PoE", "USW-24-PoE")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _build_access_switches(
|
|
75
|
+
count: int, state: _MockState, core_switch: dict[str, Any]
|
|
76
|
+
) -> list[dict[str, Any]]:
|
|
77
|
+
switches = []
|
|
78
|
+
for _ in range(max(0, count)):
|
|
79
|
+
room = _unique_room(state)
|
|
80
|
+
name = _unique_name(state, f"Switch {room}")
|
|
81
|
+
device = _device_base(state, name, "usw", "UniFi Switch Lite 8 PoE", "USW-Lite-8-PoE")
|
|
82
|
+
_link_core_device(state, core_switch, device, poe_enabled=False)
|
|
83
|
+
switches.append(device)
|
|
84
|
+
return switches
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _build_aps(count: int, state: _MockState, core_switch: dict[str, Any]) -> list[dict[str, Any]]:
|
|
88
|
+
aps = []
|
|
89
|
+
for _ in range(max(0, count)):
|
|
90
|
+
room = _unique_room(state)
|
|
91
|
+
name = _unique_name(state, f"AP {room}")
|
|
92
|
+
device = _device_base(state, name, "uap", "UniFi AP 6 Lite", "U6-Lite")
|
|
93
|
+
_add_port(device, 1, poe_enabled=True, rng=state.rng)
|
|
94
|
+
_link_core_device(state, core_switch, device, poe_enabled=True)
|
|
95
|
+
aps.append(device)
|
|
96
|
+
return aps
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _build_clients(
|
|
100
|
+
options: MockOptions,
|
|
101
|
+
state: _MockState,
|
|
102
|
+
core_switch: dict[str, Any],
|
|
103
|
+
aps: list[dict[str, Any]],
|
|
104
|
+
) -> list[dict[str, Any]]:
|
|
105
|
+
clients = []
|
|
106
|
+
clients.extend(_build_wired_clients(options.wired_client_count, state, core_switch))
|
|
107
|
+
clients.extend(_build_wireless_clients(options.wireless_client_count, state, aps))
|
|
108
|
+
return clients
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _build_wired_clients(
|
|
112
|
+
count: int, state: _MockState, core_switch: dict[str, Any]
|
|
113
|
+
) -> list[dict[str, Any]]:
|
|
114
|
+
clients = []
|
|
115
|
+
for _ in range(max(0, count)):
|
|
116
|
+
port_idx = _next_core_port(state)
|
|
117
|
+
_add_port(core_switch, port_idx, poe_enabled=False, rng=state.rng)
|
|
118
|
+
name = _unique_client_name(state)
|
|
119
|
+
clients.append(
|
|
120
|
+
{
|
|
121
|
+
"name": name,
|
|
122
|
+
"is_wired": True,
|
|
123
|
+
"sw_mac": core_switch["mac"],
|
|
124
|
+
"sw_port": port_idx,
|
|
125
|
+
}
|
|
126
|
+
)
|
|
127
|
+
return clients
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _build_wireless_clients(
|
|
131
|
+
count: int, state: _MockState, aps: list[dict[str, Any]]
|
|
132
|
+
) -> list[dict[str, Any]]:
|
|
133
|
+
if not aps:
|
|
134
|
+
return []
|
|
135
|
+
clients = []
|
|
136
|
+
for idx in range(max(0, count)):
|
|
137
|
+
ap = aps[idx % len(aps)]
|
|
138
|
+
name = _unique_client_name(state)
|
|
139
|
+
clients.append(
|
|
140
|
+
{
|
|
141
|
+
"name": name,
|
|
142
|
+
"is_wired": False,
|
|
143
|
+
"ap_mac": ap["mac"],
|
|
144
|
+
"ap_port": 1,
|
|
145
|
+
}
|
|
146
|
+
)
|
|
147
|
+
return clients
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _device_base(
|
|
151
|
+
state: _MockState, name: str, dev_type: str, model_name: str, model: str
|
|
152
|
+
) -> dict[str, Any]:
|
|
153
|
+
version = _pick_version(state, dev_type)
|
|
154
|
+
return {
|
|
155
|
+
"name": name,
|
|
156
|
+
"model_name": model_name,
|
|
157
|
+
"model": model,
|
|
158
|
+
"mac": _unique_mac(state),
|
|
159
|
+
"ip": _unique_ip(state),
|
|
160
|
+
"type": dev_type,
|
|
161
|
+
"version": version,
|
|
162
|
+
"port_table": [],
|
|
163
|
+
"lldp_info": [],
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _pick_version(state: _MockState, dev_type: str) -> str:
|
|
168
|
+
versions = {
|
|
169
|
+
"udm": ["3.1.0", "3.1.1"],
|
|
170
|
+
"usw": ["7.0.0", "7.1.2"],
|
|
171
|
+
"uap": ["6.6.55", "6.7.10"],
|
|
172
|
+
}
|
|
173
|
+
return state.rng.choice(versions.get(dev_type, ["1.0.0"]))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _link_gateway_to_switch(
|
|
177
|
+
state: _MockState, gateway: dict[str, Any], core_switch: dict[str, Any]
|
|
178
|
+
) -> None:
|
|
179
|
+
_add_port(core_switch, 1, poe_enabled=False, rng=state.rng)
|
|
180
|
+
_add_lldp_link(
|
|
181
|
+
gateway,
|
|
182
|
+
core_switch,
|
|
183
|
+
local_port=9,
|
|
184
|
+
remote_port=1,
|
|
185
|
+
remote_name=core_switch["name"],
|
|
186
|
+
)
|
|
187
|
+
_add_lldp_link(
|
|
188
|
+
core_switch,
|
|
189
|
+
gateway,
|
|
190
|
+
local_port=1,
|
|
191
|
+
remote_port=9,
|
|
192
|
+
remote_name=gateway["name"],
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _link_core_device(
|
|
197
|
+
state: _MockState,
|
|
198
|
+
core_switch: dict[str, Any],
|
|
199
|
+
device: dict[str, Any],
|
|
200
|
+
*,
|
|
201
|
+
poe_enabled: bool,
|
|
202
|
+
) -> None:
|
|
203
|
+
port_idx = _next_core_port(state)
|
|
204
|
+
_add_port(core_switch, port_idx, poe_enabled=poe_enabled, rng=state.rng)
|
|
205
|
+
_add_lldp_link(
|
|
206
|
+
core_switch,
|
|
207
|
+
device,
|
|
208
|
+
local_port=port_idx,
|
|
209
|
+
remote_port=1,
|
|
210
|
+
remote_name=device["name"],
|
|
211
|
+
)
|
|
212
|
+
_add_lldp_link(
|
|
213
|
+
device,
|
|
214
|
+
core_switch,
|
|
215
|
+
local_port=1,
|
|
216
|
+
remote_port=port_idx,
|
|
217
|
+
remote_name=core_switch["name"],
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _add_lldp_link(
|
|
222
|
+
source: dict[str, Any],
|
|
223
|
+
dest: dict[str, Any],
|
|
224
|
+
*,
|
|
225
|
+
local_port: int,
|
|
226
|
+
remote_port: int,
|
|
227
|
+
remote_name: str,
|
|
228
|
+
) -> None:
|
|
229
|
+
source["lldp_info"].append(
|
|
230
|
+
{
|
|
231
|
+
"chassis_id": dest["mac"],
|
|
232
|
+
"port_id": f"Port {remote_port}",
|
|
233
|
+
"port_desc": remote_name,
|
|
234
|
+
"local_port_name": f"Port {local_port}",
|
|
235
|
+
"local_port_idx": local_port,
|
|
236
|
+
}
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _add_port(
|
|
241
|
+
device: dict[str, Any], port_idx: int, *, poe_enabled: bool, rng: random.Random
|
|
242
|
+
) -> None:
|
|
243
|
+
entry = {
|
|
244
|
+
"port_idx": port_idx,
|
|
245
|
+
"name": f"Port {port_idx}",
|
|
246
|
+
"ifname": f"eth{max(0, port_idx - 1)}",
|
|
247
|
+
"poe_enable": poe_enabled,
|
|
248
|
+
"port_poe": poe_enabled,
|
|
249
|
+
}
|
|
250
|
+
if poe_enabled:
|
|
251
|
+
entry["poe_power"] = round(rng.uniform(4.5, 7.5), 1)
|
|
252
|
+
device["port_table"].append(entry)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _unique_mac(state: _MockState) -> str:
|
|
256
|
+
return _unique_value(state, state.fake.mac_address, state.used_macs)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _unique_ip(state: _MockState) -> str:
|
|
260
|
+
return _unique_value(state, state.fake.ipv4_private, state.used_ips)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _unique_room(state: _MockState) -> str:
|
|
264
|
+
def _room() -> str:
|
|
265
|
+
return state.fake.word().title()
|
|
266
|
+
|
|
267
|
+
return _unique_value(state, _room, state.used_rooms)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _unique_name(state: _MockState, candidate: str) -> str:
|
|
271
|
+
if candidate not in state.used_names:
|
|
272
|
+
state.used_names.add(candidate)
|
|
273
|
+
return candidate
|
|
274
|
+
suffix = state.rng.randint(2, 99)
|
|
275
|
+
value = f"{candidate} {suffix}"
|
|
276
|
+
state.used_names.add(value)
|
|
277
|
+
return value
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _unique_client_name(state: _MockState) -> str:
|
|
281
|
+
def _name() -> str:
|
|
282
|
+
return state.fake.hostname()
|
|
283
|
+
|
|
284
|
+
return _unique_value(state, _name, state.used_names)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _unique_value(state: _MockState, generator, used: set[str]) -> str:
|
|
288
|
+
for _ in range(100):
|
|
289
|
+
value = str(generator())
|
|
290
|
+
if value not in used:
|
|
291
|
+
used.add(value)
|
|
292
|
+
return value
|
|
293
|
+
raise ValueError("Failed to generate a unique value")
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _next_core_port(state: _MockState) -> int:
|
|
297
|
+
port_idx = state.core_port_next
|
|
298
|
+
state.core_port_next += 1
|
|
299
|
+
return port_idx
|
unifi_network_maps/model/lldp.py
CHANGED
|
@@ -17,18 +17,32 @@ class LLDPEntry:
|
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
def coerce_lldp(entry: object) -> LLDPEntry:
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
entry
|
|
30
|
-
|
|
31
|
-
|
|
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
|
+
)
|
|
32
46
|
if not chassis_id or not port_id:
|
|
33
47
|
raise ValueError("LLDP entry missing chassis_id or port_id")
|
|
34
48
|
return LLDPEntry(
|
|
@@ -6,7 +6,7 @@ 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
|
|
9
|
+
from typing import Protocol
|
|
10
10
|
|
|
11
11
|
from .labels import compose_port_label, order_edge_names
|
|
12
12
|
from .lldp import LLDPEntry, coerce_lldp, local_port_label
|
|
@@ -19,6 +19,7 @@ logger = logging.getLogger(__name__)
|
|
|
19
19
|
class Device:
|
|
20
20
|
name: str
|
|
21
21
|
model_name: str
|
|
22
|
+
model: str
|
|
22
23
|
mac: str
|
|
23
24
|
ip: str
|
|
24
25
|
type: str
|
|
@@ -27,6 +28,7 @@ class Device:
|
|
|
27
28
|
poe_ports: dict[int, bool] = field(default_factory=dict)
|
|
28
29
|
uplink: UplinkInfo | None = None
|
|
29
30
|
last_uplink: UplinkInfo | None = None
|
|
31
|
+
version: str = ""
|
|
30
32
|
|
|
31
33
|
|
|
32
34
|
@dataclass(frozen=True)
|
|
@@ -35,6 +37,7 @@ class Edge:
|
|
|
35
37
|
right: str
|
|
36
38
|
label: str | None = None
|
|
37
39
|
poe: bool = False
|
|
40
|
+
wireless: bool = False
|
|
38
41
|
|
|
39
42
|
|
|
40
43
|
class DeviceLike(Protocol):
|
|
@@ -56,6 +59,8 @@ class DeviceLike(Protocol):
|
|
|
56
59
|
last_uplink_mac: object | None
|
|
57
60
|
uplink_device_name: object | None
|
|
58
61
|
uplink_remote_port: object | None
|
|
62
|
+
version: object | None
|
|
63
|
+
displayable_version: object | None
|
|
59
64
|
|
|
60
65
|
|
|
61
66
|
@dataclass(frozen=True)
|
|
@@ -70,17 +75,22 @@ class PortInfo:
|
|
|
70
75
|
port_idx: int | None
|
|
71
76
|
name: str | None
|
|
72
77
|
ifname: str | None
|
|
78
|
+
speed: int | None
|
|
79
|
+
aggregation_group: str | None
|
|
73
80
|
port_poe: bool
|
|
74
81
|
poe_enable: bool
|
|
75
82
|
poe_good: bool
|
|
76
83
|
poe_power: float | None
|
|
77
84
|
|
|
78
85
|
|
|
79
|
-
PortMap
|
|
80
|
-
PoeMap
|
|
86
|
+
type PortMap = dict[tuple[str, str], str]
|
|
87
|
+
type PoeMap = dict[tuple[str, str], bool]
|
|
88
|
+
type ClientPortMap = dict[str, list[tuple[int, str]]]
|
|
81
89
|
|
|
82
90
|
|
|
83
91
|
def _get_attr(obj: object, name: str) -> object | None:
|
|
92
|
+
if isinstance(obj, dict):
|
|
93
|
+
return obj.get(name)
|
|
84
94
|
return getattr(obj, name, None)
|
|
85
95
|
|
|
86
96
|
|
|
@@ -119,6 +129,44 @@ def _as_int(value: object | None) -> int | None:
|
|
|
119
129
|
return None
|
|
120
130
|
|
|
121
131
|
|
|
132
|
+
def _as_group_id(value: object | None) -> str | None:
|
|
133
|
+
if value is None:
|
|
134
|
+
return None
|
|
135
|
+
if isinstance(value, bool):
|
|
136
|
+
return None
|
|
137
|
+
if isinstance(value, int):
|
|
138
|
+
return str(value)
|
|
139
|
+
if isinstance(value, str):
|
|
140
|
+
return value.strip() or None
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _aggregation_group(port_entry: object) -> object | None:
|
|
145
|
+
keys = (
|
|
146
|
+
"aggregation_group",
|
|
147
|
+
"aggregation_id",
|
|
148
|
+
"aggregate_id",
|
|
149
|
+
"agg_id",
|
|
150
|
+
"lag_id",
|
|
151
|
+
"lag_group",
|
|
152
|
+
"link_aggregation_group",
|
|
153
|
+
"link_aggregation_id",
|
|
154
|
+
"aggregate",
|
|
155
|
+
"aggregated_by",
|
|
156
|
+
)
|
|
157
|
+
if isinstance(port_entry, dict):
|
|
158
|
+
for key in keys:
|
|
159
|
+
value = port_entry.get(key)
|
|
160
|
+
if value not in (None, "", False):
|
|
161
|
+
return value
|
|
162
|
+
return None
|
|
163
|
+
for key in keys:
|
|
164
|
+
value = _get_attr(port_entry, key)
|
|
165
|
+
if value not in (None, "", False):
|
|
166
|
+
return value
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
|
|
122
170
|
def _resolve_port_idx_from_lldp(lldp_entry: LLDPEntry, port_table: list[PortInfo]) -> int | None:
|
|
123
171
|
if lldp_entry.local_port_idx is not None:
|
|
124
172
|
return lldp_entry.local_port_idx
|
|
@@ -149,6 +197,8 @@ def _port_info_from_entry(port_entry: object) -> PortInfo:
|
|
|
149
197
|
port_idx = port_entry.get("port_idx") or port_entry.get("portIdx")
|
|
150
198
|
name = port_entry.get("name")
|
|
151
199
|
ifname = port_entry.get("ifname")
|
|
200
|
+
speed = port_entry.get("speed")
|
|
201
|
+
aggregation_group = _aggregation_group(port_entry)
|
|
152
202
|
port_poe = _as_bool(port_entry.get("port_poe"))
|
|
153
203
|
poe_enable = _as_bool(port_entry.get("poe_enable"))
|
|
154
204
|
poe_good = _as_bool(port_entry.get("poe_good"))
|
|
@@ -157,6 +207,8 @@ def _port_info_from_entry(port_entry: object) -> PortInfo:
|
|
|
157
207
|
port_idx = _get_attr(port_entry, "port_idx") or _get_attr(port_entry, "portIdx")
|
|
158
208
|
name = _get_attr(port_entry, "name")
|
|
159
209
|
ifname = _get_attr(port_entry, "ifname")
|
|
210
|
+
speed = _get_attr(port_entry, "speed")
|
|
211
|
+
aggregation_group = _aggregation_group(port_entry)
|
|
160
212
|
port_poe = _as_bool(_get_attr(port_entry, "port_poe"))
|
|
161
213
|
poe_enable = _as_bool(_get_attr(port_entry, "poe_enable"))
|
|
162
214
|
poe_good = _as_bool(_get_attr(port_entry, "poe_good"))
|
|
@@ -165,6 +217,8 @@ def _port_info_from_entry(port_entry: object) -> PortInfo:
|
|
|
165
217
|
port_idx=_as_int(port_idx),
|
|
166
218
|
name=str(name) if isinstance(name, str) and name.strip() else None,
|
|
167
219
|
ifname=str(ifname) if isinstance(ifname, str) and ifname.strip() else None,
|
|
220
|
+
speed=_as_int(speed),
|
|
221
|
+
aggregation_group=_as_group_id(aggregation_group),
|
|
168
222
|
port_poe=port_poe,
|
|
169
223
|
poe_enable=poe_enable,
|
|
170
224
|
poe_good=poe_good,
|
|
@@ -239,9 +293,11 @@ def _uplink_info(device: DeviceLike) -> tuple[UplinkInfo | None, UplinkInfo | No
|
|
|
239
293
|
def coerce_device(device: DeviceLike) -> Device:
|
|
240
294
|
name = _get_attr(device, "name")
|
|
241
295
|
model_name = _get_attr(device, "model_name") or _get_attr(device, "model")
|
|
296
|
+
model = _get_attr(device, "model")
|
|
242
297
|
mac = _get_attr(device, "mac")
|
|
243
298
|
ip = _get_attr(device, "ip") or _get_attr(device, "ip_address")
|
|
244
299
|
dev_type = _get_attr(device, "type") or _get_attr(device, "device_type")
|
|
300
|
+
version = _get_attr(device, "displayable_version") or _get_attr(device, "version")
|
|
245
301
|
lldp_info = _get_attr(device, "lldp_info")
|
|
246
302
|
if lldp_info is None:
|
|
247
303
|
lldp_info = _get_attr(device, "lldp")
|
|
@@ -263,6 +319,7 @@ def coerce_device(device: DeviceLike) -> Device:
|
|
|
263
319
|
return Device(
|
|
264
320
|
name=str(name),
|
|
265
321
|
model_name=str(model_name or ""),
|
|
322
|
+
model=str(model or ""),
|
|
266
323
|
mac=str(mac),
|
|
267
324
|
ip=str(ip or ""),
|
|
268
325
|
type=str(dev_type or ""),
|
|
@@ -271,6 +328,7 @@ def coerce_device(device: DeviceLike) -> Device:
|
|
|
271
328
|
poe_ports=poe_ports,
|
|
272
329
|
uplink=uplink,
|
|
273
330
|
last_uplink=last_uplink,
|
|
331
|
+
version=str(version or ""),
|
|
274
332
|
)
|
|
275
333
|
|
|
276
334
|
|
|
@@ -407,16 +465,26 @@ def _client_is_wired(client: object) -> bool:
|
|
|
407
465
|
return bool(_client_field(client, "is_wired"))
|
|
408
466
|
|
|
409
467
|
|
|
468
|
+
def _client_matches_mode(client: object, mode: str) -> bool:
|
|
469
|
+
wired = _client_is_wired(client)
|
|
470
|
+
if mode == "all":
|
|
471
|
+
return True
|
|
472
|
+
if mode == "wireless":
|
|
473
|
+
return not wired
|
|
474
|
+
return wired
|
|
475
|
+
|
|
476
|
+
|
|
410
477
|
def build_client_edges(
|
|
411
478
|
clients: Iterable[object],
|
|
412
479
|
device_index: dict[str, str],
|
|
413
480
|
*,
|
|
414
481
|
include_ports: bool = False,
|
|
482
|
+
client_mode: str = "wired",
|
|
415
483
|
) -> list[Edge]:
|
|
416
484
|
edges: list[Edge] = []
|
|
417
485
|
seen: set[tuple[str, str]] = set()
|
|
418
486
|
for client in clients:
|
|
419
|
-
if not
|
|
487
|
+
if not _client_matches_mode(client, client_mode):
|
|
420
488
|
continue
|
|
421
489
|
name = _client_display_name(client)
|
|
422
490
|
uplink_mac = _client_uplink_mac(client)
|
|
@@ -433,20 +501,30 @@ def build_client_edges(
|
|
|
433
501
|
key = (device_name, name)
|
|
434
502
|
if key in seen:
|
|
435
503
|
continue
|
|
436
|
-
edges.append(
|
|
504
|
+
edges.append(
|
|
505
|
+
Edge(
|
|
506
|
+
left=device_name,
|
|
507
|
+
right=name,
|
|
508
|
+
label=label,
|
|
509
|
+
wireless=not _client_is_wired(client),
|
|
510
|
+
)
|
|
511
|
+
)
|
|
437
512
|
seen.add(key)
|
|
438
513
|
return edges
|
|
439
514
|
|
|
440
515
|
|
|
441
516
|
def build_node_type_map(
|
|
442
|
-
devices: Iterable[Device],
|
|
517
|
+
devices: Iterable[Device],
|
|
518
|
+
clients: Iterable[object] | None = None,
|
|
519
|
+
*,
|
|
520
|
+
client_mode: str = "wired",
|
|
443
521
|
) -> dict[str, str]:
|
|
444
522
|
node_types: dict[str, str] = {}
|
|
445
523
|
for device in devices:
|
|
446
524
|
node_types[device.name] = classify_device_type(device)
|
|
447
525
|
if clients:
|
|
448
526
|
for client in clients:
|
|
449
|
-
if not
|
|
527
|
+
if not _client_matches_mode(client, client_mode):
|
|
450
528
|
continue
|
|
451
529
|
name = _client_display_name(client)
|
|
452
530
|
if name:
|
|
@@ -502,6 +580,62 @@ def build_edges(
|
|
|
502
580
|
return edges
|
|
503
581
|
|
|
504
582
|
|
|
583
|
+
def build_port_map(devices: Iterable[Device], *, only_unifi: bool = True) -> PortMap:
|
|
584
|
+
ordered_devices = sorted(devices, key=lambda item: (item.name.lower(), item.mac.lower()))
|
|
585
|
+
index = build_device_index(ordered_devices)
|
|
586
|
+
device_by_name = {device.name: device for device in ordered_devices}
|
|
587
|
+
raw_links: list[tuple[str, str]] = []
|
|
588
|
+
seen: set[frozenset[str]] = set()
|
|
589
|
+
port_map: PortMap = {}
|
|
590
|
+
poe_map: PoeMap = {}
|
|
591
|
+
|
|
592
|
+
devices_with_lldp_edges = _collect_lldp_links(
|
|
593
|
+
ordered_devices,
|
|
594
|
+
index,
|
|
595
|
+
port_map,
|
|
596
|
+
poe_map,
|
|
597
|
+
raw_links,
|
|
598
|
+
seen,
|
|
599
|
+
only_unifi=only_unifi,
|
|
600
|
+
)
|
|
601
|
+
_collect_uplink_links(
|
|
602
|
+
ordered_devices,
|
|
603
|
+
devices_with_lldp_edges,
|
|
604
|
+
index,
|
|
605
|
+
device_by_name,
|
|
606
|
+
port_map,
|
|
607
|
+
poe_map,
|
|
608
|
+
raw_links,
|
|
609
|
+
seen,
|
|
610
|
+
include_ports=True,
|
|
611
|
+
only_unifi=only_unifi,
|
|
612
|
+
)
|
|
613
|
+
return port_map
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def build_client_port_map(
|
|
617
|
+
devices: Iterable[Device],
|
|
618
|
+
clients: Iterable[object],
|
|
619
|
+
*,
|
|
620
|
+
client_mode: str,
|
|
621
|
+
) -> ClientPortMap:
|
|
622
|
+
device_index = build_device_index(devices)
|
|
623
|
+
port_map: ClientPortMap = {}
|
|
624
|
+
for client in clients:
|
|
625
|
+
if not _client_matches_mode(client, client_mode):
|
|
626
|
+
continue
|
|
627
|
+
name = _client_display_name(client)
|
|
628
|
+
uplink_mac = _client_uplink_mac(client)
|
|
629
|
+
uplink_port = _client_uplink_port(client)
|
|
630
|
+
if not name or not uplink_mac or uplink_port is None:
|
|
631
|
+
continue
|
|
632
|
+
device_name = device_index.get(_normalize_mac(uplink_mac))
|
|
633
|
+
if not device_name:
|
|
634
|
+
continue
|
|
635
|
+
port_map.setdefault(device_name, []).append((uplink_port, name))
|
|
636
|
+
return port_map
|
|
637
|
+
|
|
638
|
+
|
|
505
639
|
def _collect_lldp_links(
|
|
506
640
|
devices: list[Device],
|
|
507
641
|
index: dict[str, str],
|