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.
@@ -1 +1 @@
1
- __version__ = "1.3.0"
1
+ __version__ = "1.4.0"
@@ -39,30 +39,83 @@ def _cache_key(*parts: str) -> str:
39
39
 
40
40
 
41
41
  def _load_cache(path: Path, ttl_seconds: int) -> object | None:
42
- if ttl_seconds <= 0 or not path.exists():
42
+ data, age = _load_cache_with_age(path)
43
+ if data is None:
43
44
  return None
45
+ if ttl_seconds <= 0:
46
+ return None
47
+ if age is None or age > ttl_seconds:
48
+ return None
49
+ return data
50
+
51
+
52
+ def _load_cache_with_age(path: Path) -> tuple[object | None, float | None]:
53
+ if not path.exists():
54
+ return None, None
44
55
  try:
45
56
  payload = pickle.loads(path.read_bytes())
46
57
  except Exception as exc:
47
58
  logger.debug("Failed to read cache %s: %s", path, exc)
48
- return None
59
+ return None, None
49
60
  timestamp = payload.get("timestamp")
50
61
  if not isinstance(timestamp, int | float):
51
- return None
52
- if time.time() - timestamp > ttl_seconds:
53
- return None
54
- return payload.get("data")
62
+ return None, None
63
+ data = payload.get("data")
64
+ if not isinstance(data, list):
65
+ logger.debug("Cached payload at %s is not a list", path)
66
+ return None, None
67
+ return data, time.time() - timestamp
55
68
 
56
69
 
57
70
  def _save_cache(path: Path, data: object) -> None:
58
71
  try:
59
72
  path.parent.mkdir(parents=True, exist_ok=True)
60
73
  payload = {"timestamp": time.time(), "data": data}
61
- path.write_bytes(pickle.dumps(payload))
74
+ tmp_path = path.with_suffix(path.suffix + ".tmp")
75
+ tmp_path.write_bytes(pickle.dumps(payload))
76
+ tmp_path.replace(path)
62
77
  except Exception as exc:
63
78
  logger.debug("Failed to write cache %s: %s", path, exc)
64
79
 
65
80
 
81
+ def _retry_attempts() -> int:
82
+ value = os.environ.get("UNIFI_RETRY_ATTEMPTS", "").strip()
83
+ if not value:
84
+ return 2
85
+ if value.isdigit():
86
+ return max(1, int(value))
87
+ logger.warning("Invalid UNIFI_RETRY_ATTEMPTS value: %s", value)
88
+ return 2
89
+
90
+
91
+ def _retry_backoff_seconds() -> float:
92
+ value = os.environ.get("UNIFI_RETRY_BACKOFF_SECONDS", "").strip()
93
+ if not value:
94
+ return 0.5
95
+ try:
96
+ return max(0.0, float(value))
97
+ except ValueError:
98
+ logger.warning("Invalid UNIFI_RETRY_BACKOFF_SECONDS value: %s", value)
99
+ return 0.5
100
+
101
+
102
+ def _call_with_retries(operation: str, func) -> object:
103
+ attempts = _retry_attempts()
104
+ backoff = _retry_backoff_seconds()
105
+ last_exc: Exception | None = None
106
+ for attempt in range(1, attempts + 1):
107
+ try:
108
+ return func()
109
+ except Exception as exc: # noqa: BLE001 - surface full error after retries
110
+ last_exc = exc
111
+ logger.warning("Failed %s attempt %d/%d: %s", operation, attempt, attempts, exc)
112
+ if attempt < attempts and backoff > 0:
113
+ time.sleep(backoff * attempt)
114
+ if last_exc:
115
+ raise last_exc
116
+ raise RuntimeError(f"Failed {operation}")
117
+
118
+
66
119
  def _init_controller(config: Config, *, is_udm_pro: bool) -> UnifiController:
67
120
  from unifi_controller_api import UnifiController
68
121
 
@@ -91,6 +144,7 @@ def fetch_devices(
91
144
  ttl_seconds = _cache_ttl_seconds()
92
145
  cache_path = _cache_dir() / f"devices_{_cache_key(config.url, site_name, str(detailed))}.pkl"
93
146
  cached = _load_cache(cache_path, ttl_seconds)
147
+ stale_cached, cache_age = _load_cache_with_age(cache_path)
94
148
  if cached is not None:
95
149
  logger.info("Using cached devices (%d)", len(cached))
96
150
  return cached
@@ -101,7 +155,20 @@ def fetch_devices(
101
155
  logger.info("UDM Pro authentication failed, retrying legacy auth")
102
156
  controller = _init_controller(config, is_udm_pro=False)
103
157
 
104
- devices = controller.get_unifi_site_device(site_name=site_name, detailed=detailed, raw=False)
158
+ def _fetch() -> list[object]:
159
+ return controller.get_unifi_site_device(site_name=site_name, detailed=detailed, raw=False)
160
+
161
+ try:
162
+ devices = _call_with_retries("device fetch", _fetch)
163
+ except Exception as exc: # noqa: BLE001 - fallback to cache
164
+ if stale_cached is not None:
165
+ logger.warning(
166
+ "Device fetch failed; using stale cache (%ds old): %s",
167
+ int(cache_age or 0),
168
+ exc,
169
+ )
170
+ return stale_cached
171
+ raise
105
172
  _save_cache(cache_path, devices)
106
173
  logger.info("Fetched %d devices", len(devices))
107
174
  return devices
@@ -118,6 +185,7 @@ def fetch_clients(config: Config, *, site: str | None = None) -> Iterable[object
118
185
  ttl_seconds = _cache_ttl_seconds()
119
186
  cache_path = _cache_dir() / f"clients_{_cache_key(config.url, site_name)}.pkl"
120
187
  cached = _load_cache(cache_path, ttl_seconds)
188
+ stale_cached, cache_age = _load_cache_with_age(cache_path)
121
189
  if cached is not None:
122
190
  logger.info("Using cached clients (%d)", len(cached))
123
191
  return cached
@@ -128,7 +196,20 @@ def fetch_clients(config: Config, *, site: str | None = None) -> Iterable[object
128
196
  logger.info("UDM Pro authentication failed, retrying legacy auth")
129
197
  controller = _init_controller(config, is_udm_pro=False)
130
198
 
131
- clients = controller.get_unifi_site_client(site_name=site_name, raw=True)
199
+ def _fetch() -> list[object]:
200
+ return controller.get_unifi_site_client(site_name=site_name, raw=True)
201
+
202
+ try:
203
+ clients = _call_with_retries("client fetch", _fetch)
204
+ except Exception as exc: # noqa: BLE001 - fallback to cache
205
+ if stale_cached is not None:
206
+ logger.warning(
207
+ "Client fetch failed; using stale cache (%ds old): %s",
208
+ int(cache_age or 0),
209
+ exc,
210
+ )
211
+ return stale_cached
212
+ raise
132
213
  _save_cache(cache_path, clients)
133
214
  logger.info("Fetched %d clients", len(clients))
134
215
  return clients
@@ -9,16 +9,23 @@ from ..adapters.config import Config
9
9
  from ..adapters.unifi import fetch_clients, fetch_devices
10
10
  from ..io.debug import debug_dump_devices
11
11
  from ..io.export import write_output
12
+ from ..io.mock_data import load_mock_data
12
13
  from ..model.topology import (
14
+ ClientPortMap,
13
15
  Device,
16
+ PortMap,
14
17
  build_client_edges,
18
+ build_client_port_map,
15
19
  build_device_index,
16
20
  build_node_type_map,
21
+ build_port_map,
17
22
  build_topology,
18
23
  group_devices_by_type,
19
24
  normalize_devices,
20
25
  )
21
- from ..render.mermaid import render_legend, render_mermaid
26
+ from ..render.device_ports_md import render_device_port_overview
27
+ from ..render.lldp_md import render_lldp_md
28
+ from ..render.mermaid import render_legend, render_legend_compact, render_mermaid
22
29
  from ..render.mermaid_theme import MermaidTheme
23
30
  from ..render.svg import SvgOptions, render_svg
24
31
  from ..render.svg_theme import SvgTheme
@@ -41,6 +48,7 @@ def _build_parser() -> argparse.ArgumentParser:
41
48
  description="Generate network maps from UniFi LLDP data, as mermaid or SVG"
42
49
  )
43
50
  _add_source_args(parser.add_argument_group("Source"))
51
+ _add_mock_args(parser.add_argument_group("Mock"))
44
52
  _add_functional_args(parser.add_argument_group("Functional"))
45
53
  _add_mermaid_args(parser.add_argument_group("Mermaid"))
46
54
  _add_svg_args(parser.add_argument_group("SVG"))
@@ -56,6 +64,44 @@ def _add_source_args(parser: argparse._ArgumentGroup) -> None:
56
64
  default=None,
57
65
  help="Path to .env file (overrides default .env discovery)",
58
66
  )
67
+ parser.add_argument(
68
+ "--mock-data",
69
+ default=None,
70
+ help="Path to mock data JSON (skips UniFi API calls)",
71
+ )
72
+
73
+
74
+ def _add_mock_args(parser: argparse._ArgumentGroup) -> None:
75
+ parser.add_argument(
76
+ "--generate-mock",
77
+ default=None,
78
+ help="Write mock data JSON to the given path and exit",
79
+ )
80
+ parser.add_argument("--mock-seed", type=int, default=1337, help="Seed for mock generation")
81
+ parser.add_argument(
82
+ "--mock-switches",
83
+ type=int,
84
+ default=1,
85
+ help="Number of switches to generate (default: 1)",
86
+ )
87
+ parser.add_argument(
88
+ "--mock-aps",
89
+ type=int,
90
+ default=2,
91
+ help="Number of access points to generate (default: 2)",
92
+ )
93
+ parser.add_argument(
94
+ "--mock-wired-clients",
95
+ type=int,
96
+ default=2,
97
+ help="Number of wired clients to generate (default: 2)",
98
+ )
99
+ parser.add_argument(
100
+ "--mock-wireless-clients",
101
+ type=int,
102
+ default=2,
103
+ help="Number of wireless clients to generate (default: 2)",
104
+ )
59
105
 
60
106
 
61
107
  def _add_functional_args(parser: argparse._ArgumentGroup) -> None:
@@ -65,6 +111,12 @@ def _add_functional_args(parser: argparse._ArgumentGroup) -> None:
65
111
  action="store_true",
66
112
  help="Include active clients as leaf nodes",
67
113
  )
114
+ parser.add_argument(
115
+ "--client-scope",
116
+ choices=["wired", "wireless", "all"],
117
+ default="wired",
118
+ help="Client types to include (default: wired)",
119
+ )
68
120
  parser.add_argument(
69
121
  "--only-unifi", action="store_true", help="Only include neighbors that are UniFi devices"
70
122
  )
@@ -77,6 +129,18 @@ def _add_mermaid_args(parser: argparse._ArgumentGroup) -> None:
77
129
  action="store_true",
78
130
  help="Group nodes by gateway/switch/ap in Mermaid subgraphs",
79
131
  )
132
+ parser.add_argument(
133
+ "--legend-scale",
134
+ type=float,
135
+ default=1.0,
136
+ help="Scale legend font/link sizes for Mermaid output (default: 1.0)",
137
+ )
138
+ parser.add_argument(
139
+ "--legend-style",
140
+ default="auto",
141
+ choices=["auto", "compact", "diagram"],
142
+ help="Legend style (auto uses compact for mkdocs, diagram otherwise)",
143
+ )
80
144
  parser.add_argument(
81
145
  "--legend-only",
82
146
  action="store_true",
@@ -94,7 +158,7 @@ def _add_general_render_args(parser: argparse._ArgumentGroup) -> None:
94
158
  parser.add_argument(
95
159
  "--format",
96
160
  default="mermaid",
97
- choices=["mermaid", "svg", "svg-iso"],
161
+ choices=["mermaid", "svg", "svg-iso", "lldp-md", "mkdocs"],
98
162
  help="Output format",
99
163
  )
100
164
  parser.add_argument(
@@ -104,6 +168,11 @@ def _add_general_render_args(parser: argparse._ArgumentGroup) -> None:
104
168
  )
105
169
  parser.add_argument("--output", default=None, help="Output file path")
106
170
  parser.add_argument("--stdout", action="store_true", help="Write output to stdout")
171
+ parser.add_argument(
172
+ "--mkdocs-sidebar-legend",
173
+ action="store_true",
174
+ help="For mkdocs output, write sidebar legend assets next to the output file",
175
+ )
107
176
 
108
177
 
109
178
  def _add_debug_args(parser: argparse._ArgumentGroup) -> None:
@@ -138,8 +207,18 @@ def _resolve_site(args: argparse.Namespace, config: Config) -> str:
138
207
  return args.site or config.site
139
208
 
140
209
 
210
+ def _resolve_legend_style(args: argparse.Namespace) -> str:
211
+ if args.legend_style == "auto":
212
+ return "compact" if args.format == "mkdocs" else "diagram"
213
+ return args.legend_style
214
+
215
+
141
216
  def _render_legend_only(args: argparse.Namespace, mermaid_theme: MermaidTheme) -> str:
142
- content = render_legend(theme=mermaid_theme)
217
+ legend_style = _resolve_legend_style(args)
218
+ if legend_style == "compact":
219
+ content = "# Legend\n\n" + render_legend_compact(theme=mermaid_theme)
220
+ else:
221
+ content = render_legend(theme=mermaid_theme, legend_scale=args.legend_scale)
143
222
  if args.markdown:
144
223
  content = f"""```mermaid
145
224
  {content}```
@@ -147,18 +226,44 @@ def _render_legend_only(args: argparse.Namespace, mermaid_theme: MermaidTheme) -
147
226
  return content
148
227
 
149
228
 
150
- def _build_topology_data(
151
- args: argparse.Namespace, config: Config, site: str
152
- ) -> tuple[list[Device], list[str], object]:
153
- raw_devices = list(fetch_devices(config, site=site, detailed=True))
229
+ def _load_devices_data(
230
+ args: argparse.Namespace,
231
+ config: Config | None,
232
+ site: str,
233
+ *,
234
+ raw_devices_override: list[object] | None = None,
235
+ ) -> tuple[list[object], list[Device]]:
236
+ if raw_devices_override is None:
237
+ if config is None:
238
+ raise ValueError("Config required to fetch devices")
239
+ raw_devices = list(fetch_devices(config, site=site, detailed=True))
240
+ else:
241
+ raw_devices = raw_devices_override
154
242
  devices = normalize_devices(raw_devices)
155
243
  if args.debug_dump:
156
244
  debug_dump_devices(raw_devices, devices, sample_count=max(0, args.debug_sample))
245
+ return raw_devices, devices
246
+
247
+
248
+ def _build_topology_data(
249
+ args: argparse.Namespace,
250
+ config: Config | None,
251
+ site: str,
252
+ *,
253
+ include_ports: bool | None = None,
254
+ raw_devices_override: list[object] | None = None,
255
+ ) -> tuple[list[Device], list[str], object]:
256
+ _raw_devices, devices = _load_devices_data(
257
+ args,
258
+ config,
259
+ site,
260
+ raw_devices_override=raw_devices_override,
261
+ )
157
262
  groups_for_rank = group_devices_by_type(devices)
158
263
  gateways = groups_for_rank.get("gateway", [])
159
264
  topology = build_topology(
160
265
  devices,
161
- include_ports=args.include_ports,
266
+ include_ports=include_ports if include_ports is not None else args.include_ports,
162
267
  only_unifi=args.only_unifi,
163
268
  gateways=gateways,
164
269
  )
@@ -169,14 +274,26 @@ def _build_edges_with_clients(
169
274
  args: argparse.Namespace,
170
275
  edges: list,
171
276
  devices: list[Device],
172
- config: Config,
277
+ config: Config | None,
173
278
  site: str,
279
+ *,
280
+ clients_override: list[object] | None = None,
174
281
  ) -> tuple[list, list | None]:
175
282
  clients = None
176
283
  if args.include_clients:
177
- clients = list(fetch_clients(config, site=site))
284
+ if clients_override is None:
285
+ if config is None:
286
+ raise ValueError("Config required to fetch clients")
287
+ clients = list(fetch_clients(config, site=site))
288
+ else:
289
+ clients = clients_override
178
290
  device_index = build_device_index(devices)
179
- edges = edges + build_client_edges(clients, device_index, include_ports=args.include_ports)
291
+ edges = edges + build_client_edges(
292
+ clients,
293
+ device_index,
294
+ include_ports=args.include_ports,
295
+ client_mode=args.client_scope,
296
+ )
180
297
  return edges, clients
181
298
 
182
299
 
@@ -191,12 +308,21 @@ def _render_mermaid_output(
191
308
  args: argparse.Namespace,
192
309
  devices: list[Device],
193
310
  topology: object,
194
- config: Config,
311
+ config: Config | None,
195
312
  site: str,
196
313
  mermaid_theme: MermaidTheme,
314
+ *,
315
+ clients_override: list[object] | None = None,
197
316
  ) -> str:
198
317
  edges, _has_tree = _select_edges(topology)
199
- edges, clients = _build_edges_with_clients(args, edges, devices, config, site)
318
+ edges, clients = _build_edges_with_clients(
319
+ args,
320
+ edges,
321
+ devices,
322
+ config,
323
+ site,
324
+ clients_override=clients_override,
325
+ )
200
326
  groups = None
201
327
  group_order = None
202
328
  if args.group_by_type:
@@ -207,7 +333,7 @@ def _render_mermaid_output(
207
333
  direction=args.direction,
208
334
  groups=groups,
209
335
  group_order=group_order,
210
- node_types=build_node_type_map(devices, clients),
336
+ node_types=build_node_type_map(devices, clients, client_mode=args.client_scope),
211
337
  theme=mermaid_theme,
212
338
  )
213
339
  if args.markdown:
@@ -217,29 +343,138 @@ def _render_mermaid_output(
217
343
  return content
218
344
 
219
345
 
220
- def _render_svg_output(
346
+ def _render_mkdocs_output(
221
347
  args: argparse.Namespace,
222
348
  devices: list[Device],
223
349
  topology: object,
224
350
  config: Config,
225
351
  site: str,
352
+ mermaid_theme: MermaidTheme,
353
+ port_map: PortMap,
354
+ client_ports: ClientPortMap | None,
355
+ ) -> str:
356
+ edges, _has_tree = _select_edges(topology)
357
+ clients = None
358
+ content = render_mermaid(
359
+ edges,
360
+ direction=args.direction,
361
+ node_types=build_node_type_map(devices, clients, client_mode=args.client_scope),
362
+ theme=mermaid_theme,
363
+ )
364
+ legend_style = _resolve_legend_style(args)
365
+ if legend_style == "compact":
366
+ legend_block = (
367
+ '<div class="unifi-legend" data-unifi-legend>\n'
368
+ + render_legend_compact(theme=mermaid_theme)
369
+ + "</div>"
370
+ )
371
+ legend_header = ""
372
+ else:
373
+ legend_block = (
374
+ "```mermaid\n"
375
+ + render_legend(theme=mermaid_theme, legend_scale=args.legend_scale)
376
+ + "```"
377
+ )
378
+ legend_header = "## Legend\n\n"
379
+ return (
380
+ f"# UniFi network\n\n## Map\n\n```mermaid\n{content}```\n\n"
381
+ f"{legend_header}{legend_block}\n\n"
382
+ f"{render_device_port_overview(devices, port_map, client_ports=client_ports)}"
383
+ )
384
+
385
+
386
+ def _write_mkdocs_sidebar_assets(output_path: str) -> None:
387
+ from pathlib import Path
388
+
389
+ output_dir = Path(output_path).resolve().parent
390
+ assets_dir = output_dir / "assets"
391
+ assets_dir.mkdir(parents=True, exist_ok=True)
392
+ (assets_dir / "legend.js").write_text(
393
+ (
394
+ 'document.addEventListener("DOMContentLoaded", () => {\n'
395
+ ' const legend = document.querySelector("[data-unifi-legend]");\n'
396
+ ' const sidebar = document.querySelector(".md-sidebar--secondary .md-sidebar__scrollwrap");\n'
397
+ " if (!legend || !sidebar) {\n"
398
+ " return;\n"
399
+ " }\n"
400
+ ' const wrapper = document.createElement("div");\n'
401
+ ' wrapper.className = "unifi-legend-sidebar";\n'
402
+ ' const title = document.createElement("div");\n'
403
+ ' title.className = "unifi-legend-title";\n'
404
+ ' title.textContent = "Legend";\n'
405
+ " wrapper.appendChild(title);\n"
406
+ " wrapper.appendChild(legend.cloneNode(true));\n"
407
+ " sidebar.appendChild(wrapper);\n"
408
+ ' legend.classList.add("unifi-legend-hidden");\n'
409
+ "});\n"
410
+ ),
411
+ encoding="utf-8",
412
+ )
413
+ (assets_dir / "legend.css").write_text(
414
+ (
415
+ ".unifi-legend-hidden {\n"
416
+ " display: none;\n"
417
+ "}\n\n"
418
+ ".unifi-legend-sidebar {\n"
419
+ " margin-top: 1rem;\n"
420
+ " padding: 0.5rem 0.75rem;\n"
421
+ " border: 1px solid rgba(0, 0, 0, 0.08);\n"
422
+ " border-radius: 6px;\n"
423
+ " font-size: 0.75rem;\n"
424
+ "}\n\n"
425
+ ".unifi-legend-title {\n"
426
+ " font-weight: 600;\n"
427
+ " margin-bottom: 0.5rem;\n"
428
+ "}\n\n"
429
+ ".unifi-legend-sidebar table {\n"
430
+ " width: 100%;\n"
431
+ " border-collapse: collapse;\n"
432
+ "}\n\n"
433
+ ".unifi-legend-sidebar td,\n"
434
+ ".unifi-legend-sidebar th {\n"
435
+ " border: 0;\n"
436
+ " padding: 0.15rem 0;\n"
437
+ "}\n\n"
438
+ ".unifi-legend-sidebar svg {\n"
439
+ " display: block;\n"
440
+ "}\n"
441
+ ),
442
+ encoding="utf-8",
443
+ )
444
+
445
+
446
+ def _render_svg_output(
447
+ args: argparse.Namespace,
448
+ devices: list[Device],
449
+ topology: object,
450
+ config: Config | None,
451
+ site: str,
226
452
  svg_theme: SvgTheme,
453
+ *,
454
+ clients_override: list[object] | None = None,
227
455
  ) -> str:
228
456
  edges, _has_tree = _select_edges(topology)
229
- edges, clients = _build_edges_with_clients(args, edges, devices, config, site)
457
+ edges, clients = _build_edges_with_clients(
458
+ args,
459
+ edges,
460
+ devices,
461
+ config,
462
+ site,
463
+ clients_override=clients_override,
464
+ )
230
465
  options = SvgOptions(width=args.svg_width, height=args.svg_height)
231
466
  if args.format == "svg-iso":
232
467
  from ..render.svg import render_svg_isometric
233
468
 
234
469
  return render_svg_isometric(
235
470
  edges,
236
- node_types=build_node_type_map(devices, clients),
471
+ node_types=build_node_type_map(devices, clients, client_mode=args.client_scope),
237
472
  options=options,
238
473
  theme=svg_theme,
239
474
  )
240
475
  return render_svg(
241
476
  edges,
242
- node_types=build_node_type_map(devices, clients),
477
+ node_types=build_node_type_map(devices, clients, client_mode=args.client_scope),
243
478
  options=options,
244
479
  theme=svg_theme,
245
480
  )
@@ -248,10 +483,37 @@ def _render_svg_output(
248
483
  def main(argv: list[str] | None = None) -> int:
249
484
  logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
250
485
  args = _parse_args(argv)
251
- config = _load_config(args)
252
- if config is None:
253
- return 2
254
- site = _resolve_site(args, config)
486
+ if args.generate_mock:
487
+ try:
488
+ from ..io.mock_generate import MockOptions, mock_payload_json
489
+ except ImportError as exc:
490
+ logging.error("Faker is required for --generate-mock: %s", exc)
491
+ return 2
492
+ options = MockOptions(
493
+ seed=args.mock_seed,
494
+ switch_count=max(1, args.mock_switches),
495
+ ap_count=max(0, args.mock_aps),
496
+ wired_client_count=max(0, args.mock_wired_clients),
497
+ wireless_client_count=max(0, args.mock_wireless_clients),
498
+ )
499
+ content = mock_payload_json(options)
500
+ write_output(content, output_path=args.generate_mock, stdout=args.stdout)
501
+ return 0
502
+ mock_devices = None
503
+ mock_clients: list[object] | None = None
504
+ if args.mock_data:
505
+ try:
506
+ mock_devices, mock_clients = load_mock_data(args.mock_data)
507
+ except Exception as exc: # noqa: BLE001
508
+ logging.error("Failed to load mock data: %s", exc)
509
+ return 2
510
+ config = None
511
+ site = "mock"
512
+ else:
513
+ config = _load_config(args)
514
+ if config is None:
515
+ return 2
516
+ site = _resolve_site(args, config)
255
517
  mermaid_theme, svg_theme = resolve_themes(args.theme_file)
256
518
 
257
519
  if args.legend_only:
@@ -259,16 +521,87 @@ def main(argv: list[str] | None = None) -> int:
259
521
  write_output(content, output_path=args.output, stdout=args.stdout)
260
522
  return 0
261
523
 
524
+ if args.format == "lldp-md":
525
+ try:
526
+ _raw_devices, devices = _load_devices_data(
527
+ args,
528
+ config,
529
+ site,
530
+ raw_devices_override=mock_devices,
531
+ )
532
+ except Exception as exc:
533
+ logging.error("Failed to load devices: %s", exc)
534
+ return 1
535
+ if mock_clients is None:
536
+ if config is None:
537
+ logging.error("Mock data required for client rendering")
538
+ return 2
539
+ clients = list(fetch_clients(config, site=site))
540
+ else:
541
+ clients = mock_clients
542
+ content = render_lldp_md(
543
+ devices,
544
+ clients=clients,
545
+ include_ports=args.include_ports,
546
+ show_clients=args.include_clients,
547
+ client_mode=args.client_scope,
548
+ )
549
+ write_output(content, output_path=args.output, stdout=args.stdout)
550
+ return 0
551
+
262
552
  try:
263
- devices, _gateways, topology = _build_topology_data(args, config, site)
553
+ include_ports = True if args.format == "mkdocs" else None
554
+ devices, _gateways, topology = _build_topology_data(
555
+ args,
556
+ config,
557
+ site,
558
+ include_ports=include_ports,
559
+ raw_devices_override=mock_devices,
560
+ )
264
561
  except Exception as exc:
265
562
  logging.error("Failed to build topology: %s", exc)
266
563
  return 1
267
564
 
268
565
  if args.format == "mermaid":
269
- content = _render_mermaid_output(args, devices, topology, config, site, mermaid_theme)
566
+ content = _render_mermaid_output(
567
+ args,
568
+ devices,
569
+ topology,
570
+ config,
571
+ site,
572
+ mermaid_theme,
573
+ clients_override=mock_clients,
574
+ )
575
+ elif args.format == "mkdocs":
576
+ if args.mkdocs_sidebar_legend and not args.output:
577
+ logging.error("--mkdocs-sidebar-legend requires --output")
578
+ return 2
579
+ if args.mkdocs_sidebar_legend:
580
+ _write_mkdocs_sidebar_assets(args.output)
581
+ port_map = build_port_map(devices, only_unifi=args.only_unifi)
582
+ client_ports = None
583
+ if args.include_clients:
584
+ if mock_clients is None:
585
+ if config is None:
586
+ logging.error("Mock data required for client rendering")
587
+ return 2
588
+ clients = list(fetch_clients(config, site=site))
589
+ else:
590
+ clients = mock_clients
591
+ client_ports = build_client_port_map(devices, clients, client_mode=args.client_scope)
592
+ content = _render_mkdocs_output(
593
+ args, devices, topology, config, site, mermaid_theme, port_map, client_ports
594
+ )
270
595
  elif args.format in {"svg", "svg-iso"}:
271
- content = _render_svg_output(args, devices, topology, config, site, svg_theme)
596
+ content = _render_svg_output(
597
+ args,
598
+ devices,
599
+ topology,
600
+ config,
601
+ site,
602
+ svg_theme,
603
+ clients_override=mock_clients,
604
+ )
272
605
  else:
273
606
  logging.error("Unsupported format: %s", args.format)
274
607
  return 2