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,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"
|