unifi-network-maps 1.3.1__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.1"
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,17 +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
  )
26
+ from ..render.device_ports_md import render_device_port_overview
21
27
  from ..render.lldp_md import render_lldp_md
22
- from ..render.mermaid import render_legend, render_mermaid
28
+ from ..render.mermaid import render_legend, render_legend_compact, render_mermaid
23
29
  from ..render.mermaid_theme import MermaidTheme
24
30
  from ..render.svg import SvgOptions, render_svg
25
31
  from ..render.svg_theme import SvgTheme
@@ -42,6 +48,7 @@ def _build_parser() -> argparse.ArgumentParser:
42
48
  description="Generate network maps from UniFi LLDP data, as mermaid or SVG"
43
49
  )
44
50
  _add_source_args(parser.add_argument_group("Source"))
51
+ _add_mock_args(parser.add_argument_group("Mock"))
45
52
  _add_functional_args(parser.add_argument_group("Functional"))
46
53
  _add_mermaid_args(parser.add_argument_group("Mermaid"))
47
54
  _add_svg_args(parser.add_argument_group("SVG"))
@@ -57,6 +64,44 @@ def _add_source_args(parser: argparse._ArgumentGroup) -> None:
57
64
  default=None,
58
65
  help="Path to .env file (overrides default .env discovery)",
59
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
+ )
60
105
 
61
106
 
62
107
  def _add_functional_args(parser: argparse._ArgumentGroup) -> None:
@@ -84,6 +129,18 @@ def _add_mermaid_args(parser: argparse._ArgumentGroup) -> None:
84
129
  action="store_true",
85
130
  help="Group nodes by gateway/switch/ap in Mermaid subgraphs",
86
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
+ )
87
144
  parser.add_argument(
88
145
  "--legend-only",
89
146
  action="store_true",
@@ -101,7 +158,7 @@ def _add_general_render_args(parser: argparse._ArgumentGroup) -> None:
101
158
  parser.add_argument(
102
159
  "--format",
103
160
  default="mermaid",
104
- choices=["mermaid", "svg", "svg-iso", "lldp-md"],
161
+ choices=["mermaid", "svg", "svg-iso", "lldp-md", "mkdocs"],
105
162
  help="Output format",
106
163
  )
107
164
  parser.add_argument(
@@ -111,6 +168,11 @@ def _add_general_render_args(parser: argparse._ArgumentGroup) -> None:
111
168
  )
112
169
  parser.add_argument("--output", default=None, help="Output file path")
113
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
+ )
114
176
 
115
177
 
116
178
  def _add_debug_args(parser: argparse._ArgumentGroup) -> None:
@@ -145,8 +207,18 @@ def _resolve_site(args: argparse.Namespace, config: Config) -> str:
145
207
  return args.site or config.site
146
208
 
147
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
+
148
216
  def _render_legend_only(args: argparse.Namespace, mermaid_theme: MermaidTheme) -> str:
149
- 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)
150
222
  if args.markdown:
151
223
  content = f"""```mermaid
152
224
  {content}```
@@ -155,9 +227,18 @@ def _render_legend_only(args: argparse.Namespace, mermaid_theme: MermaidTheme) -
155
227
 
156
228
 
157
229
  def _load_devices_data(
158
- args: argparse.Namespace, config: Config, site: str
230
+ args: argparse.Namespace,
231
+ config: Config | None,
232
+ site: str,
233
+ *,
234
+ raw_devices_override: list[object] | None = None,
159
235
  ) -> tuple[list[object], list[Device]]:
160
- raw_devices = list(fetch_devices(config, site=site, detailed=True))
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
161
242
  devices = normalize_devices(raw_devices)
162
243
  if args.debug_dump:
163
244
  debug_dump_devices(raw_devices, devices, sample_count=max(0, args.debug_sample))
@@ -165,14 +246,24 @@ def _load_devices_data(
165
246
 
166
247
 
167
248
  def _build_topology_data(
168
- args: argparse.Namespace, config: Config, site: str
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,
169
255
  ) -> tuple[list[Device], list[str], object]:
170
- _raw_devices, devices = _load_devices_data(args, config, site)
256
+ _raw_devices, devices = _load_devices_data(
257
+ args,
258
+ config,
259
+ site,
260
+ raw_devices_override=raw_devices_override,
261
+ )
171
262
  groups_for_rank = group_devices_by_type(devices)
172
263
  gateways = groups_for_rank.get("gateway", [])
173
264
  topology = build_topology(
174
265
  devices,
175
- include_ports=args.include_ports,
266
+ include_ports=include_ports if include_ports is not None else args.include_ports,
176
267
  only_unifi=args.only_unifi,
177
268
  gateways=gateways,
178
269
  )
@@ -183,12 +274,19 @@ def _build_edges_with_clients(
183
274
  args: argparse.Namespace,
184
275
  edges: list,
185
276
  devices: list[Device],
186
- config: Config,
277
+ config: Config | None,
187
278
  site: str,
279
+ *,
280
+ clients_override: list[object] | None = None,
188
281
  ) -> tuple[list, list | None]:
189
282
  clients = None
190
283
  if args.include_clients:
191
- 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
192
290
  device_index = build_device_index(devices)
193
291
  edges = edges + build_client_edges(
194
292
  clients,
@@ -210,12 +308,21 @@ def _render_mermaid_output(
210
308
  args: argparse.Namespace,
211
309
  devices: list[Device],
212
310
  topology: object,
213
- config: Config,
311
+ config: Config | None,
214
312
  site: str,
215
313
  mermaid_theme: MermaidTheme,
314
+ *,
315
+ clients_override: list[object] | None = None,
216
316
  ) -> str:
217
317
  edges, _has_tree = _select_edges(topology)
218
- 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
+ )
219
326
  groups = None
220
327
  group_order = None
221
328
  if args.group_by_type:
@@ -236,16 +343,125 @@ def _render_mermaid_output(
236
343
  return content
237
344
 
238
345
 
239
- def _render_svg_output(
346
+ def _render_mkdocs_output(
240
347
  args: argparse.Namespace,
241
348
  devices: list[Device],
242
349
  topology: object,
243
350
  config: Config,
244
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,
245
452
  svg_theme: SvgTheme,
453
+ *,
454
+ clients_override: list[object] | None = None,
246
455
  ) -> str:
247
456
  edges, _has_tree = _select_edges(topology)
248
- 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
+ )
249
465
  options = SvgOptions(width=args.svg_width, height=args.svg_height)
250
466
  if args.format == "svg-iso":
251
467
  from ..render.svg import render_svg_isometric
@@ -267,10 +483,37 @@ def _render_svg_output(
267
483
  def main(argv: list[str] | None = None) -> int:
268
484
  logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
269
485
  args = _parse_args(argv)
270
- config = _load_config(args)
271
- if config is None:
272
- return 2
273
- 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)
274
517
  mermaid_theme, svg_theme = resolve_themes(args.theme_file)
275
518
 
276
519
  if args.legend_only:
@@ -280,11 +523,22 @@ def main(argv: list[str] | None = None) -> int:
280
523
 
281
524
  if args.format == "lldp-md":
282
525
  try:
283
- _raw_devices, devices = _load_devices_data(args, config, site)
526
+ _raw_devices, devices = _load_devices_data(
527
+ args,
528
+ config,
529
+ site,
530
+ raw_devices_override=mock_devices,
531
+ )
284
532
  except Exception as exc:
285
533
  logging.error("Failed to load devices: %s", exc)
286
534
  return 1
287
- clients = list(fetch_clients(config, site=site))
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
288
542
  content = render_lldp_md(
289
543
  devices,
290
544
  clients=clients,
@@ -296,15 +550,58 @@ def main(argv: list[str] | None = None) -> int:
296
550
  return 0
297
551
 
298
552
  try:
299
- 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
+ )
300
561
  except Exception as exc:
301
562
  logging.error("Failed to build topology: %s", exc)
302
563
  return 1
303
564
 
304
565
  if args.format == "mermaid":
305
- 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
+ )
306
595
  elif args.format in {"svg", "svg-iso"}:
307
- 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
+ )
308
605
  else:
309
606
  logging.error("Unsupported format: %s", args.format)
310
607
  return 2
@@ -0,0 +1,23 @@
1
+ """Load mock UniFi data from JSON fixtures."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+
9
+ def _as_list(value: object, name: str) -> list[object]:
10
+ if value is None:
11
+ return []
12
+ if isinstance(value, list):
13
+ return value
14
+ raise ValueError(f"Mock data field '{name}' must be a list")
15
+
16
+
17
+ def load_mock_data(path: str) -> tuple[list[object], list[object]]:
18
+ payload = json.loads(Path(path).read_text(encoding="utf-8"))
19
+ if not isinstance(payload, dict):
20
+ raise ValueError("Mock data must be a JSON object")
21
+ devices = _as_list(payload.get("devices"), "devices")
22
+ clients = _as_list(payload.get("clients"), "clients")
23
+ return devices, clients