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,275 @@
|
|
|
1
|
+
"""Render LLDP data as Markdown tables."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
|
|
7
|
+
from ..model.lldp import LLDPEntry, local_port_label
|
|
8
|
+
from ..model.topology import Device, build_client_port_map, build_device_index, build_port_map
|
|
9
|
+
from .device_ports_md import render_device_port_details
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _normalize_mac(value: str) -> str:
|
|
13
|
+
return value.strip().lower()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _client_field(client: object, name: str) -> object | None:
|
|
17
|
+
if isinstance(client, dict):
|
|
18
|
+
return client.get(name)
|
|
19
|
+
return getattr(client, name, None)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _client_display_name(client: object) -> str | None:
|
|
23
|
+
for key in ("name", "hostname", "mac"):
|
|
24
|
+
value = _client_field(client, key)
|
|
25
|
+
if isinstance(value, str) and value.strip():
|
|
26
|
+
return value.strip()
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _client_uplink_mac(client: object) -> str | None:
|
|
31
|
+
for key in ("ap_mac", "sw_mac", "uplink_mac", "uplink_device_mac", "last_uplink_mac"):
|
|
32
|
+
value = _client_field(client, key)
|
|
33
|
+
if isinstance(value, str) and value.strip():
|
|
34
|
+
return value.strip()
|
|
35
|
+
for key in ("uplink", "last_uplink"):
|
|
36
|
+
nested = _client_field(client, key)
|
|
37
|
+
if isinstance(nested, dict):
|
|
38
|
+
value = nested.get("uplink_mac") or nested.get("uplink_device_mac")
|
|
39
|
+
if isinstance(value, str) and value.strip():
|
|
40
|
+
return value.strip()
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _client_uplink_port(client: object) -> int | None:
|
|
45
|
+
for key in ("uplink_remote_port", "sw_port", "ap_port"):
|
|
46
|
+
value = _client_field(client, key)
|
|
47
|
+
if isinstance(value, int):
|
|
48
|
+
return value
|
|
49
|
+
if isinstance(value, str) and value.isdigit():
|
|
50
|
+
return int(value)
|
|
51
|
+
for key in ("uplink", "last_uplink"):
|
|
52
|
+
nested = _client_field(client, key)
|
|
53
|
+
if isinstance(nested, dict):
|
|
54
|
+
value = nested.get("uplink_remote_port")
|
|
55
|
+
if isinstance(value, int):
|
|
56
|
+
return value
|
|
57
|
+
if isinstance(value, str) and value.isdigit():
|
|
58
|
+
return int(value)
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _client_is_wired(client: object) -> bool:
|
|
63
|
+
return bool(_client_field(client, "is_wired"))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _client_matches_mode(client: object, mode: str) -> bool:
|
|
67
|
+
wired = _client_is_wired(client)
|
|
68
|
+
if mode == "all":
|
|
69
|
+
return True
|
|
70
|
+
if mode == "wireless":
|
|
71
|
+
return not wired
|
|
72
|
+
return wired
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _lldp_sort_key(entry: LLDPEntry) -> tuple[int, str, str]:
|
|
76
|
+
port_label = local_port_label(entry) or ""
|
|
77
|
+
port_number = "".join(ch for ch in port_label if ch.isdigit())
|
|
78
|
+
return (int(port_number or 0), port_label, entry.port_id)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _device_header_lines(device: Device) -> list[str]:
|
|
82
|
+
return [f"## {device.name}"]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _port_summary(device: Device) -> str:
|
|
86
|
+
ports = [port for port in device.port_table if port.port_idx is not None]
|
|
87
|
+
if not ports:
|
|
88
|
+
return "-"
|
|
89
|
+
total_ports = len(ports)
|
|
90
|
+
poe_capable = sum(1 for port in ports if port.port_poe or port.poe_enable)
|
|
91
|
+
poe_active = sum(1 for port in ports if device.poe_ports.get(port.port_idx or -1))
|
|
92
|
+
total_power = sum(port.poe_power or 0.0 for port in ports)
|
|
93
|
+
summary = f"Total {total_ports}, PoE {poe_capable} (active {poe_active})"
|
|
94
|
+
if total_power > 0:
|
|
95
|
+
summary = f"{summary}, {total_power:.2f}W"
|
|
96
|
+
return summary
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _poe_summary(device: Device) -> str:
|
|
100
|
+
ports = [port for port in device.port_table if port.port_idx is not None]
|
|
101
|
+
if not ports:
|
|
102
|
+
return "-"
|
|
103
|
+
poe_capable = sum(1 for port in ports if port.port_poe or port.poe_enable)
|
|
104
|
+
poe_active = sum(1 for port in ports if (port.poe_power or 0.0) > 0 or port.poe_good)
|
|
105
|
+
total_power = sum(port.poe_power or 0.0 for port in ports)
|
|
106
|
+
summary = f"{poe_capable} capable, {poe_active} active"
|
|
107
|
+
if total_power > 0:
|
|
108
|
+
summary = f"{summary}, {total_power:.2f}W"
|
|
109
|
+
return summary
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _uplink_summary(device: Device) -> str:
|
|
113
|
+
uplink = device.uplink or device.last_uplink
|
|
114
|
+
if not uplink:
|
|
115
|
+
return "-"
|
|
116
|
+
name = uplink.name or uplink.mac or "Unknown"
|
|
117
|
+
if uplink.port is not None:
|
|
118
|
+
return f"{name} (Port {uplink.port})"
|
|
119
|
+
return name
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _client_summary(
|
|
123
|
+
device: Device, client_rows: dict[str, list[tuple[str, str | None]]]
|
|
124
|
+
) -> tuple[str, str]:
|
|
125
|
+
rows = client_rows.get(device.name)
|
|
126
|
+
if rows is None:
|
|
127
|
+
return "-", "-"
|
|
128
|
+
count = len(rows)
|
|
129
|
+
names = [name for name, _port in rows]
|
|
130
|
+
sample = ", ".join(names[:3])
|
|
131
|
+
if len(names) > 3:
|
|
132
|
+
sample = f"{sample}, ..."
|
|
133
|
+
return str(count), sample or "-"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _details_table_lines(
|
|
137
|
+
device: Device,
|
|
138
|
+
client_rows: dict[str, list[tuple[str, str | None]]],
|
|
139
|
+
client_mode: str,
|
|
140
|
+
) -> list[str]:
|
|
141
|
+
wired_count, client_sample = _client_summary(device, client_rows)
|
|
142
|
+
client_label = f"Clients ({client_mode})"
|
|
143
|
+
lines = [
|
|
144
|
+
"### Details",
|
|
145
|
+
"",
|
|
146
|
+
"| Field | Value |",
|
|
147
|
+
"| --- | --- |",
|
|
148
|
+
f"| Model | {_escape_cell(device.model_name or device.type or '-')} |",
|
|
149
|
+
f"| Type | {_escape_cell(device.type or '-')} |",
|
|
150
|
+
f"| IP | {_escape_cell(device.ip or '-')} |",
|
|
151
|
+
f"| MAC | {_escape_cell(device.mac or '-')} |",
|
|
152
|
+
f"| Firmware | {_escape_cell(device.version or '-')} |",
|
|
153
|
+
f"| Uplink | {_escape_cell(_uplink_summary(device))} |",
|
|
154
|
+
f"| Ports | {_escape_cell(_port_summary(device))} |",
|
|
155
|
+
f"| PoE | {_escape_cell(_poe_summary(device))} |",
|
|
156
|
+
f"| {client_label} | {_escape_cell(wired_count)} |",
|
|
157
|
+
f"| Client examples | {_escape_cell(client_sample)} |",
|
|
158
|
+
"",
|
|
159
|
+
]
|
|
160
|
+
return lines
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _lldp_rows(
|
|
164
|
+
entries: Iterable[LLDPEntry],
|
|
165
|
+
device_index: dict[str, str],
|
|
166
|
+
) -> list[list[str]]:
|
|
167
|
+
rows: list[list[str]] = []
|
|
168
|
+
for entry in sorted(entries, key=_lldp_sort_key):
|
|
169
|
+
local_label = local_port_label(entry) or "?"
|
|
170
|
+
peer_name = device_index.get(_normalize_mac(entry.chassis_id), "")
|
|
171
|
+
peer_port = entry.port_id or "?"
|
|
172
|
+
port_desc = entry.port_desc or ""
|
|
173
|
+
rows.append(
|
|
174
|
+
[
|
|
175
|
+
local_label,
|
|
176
|
+
peer_name or "-",
|
|
177
|
+
peer_port,
|
|
178
|
+
entry.chassis_id,
|
|
179
|
+
port_desc or "-",
|
|
180
|
+
]
|
|
181
|
+
)
|
|
182
|
+
return rows
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _escape_cell(value: str) -> str:
|
|
186
|
+
return value.replace("|", "\\|")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _client_rows(
|
|
190
|
+
clients: Iterable[object],
|
|
191
|
+
device_index: dict[str, str],
|
|
192
|
+
*,
|
|
193
|
+
include_ports: bool,
|
|
194
|
+
client_mode: str,
|
|
195
|
+
) -> dict[str, list[tuple[str, str | None]]]:
|
|
196
|
+
rows_by_device: dict[str, list[tuple[str, str | None]]] = {}
|
|
197
|
+
for client in clients:
|
|
198
|
+
if not _client_matches_mode(client, client_mode):
|
|
199
|
+
continue
|
|
200
|
+
name = _client_display_name(client)
|
|
201
|
+
uplink_mac = _client_uplink_mac(client)
|
|
202
|
+
if not name or not uplink_mac:
|
|
203
|
+
continue
|
|
204
|
+
device_name = device_index.get(_normalize_mac(uplink_mac))
|
|
205
|
+
if not device_name:
|
|
206
|
+
continue
|
|
207
|
+
port_label = None
|
|
208
|
+
if include_ports:
|
|
209
|
+
uplink_port = _client_uplink_port(client)
|
|
210
|
+
if uplink_port is not None:
|
|
211
|
+
port_label = f"Port {uplink_port}"
|
|
212
|
+
rows_by_device.setdefault(device_name, []).append((name, port_label))
|
|
213
|
+
return rows_by_device
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def render_lldp_md(
|
|
217
|
+
devices: list[Device],
|
|
218
|
+
*,
|
|
219
|
+
clients: Iterable[object] | None = None,
|
|
220
|
+
include_ports: bool = False,
|
|
221
|
+
show_clients: bool = False,
|
|
222
|
+
client_mode: str = "wired",
|
|
223
|
+
) -> str:
|
|
224
|
+
device_index = build_device_index(devices)
|
|
225
|
+
port_map = {}
|
|
226
|
+
client_port_map = None
|
|
227
|
+
client_rows = (
|
|
228
|
+
_client_rows(clients, device_index, include_ports=include_ports, client_mode=client_mode)
|
|
229
|
+
if clients
|
|
230
|
+
else {}
|
|
231
|
+
)
|
|
232
|
+
if include_ports:
|
|
233
|
+
port_map = build_port_map(devices, only_unifi=False)
|
|
234
|
+
if clients and show_clients:
|
|
235
|
+
client_port_map = build_client_port_map(devices, clients, client_mode=client_mode)
|
|
236
|
+
lines: list[str] = ["# LLDP Neighbors", ""]
|
|
237
|
+
for device in sorted(devices, key=lambda item: item.name.lower()):
|
|
238
|
+
lines.extend(_device_header_lines(device))
|
|
239
|
+
lines.append("")
|
|
240
|
+
lines.extend(_details_table_lines(device, client_rows, client_mode))
|
|
241
|
+
if include_ports:
|
|
242
|
+
lines.append("### Ports")
|
|
243
|
+
lines.append("")
|
|
244
|
+
lines.append(
|
|
245
|
+
render_device_port_details(device, port_map, client_ports=client_port_map).strip()
|
|
246
|
+
)
|
|
247
|
+
if device.lldp_info:
|
|
248
|
+
lines.append("")
|
|
249
|
+
lines.append(
|
|
250
|
+
"| Local Port | Neighbor | Neighbor Port | Chassis ID | Port Description |"
|
|
251
|
+
)
|
|
252
|
+
lines.append("| --- | --- | --- | --- | --- |")
|
|
253
|
+
for row in _lldp_rows(device.lldp_info, device_index):
|
|
254
|
+
lines.append("| " + " | ".join(_escape_cell(cell) for cell in row) + " |")
|
|
255
|
+
lines.append("")
|
|
256
|
+
else:
|
|
257
|
+
lines.append("_No LLDP neighbors._")
|
|
258
|
+
lines.append("")
|
|
259
|
+
rows = client_rows.get(device.name)
|
|
260
|
+
if rows and show_clients:
|
|
261
|
+
lines.append("")
|
|
262
|
+
lines.append("### Clients")
|
|
263
|
+
if include_ports:
|
|
264
|
+
lines.append("")
|
|
265
|
+
lines.append("| Client | Port |")
|
|
266
|
+
lines.append("| --- | --- |")
|
|
267
|
+
for client_name, port_label in rows:
|
|
268
|
+
lines.append(
|
|
269
|
+
f"| {_escape_cell(client_name)} | {_escape_cell(port_label or '-')} |"
|
|
270
|
+
)
|
|
271
|
+
else:
|
|
272
|
+
for client_name, _port_label in rows:
|
|
273
|
+
lines.append(f"- {_escape_cell(client_name)}")
|
|
274
|
+
lines.append("")
|
|
275
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
@@ -71,6 +71,7 @@ def render_mermaid(
|
|
|
71
71
|
id_map = _build_id_map(edge_list, group_nodes)
|
|
72
72
|
lines = [f"graph {direction}"]
|
|
73
73
|
poe_links: list[int] = []
|
|
74
|
+
wireless_links: list[int] = []
|
|
74
75
|
link_index = 0
|
|
75
76
|
if groups:
|
|
76
77
|
ordered = group_order or list(groups.keys())
|
|
@@ -99,6 +100,8 @@ def render_mermaid(
|
|
|
99
100
|
lines.append(f" {left} --- {right};")
|
|
100
101
|
if edge.poe:
|
|
101
102
|
poe_links.append(link_index)
|
|
103
|
+
if edge.wireless:
|
|
104
|
+
wireless_links.append(link_index)
|
|
102
105
|
link_index += 1
|
|
103
106
|
if node_types:
|
|
104
107
|
class_map = {
|
|
@@ -121,11 +124,24 @@ def render_mermaid(
|
|
|
121
124
|
f"{index} stroke:{theme.poe_link},stroke-width:{theme.poe_link_width}px,"
|
|
122
125
|
f"arrowhead:{theme.poe_link_arrow};"
|
|
123
126
|
)
|
|
127
|
+
for index in wireless_links:
|
|
128
|
+
lines.append(f" linkStyle {index} stroke-dasharray: 5 4;")
|
|
124
129
|
return "\n".join(lines) + "\n"
|
|
125
130
|
|
|
126
131
|
|
|
127
|
-
def render_legend(theme: MermaidTheme = DEFAULT_THEME) -> str:
|
|
132
|
+
def render_legend(theme: MermaidTheme = DEFAULT_THEME, *, legend_scale: float = 1.0) -> str:
|
|
133
|
+
scale = legend_scale if legend_scale > 0 else 1.0
|
|
134
|
+
legend_font_size = max(7, round(10 * scale))
|
|
135
|
+
poe_link_width = max(1, round(theme.poe_link_width * scale))
|
|
136
|
+
standard_link_width = max(1, round(theme.standard_link_width * scale))
|
|
137
|
+
node_spacing = max(10, round(50 * scale))
|
|
138
|
+
rank_spacing = max(10, round(50 * scale))
|
|
139
|
+
node_padding = max(4, round(12 * scale))
|
|
128
140
|
lines = [
|
|
141
|
+
"%%{init: {"
|
|
142
|
+
f'"flowchart": {{"nodeSpacing": {node_spacing}, "rankSpacing": {rank_spacing}}}, '
|
|
143
|
+
f'"themeVariables": {{"fontSize": "{legend_font_size}px", "nodePadding": {node_padding}}}'
|
|
144
|
+
"}}%%",
|
|
129
145
|
"graph TB",
|
|
130
146
|
' subgraph legend["Legend"];',
|
|
131
147
|
' legend_gateway["Gateway"];',
|
|
@@ -153,14 +169,62 @@ def render_legend(theme: MermaidTheme = DEFAULT_THEME) -> str:
|
|
|
153
169
|
" class legend_no_poe_b node_legend;",
|
|
154
170
|
]
|
|
155
171
|
lines.extend(class_defs(theme))
|
|
172
|
+
lines.append(f" classDef node_legend font-size:{legend_font_size}px;")
|
|
156
173
|
lines.append(
|
|
157
174
|
" linkStyle 0 "
|
|
158
|
-
f"stroke:{theme.poe_link},stroke-width:{
|
|
175
|
+
f"stroke:{theme.poe_link},stroke-width:{poe_link_width}px,"
|
|
159
176
|
f"arrowhead:{theme.poe_link_arrow};"
|
|
160
177
|
)
|
|
161
178
|
lines.append(
|
|
162
179
|
" linkStyle 1 "
|
|
163
|
-
f"stroke:{theme.standard_link},stroke-width:{
|
|
180
|
+
f"stroke:{theme.standard_link},stroke-width:{standard_link_width}px,"
|
|
164
181
|
f"arrowhead:{theme.standard_link_arrow};"
|
|
165
182
|
)
|
|
166
183
|
return "\n".join(lines) + "\n"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def render_legend_compact(theme: MermaidTheme = DEFAULT_THEME) -> str:
|
|
187
|
+
def swatch(fill: str, stroke: str, label: str) -> str:
|
|
188
|
+
return (
|
|
189
|
+
f'<span style="display:inline-block;width:12px;height:12px;'
|
|
190
|
+
f"background:{fill};border:1px solid {stroke};border-radius:2px;"
|
|
191
|
+
f'margin-right:6px;"></span>{label}'
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
def line_sample(
|
|
195
|
+
color: str,
|
|
196
|
+
width: int,
|
|
197
|
+
*,
|
|
198
|
+
dashed: bool = False,
|
|
199
|
+
label: str = "",
|
|
200
|
+
bolt: bool = False,
|
|
201
|
+
) -> str:
|
|
202
|
+
dash = ' stroke-dasharray="5 4"' if dashed else ""
|
|
203
|
+
bolt_suffix = " ⚡" if bolt else ""
|
|
204
|
+
return (
|
|
205
|
+
f'<span style="display:inline-flex;align-items:center;gap:6px;">'
|
|
206
|
+
f'<svg width="42" height="10" viewBox="0 0 42 10" '
|
|
207
|
+
f'xmlns="http://www.w3.org/2000/svg" aria-hidden="true">'
|
|
208
|
+
f'<line x1="2" y1="5" x2="40" y2="5" stroke="{color}" '
|
|
209
|
+
f'stroke-width="{max(1, width)}"{dash} />'
|
|
210
|
+
f"</svg>{label}{bolt_suffix}</span>"
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
rows = [
|
|
214
|
+
swatch(theme.node_gateway[0], theme.node_gateway[1], "Gateway"),
|
|
215
|
+
swatch(theme.node_switch[0], theme.node_switch[1], "Switch"),
|
|
216
|
+
swatch(theme.node_ap[0], theme.node_ap[1], "AP"),
|
|
217
|
+
swatch(theme.node_client[0], theme.node_client[1], "Client"),
|
|
218
|
+
swatch(theme.node_other[0], theme.node_other[1], "Other"),
|
|
219
|
+
line_sample(theme.poe_link, theme.poe_link_width, label="PoE", bolt=True),
|
|
220
|
+
line_sample(theme.standard_link, theme.standard_link_width, label="Link"),
|
|
221
|
+
line_sample(theme.standard_link, theme.standard_link_width, dashed=True, label="Wireless"),
|
|
222
|
+
]
|
|
223
|
+
lines = [
|
|
224
|
+
'<table class="unifi-legend-table">',
|
|
225
|
+
"<tbody>",
|
|
226
|
+
]
|
|
227
|
+
lines.extend(f" <tr><td>{style}</td></tr>" for style in rows)
|
|
228
|
+
lines.append("</tbody>")
|
|
229
|
+
lines.append("</table>")
|
|
230
|
+
return "\n".join(lines) + "\n"
|
unifi_network_maps/render/svg.py
CHANGED
|
@@ -194,8 +194,16 @@ def _iso_front_text_position(
|
|
|
194
194
|
normal_x /= normal_len
|
|
195
195
|
normal_y /= normal_len
|
|
196
196
|
inset = tile_height * 0.27
|
|
197
|
-
text_x = edge_mid_x + normal_x * inset
|
|
198
|
-
text_y = edge_mid_y + normal_y * inset + tile_height * 0.
|
|
197
|
+
text_x = edge_mid_x + normal_x * inset + tile_width * 0.02
|
|
198
|
+
text_y = edge_mid_y + normal_y * inset + tile_height * 0.33
|
|
199
|
+
edge_dx = left_edge_bottom[0] - left_edge_top[0]
|
|
200
|
+
edge_dy = left_edge_bottom[1] - left_edge_top[1]
|
|
201
|
+
edge_len = math.hypot(edge_dx, edge_dy) or 1.0
|
|
202
|
+
edge_dx /= edge_len
|
|
203
|
+
edge_dy /= edge_len
|
|
204
|
+
slide = tile_height * 0.32
|
|
205
|
+
text_x += edge_dx * slide
|
|
206
|
+
text_y += edge_dy * slide
|
|
199
207
|
name_edge_left = top_points[3]
|
|
200
208
|
name_edge_right = top_points[2]
|
|
201
209
|
angle = math.degrees(
|
|
@@ -293,7 +301,7 @@ def _label_metrics(
|
|
|
293
301
|
|
|
294
302
|
|
|
295
303
|
def _load_icons() -> dict[str, str]:
|
|
296
|
-
base = Path(__file__).resolve().
|
|
304
|
+
base = Path(__file__).resolve().parents[1] / "assets" / "icons"
|
|
297
305
|
icons: dict[str, str] = {}
|
|
298
306
|
for node_type, filename in _ICON_FILES.items():
|
|
299
307
|
path = base / filename
|
|
@@ -306,7 +314,7 @@ def _load_icons() -> dict[str, str]:
|
|
|
306
314
|
|
|
307
315
|
|
|
308
316
|
def _load_isometric_icons() -> dict[str, str]:
|
|
309
|
-
base = Path(__file__).resolve().
|
|
317
|
+
base = Path(__file__).resolve().parents[1] / "assets" / "icons" / "isometric"
|
|
310
318
|
icons: dict[str, str] = {}
|
|
311
319
|
for node_type, filename in _ISO_ICON_FILES.items():
|
|
312
320
|
path = base / filename
|
|
@@ -450,7 +458,10 @@ def render_svg(
|
|
|
450
458
|
color = "url(#link-poe)" if edge.poe else "url(#link-standard)"
|
|
451
459
|
width_px = 2 if edge.poe else 1
|
|
452
460
|
path = f"M {src_cx} {src_bottom} L {src_cx} {mid_y} L {dst_cx} {mid_y} L {dst_cx} {dst_top}"
|
|
453
|
-
|
|
461
|
+
dash = ' stroke-dasharray="6 4"' if edge.wireless else ""
|
|
462
|
+
lines.append(
|
|
463
|
+
f'<path d="{path}" stroke="{color}" stroke-width="{width_px}" fill="none"{dash}/>'
|
|
464
|
+
)
|
|
454
465
|
if edge.poe:
|
|
455
466
|
icon_x = dst_cx
|
|
456
467
|
icon_y = dst_top - 6
|
|
@@ -677,9 +688,10 @@ def render_svg_isometric(
|
|
|
677
688
|
f"L {dst_cx} {dst_cy}",
|
|
678
689
|
]
|
|
679
690
|
path = " ".join(path_cmds)
|
|
691
|
+
dash = ' stroke-dasharray="8 6"' if edge.wireless else ""
|
|
680
692
|
lines.append(
|
|
681
693
|
f'<path d="{path}" stroke="{color}" stroke-width="{width_px}" '
|
|
682
|
-
f'fill="none" stroke-linecap="round" stroke-linejoin="round"/>'
|
|
694
|
+
f'fill="none" stroke-linecap="round" stroke-linejoin="round"{dash}/>'
|
|
683
695
|
)
|
|
684
696
|
if edge.poe:
|
|
685
697
|
icon_x = dst_cx
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: unifi-network-maps
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.0
|
|
4
4
|
Summary: Dynamic UniFi -> network maps in mermaid or svg
|
|
5
5
|
Author: Merlijn
|
|
6
|
-
License: MIT
|
|
6
|
+
License-Expression: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/merlijntishauser/unifi-network-maps
|
|
8
8
|
Project-URL: Repository, https://github.com/merlijntishauser/unifi-network-maps
|
|
9
9
|
Project-URL: Issues, https://github.com/merlijntishauser/unifi-network-maps/issues
|
|
@@ -11,27 +11,24 @@ Project-URL: Changelog, https://github.com/merlijntishauser/unifi-network-maps/b
|
|
|
11
11
|
Keywords: unifi,mermaid,network,topology,diagram,svg
|
|
12
12
|
Classifier: Development Status :: 3 - Alpha
|
|
13
13
|
Classifier: Intended Audience :: System Administrators
|
|
14
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
15
14
|
Classifier: Operating System :: OS Independent
|
|
16
15
|
Classifier: Programming Language :: Python :: 3
|
|
17
16
|
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
-
Classifier: Programming Language :: Python :: 3.
|
|
19
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
18
|
Classifier: Topic :: Documentation
|
|
22
19
|
Classifier: Topic :: System :: Networking
|
|
23
|
-
Requires-Python: >=3.
|
|
20
|
+
Requires-Python: >=3.13
|
|
24
21
|
Description-Content-Type: text/markdown
|
|
25
22
|
License-File: LICENSE
|
|
26
|
-
|
|
27
|
-
Requires-Dist:
|
|
28
|
-
Requires-Dist:
|
|
29
|
-
Requires-Dist: PyYAML
|
|
23
|
+
Requires-Dist: unifi-controller-api==0.3.2
|
|
24
|
+
Requires-Dist: python-dotenv==1.2.1
|
|
25
|
+
Requires-Dist: PyYAML==6.0.3
|
|
30
26
|
Provides-Extra: dev
|
|
31
|
-
Requires-Dist:
|
|
32
|
-
Requires-Dist:
|
|
33
|
-
Requires-Dist: pytest
|
|
34
|
-
Requires-Dist:
|
|
27
|
+
Requires-Dist: Faker==40.1.0; extra == "dev"
|
|
28
|
+
Requires-Dist: pre-commit==4.5.1; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest==9.0.2; extra == "dev"
|
|
30
|
+
Requires-Dist: pytest-cov==7.0.0; extra == "dev"
|
|
31
|
+
Requires-Dist: ruff==0.14.10; extra == "dev"
|
|
35
32
|
Dynamic: license-file
|
|
36
33
|
|
|
37
34
|
# unifi-network-maps
|
|
@@ -40,13 +37,14 @@ Dynamic UniFi -> Mermaid network maps generated from LLDP topology.
|
|
|
40
37
|
|
|
41
38
|
## Setup
|
|
42
39
|
|
|
43
|
-
- Python >= 3.
|
|
40
|
+
- Python >= 3.13
|
|
44
41
|
- Virtualenv required
|
|
45
42
|
|
|
46
43
|
```bash
|
|
47
44
|
python -m venv .venv
|
|
48
45
|
source .venv/bin/activate
|
|
49
|
-
pip install -
|
|
46
|
+
pip install -r requirements-build.txt
|
|
47
|
+
pip install -e . -c constraints.txt
|
|
50
48
|
```
|
|
51
49
|
|
|
52
50
|
Local install (non-editable):
|
|
@@ -103,12 +101,24 @@ Isometric SVG output:
|
|
|
103
101
|
|
|
104
102
|
```bash
|
|
105
103
|
unifi-network-maps --format svg-iso --output ./network.svg
|
|
104
|
+
|
|
105
|
+
# Single-page MkDocs output (ports included, no clients)
|
|
106
|
+
unifi-network-maps --format mkdocs --output ./docs/unifi-network.md
|
|
107
|
+
|
|
108
|
+
# MkDocs output (map + legend + gateway/switch port tables)
|
|
109
|
+
unifi-network-maps --format mkdocs --output ./docs/unifi-network.md
|
|
110
|
+
|
|
111
|
+
# Include wired clients in the port tables
|
|
112
|
+
unifi-network-maps --format mkdocs --include-clients --output ./docs/unifi-network.md
|
|
106
113
|
```
|
|
107
114
|
|
|
108
115
|
SVG size overrides:
|
|
109
116
|
|
|
110
117
|
```bash
|
|
111
118
|
unifi-network-maps --format svg --svg-width 1400 --svg-height 900 --output ./network.svg
|
|
119
|
+
|
|
120
|
+
# LLDP tables for troubleshooting
|
|
121
|
+
unifi-network-maps --format lldp-md --output ./lldp.md
|
|
112
122
|
```
|
|
113
123
|
|
|
114
124
|
Legend only:
|
|
@@ -117,12 +127,61 @@ Legend only:
|
|
|
117
127
|
unifi-network-maps --legend-only --stdout
|
|
118
128
|
```
|
|
119
129
|
|
|
130
|
+
## Examples (mock data)
|
|
131
|
+
|
|
132
|
+
These examples are generated from `examples/mock_data.json` (safe, anonymized fixture).
|
|
133
|
+
Mock generation requires dev dependencies (`pip install -r requirements-dev.txt -c constraints.txt`).
|
|
134
|
+
Regenerate the fixture + SVG with `make mock-data`.
|
|
135
|
+
|
|
136
|
+
Generate mock data (dev-only, uses Faker):
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
unifi-network-maps --generate-mock examples/mock_data.json --mock-seed 1337
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Generate the isometric SVG:
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
unifi-network-maps --mock-data examples/mock_data.json \
|
|
146
|
+
--include-ports --include-clients --format svg-iso \
|
|
147
|
+
--output examples/output/network_ports_clients_iso.svg
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+

|
|
151
|
+
|
|
152
|
+
Mermaid example with ports:
|
|
153
|
+
|
|
154
|
+
```mermaid
|
|
155
|
+
graph TB
|
|
156
|
+
core_switch["Core Switch"] ---|"Core Switch: Port 7 (AP Attic) <-> AP Attic: Port 1 (Core Switch)"| ap_attic["AP Attic"];
|
|
157
|
+
core_switch["Core Switch"] ---|"Core Switch: Port 3 (AP Living Room) <-> AP Living Room: Port 1 (Core Switch)"| ap_living_room["AP Living Room"];
|
|
158
|
+
cloud_gateway["Cloud Gateway"] ---|"Cloud Gateway: Port 9 (Core Switch) <-> Core Switch: Port 1 (Cloud Gateway)"| core_switch["Core Switch"];
|
|
159
|
+
class cloud_gateway node_gateway;
|
|
160
|
+
class core_switch node_switch;
|
|
161
|
+
class ap_living_room node_ap;
|
|
162
|
+
class ap_attic node_ap;
|
|
163
|
+
classDef node_gateway fill:#ffe3b3,stroke:#d98300,stroke-width:1px;
|
|
164
|
+
classDef node_switch fill:#d6ecff,stroke:#3a7bd5,stroke-width:1px;
|
|
165
|
+
classDef node_ap fill:#d7f5e7,stroke:#27ae60,stroke-width:1px;
|
|
166
|
+
classDef node_client fill:#f2e5ff,stroke:#7f3fbf,stroke-width:1px;
|
|
167
|
+
classDef node_other fill:#eeeeee,stroke:#8f8f8f,stroke-width:1px;
|
|
168
|
+
classDef node_legend font-size:10px;
|
|
169
|
+
linkStyle 0 stroke:#1e88e5,stroke-width:2px,arrowhead:none;
|
|
170
|
+
linkStyle 1 stroke:#1e88e5,stroke-width:2px,arrowhead:none;
|
|
171
|
+
```
|
|
172
|
+
|
|
120
173
|
## Local install check
|
|
121
174
|
|
|
122
175
|
```bash
|
|
123
176
|
pip install .
|
|
124
177
|
```
|
|
125
178
|
|
|
179
|
+
## Dev
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
pip install -r requirements-dev.txt -c constraints.txt
|
|
183
|
+
```
|
|
184
|
+
|
|
126
185
|
## Release
|
|
127
186
|
|
|
128
187
|
Build and upload to PyPI:
|
|
@@ -142,6 +201,20 @@ git push origin vX.Y.Z
|
|
|
142
201
|
|
|
143
202
|
See `LICENSES.md` for third-party license info.
|
|
144
203
|
|
|
204
|
+
## Installation
|
|
205
|
+
|
|
206
|
+
PyPI: https://pypi.org/project/unifi-network-maps/
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
pip install unifi-network-maps
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Then run:
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
unifi-network-maps --help
|
|
216
|
+
```
|
|
217
|
+
|
|
145
218
|
## Options
|
|
146
219
|
|
|
147
220
|
The CLI groups options by category (`Source`, `Functional`, `Mermaid`, `SVG`, `Output`, `Debug`).
|
|
@@ -149,15 +222,26 @@ The CLI groups options by category (`Source`, `Functional`, `Mermaid`, `SVG`, `O
|
|
|
149
222
|
Source:
|
|
150
223
|
- `--site`: override `UNIFI_SITE`.
|
|
151
224
|
- `--env-file`: load environment variables from a specific `.env` file.
|
|
225
|
+
- `--mock-data`: use mock data JSON instead of the UniFi API.
|
|
226
|
+
Mock:
|
|
227
|
+
- `--generate-mock`: write mock data JSON and exit.
|
|
228
|
+
- `--mock-seed`: seed for deterministic mock generation.
|
|
229
|
+
- `--mock-switches`: number of switches to generate.
|
|
230
|
+
- `--mock-aps`: number of access points to generate.
|
|
231
|
+
- `--mock-wired-clients`: number of wired clients to generate.
|
|
232
|
+
- `--mock-wireless-clients`: number of wireless clients to generate.
|
|
152
233
|
|
|
153
234
|
Functional:
|
|
154
235
|
- `--include-ports`: show port labels (Mermaid shows both ends; SVG shows compact labels).
|
|
155
236
|
- `--include-clients`: add active wired clients as leaf nodes.
|
|
237
|
+
- `--client-scope wired|wireless|all`: which client types to include (default wired).
|
|
156
238
|
- `--only-unifi`: only include neighbors that are UniFi devices.
|
|
157
239
|
|
|
158
240
|
Mermaid:
|
|
159
241
|
- `--direction LR|TB`: diagram direction for Mermaid (default TB).
|
|
160
242
|
- `--group-by-type`: group nodes by gateway/switch/AP in Mermaid subgraphs.
|
|
243
|
+
- `--legend-scale`: scale legend font/link sizes for Mermaid outputs (default 1.0).
|
|
244
|
+
- `--legend-style auto|compact|diagram`: legend rendering mode (auto uses compact for mkdocs).
|
|
161
245
|
- `--legend-only`: render just the legend as a separate Mermaid graph (Mermaid only).
|
|
162
246
|
|
|
163
247
|
SVG:
|
|
@@ -165,9 +249,10 @@ SVG:
|
|
|
165
249
|
- `--theme-file`: load a YAML theme for Mermaid + SVG colors (see `examples/theme.yaml` and `examples/theme-dark.yaml`).
|
|
166
250
|
|
|
167
251
|
Output:
|
|
168
|
-
- `--format mermaid|svg|svg-iso`: output format (default mermaid).
|
|
252
|
+
- `--format mermaid|svg|svg-iso|lldp-md|mkdocs`: output format (default mermaid).
|
|
169
253
|
- `--stdout`: write output to stdout.
|
|
170
254
|
- `--markdown`: wrap Mermaid output in a code fence.
|
|
255
|
+
- `--mkdocs-sidebar-legend`: write assets to place the compact legend in the MkDocs right sidebar.
|
|
171
256
|
|
|
172
257
|
Debug:
|
|
173
258
|
- `--debug-dump`: dump gateway + sample devices to stderr for debugging.
|
|
@@ -178,6 +263,7 @@ Debug:
|
|
|
178
263
|
- Default output is top-to-bottom (TB) and rendered as a hop-based tree from the gateway(s).
|
|
179
264
|
- Nodes are color-coded by type (gateway/switch/AP/client) with a sensible default palette.
|
|
180
265
|
- PoE links are highlighted in blue and annotated with a power icon when detected from `port_table`.
|
|
266
|
+
- Wireless client links render as dashed lines to indicate the last-known upstream.
|
|
181
267
|
- SVG output uses vendored device glyphs from `src/unifi_network_maps/assets/icons`.
|
|
182
268
|
- Isometric SVG output uses MIT-licensed icons from `markmanx/isopacks`.
|
|
183
269
|
- SVG port labels render inside child nodes for readability.
|
|
@@ -211,5 +297,10 @@ svg:
|
|
|
211
297
|
to: "#b6dcff"
|
|
212
298
|
```
|
|
213
299
|
|
|
300
|
+
## MkDocs Material example
|
|
301
|
+
|
|
302
|
+
See `examples/mkdocs/` for a ready-to-use setup that renders Mermaid diagrams
|
|
303
|
+
with Material for MkDocs, including a sample `unifi-network` page and legend.
|
|
304
|
+
|
|
214
305
|
The built-in themes live at `src/unifi_network_maps/assets/themes/default.yaml` and
|
|
215
306
|
`src/unifi_network_maps/assets/themes/dark.yaml`.
|