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.
Files changed (74) hide show
  1. unifi_network_maps/__init__.py +1 -1
  2. unifi_network_maps/adapters/unifi.py +83 -101
  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 -931
  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.14.dist-info → unifi_network_maps-1.5.0.dist-info}/METADATA +107 -31
  69. {unifi_network_maps-1.4.14.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.14.dist-info → unifi_network_maps-1.5.0.dist-info}/WHEEL +0 -0
  72. {unifi_network_maps-1.4.14.dist-info → unifi_network_maps-1.5.0.dist-info}/entry_points.txt +0 -0
  73. {unifi_network_maps-1.4.14.dist-info → unifi_network_maps-1.5.0.dist-info}/licenses/LICENSE +0 -0
  74. {unifi_network_maps-1.4.14.dist-info → unifi_network_maps-1.5.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,271 @@
1
+ """Client handling and edge building."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+
7
+ from .classify import (
8
+ classify_client_type,
9
+ classify_device_type,
10
+ client_display_name,
11
+ client_is_unifi,
12
+ )
13
+ from .connection import ConnectionInfo, classify_signal_quality
14
+ from .helpers import first_string_field, get_field, normalize_mac
15
+ from .ports import extract_port_number
16
+ from .topology import ClientPortMap, Device, Edge
17
+
18
+
19
+ def _client_uplink_mac(client: object) -> str | None:
20
+ """Get the MAC address of the device this client is connected to."""
21
+ mac = first_string_field(
22
+ client, "ap_mac", "sw_mac", "uplink_mac", "uplink_device_mac", "last_uplink_mac"
23
+ )
24
+ if mac:
25
+ return mac
26
+ for key in ("uplink", "last_uplink"):
27
+ nested = get_field(client, key)
28
+ if isinstance(nested, dict):
29
+ mac = first_string_field(nested, "uplink_mac", "uplink_device_mac")
30
+ if mac:
31
+ return mac
32
+ return None
33
+
34
+
35
+ def _client_port_values(client: object) -> Iterable[object | None]:
36
+ """Yield all possible port values from client data."""
37
+ for key in ("uplink_remote_port", "sw_port", "ap_port", "port_idx"):
38
+ yield get_field(client, key)
39
+ for key in ("uplink", "last_uplink"):
40
+ nested = get_field(client, key)
41
+ if isinstance(nested, dict):
42
+ for nested_key in ("uplink_remote_port", "port_idx"):
43
+ yield nested.get(nested_key)
44
+
45
+
46
+ def _parse_port_value(value: object | None) -> int | None:
47
+ """Parse a port value to int."""
48
+ if isinstance(value, int):
49
+ return value
50
+ if isinstance(value, str):
51
+ stripped = value.strip()
52
+ if stripped.isdigit():
53
+ return int(stripped)
54
+ return extract_port_number(stripped)
55
+ return None
56
+
57
+
58
+ def _client_uplink_port(client: object) -> int | None:
59
+ """Get the port number this client is connected to."""
60
+ for value in _client_port_values(client):
61
+ parsed = _parse_port_value(value)
62
+ if parsed is not None:
63
+ return parsed
64
+ return None
65
+
66
+
67
+ def _client_is_wired(client: object) -> bool:
68
+ """Check if client is wired."""
69
+ return bool(get_field(client, "is_wired"))
70
+
71
+
72
+ def _client_channel(client: object) -> int | None:
73
+ """Get wireless channel for client."""
74
+ for key in ("channel", "radio_channel", "wifi_channel"):
75
+ value = get_field(client, key)
76
+ if isinstance(value, int):
77
+ return value
78
+ if isinstance(value, str) and value.isdigit():
79
+ return int(value)
80
+ return None
81
+
82
+
83
+ def _client_vlan(client: object) -> int | None:
84
+ """Get VLAN ID for client."""
85
+ for key in ("vlan", "vlan_id", "vlanId", "vlanid"):
86
+ value = get_field(client, key)
87
+ if isinstance(value, int) and value > 0:
88
+ return value
89
+ if isinstance(value, str) and value.isdigit() and int(value) > 0:
90
+ return int(value)
91
+ return None
92
+
93
+
94
+ def _extract_connection_info(client: object) -> ConnectionInfo | None:
95
+ """Extract connection quality metrics for wireless clients."""
96
+ if _client_is_wired(client):
97
+ return None
98
+
99
+ signal = get_field(client, "signal")
100
+ noise = get_field(client, "noise")
101
+ tx_rate = get_field(client, "tx_rate")
102
+ rx_rate = get_field(client, "rx_rate")
103
+ satisfaction = get_field(client, "satisfaction")
104
+
105
+ signal_dbm = int(signal) if isinstance(signal, int | float) else None
106
+ noise_dbm = int(noise) if isinstance(noise, int | float) else None
107
+ tx_rate_mbps = int(tx_rate) if isinstance(tx_rate, int | float) else None
108
+ rx_rate_mbps = int(rx_rate) if isinstance(rx_rate, int | float) else None
109
+ satisfaction_val = int(satisfaction) if isinstance(satisfaction, int | float) else None
110
+
111
+ return ConnectionInfo(
112
+ signal_dbm=signal_dbm,
113
+ noise_dbm=noise_dbm,
114
+ tx_rate_mbps=tx_rate_mbps,
115
+ rx_rate_mbps=rx_rate_mbps,
116
+ satisfaction=satisfaction_val,
117
+ quality=classify_signal_quality(signal_dbm),
118
+ )
119
+
120
+
121
+ def _client_matches_mode(client: object, mode: str) -> bool:
122
+ """Check if client matches wired/wireless mode filter."""
123
+ wired = _client_is_wired(client)
124
+ if mode == "all":
125
+ return True
126
+ if mode == "wireless":
127
+ return not wired
128
+ return wired
129
+
130
+
131
+ def client_matches_filters(client: object, *, client_mode: str, only_unifi: bool) -> bool:
132
+ """Check if client matches all filters."""
133
+ if not _client_matches_mode(client, client_mode):
134
+ return False
135
+ if only_unifi and not client_is_unifi(client):
136
+ return False
137
+ return True
138
+
139
+
140
+ def build_client_edges(
141
+ clients: Iterable[object],
142
+ device_index: dict[str, str],
143
+ *,
144
+ include_ports: bool = False,
145
+ client_mode: str = "wired",
146
+ only_unifi: bool = False,
147
+ ) -> list[Edge]:
148
+ """Build edges from devices to their connected clients."""
149
+ edges: list[Edge] = []
150
+ seen: set[tuple[str, str]] = set()
151
+ for client in clients:
152
+ if not client_matches_filters(client, client_mode=client_mode, only_unifi=only_unifi):
153
+ continue
154
+ name = client_display_name(client)
155
+ uplink_mac = _client_uplink_mac(client)
156
+ if not name or not uplink_mac:
157
+ continue
158
+ device_name = device_index.get(normalize_mac(uplink_mac))
159
+ if not device_name:
160
+ continue
161
+ label = None
162
+ if include_ports:
163
+ uplink_port = _client_uplink_port(client)
164
+ if uplink_port is not None:
165
+ label = f"{device_name}: Port {uplink_port} <-> {name}"
166
+ key = (device_name, name)
167
+ if key in seen:
168
+ continue
169
+ is_wireless = not _client_is_wired(client)
170
+ channel = _client_channel(client) if is_wireless else None
171
+ client_vlan = _client_vlan(client)
172
+ vlans = (client_vlan,) if client_vlan else ()
173
+ connection = _extract_connection_info(client)
174
+ edges.append(
175
+ Edge(
176
+ left=device_name,
177
+ right=name,
178
+ label=label,
179
+ wireless=is_wireless,
180
+ channel=channel,
181
+ vlans=vlans,
182
+ active_vlans=vlans,
183
+ is_trunk=False,
184
+ connection=connection,
185
+ )
186
+ )
187
+ seen.add(key)
188
+ return edges
189
+
190
+
191
+ def build_node_type_map(
192
+ devices: Iterable[Device],
193
+ clients: Iterable[object] | None = None,
194
+ *,
195
+ client_mode: str = "wired",
196
+ only_unifi: bool = False,
197
+ ) -> dict[str, str]:
198
+ """Build a map of node names to their types."""
199
+ node_types: dict[str, str] = {}
200
+ for device in devices:
201
+ node_types[device.name] = classify_device_type(device)
202
+ if clients:
203
+ for client in clients:
204
+ if not client_matches_filters(client, client_mode=client_mode, only_unifi=only_unifi):
205
+ continue
206
+ name = client_display_name(client)
207
+ if name:
208
+ node_types[name] = classify_client_type(client)
209
+ return node_types
210
+
211
+
212
+ def build_client_port_map(
213
+ devices: Iterable[Device],
214
+ clients: Iterable[object],
215
+ *,
216
+ client_mode: str,
217
+ only_unifi: bool = False,
218
+ ) -> ClientPortMap:
219
+ """Build a map of device names to their connected client ports."""
220
+ from .topology import build_device_index
221
+
222
+ device_index = build_device_index(devices)
223
+ port_map: ClientPortMap = {}
224
+ for client in clients:
225
+ if not client_matches_filters(client, client_mode=client_mode, only_unifi=only_unifi):
226
+ continue
227
+ name = client_display_name(client)
228
+ uplink_mac = _client_uplink_mac(client)
229
+ uplink_port = _client_uplink_port(client)
230
+ if not name or not uplink_mac or uplink_port is None:
231
+ continue
232
+ device_name = device_index.get(normalize_mac(uplink_mac))
233
+ if not device_name:
234
+ continue
235
+ port_map.setdefault(device_name, []).append((uplink_port, name))
236
+ return port_map
237
+
238
+
239
+ def collapse_client_edges(
240
+ edges: list[Edge],
241
+ node_types: dict[str, str],
242
+ ) -> tuple[list[Edge], dict[str, int]]:
243
+ """Collapse individual client edges into cluster nodes."""
244
+ client_counts: dict[str, int] = {}
245
+ collapsed_edges: list[Edge] = []
246
+ collapsed_clients: set[str] = set()
247
+
248
+ for edge in edges:
249
+ if node_types.get(edge.right) == "client":
250
+ client_counts[edge.left] = client_counts.get(edge.left, 0) + 1
251
+ collapsed_clients.add(edge.right)
252
+ else:
253
+ collapsed_edges.append(edge)
254
+
255
+ for client_name in collapsed_clients:
256
+ node_types.pop(client_name, None)
257
+
258
+ for device_name, count in sorted(client_counts.items()):
259
+ cluster_name = f"{device_name} ({count} clients)"
260
+ collapsed_edges.append(
261
+ Edge(
262
+ left=device_name,
263
+ right=cluster_name,
264
+ label=None,
265
+ poe=False,
266
+ wireless=False,
267
+ )
268
+ )
269
+ node_types[cluster_name] = "client_cluster"
270
+
271
+ return collapsed_edges, client_counts
@@ -0,0 +1,37 @@
1
+ """Connection quality data for wireless clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class ConnectionInfo:
10
+ """Quality metrics for a wireless client connection."""
11
+
12
+ signal_dbm: int | None = None
13
+ noise_dbm: int | None = None
14
+ tx_rate_mbps: int | None = None
15
+ rx_rate_mbps: int | None = None
16
+ satisfaction: int | None = None
17
+ quality: str | None = None
18
+
19
+
20
+ def classify_signal_quality(signal_dbm: int | None) -> str | None:
21
+ """Classify wireless signal strength into quality categories.
22
+
23
+ Thresholds:
24
+ excellent: > -50 dBm
25
+ good: -50 to -65 dBm
26
+ fair: -65 to -75 dBm
27
+ poor: < -75 dBm
28
+ """
29
+ if signal_dbm is None:
30
+ return None
31
+ if signal_dbm > -50:
32
+ return "excellent"
33
+ if signal_dbm > -65:
34
+ return "good"
35
+ if signal_dbm > -75:
36
+ return "fair"
37
+ return "poor"