unifi-network-maps 1.4.15__py3-none-any.whl → 1.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. unifi_network_maps/__init__.py +1 -1
  2. unifi_network_maps/adapters/unifi.py +80 -96
  3. unifi_network_maps/assets/icons/modern/ap.svg +9 -0
  4. unifi_network_maps/assets/icons/modern/camera.svg +9 -0
  5. unifi_network_maps/assets/icons/modern/client.svg +9 -0
  6. unifi_network_maps/assets/icons/modern/game_console.svg +10 -0
  7. unifi_network_maps/assets/icons/modern/gateway.svg +17 -0
  8. unifi_network_maps/assets/icons/modern/iot.svg +9 -0
  9. unifi_network_maps/assets/icons/modern/nas.svg +9 -0
  10. unifi_network_maps/assets/icons/modern/other.svg +10 -0
  11. unifi_network_maps/assets/icons/modern/phone.svg +10 -0
  12. unifi_network_maps/assets/icons/modern/printer.svg +9 -0
  13. unifi_network_maps/assets/icons/modern/speaker.svg +10 -0
  14. unifi_network_maps/assets/icons/modern/switch.svg +10 -0
  15. unifi_network_maps/assets/icons/modern/tv.svg +10 -0
  16. unifi_network_maps/assets/icons/modern-flat/ap.svg +5 -0
  17. unifi_network_maps/assets/icons/modern-flat/camera.svg +5 -0
  18. unifi_network_maps/assets/icons/modern-flat/client.svg +5 -0
  19. unifi_network_maps/assets/icons/modern-flat/game_console.svg +6 -0
  20. unifi_network_maps/assets/icons/modern-flat/gateway.svg +13 -0
  21. unifi_network_maps/assets/icons/modern-flat/iot.svg +5 -0
  22. unifi_network_maps/assets/icons/modern-flat/nas.svg +5 -0
  23. unifi_network_maps/assets/icons/modern-flat/other.svg +6 -0
  24. unifi_network_maps/assets/icons/modern-flat/phone.svg +6 -0
  25. unifi_network_maps/assets/icons/modern-flat/printer.svg +5 -0
  26. unifi_network_maps/assets/icons/modern-flat/speaker.svg +6 -0
  27. unifi_network_maps/assets/icons/modern-flat/switch.svg +6 -0
  28. unifi_network_maps/assets/icons/modern-flat/tv.svg +6 -0
  29. unifi_network_maps/assets/themes/dark.yaml +53 -10
  30. unifi_network_maps/assets/themes/default.yaml +34 -0
  31. unifi_network_maps/assets/themes/minimal-dark.yaml +98 -0
  32. unifi_network_maps/assets/themes/minimal.yaml +92 -0
  33. unifi_network_maps/assets/themes/unifi-dark.yaml +97 -0
  34. unifi_network_maps/assets/themes/unifi.yaml +92 -0
  35. unifi_network_maps/cli/args.py +54 -0
  36. unifi_network_maps/cli/main.py +18 -7
  37. unifi_network_maps/cli/render.py +79 -27
  38. unifi_network_maps/cli/runtime.py +29 -15
  39. unifi_network_maps/io/debug.py +2 -1
  40. unifi_network_maps/io/export.py +19 -13
  41. unifi_network_maps/io/mock_data.py +5 -3
  42. unifi_network_maps/io/paths.py +5 -3
  43. unifi_network_maps/model/classify.py +199 -0
  44. unifi_network_maps/model/clients.py +271 -0
  45. unifi_network_maps/model/connection.py +37 -0
  46. unifi_network_maps/model/diff.py +544 -0
  47. unifi_network_maps/model/edges.py +558 -0
  48. unifi_network_maps/model/helpers.py +64 -0
  49. unifi_network_maps/model/lldp.py +20 -25
  50. unifi_network_maps/model/mock.py +110 -23
  51. unifi_network_maps/model/snapshot.py +294 -0
  52. unifi_network_maps/model/topology.py +143 -951
  53. unifi_network_maps/model/topology_coerce.py +339 -0
  54. unifi_network_maps/model/vlans.py +32 -46
  55. unifi_network_maps/model/wan.py +132 -0
  56. unifi_network_maps/render/device_ports_md.py +39 -97
  57. unifi_network_maps/render/device_summary.py +53 -0
  58. unifi_network_maps/render/lldp_md.py +29 -219
  59. unifi_network_maps/render/markdown_tables.py +8 -0
  60. unifi_network_maps/render/mermaid.py +11 -2
  61. unifi_network_maps/render/mkdocs.py +2 -1
  62. unifi_network_maps/render/svg.py +566 -908
  63. unifi_network_maps/render/svg_icons.py +231 -0
  64. unifi_network_maps/render/svg_isometric.py +1196 -0
  65. unifi_network_maps/render/svg_labels.py +184 -0
  66. unifi_network_maps/render/svg_theme.py +166 -32
  67. unifi_network_maps/render/theme.py +86 -1
  68. {unifi_network_maps-1.4.15.dist-info → unifi_network_maps-1.5.0.dist-info}/METADATA +107 -31
  69. {unifi_network_maps-1.4.15.dist-info → unifi_network_maps-1.5.0.dist-info}/RECORD +73 -31
  70. unifi_network_maps/assets/icons/isometric/printer.svg +0 -122
  71. {unifi_network_maps-1.4.15.dist-info → unifi_network_maps-1.5.0.dist-info}/WHEEL +0 -0
  72. {unifi_network_maps-1.4.15.dist-info → unifi_network_maps-1.5.0.dist-info}/entry_points.txt +0 -0
  73. {unifi_network_maps-1.4.15.dist-info → unifi_network_maps-1.5.0.dist-info}/licenses/LICENSE +0 -0
  74. {unifi_network_maps-1.4.15.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 = _first_attr(network, "vlan", "vlan_id", "vlanId", "vlanid")
56
- vlan_enabled = _as_bool(_first_attr(network, "vlan_enabled", "vlanEnabled"))
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 _as_list(networks):
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": _first_attr(network, "_id", "id", "network_id", "networkId"),
73
- "name": _first_attr(network, "name", "network_name", "networkName"),
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": _as_bool(_first_attr(network, "vlan_enabled", "vlanEnabled")),
76
- "purpose": _first_attr(network, "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 _as_list(clients):
99
- vlan_id = _as_vlan_id(_first_attr(client, "vlan", "vlan_id", "vlanId", "vlanid"))
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