unifi-network-maps 1.4.14__py3-none-any.whl → 1.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- unifi_network_maps/__init__.py +1 -1
- unifi_network_maps/adapters/unifi.py +83 -101
- unifi_network_maps/assets/icons/modern/ap.svg +9 -0
- unifi_network_maps/assets/icons/modern/camera.svg +9 -0
- unifi_network_maps/assets/icons/modern/client.svg +9 -0
- unifi_network_maps/assets/icons/modern/game_console.svg +10 -0
- unifi_network_maps/assets/icons/modern/gateway.svg +17 -0
- unifi_network_maps/assets/icons/modern/iot.svg +9 -0
- unifi_network_maps/assets/icons/modern/nas.svg +9 -0
- unifi_network_maps/assets/icons/modern/other.svg +10 -0
- unifi_network_maps/assets/icons/modern/phone.svg +10 -0
- unifi_network_maps/assets/icons/modern/printer.svg +9 -0
- unifi_network_maps/assets/icons/modern/speaker.svg +10 -0
- unifi_network_maps/assets/icons/modern/switch.svg +10 -0
- unifi_network_maps/assets/icons/modern/tv.svg +10 -0
- unifi_network_maps/assets/icons/modern-flat/ap.svg +5 -0
- unifi_network_maps/assets/icons/modern-flat/camera.svg +5 -0
- unifi_network_maps/assets/icons/modern-flat/client.svg +5 -0
- unifi_network_maps/assets/icons/modern-flat/game_console.svg +6 -0
- unifi_network_maps/assets/icons/modern-flat/gateway.svg +13 -0
- unifi_network_maps/assets/icons/modern-flat/iot.svg +5 -0
- unifi_network_maps/assets/icons/modern-flat/nas.svg +5 -0
- unifi_network_maps/assets/icons/modern-flat/other.svg +6 -0
- unifi_network_maps/assets/icons/modern-flat/phone.svg +6 -0
- unifi_network_maps/assets/icons/modern-flat/printer.svg +5 -0
- unifi_network_maps/assets/icons/modern-flat/speaker.svg +6 -0
- unifi_network_maps/assets/icons/modern-flat/switch.svg +6 -0
- unifi_network_maps/assets/icons/modern-flat/tv.svg +6 -0
- unifi_network_maps/assets/themes/dark.yaml +53 -10
- unifi_network_maps/assets/themes/default.yaml +34 -0
- unifi_network_maps/assets/themes/minimal-dark.yaml +98 -0
- unifi_network_maps/assets/themes/minimal.yaml +92 -0
- unifi_network_maps/assets/themes/unifi-dark.yaml +97 -0
- unifi_network_maps/assets/themes/unifi.yaml +92 -0
- unifi_network_maps/cli/args.py +54 -0
- unifi_network_maps/cli/main.py +18 -7
- unifi_network_maps/cli/render.py +79 -27
- unifi_network_maps/cli/runtime.py +29 -15
- unifi_network_maps/io/debug.py +2 -1
- unifi_network_maps/io/export.py +19 -13
- unifi_network_maps/io/mock_data.py +5 -3
- unifi_network_maps/io/paths.py +5 -3
- unifi_network_maps/model/classify.py +199 -0
- unifi_network_maps/model/clients.py +271 -0
- unifi_network_maps/model/connection.py +37 -0
- unifi_network_maps/model/diff.py +544 -0
- unifi_network_maps/model/edges.py +558 -0
- unifi_network_maps/model/helpers.py +64 -0
- unifi_network_maps/model/lldp.py +20 -25
- unifi_network_maps/model/mock.py +110 -23
- unifi_network_maps/model/snapshot.py +294 -0
- unifi_network_maps/model/topology.py +143 -931
- unifi_network_maps/model/topology_coerce.py +339 -0
- unifi_network_maps/model/vlans.py +32 -46
- unifi_network_maps/model/wan.py +132 -0
- unifi_network_maps/render/device_ports_md.py +39 -97
- unifi_network_maps/render/device_summary.py +53 -0
- unifi_network_maps/render/lldp_md.py +29 -219
- unifi_network_maps/render/markdown_tables.py +8 -0
- unifi_network_maps/render/mermaid.py +11 -2
- unifi_network_maps/render/mkdocs.py +2 -1
- unifi_network_maps/render/svg.py +566 -908
- unifi_network_maps/render/svg_icons.py +231 -0
- unifi_network_maps/render/svg_isometric.py +1196 -0
- unifi_network_maps/render/svg_labels.py +184 -0
- unifi_network_maps/render/svg_theme.py +166 -32
- unifi_network_maps/render/theme.py +86 -1
- {unifi_network_maps-1.4.14.dist-info → unifi_network_maps-1.5.0.dist-info}/METADATA +107 -31
- {unifi_network_maps-1.4.14.dist-info → unifi_network_maps-1.5.0.dist-info}/RECORD +73 -31
- unifi_network_maps/assets/icons/isometric/printer.svg +0 -122
- {unifi_network_maps-1.4.14.dist-info → unifi_network_maps-1.5.0.dist-info}/WHEEL +0 -0
- {unifi_network_maps-1.4.14.dist-info → unifi_network_maps-1.5.0.dist-info}/entry_points.txt +0 -0
- {unifi_network_maps-1.4.14.dist-info → unifi_network_maps-1.5.0.dist-info}/licenses/LICENSE +0 -0
- {unifi_network_maps-1.4.14.dist-info → unifi_network_maps-1.5.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""Type coercion and device normalization utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
|
|
8
|
+
from .helpers import as_bool, as_list, get_field
|
|
9
|
+
from .lldp import coerce_lldp
|
|
10
|
+
from .topology import Device, DeviceSource, PortInfo, UplinkInfo
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _as_float(value: object | None) -> float:
|
|
16
|
+
if value is None:
|
|
17
|
+
return 0.0
|
|
18
|
+
if isinstance(value, int | float):
|
|
19
|
+
return float(value)
|
|
20
|
+
if isinstance(value, str):
|
|
21
|
+
try:
|
|
22
|
+
return float(value)
|
|
23
|
+
except ValueError:
|
|
24
|
+
return 0.0
|
|
25
|
+
return 0.0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _as_int(value: object | None) -> int | None:
|
|
29
|
+
if isinstance(value, int):
|
|
30
|
+
return value
|
|
31
|
+
if isinstance(value, str):
|
|
32
|
+
try:
|
|
33
|
+
return int(value)
|
|
34
|
+
except ValueError:
|
|
35
|
+
return None
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _as_group_id(value: object | None) -> str | None:
|
|
40
|
+
if value is None:
|
|
41
|
+
return None
|
|
42
|
+
if isinstance(value, bool):
|
|
43
|
+
return None
|
|
44
|
+
if isinstance(value, int):
|
|
45
|
+
return str(value)
|
|
46
|
+
if isinstance(value, str):
|
|
47
|
+
return value.strip() or None
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _aggregation_group(port_entry: object) -> object | None:
|
|
52
|
+
keys = (
|
|
53
|
+
"aggregation_group",
|
|
54
|
+
"aggregation_id",
|
|
55
|
+
"aggregate_id",
|
|
56
|
+
"agg_id",
|
|
57
|
+
"lag_id",
|
|
58
|
+
"lag_group",
|
|
59
|
+
"link_aggregation_group",
|
|
60
|
+
"link_aggregation_id",
|
|
61
|
+
"aggregate",
|
|
62
|
+
"aggregated_by",
|
|
63
|
+
)
|
|
64
|
+
if isinstance(port_entry, dict):
|
|
65
|
+
for key in keys:
|
|
66
|
+
value = port_entry.get(key)
|
|
67
|
+
if value not in (None, "", False):
|
|
68
|
+
return value
|
|
69
|
+
return None
|
|
70
|
+
for key in keys:
|
|
71
|
+
value = get_field(port_entry, key)
|
|
72
|
+
if value not in (None, "", False):
|
|
73
|
+
return value
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _coerce_vlan_string(value: str) -> tuple[int, ...]:
|
|
78
|
+
"""Parse a comma-separated VLAN string to tuple of ints."""
|
|
79
|
+
normalized = value.strip().lower()
|
|
80
|
+
if normalized in ("auto", "block_all", "all", "none", ""):
|
|
81
|
+
return ()
|
|
82
|
+
parts = [p.strip() for p in value.split(",") if p.strip()]
|
|
83
|
+
parsed = [_as_int(p) for p in parts]
|
|
84
|
+
return tuple(sorted(v for v in parsed if v is not None))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _coerce_vlan_sequence(
|
|
88
|
+
items: list | tuple, network_vlan_map: dict[str, int] | None
|
|
89
|
+
) -> tuple[int, ...]:
|
|
90
|
+
"""Convert a sequence of VLAN IDs or network names to tuple of ints."""
|
|
91
|
+
result: list[int] = []
|
|
92
|
+
for item in items:
|
|
93
|
+
parsed_int = _as_int(item)
|
|
94
|
+
if parsed_int is not None:
|
|
95
|
+
result.append(parsed_int)
|
|
96
|
+
elif network_vlan_map and isinstance(item, str):
|
|
97
|
+
vlan_id = network_vlan_map.get(item)
|
|
98
|
+
if vlan_id is not None:
|
|
99
|
+
result.append(vlan_id)
|
|
100
|
+
return tuple(sorted(set(result)))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _coerce_vlan_list(
|
|
104
|
+
value: object, network_vlan_map: dict[str, int] | None = None
|
|
105
|
+
) -> tuple[int, ...]:
|
|
106
|
+
"""Convert a VLAN list from various formats to a tuple of ints."""
|
|
107
|
+
if value is None:
|
|
108
|
+
return ()
|
|
109
|
+
if isinstance(value, str):
|
|
110
|
+
return _coerce_vlan_string(value)
|
|
111
|
+
if isinstance(value, int):
|
|
112
|
+
return (value,)
|
|
113
|
+
if isinstance(value, list | tuple):
|
|
114
|
+
return _coerce_vlan_sequence(value, network_vlan_map)
|
|
115
|
+
return ()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _resolve_vlan_id(value: object, network_vlan_map: dict[str, int] | None = None) -> int | None:
|
|
119
|
+
"""Resolve a VLAN ID, which may be a network ID string."""
|
|
120
|
+
parsed = _as_int(value)
|
|
121
|
+
if parsed is not None:
|
|
122
|
+
return parsed
|
|
123
|
+
# Try to resolve network ID to VLAN number
|
|
124
|
+
if network_vlan_map and isinstance(value, str):
|
|
125
|
+
return network_vlan_map.get(value)
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _extract_wan_networkconf_id(port_entry: object) -> str | None:
|
|
130
|
+
"""Extract WAN network configuration ID from a port entry."""
|
|
131
|
+
if isinstance(port_entry, dict):
|
|
132
|
+
value = port_entry.get("wan_networkconf_id")
|
|
133
|
+
else:
|
|
134
|
+
value = get_field(port_entry, "wan_networkconf_id")
|
|
135
|
+
if isinstance(value, str) and value.strip():
|
|
136
|
+
return value.strip()
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _port_info_from_entry(
|
|
141
|
+
port_entry: object, network_vlan_map: dict[str, int] | None = None
|
|
142
|
+
) -> PortInfo:
|
|
143
|
+
if isinstance(port_entry, dict):
|
|
144
|
+
port_idx = port_entry.get("port_idx") or port_entry.get("portIdx")
|
|
145
|
+
name = port_entry.get("name")
|
|
146
|
+
ifname = port_entry.get("ifname")
|
|
147
|
+
speed = port_entry.get("speed")
|
|
148
|
+
aggregation_group = _aggregation_group(port_entry)
|
|
149
|
+
port_poe = as_bool(port_entry.get("port_poe"))
|
|
150
|
+
poe_enable = as_bool(port_entry.get("poe_enable"))
|
|
151
|
+
poe_good = as_bool(port_entry.get("poe_good"))
|
|
152
|
+
poe_power = _as_float(port_entry.get("poe_power"))
|
|
153
|
+
native_vlan = port_entry.get("native_vlan")
|
|
154
|
+
tagged_vlans = port_entry.get("tagged_vlans")
|
|
155
|
+
else:
|
|
156
|
+
port_idx = get_field(port_entry, "port_idx") or get_field(port_entry, "portIdx")
|
|
157
|
+
name = get_field(port_entry, "name")
|
|
158
|
+
ifname = get_field(port_entry, "ifname")
|
|
159
|
+
speed = get_field(port_entry, "speed")
|
|
160
|
+
aggregation_group = _aggregation_group(port_entry)
|
|
161
|
+
port_poe = as_bool(get_field(port_entry, "port_poe"))
|
|
162
|
+
poe_enable = as_bool(get_field(port_entry, "poe_enable"))
|
|
163
|
+
poe_good = as_bool(get_field(port_entry, "poe_good"))
|
|
164
|
+
poe_power = _as_float(get_field(port_entry, "poe_power"))
|
|
165
|
+
native_vlan = get_field(port_entry, "native_vlan")
|
|
166
|
+
tagged_vlans = get_field(port_entry, "tagged_vlans")
|
|
167
|
+
return PortInfo(
|
|
168
|
+
port_idx=_as_int(port_idx),
|
|
169
|
+
name=str(name) if isinstance(name, str) and name.strip() else None,
|
|
170
|
+
ifname=str(ifname) if isinstance(ifname, str) and ifname.strip() else None,
|
|
171
|
+
speed=_as_int(speed),
|
|
172
|
+
aggregation_group=_as_group_id(aggregation_group),
|
|
173
|
+
port_poe=port_poe,
|
|
174
|
+
poe_enable=poe_enable,
|
|
175
|
+
poe_good=poe_good,
|
|
176
|
+
poe_power=poe_power,
|
|
177
|
+
native_vlan=_resolve_vlan_id(native_vlan, network_vlan_map),
|
|
178
|
+
tagged_vlans=_coerce_vlan_list(tagged_vlans, network_vlan_map),
|
|
179
|
+
wan_networkconf_id=_extract_wan_networkconf_id(port_entry),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _coerce_port_table(
|
|
184
|
+
device: DeviceSource, network_vlan_map: dict[str, int] | None = None
|
|
185
|
+
) -> list[PortInfo]:
|
|
186
|
+
port_table = as_list(get_field(device, "port_table"))
|
|
187
|
+
return [_port_info_from_entry(port_entry, network_vlan_map) for port_entry in port_table]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _poe_ports_from_device(
|
|
191
|
+
device: DeviceSource, network_vlan_map: dict[str, int] | None = None
|
|
192
|
+
) -> dict[int, bool]:
|
|
193
|
+
port_table = _coerce_port_table(device, network_vlan_map)
|
|
194
|
+
poe_ports: dict[int, bool] = {}
|
|
195
|
+
for port_entry in port_table:
|
|
196
|
+
if port_entry.port_idx is None:
|
|
197
|
+
continue
|
|
198
|
+
active = (
|
|
199
|
+
port_entry.poe_enable
|
|
200
|
+
or port_entry.port_poe
|
|
201
|
+
or port_entry.poe_good
|
|
202
|
+
or _as_float(port_entry.poe_power) > 0.0
|
|
203
|
+
)
|
|
204
|
+
poe_ports[int(port_entry.port_idx)] = active
|
|
205
|
+
return poe_ports
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _extract_uplink_fields(value: object) -> tuple[object, object, object]:
|
|
209
|
+
"""Extract mac, name, and port from uplink data (dict or object)."""
|
|
210
|
+
if isinstance(value, dict):
|
|
211
|
+
mac = value.get("uplink_mac") or value.get("uplink_device_mac")
|
|
212
|
+
name = value.get("uplink_device_name") or value.get("uplink_name")
|
|
213
|
+
port_raw = value.get("uplink_remote_port") or value.get("port_idx")
|
|
214
|
+
else:
|
|
215
|
+
mac = get_field(value, "uplink_mac") or get_field(value, "uplink_device_mac")
|
|
216
|
+
name = get_field(value, "uplink_device_name") or get_field(value, "uplink_name")
|
|
217
|
+
port_raw = get_field(value, "uplink_remote_port") or get_field(value, "port_idx")
|
|
218
|
+
return mac, name, port_raw
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _coerce_uplink_string(value: object) -> str | None:
|
|
222
|
+
"""Coerce a value to a stripped string or None."""
|
|
223
|
+
return str(value).strip() if isinstance(value, str) and value.strip() else None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _parse_uplink(value: object | None) -> UplinkInfo | None:
|
|
227
|
+
if value is None:
|
|
228
|
+
return None
|
|
229
|
+
mac, name, port_raw = _extract_uplink_fields(value)
|
|
230
|
+
mac_value = _coerce_uplink_string(mac)
|
|
231
|
+
name_value = _coerce_uplink_string(name)
|
|
232
|
+
port = _as_int(port_raw)
|
|
233
|
+
if mac_value is None and name_value is None and port is None:
|
|
234
|
+
return None
|
|
235
|
+
return UplinkInfo(mac=mac_value, name=name_value, port=port)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _uplink_info(device: DeviceSource) -> tuple[UplinkInfo | None, UplinkInfo | None]:
|
|
239
|
+
uplink = _parse_uplink(get_field(device, "uplink"))
|
|
240
|
+
last_uplink = _parse_uplink(get_field(device, "last_uplink"))
|
|
241
|
+
|
|
242
|
+
if uplink is None:
|
|
243
|
+
mac = get_field(device, "uplink_mac") or get_field(device, "uplink_device_mac")
|
|
244
|
+
name = get_field(device, "uplink_device_name")
|
|
245
|
+
port = _as_int(get_field(device, "uplink_remote_port"))
|
|
246
|
+
uplink = _parse_uplink(
|
|
247
|
+
{"uplink_mac": mac, "uplink_device_name": name, "uplink_remote_port": port}
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
if last_uplink is None:
|
|
251
|
+
mac = get_field(device, "last_uplink_mac")
|
|
252
|
+
last_uplink = _parse_uplink({"uplink_mac": mac})
|
|
253
|
+
|
|
254
|
+
return uplink, last_uplink
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _get_model_display_name(device: DeviceSource) -> str | None:
|
|
258
|
+
"""Extract the human-readable model name from device data.
|
|
259
|
+
|
|
260
|
+
UniFi stores the friendly model name (e.g., 'USW Flex 2.5G 8 PoE') in various
|
|
261
|
+
fields depending on controller version. This function checks multiple candidates
|
|
262
|
+
and returns the first non-empty value found.
|
|
263
|
+
"""
|
|
264
|
+
candidates = (
|
|
265
|
+
"model_in_lts",
|
|
266
|
+
"model_in_eol",
|
|
267
|
+
"shortname",
|
|
268
|
+
"model_name",
|
|
269
|
+
)
|
|
270
|
+
for key in candidates:
|
|
271
|
+
value = get_field(device, key)
|
|
272
|
+
if isinstance(value, str) and value.strip():
|
|
273
|
+
return value.strip()
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _get_lldp_info(device: DeviceSource) -> object | None:
|
|
278
|
+
"""Try multiple field names to get LLDP info from device."""
|
|
279
|
+
for field_name in ("lldp_info", "lldp", "lldp_table"):
|
|
280
|
+
lldp = get_field(device, field_name)
|
|
281
|
+
if lldp is not None:
|
|
282
|
+
return lldp
|
|
283
|
+
return None
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _resolve_lldp_info(
|
|
287
|
+
device: DeviceSource,
|
|
288
|
+
name: object,
|
|
289
|
+
uplink: UplinkInfo | None,
|
|
290
|
+
last_uplink: UplinkInfo | None,
|
|
291
|
+
) -> list[object]:
|
|
292
|
+
"""Resolve LLDP info, falling back to empty list if uplink exists."""
|
|
293
|
+
lldp_info = _get_lldp_info(device)
|
|
294
|
+
if lldp_info is not None:
|
|
295
|
+
return as_list(lldp_info)
|
|
296
|
+
if uplink or last_uplink:
|
|
297
|
+
logger.warning("Device %s missing LLDP info; using uplink fallback", name)
|
|
298
|
+
return []
|
|
299
|
+
raise ValueError(f"Device {name} missing LLDP info")
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def coerce_device(device: DeviceSource, network_vlan_map: dict[str, int] | None = None) -> Device:
|
|
303
|
+
name = get_field(device, "name")
|
|
304
|
+
mac = get_field(device, "mac")
|
|
305
|
+
if not name or not mac:
|
|
306
|
+
raise ValueError("Device missing name or mac")
|
|
307
|
+
|
|
308
|
+
model_name = _get_model_display_name(device) or get_field(device, "model")
|
|
309
|
+
model = get_field(device, "model")
|
|
310
|
+
ip = get_field(device, "ip") or get_field(device, "ip_address")
|
|
311
|
+
dev_type = get_field(device, "type") or get_field(device, "device_type")
|
|
312
|
+
version = get_field(device, "displayable_version") or get_field(device, "version")
|
|
313
|
+
|
|
314
|
+
uplink, last_uplink = _uplink_info(device)
|
|
315
|
+
lldp_entries = _resolve_lldp_info(device, name, uplink, last_uplink)
|
|
316
|
+
coerced_lldp = [coerce_lldp(entry) for entry in lldp_entries]
|
|
317
|
+
port_table = _coerce_port_table(device, network_vlan_map)
|
|
318
|
+
poe_ports = _poe_ports_from_device(device, network_vlan_map)
|
|
319
|
+
|
|
320
|
+
return Device(
|
|
321
|
+
name=str(name),
|
|
322
|
+
model_name=str(model_name or ""),
|
|
323
|
+
model=str(model or ""),
|
|
324
|
+
mac=str(mac),
|
|
325
|
+
ip=str(ip or ""),
|
|
326
|
+
type=str(dev_type or ""),
|
|
327
|
+
lldp_info=coerced_lldp,
|
|
328
|
+
port_table=port_table,
|
|
329
|
+
poe_ports=poe_ports,
|
|
330
|
+
uplink=uplink,
|
|
331
|
+
last_uplink=last_uplink,
|
|
332
|
+
version=str(version or ""),
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def normalize_devices(
|
|
337
|
+
devices: Iterable[DeviceSource], network_vlan_map: dict[str, int] | None = None
|
|
338
|
+
) -> list[Device]:
|
|
339
|
+
return [coerce_device(device, network_vlan_map) for device in devices]
|
|
@@ -4,43 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from collections.abc import Iterable
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
def _as_list(value: object | None) -> list[object]:
|
|
9
|
-
if value is None:
|
|
10
|
-
return []
|
|
11
|
-
if isinstance(value, list):
|
|
12
|
-
return value
|
|
13
|
-
if isinstance(value, dict):
|
|
14
|
-
return [value]
|
|
15
|
-
if isinstance(value, str | bytes):
|
|
16
|
-
return []
|
|
17
|
-
if isinstance(value, Iterable):
|
|
18
|
-
return list(value)
|
|
19
|
-
return []
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def _get_attr(obj: object, name: str) -> object | None:
|
|
23
|
-
if isinstance(obj, dict):
|
|
24
|
-
return obj.get(name)
|
|
25
|
-
return getattr(obj, name, None)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def _first_attr(obj: object, *names: str) -> object | None:
|
|
29
|
-
for name in names:
|
|
30
|
-
value = _get_attr(obj, name)
|
|
31
|
-
if value is not None:
|
|
32
|
-
return value
|
|
33
|
-
return None
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def _as_bool(value: object | None) -> bool:
|
|
37
|
-
if isinstance(value, bool):
|
|
38
|
-
return value
|
|
39
|
-
if isinstance(value, int | float):
|
|
40
|
-
return value != 0
|
|
41
|
-
if isinstance(value, str):
|
|
42
|
-
return value.strip().lower() in {"1", "true", "yes", "y", "on"}
|
|
43
|
-
return False
|
|
7
|
+
from .helpers import as_bool, as_list, first_attr
|
|
44
8
|
|
|
45
9
|
|
|
46
10
|
def _as_vlan_id(value: object | None) -> int | None:
|
|
@@ -52,8 +16,8 @@ def _as_vlan_id(value: object | None) -> int | None:
|
|
|
52
16
|
|
|
53
17
|
|
|
54
18
|
def _network_vlan_id(network: object) -> int | None:
|
|
55
|
-
vlan_value =
|
|
56
|
-
vlan_enabled =
|
|
19
|
+
vlan_value = first_attr(network, "vlan", "vlan_id", "vlanId", "vlanid")
|
|
20
|
+
vlan_enabled = as_bool(first_attr(network, "vlan_enabled", "vlanEnabled"))
|
|
57
21
|
vlan_id = _as_vlan_id(vlan_value)
|
|
58
22
|
if vlan_id is not None:
|
|
59
23
|
return vlan_id
|
|
@@ -64,16 +28,16 @@ def _network_vlan_id(network: object) -> int | None:
|
|
|
64
28
|
|
|
65
29
|
def normalize_networks(networks: Iterable[object]) -> list[dict[str, object]]:
|
|
66
30
|
normalized: list[dict[str, object]] = []
|
|
67
|
-
for network in
|
|
31
|
+
for network in as_list(networks):
|
|
68
32
|
if network is None:
|
|
69
33
|
continue
|
|
70
34
|
normalized.append(
|
|
71
35
|
{
|
|
72
|
-
"network_id":
|
|
73
|
-
"name":
|
|
36
|
+
"network_id": first_attr(network, "_id", "id", "network_id", "networkId"),
|
|
37
|
+
"name": first_attr(network, "name", "network_name", "networkName"),
|
|
74
38
|
"vlan_id": _network_vlan_id(network),
|
|
75
|
-
"vlan_enabled":
|
|
76
|
-
"purpose":
|
|
39
|
+
"vlan_enabled": as_bool(first_attr(network, "vlan_enabled", "vlanEnabled")),
|
|
40
|
+
"purpose": first_attr(network, "purpose"),
|
|
77
41
|
}
|
|
78
42
|
)
|
|
79
43
|
return normalized
|
|
@@ -95,8 +59,8 @@ def build_vlan_info(
|
|
|
95
59
|
|
|
96
60
|
def _client_vlan_counts(clients: Iterable[object]) -> dict[int, int]:
|
|
97
61
|
vlan_counts: dict[int, int] = {}
|
|
98
|
-
for client in
|
|
99
|
-
vlan_id = _as_vlan_id(
|
|
62
|
+
for client in as_list(clients):
|
|
63
|
+
vlan_id = _as_vlan_id(first_attr(client, "vlan", "vlan_id", "vlanId", "vlanid"))
|
|
100
64
|
if vlan_id is None:
|
|
101
65
|
continue
|
|
102
66
|
vlan_counts[vlan_id] = vlan_counts.get(vlan_id, 0) + 1
|
|
@@ -117,3 +81,25 @@ def _network_vlan_entries(networks: Iterable[object]) -> dict[int, dict[str, obj
|
|
|
117
81
|
if name and not entry["name"]:
|
|
118
82
|
entry["name"] = name
|
|
119
83
|
return vlan_entries
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def build_network_vlan_map(networks: Iterable[object]) -> dict[str, int]:
|
|
87
|
+
"""Build a mapping from network ID to VLAN ID for WLAN resolution."""
|
|
88
|
+
result: dict[str, int] = {}
|
|
89
|
+
for network in normalize_networks(networks):
|
|
90
|
+
network_id = network.get("network_id")
|
|
91
|
+
vlan_id = network.get("vlan_id")
|
|
92
|
+
if isinstance(network_id, str) and isinstance(vlan_id, int):
|
|
93
|
+
result[network_id] = vlan_id
|
|
94
|
+
return result
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def build_vlan_names(networks: Iterable[object]) -> dict[int, str]:
|
|
98
|
+
"""Build a mapping from VLAN ID to network name."""
|
|
99
|
+
result: dict[int, str] = {}
|
|
100
|
+
for network in normalize_networks(networks):
|
|
101
|
+
vlan_id = network.get("vlan_id")
|
|
102
|
+
name = network.get("name")
|
|
103
|
+
if isinstance(vlan_id, int) and isinstance(name, str) and vlan_id not in result:
|
|
104
|
+
result[vlan_id] = name
|
|
105
|
+
return result
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""WAN interface extraction from gateway devices."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .classify import classify_device_type
|
|
6
|
+
from .topology import Device, PortInfo, WanInfo, WanInterface
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _normalize_wan_speed(speed: int | None) -> int | None:
|
|
10
|
+
"""Normalize WAN port speed to Mbps.
|
|
11
|
+
|
|
12
|
+
Gateway devices report WAN port speeds in Gbps (e.g., 10 for 10G),
|
|
13
|
+
while switches report in Mbps (e.g., 1000 for 1G).
|
|
14
|
+
"""
|
|
15
|
+
if speed is None or speed == 0:
|
|
16
|
+
return speed
|
|
17
|
+
if 1 <= speed <= 100:
|
|
18
|
+
return speed * 1000
|
|
19
|
+
return speed
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _find_wan_port_by_assignment(port_table: list[PortInfo], wan_id: str) -> PortInfo | None:
|
|
23
|
+
"""Find a WAN port by its wan_networkconf_id assignment."""
|
|
24
|
+
wan_id_lower = wan_id.lower()
|
|
25
|
+
for port in port_table:
|
|
26
|
+
if port.wan_networkconf_id:
|
|
27
|
+
conf_id = port.wan_networkconf_id.lower()
|
|
28
|
+
if conf_id == wan_id_lower or wan_id_lower in conf_id:
|
|
29
|
+
return port
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _find_wan_port_by_idx(port_table: list[PortInfo], port_idx: int) -> PortInfo | None:
|
|
34
|
+
"""Find a port by index (fallback for legacy detection)."""
|
|
35
|
+
for port in port_table:
|
|
36
|
+
if port.port_idx == port_idx:
|
|
37
|
+
return port
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _find_wan1_port(port_table: list[PortInfo]) -> PortInfo | None:
|
|
42
|
+
"""Find WAN1 port by assignment, falling back to port 1."""
|
|
43
|
+
port = _find_wan_port_by_assignment(port_table, "WAN")
|
|
44
|
+
if not port:
|
|
45
|
+
port = _find_wan_port_by_assignment(port_table, "WAN1")
|
|
46
|
+
if not port:
|
|
47
|
+
port = _find_wan_port_by_idx(port_table, 1)
|
|
48
|
+
return port
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _find_wan2_port(port_table: list[PortInfo]) -> PortInfo | None:
|
|
52
|
+
"""Find WAN2 port by assignment, falling back to port 9 or 2."""
|
|
53
|
+
port = _find_wan_port_by_assignment(port_table, "WAN2")
|
|
54
|
+
if not port:
|
|
55
|
+
port = _find_wan_port_by_idx(port_table, 9)
|
|
56
|
+
if not port:
|
|
57
|
+
port = _find_wan_port_by_idx(port_table, 2)
|
|
58
|
+
return port
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _build_wan_interface(
|
|
62
|
+
port: PortInfo,
|
|
63
|
+
default_idx: int,
|
|
64
|
+
ip_address: str | None,
|
|
65
|
+
label: str | None,
|
|
66
|
+
isp_speed: str | None,
|
|
67
|
+
) -> WanInterface:
|
|
68
|
+
"""Build a WAN interface from port info."""
|
|
69
|
+
speed = _normalize_wan_speed(port.speed)
|
|
70
|
+
return WanInterface(
|
|
71
|
+
port_idx=port.port_idx or default_idx,
|
|
72
|
+
link_speed=speed,
|
|
73
|
+
ip_address=ip_address,
|
|
74
|
+
enabled=speed is not None and speed > 0,
|
|
75
|
+
label=label,
|
|
76
|
+
isp_speed=isp_speed,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _should_include_wan2(
|
|
81
|
+
port: PortInfo | None,
|
|
82
|
+
label: str | None,
|
|
83
|
+
isp_speed: str | None,
|
|
84
|
+
) -> bool:
|
|
85
|
+
"""Determine if WAN2 should be included in the output."""
|
|
86
|
+
if not port:
|
|
87
|
+
return False
|
|
88
|
+
has_assignment = port.wan_networkconf_id is not None
|
|
89
|
+
has_cli_config = label is not None or isp_speed is not None
|
|
90
|
+
speed = _normalize_wan_speed(port.speed)
|
|
91
|
+
is_active = speed is not None and speed > 0
|
|
92
|
+
return has_assignment or has_cli_config or is_active
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def extract_wan_info(
|
|
96
|
+
device: Device,
|
|
97
|
+
*,
|
|
98
|
+
wan1_label: str | None = None,
|
|
99
|
+
wan1_isp_speed: str | None = None,
|
|
100
|
+
wan2_label: str | None = None,
|
|
101
|
+
wan2_isp_speed: str | None = None,
|
|
102
|
+
) -> WanInfo | None:
|
|
103
|
+
"""Extract WAN interface information from a gateway device.
|
|
104
|
+
|
|
105
|
+
Detects WAN ports by their wan_networkconf_id assignment field. Falls back
|
|
106
|
+
to legacy port number detection (port 1 for WAN1, port 9/2 for WAN2) if
|
|
107
|
+
no WAN assignment is found.
|
|
108
|
+
"""
|
|
109
|
+
if classify_device_type(device) != "gateway":
|
|
110
|
+
return None
|
|
111
|
+
if not device.port_table:
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
wan1_port = _find_wan1_port(device.port_table)
|
|
115
|
+
wan1 = None
|
|
116
|
+
if wan1_port:
|
|
117
|
+
wan1 = _build_wan_interface(wan1_port, 1, device.ip, wan1_label, wan1_isp_speed)
|
|
118
|
+
|
|
119
|
+
wan2_port = _find_wan2_port(device.port_table)
|
|
120
|
+
wan2 = None
|
|
121
|
+
if _should_include_wan2(wan2_port, wan2_label, wan2_isp_speed):
|
|
122
|
+
wan2 = _build_wan_interface(
|
|
123
|
+
wan2_port, # type: ignore[arg-type]
|
|
124
|
+
9,
|
|
125
|
+
None,
|
|
126
|
+
wan2_label,
|
|
127
|
+
wan2_isp_speed,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
if wan1 or wan2:
|
|
131
|
+
return WanInfo(wan1=wan1, wan2=wan2)
|
|
132
|
+
return None
|