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.
@@ -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:{theme.poe_link_width}px,"
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:{theme.standard_link_width}px,"
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"
@@ -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 - tile_width * 0.16
198
- text_y = edge_mid_y + normal_y * inset + tile_height * 0.02
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().parent / "assets" / "icons"
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().parent / "assets" / "icons" / "isometric"
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
- lines.append(f'<path d="{path}" stroke="{color}" stroke-width="{width_px}" fill="none"/>')
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.0
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.10
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.10
20
+ Requires-Python: >=3.13
24
21
  Description-Content-Type: text/markdown
25
22
  License-File: LICENSE
26
- License-File: LICENSES.md
27
- Requires-Dist: unifi-controller-api
28
- Requires-Dist: python-dotenv
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: pre-commit; extra == "dev"
32
- Requires-Dist: pytest; extra == "dev"
33
- Requires-Dist: pytest-cov; extra == "dev"
34
- Requires-Dist: ruff; extra == "dev"
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.10
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 -e .
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
+ ![Isometric network example](examples/output/network_ports_clients_iso.svg)
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`.