unifi-network-maps 1.4.15__py3-none-any.whl → 1.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. unifi_network_maps/__init__.py +1 -1
  2. unifi_network_maps/adapters/unifi.py +80 -96
  3. unifi_network_maps/assets/icons/modern/ap.svg +9 -0
  4. unifi_network_maps/assets/icons/modern/camera.svg +9 -0
  5. unifi_network_maps/assets/icons/modern/client.svg +9 -0
  6. unifi_network_maps/assets/icons/modern/game_console.svg +10 -0
  7. unifi_network_maps/assets/icons/modern/gateway.svg +17 -0
  8. unifi_network_maps/assets/icons/modern/iot.svg +9 -0
  9. unifi_network_maps/assets/icons/modern/nas.svg +9 -0
  10. unifi_network_maps/assets/icons/modern/other.svg +10 -0
  11. unifi_network_maps/assets/icons/modern/phone.svg +10 -0
  12. unifi_network_maps/assets/icons/modern/printer.svg +9 -0
  13. unifi_network_maps/assets/icons/modern/speaker.svg +10 -0
  14. unifi_network_maps/assets/icons/modern/switch.svg +10 -0
  15. unifi_network_maps/assets/icons/modern/tv.svg +10 -0
  16. unifi_network_maps/assets/icons/modern-flat/ap.svg +5 -0
  17. unifi_network_maps/assets/icons/modern-flat/camera.svg +5 -0
  18. unifi_network_maps/assets/icons/modern-flat/client.svg +5 -0
  19. unifi_network_maps/assets/icons/modern-flat/game_console.svg +6 -0
  20. unifi_network_maps/assets/icons/modern-flat/gateway.svg +13 -0
  21. unifi_network_maps/assets/icons/modern-flat/iot.svg +5 -0
  22. unifi_network_maps/assets/icons/modern-flat/nas.svg +5 -0
  23. unifi_network_maps/assets/icons/modern-flat/other.svg +6 -0
  24. unifi_network_maps/assets/icons/modern-flat/phone.svg +6 -0
  25. unifi_network_maps/assets/icons/modern-flat/printer.svg +5 -0
  26. unifi_network_maps/assets/icons/modern-flat/speaker.svg +6 -0
  27. unifi_network_maps/assets/icons/modern-flat/switch.svg +6 -0
  28. unifi_network_maps/assets/icons/modern-flat/tv.svg +6 -0
  29. unifi_network_maps/assets/themes/dark.yaml +53 -10
  30. unifi_network_maps/assets/themes/default.yaml +34 -0
  31. unifi_network_maps/assets/themes/minimal-dark.yaml +98 -0
  32. unifi_network_maps/assets/themes/minimal.yaml +92 -0
  33. unifi_network_maps/assets/themes/unifi-dark.yaml +97 -0
  34. unifi_network_maps/assets/themes/unifi.yaml +92 -0
  35. unifi_network_maps/cli/args.py +54 -0
  36. unifi_network_maps/cli/main.py +18 -7
  37. unifi_network_maps/cli/render.py +79 -27
  38. unifi_network_maps/cli/runtime.py +29 -15
  39. unifi_network_maps/io/debug.py +2 -1
  40. unifi_network_maps/io/export.py +19 -13
  41. unifi_network_maps/io/mock_data.py +5 -3
  42. unifi_network_maps/io/paths.py +5 -3
  43. unifi_network_maps/model/classify.py +199 -0
  44. unifi_network_maps/model/clients.py +271 -0
  45. unifi_network_maps/model/connection.py +37 -0
  46. unifi_network_maps/model/diff.py +544 -0
  47. unifi_network_maps/model/edges.py +558 -0
  48. unifi_network_maps/model/helpers.py +64 -0
  49. unifi_network_maps/model/lldp.py +20 -25
  50. unifi_network_maps/model/mock.py +110 -23
  51. unifi_network_maps/model/snapshot.py +294 -0
  52. unifi_network_maps/model/topology.py +143 -951
  53. unifi_network_maps/model/topology_coerce.py +339 -0
  54. unifi_network_maps/model/vlans.py +32 -46
  55. unifi_network_maps/model/wan.py +132 -0
  56. unifi_network_maps/render/device_ports_md.py +39 -97
  57. unifi_network_maps/render/device_summary.py +53 -0
  58. unifi_network_maps/render/lldp_md.py +29 -219
  59. unifi_network_maps/render/markdown_tables.py +8 -0
  60. unifi_network_maps/render/mermaid.py +11 -2
  61. unifi_network_maps/render/mkdocs.py +2 -1
  62. unifi_network_maps/render/svg.py +566 -908
  63. unifi_network_maps/render/svg_icons.py +231 -0
  64. unifi_network_maps/render/svg_isometric.py +1196 -0
  65. unifi_network_maps/render/svg_labels.py +184 -0
  66. unifi_network_maps/render/svg_theme.py +166 -32
  67. unifi_network_maps/render/theme.py +86 -1
  68. {unifi_network_maps-1.4.15.dist-info → unifi_network_maps-1.5.0.dist-info}/METADATA +107 -31
  69. {unifi_network_maps-1.4.15.dist-info → unifi_network_maps-1.5.0.dist-info}/RECORD +73 -31
  70. unifi_network_maps/assets/icons/isometric/printer.svg +0 -122
  71. {unifi_network_maps-1.4.15.dist-info → unifi_network_maps-1.5.0.dist-info}/WHEEL +0 -0
  72. {unifi_network_maps-1.4.15.dist-info → unifi_network_maps-1.5.0.dist-info}/entry_points.txt +0 -0
  73. {unifi_network_maps-1.4.15.dist-info → unifi_network_maps-1.5.0.dist-info}/licenses/LICENSE +0 -0
  74. {unifi_network_maps-1.4.15.dist-info → unifi_network_maps-1.5.0.dist-info}/top_level.txt +0 -0
@@ -5,9 +5,11 @@ from __future__ import annotations
5
5
  from collections import defaultdict
6
6
  from html import escape as _escape_html
7
7
 
8
+ from ..model.classify import classify_device_type
8
9
  from ..model.ports import extract_port_number
9
- from ..model.topology import ClientPortMap, Device, PortInfo, PortMap, classify_device_type
10
- from .markdown_tables import markdown_table_lines
10
+ from ..model.topology import ClientPortMap, Device, PortInfo, PortMap
11
+ from .device_summary import poe_summary, port_summary, uplink_summary
12
+ from .markdown_tables import escape_markdown, markdown_table_lines
11
13
  from .templating import render_template
12
14
 
13
15
 
@@ -83,11 +85,11 @@ def _render_device_ports(
83
85
  rows = _build_port_rows(device, port_map, client_ports)
84
86
  table_rows = [
85
87
  [
86
- _escape_markdown_text(port_label),
87
- _escape_connected_cell(connected or "-"),
88
- _escape_markdown_text(speed),
89
- _escape_markdown_text(poe_state),
90
- _escape_markdown_text(power),
88
+ escape_markdown(port_label),
89
+ connected or "-", # Not escaped: contains intentional HTML (br, ul)
90
+ escape_markdown(speed),
91
+ escape_markdown(poe_state),
92
+ escape_markdown(power),
91
93
  ]
92
94
  for port_label, connected, speed, poe_state, power in rows
93
95
  ]
@@ -113,7 +115,7 @@ def _build_port_rows(
113
115
  port.port_idx
114
116
  for ports in aggregated.values()
115
117
  for port in ports
116
- if getattr(port, "port_idx", None) is not None
118
+ if port.port_idx is not None
117
119
  }
118
120
  rows: list[tuple[tuple[int, int], tuple[str, str, str, str, str]]] = []
119
121
  seen_ports: set[int] = set()
@@ -224,11 +226,9 @@ def _format_connections(
224
226
  for peer in sorted(peers, key=str.lower):
225
227
  peer_label = port_map.get((peer, device_name))
226
228
  if peer_label:
227
- peer_entries.append(
228
- f"{_escape_markdown_text(peer)} ({_escape_markdown_text(peer_label)})"
229
- )
229
+ peer_entries.append(f"{escape_markdown(peer)} ({escape_markdown(peer_label)})")
230
230
  else:
231
- peer_entries.append(_escape_markdown_text(peer))
231
+ peer_entries.append(escape_markdown(peer))
232
232
  peer_text = ", ".join(peer_entries)
233
233
  client_text = _format_client_connections(clients)
234
234
  if peer_text and client_text:
@@ -258,15 +258,11 @@ def _format_speed(speed: int | None) -> str:
258
258
  return f"{speed}M"
259
259
 
260
260
 
261
- def _format_poe_state(port: object) -> str:
262
- poe_power = getattr(port, "poe_power", None)
263
- poe_good = getattr(port, "poe_good", False)
264
- poe_enable = getattr(port, "poe_enable", False)
265
- port_poe = getattr(port, "port_poe", False)
266
- if (poe_power or 0.0) > 0 or poe_good:
261
+ def _format_poe_state(port: PortInfo) -> str:
262
+ if (port.poe_power or 0.0) > 0 or port.poe_good:
267
263
  return "active"
268
- if port_poe or poe_enable:
269
- if not poe_enable:
264
+ if port.port_poe or port.poe_enable:
265
+ if not port.poe_enable:
270
266
  return "disabled"
271
267
  return "capable"
272
268
  return "-"
@@ -286,23 +282,11 @@ def _port_index(port_idx: int | None, name: str | None) -> int | None:
286
282
  return None
287
283
 
288
284
 
289
- def _port_sort_key(port: object) -> tuple[int, str]:
290
- port_idx = _port_index(getattr(port, "port_idx", None), getattr(port, "name", None))
285
+ def _port_sort_key(port: PortInfo) -> tuple[int, str]:
286
+ port_idx = _port_index(port.port_idx, port.name)
291
287
  if port_idx is not None:
292
288
  return (0, f"{port_idx:04d}")
293
- name = getattr(port, "name", "") or ""
294
- return (1, name.lower())
295
-
296
-
297
- def _escape_markdown_text(value: str) -> str:
298
- escaped = value.replace("\\", "\\\\")
299
- for char in ("|", "[", "]", "*", "_", "`", "<", ">"):
300
- escaped = escaped.replace(char, f"\\{char}")
301
- return escaped
302
-
303
-
304
- def _escape_connected_cell(value: str) -> str:
305
- return value
289
+ return (1, (port.name or "").lower())
306
290
 
307
291
 
308
292
  def _render_device_details(device: Device) -> list[str]:
@@ -311,59 +295,19 @@ def _render_device_details(device: Device) -> list[str]:
311
295
  "",
312
296
  "| Field | Value |",
313
297
  "| --- | --- |",
314
- f"| Model | {_escape_markdown_text(_device_model_label(device))} |",
315
- f"| Type | {_escape_markdown_text(device.type or '-')} |",
316
- f"| IP | {_escape_markdown_text(device.ip or '-')} |",
317
- f"| MAC | {_escape_markdown_text(device.mac or '-')} |",
318
- f"| Firmware | {_escape_markdown_text(device.version or '-')} |",
319
- f"| Uplink | {_escape_markdown_text(_uplink_summary(device))} |",
320
- f"| Ports | {_escape_markdown_text(_port_summary(device))} |",
321
- f"| PoE | {_escape_markdown_text(_poe_summary(device))} |",
298
+ f"| Model | {escape_markdown(_device_model_label(device))} |",
299
+ f"| Type | {escape_markdown(device.type or '-')} |",
300
+ f"| IP | {escape_markdown(device.ip or '-')} |",
301
+ f"| MAC | {escape_markdown(device.mac or '-')} |",
302
+ f"| Firmware | {escape_markdown(device.version or '-')} |",
303
+ f"| Uplink | {escape_markdown(uplink_summary(device))} |",
304
+ f"| Ports | {escape_markdown(port_summary(device))} |",
305
+ f"| PoE | {escape_markdown(poe_summary(device))} |",
322
306
  "",
323
307
  ]
324
308
  return lines
325
309
 
326
310
 
327
- def _port_summary(device: Device) -> str:
328
- ports = [port for port in device.port_table if port.port_idx is not None]
329
- if not ports:
330
- return "-"
331
- total_ports = len(ports)
332
- active_ports = sum(1 for port in ports if (port.speed or 0) > 0)
333
- return f"{total_ports} total, {active_ports} active"
334
-
335
-
336
- def _poe_summary(device: Device) -> str:
337
- ports = [port for port in device.port_table if port.port_idx is not None]
338
- if not ports:
339
- return "-"
340
- poe_capable = sum(1 for port in ports if port.port_poe or port.poe_enable)
341
- poe_active = sum(1 for port in ports if _format_poe_state(port) == "active")
342
- total_power = sum(port.poe_power or 0.0 for port in ports)
343
- summary = f"{poe_capable} capable, {poe_active} active"
344
- if total_power > 0:
345
- summary = f"{summary}, {total_power:.2f}W"
346
- return summary
347
-
348
-
349
- def _uplink_summary(device: Device) -> str:
350
- uplink = device.uplink or device.last_uplink
351
- if not uplink:
352
- if classify_device_type(device) == "gateway":
353
- return "Internet"
354
- return "-"
355
- name = uplink.name or uplink.mac or "Unknown"
356
- if classify_device_type(device) == "gateway":
357
- lowered = name.lower()
358
- if lowered in {"unknown", "wan", "internet"}:
359
- name = "Internet"
360
- elif lowered.startswith(("eth", "wan")):
361
- name = "Internet"
362
- if uplink.port is not None:
363
- return f"{name} (Port {uplink.port})"
364
- return name
365
-
366
-
367
311
  def _device_model_label(device: Device) -> str:
368
312
  if device.model_name:
369
313
  return device.model_name
@@ -376,7 +320,7 @@ def _format_client_connections(clients: list[str]) -> str:
376
320
  if not clients:
377
321
  return ""
378
322
  if len(clients) == 1:
379
- return f"{_escape_markdown_text(clients[0])} (client)"
323
+ return f"{escape_markdown(clients[0])} (client)"
380
324
  items = "".join(f"<li>{_escape_html(name)}</li>" for name in clients)
381
325
  return f'<ul class="unifi-port-clients">{items}</ul>'
382
326
 
@@ -384,14 +328,12 @@ def _format_client_connections(clients: list[str]) -> str:
384
328
  def _aggregate_base_groups(port_table: list[PortInfo]) -> dict[str, list[PortInfo]]:
385
329
  groups: dict[str, list[PortInfo]] = defaultdict(list)
386
330
  for port in port_table:
387
- group = getattr(port, "aggregation_group", None)
388
- if group:
389
- groups[str(group)].append(port)
331
+ if port.aggregation_group:
332
+ groups[str(port.aggregation_group)].append(port)
390
333
  continue
391
334
  if _looks_like_lag(port):
392
- port_idx = getattr(port, "port_idx", None)
393
- if port_idx is not None:
394
- groups[f"lag-{port_idx}"].append(port)
335
+ if port.port_idx is not None:
336
+ groups[f"lag-{port.port_idx}"].append(port)
395
337
  return groups
396
338
 
397
339
 
@@ -416,8 +358,8 @@ def _extend_singleton_groups(
416
358
  candidates: list[PortInfo] = []
417
359
  for neighbor in (port_idx - 1, port_idx + 1):
418
360
  port = port_by_idx.get(neighbor)
419
- if port and not getattr(port, "aggregation_group", None):
420
- if getattr(port, "speed", None) == getattr(lone_port, "speed", None):
361
+ if port and not port.aggregation_group:
362
+ if port.speed == lone_port.speed:
421
363
  candidates.append(port)
422
364
  if candidates:
423
365
  groups[group_id].extend(candidates)
@@ -430,8 +372,8 @@ def _aggregate_ports(port_table: list[PortInfo]) -> dict[str, list[PortInfo]]:
430
372
 
431
373
 
432
374
  def _looks_like_lag(port: PortInfo) -> bool:
433
- name = (getattr(port, "name", "") or "").lower()
434
- ifname = (getattr(port, "ifname", "") or "").lower()
375
+ name = (port.name or "").lower()
376
+ ifname = (port.ifname or "").lower()
435
377
  return "lag" in name or "lag" in ifname or "aggregate" in name
436
378
 
437
379
 
@@ -460,7 +402,7 @@ def _format_aggregate_connections(
460
402
  ) -> str:
461
403
  rendered: list[str] = []
462
404
  for port in group_ports:
463
- port_idx = _port_index(getattr(port, "port_idx", None), getattr(port, "name", None))
405
+ port_idx = _port_index(port.port_idx, port.name)
464
406
  if port_idx is None:
465
407
  continue
466
408
  text = _format_connections(
@@ -476,7 +418,7 @@ def _format_aggregate_connections(
476
418
 
477
419
 
478
420
  def _format_aggregate_speed(group_ports: list[PortInfo]) -> str:
479
- speeds = {getattr(port, "speed", None) for port in group_ports}
421
+ speeds = {port.speed for port in group_ports}
480
422
  speeds.discard(None)
481
423
  if not speeds:
482
424
  return "-"
@@ -497,5 +439,5 @@ def _format_aggregate_poe_state(group_ports: list[PortInfo]) -> str:
497
439
 
498
440
 
499
441
  def _format_aggregate_power(group_ports: list[PortInfo]) -> str:
500
- total = sum(getattr(port, "poe_power", 0.0) or 0.0 for port in group_ports)
442
+ total = sum(port.poe_power or 0.0 for port in group_ports)
501
443
  return _format_poe_power(total)
@@ -0,0 +1,53 @@
1
+ """Shared device summary helpers for markdown renderers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..model.classify import classify_device_type
6
+ from ..model.topology import Device, PortInfo
7
+
8
+
9
+ def port_summary(device: Device) -> str:
10
+ """Summarize port count and activity for a device."""
11
+ ports = [port for port in device.port_table if port.port_idx is not None]
12
+ if not ports:
13
+ return "-"
14
+ total_ports = len(ports)
15
+ active_ports = sum(1 for port in ports if (port.speed or 0) > 0)
16
+ return f"{total_ports} total, {active_ports} active"
17
+
18
+
19
+ def poe_summary(device: Device) -> str:
20
+ """Summarize PoE capability, activity, and power draw."""
21
+ ports = [port for port in device.port_table if port.port_idx is not None]
22
+ if not ports:
23
+ return "-"
24
+ poe_capable = sum(1 for port in ports if port.port_poe or port.poe_enable)
25
+ poe_active = sum(1 for port in ports if _is_poe_active(port))
26
+ total_power = sum(port.poe_power or 0.0 for port in ports)
27
+ summary = f"{poe_capable} capable, {poe_active} active"
28
+ if total_power > 0:
29
+ summary = f"{summary}, {total_power:.2f}W"
30
+ return summary
31
+
32
+
33
+ def uplink_summary(device: Device) -> str:
34
+ """Describe the device's uplink connection."""
35
+ uplink = device.uplink or device.last_uplink
36
+ if not uplink:
37
+ if classify_device_type(device) == "gateway":
38
+ return "Internet"
39
+ return "-"
40
+ name = uplink.name or uplink.mac or "Unknown"
41
+ if classify_device_type(device) == "gateway":
42
+ lowered = name.lower()
43
+ if lowered in {"unknown", "wan", "internet"}:
44
+ name = "Internet"
45
+ elif lowered.startswith(("eth", "wan")):
46
+ name = "Internet"
47
+ if uplink.port is not None:
48
+ return f"{name} (Port {uplink.port})"
49
+ return name
50
+
51
+
52
+ def _is_poe_active(port: PortInfo) -> bool:
53
+ return (port.poe_power or 0.0) > 0 or port.poe_good
@@ -4,212 +4,29 @@ from __future__ import annotations
4
4
 
5
5
  from collections.abc import Iterable
6
6
 
7
+ from ..model.classify import client_display_name
8
+ from ..model.clients import (
9
+ _client_uplink_mac,
10
+ _client_uplink_port,
11
+ build_client_port_map,
12
+ client_matches_filters,
13
+ )
14
+ from ..model.edges import build_device_index, build_port_map
15
+ from ..model.helpers import normalize_mac
7
16
  from ..model.lldp import LLDPEntry, local_port_label
8
- from ..model.ports import extract_port_number
9
- from ..model.topology import Device, build_client_port_map, build_device_index, build_port_map
17
+ from ..model.topology import Device
10
18
  from .device_ports_md import render_device_port_details
11
- from .markdown_tables import markdown_table_lines
19
+ from .device_summary import poe_summary, port_summary, uplink_summary
20
+ from .markdown_tables import escape_markdown, markdown_table_lines
12
21
  from .templating import render_template
13
22
 
14
23
 
15
- def _normalize_mac(value: str) -> str:
16
- return value.strip().lower()
17
-
18
-
19
- def _client_field(client: object, name: str) -> object | None:
20
- if isinstance(client, dict):
21
- return client.get(name)
22
- return getattr(client, name, None)
23
-
24
-
25
- def _client_display_name(client: object) -> str | None:
26
- raw_name = _client_field(client, "name")
27
- if isinstance(raw_name, str) and raw_name.strip():
28
- return raw_name.strip()
29
- preferred = _client_ucore_display_name(client)
30
- if preferred:
31
- return preferred
32
- for key in ("hostname", "mac"):
33
- value = _client_field(client, key)
34
- if isinstance(value, str) and value.strip():
35
- return value.strip()
36
- return None
37
-
38
-
39
- def _client_uplink_mac(client: object) -> str | None:
40
- for key in ("ap_mac", "sw_mac", "uplink_mac", "uplink_device_mac", "last_uplink_mac"):
41
- value = _client_field(client, key)
42
- if isinstance(value, str) and value.strip():
43
- return value.strip()
44
- for key in ("uplink", "last_uplink"):
45
- nested = _client_field(client, key)
46
- if isinstance(nested, dict):
47
- value = nested.get("uplink_mac") or nested.get("uplink_device_mac")
48
- if isinstance(value, str) and value.strip():
49
- return value.strip()
50
- return None
51
-
52
-
53
- def _client_uplink_port(client: object) -> int | None:
54
- for value in _client_port_values(client):
55
- parsed = _parse_port_value(value)
56
- if parsed is not None:
57
- return parsed
58
- return None
59
-
60
-
61
- def _client_port_values(client: object) -> Iterable[object | None]:
62
- for key in ("uplink_remote_port", "sw_port", "ap_port", "port_idx"):
63
- yield _client_field(client, key)
64
- for key in ("uplink", "last_uplink"):
65
- nested = _client_field(client, key)
66
- if isinstance(nested, dict):
67
- for nested_key in ("uplink_remote_port", "port_idx"):
68
- yield nested.get(nested_key)
69
-
70
-
71
- def _parse_port_value(value: object | None) -> int | None:
72
- if isinstance(value, int):
73
- return value
74
- if isinstance(value, str):
75
- stripped = value.strip()
76
- if stripped.isdigit():
77
- return int(stripped)
78
- return extract_port_number(stripped)
79
- return None
80
-
81
-
82
- def _client_is_wired(client: object) -> bool:
83
- return bool(_client_field(client, "is_wired"))
84
-
85
-
86
- def _client_unifi_flag(client: object) -> bool | None:
87
- for key in ("is_unifi", "is_unifi_device", "is_ubnt", "is_uap", "is_managed"):
88
- value = _client_field(client, key)
89
- if isinstance(value, bool):
90
- return value
91
- if isinstance(value, int):
92
- return value != 0
93
- return None
94
-
95
-
96
- def _client_vendor(client: object) -> str | None:
97
- for key in ("oui", "vendor", "vendor_name", "manufacturer", "manufacturer_name"):
98
- value = _client_field(client, key)
99
- if isinstance(value, str) and value.strip():
100
- return value.strip()
101
- return None
102
-
103
-
104
- def _client_ucore_info(client: object) -> dict[str, object] | None:
105
- info = _client_field(client, "unifi_device_info_from_ucore")
106
- if isinstance(info, dict):
107
- return info
108
- return None
109
-
110
-
111
- def _client_ucore_display_name(client: object) -> str | None:
112
- ucore = _client_ucore_info(client)
113
- if not ucore:
114
- return None
115
- for key in ("name", "computed_model", "product_model", "product_shortname"):
116
- value = ucore.get(key)
117
- if isinstance(value, str) and value.strip():
118
- return value.strip()
119
- return None
120
-
121
-
122
- def _client_hostname_source(client: object) -> str | None:
123
- value = _client_field(client, "hostname_source")
124
- if isinstance(value, str) and value.strip():
125
- return value.strip()
126
- return None
127
-
128
-
129
- def _client_is_unifi(client: object) -> bool:
130
- flag = _client_unifi_flag(client)
131
- if flag is not None:
132
- return flag
133
- ucore = _client_ucore_info(client)
134
- if ucore:
135
- managed = ucore.get("managed")
136
- if isinstance(managed, bool) and managed:
137
- return True
138
- if isinstance(ucore.get("product_line"), str) and ucore.get("product_line"):
139
- return True
140
- if isinstance(ucore.get("product_shortname"), str) and ucore.get("product_shortname"):
141
- return True
142
- for key in ("name", "computed_model", "product_model"):
143
- value = ucore.get(key)
144
- if isinstance(value, str) and value.strip():
145
- return True
146
- vendor = _client_vendor(client)
147
- if not vendor:
148
- return False
149
- normalized = vendor.lower()
150
- return "ubiquiti" in normalized or "unifi" in normalized
151
-
152
-
153
- def _client_matches_mode(client: object, mode: str) -> bool:
154
- wired = _client_is_wired(client)
155
- if mode == "all":
156
- return True
157
- if mode == "wireless":
158
- return not wired
159
- return wired
160
-
161
-
162
- def _client_matches_filters(client: object, *, client_mode: str, only_unifi: bool) -> bool:
163
- if not _client_matches_mode(client, client_mode):
164
- return False
165
- if only_unifi and not _client_is_unifi(client):
166
- return False
167
- return True
168
-
169
-
170
24
  def _lldp_sort_key(entry: LLDPEntry) -> tuple[int, str, str]:
171
25
  port_label = local_port_label(entry) or ""
172
26
  port_number = "".join(ch for ch in port_label if ch.isdigit())
173
27
  return (int(port_number or 0), port_label, entry.port_id)
174
28
 
175
29
 
176
- def _port_summary(device: Device) -> str:
177
- ports = [port for port in device.port_table if port.port_idx is not None]
178
- if not ports:
179
- return "-"
180
- total_ports = len(ports)
181
- poe_capable = sum(1 for port in ports if port.port_poe or port.poe_enable)
182
- poe_active = sum(1 for port in ports if device.poe_ports.get(port.port_idx or -1))
183
- total_power = sum(port.poe_power or 0.0 for port in ports)
184
- summary = f"Total {total_ports}, PoE {poe_capable} (active {poe_active})"
185
- if total_power > 0:
186
- summary = f"{summary}, {total_power:.2f}W"
187
- return summary
188
-
189
-
190
- def _poe_summary(device: Device) -> str:
191
- ports = [port for port in device.port_table if port.port_idx is not None]
192
- if not ports:
193
- return "-"
194
- poe_capable = sum(1 for port in ports if port.port_poe or port.poe_enable)
195
- poe_active = sum(1 for port in ports if (port.poe_power or 0.0) > 0 or port.poe_good)
196
- total_power = sum(port.poe_power or 0.0 for port in ports)
197
- summary = f"{poe_capable} capable, {poe_active} active"
198
- if total_power > 0:
199
- summary = f"{summary}, {total_power:.2f}W"
200
- return summary
201
-
202
-
203
- def _uplink_summary(device: Device) -> str:
204
- uplink = device.uplink or device.last_uplink
205
- if not uplink:
206
- return "-"
207
- name = uplink.name or uplink.mac or "Unknown"
208
- if uplink.port is not None:
209
- return f"{name} (Port {uplink.port})"
210
- return name
211
-
212
-
213
30
  def _client_summary(
214
31
  device: Device, client_rows: dict[str, list[tuple[str, str | None]]]
215
32
  ) -> tuple[str, str]:
@@ -232,16 +49,16 @@ def _details_table_lines(
232
49
  wired_count, client_sample = _client_summary(device, client_rows)
233
50
  client_label = f"Clients ({client_mode})"
234
51
  rows = [
235
- ["Model", _escape_cell(device.model_name or device.type or "-")],
236
- ["Type", _escape_cell(device.type or "-")],
237
- ["IP", _escape_cell(device.ip or "-")],
238
- ["MAC", _escape_cell(device.mac or "-")],
239
- ["Firmware", _escape_cell(device.version or "-")],
240
- ["Uplink", _escape_cell(_uplink_summary(device))],
241
- ["Ports", _escape_cell(_port_summary(device))],
242
- ["PoE", _escape_cell(_poe_summary(device))],
243
- [client_label, _escape_cell(wired_count)],
244
- ["Client examples", _escape_cell(client_sample)],
52
+ ["Model", escape_markdown(device.model_name or device.type or "-")],
53
+ ["Type", escape_markdown(device.type or "-")],
54
+ ["IP", escape_markdown(device.ip or "-")],
55
+ ["MAC", escape_markdown(device.mac or "-")],
56
+ ["Firmware", escape_markdown(device.version or "-")],
57
+ ["Uplink", escape_markdown(uplink_summary(device))],
58
+ ["Ports", escape_markdown(port_summary(device))],
59
+ ["PoE", escape_markdown(poe_summary(device))],
60
+ [client_label, escape_markdown(wired_count)],
61
+ ["Client examples", escape_markdown(client_sample)],
245
62
  ]
246
63
  lines = ["### Details", ""]
247
64
  lines.extend(markdown_table_lines(["Field", "Value"], rows))
@@ -255,7 +72,7 @@ def _lldp_rows(
255
72
  rows: list[list[str]] = []
256
73
  for entry in sorted(entries, key=_lldp_sort_key):
257
74
  local_label = local_port_label(entry) or "?"
258
- peer_name = device_index.get(_normalize_mac(entry.chassis_id), "")
75
+ peer_name = device_index.get(normalize_mac(entry.chassis_id), "")
259
76
  peer_port = entry.port_id or "?"
260
77
  port_desc = entry.port_desc or ""
261
78
  rows.append(
@@ -270,13 +87,6 @@ def _lldp_rows(
270
87
  return rows
271
88
 
272
89
 
273
- def _escape_cell(value: str) -> str:
274
- escaped = value.replace("\\", "\\\\")
275
- for char in ("|", "[", "]", "*", "_", "`", "<", ">"):
276
- escaped = escaped.replace(char, f"\\{char}")
277
- return escaped
278
-
279
-
280
90
  def _client_rows(
281
91
  clients: Iterable[object],
282
92
  device_index: dict[str, str],
@@ -287,13 +97,13 @@ def _client_rows(
287
97
  ) -> dict[str, list[tuple[str, str | None]]]:
288
98
  rows_by_device: dict[str, list[tuple[str, str | None]]] = {}
289
99
  for client in clients:
290
- if not _client_matches_filters(client, client_mode=client_mode, only_unifi=only_unifi):
100
+ if not client_matches_filters(client, client_mode=client_mode, only_unifi=only_unifi):
291
101
  continue
292
- name = _client_display_name(client)
102
+ name = client_display_name(client)
293
103
  uplink_mac = _client_uplink_mac(client)
294
104
  if not name or not uplink_mac:
295
105
  continue
296
- device_name = device_index.get(_normalize_mac(uplink_mac))
106
+ device_name = device_index.get(normalize_mac(uplink_mac))
297
107
  if not device_name:
298
108
  continue
299
109
  port_label = None
@@ -370,7 +180,7 @@ def _render_device_lldp_section(
370
180
  markdown_table_lines(
371
181
  ["Local Port", "Neighbor", "Neighbor Port", "Chassis ID", "Port Description"],
372
182
  _lldp_rows(device.lldp_info, device_index),
373
- escape=_escape_cell,
183
+ escape=escape_markdown,
374
184
  )
375
185
  ).rstrip()
376
186
  else:
@@ -387,7 +197,7 @@ def _render_device_lldp_section(
387
197
  markdown_table_lines(
388
198
  ["Client", "Port"],
389
199
  [
390
- [_escape_cell(client_name), _escape_cell(port_label or "-")]
200
+ [escape_markdown(client_name), escape_markdown(port_label or "-")]
391
201
  for client_name, port_label in rows
392
202
  ],
393
203
  )
@@ -396,7 +206,7 @@ def _render_device_lldp_section(
396
206
  ).rstrip()
397
207
  else:
398
208
  clients_section = "\n".join(
399
- ["### Clients", *[f"- {_escape_cell(name)}" for name, _ in rows]]
209
+ ["### Clients", *[f"- {escape_markdown(name)}" for name, _ in rows]]
400
210
  ).rstrip()
401
211
  return render_template(
402
212
  "lldp_device_section.md.j2",
@@ -5,6 +5,14 @@ from __future__ import annotations
5
5
  from collections.abc import Callable, Iterable
6
6
 
7
7
 
8
+ def escape_markdown(value: str) -> str:
9
+ """Escape characters that have special meaning in Markdown table cells."""
10
+ escaped = value.replace("\\", "\\\\")
11
+ for char in ("|", "[", "]", "*", "_", "`", "<", ">"):
12
+ escaped = escaped.replace(char, f"\\{char}")
13
+ return escaped
14
+
15
+
8
16
  def markdown_table_lines(
9
17
  headers: list[str],
10
18
  rows: Iterable[Iterable[str]],
@@ -87,6 +87,14 @@ def _render_group_sections(
87
87
  lines.append(" end")
88
88
 
89
89
 
90
+ def _format_vlan_suffix(active_vlans: tuple[int, ...]) -> str:
91
+ """Format VLAN suffix for edge labels."""
92
+ if not active_vlans:
93
+ return ""
94
+ vlan_str = ",".join(f"V{v}" for v in sorted(active_vlans))
95
+ return f" [{vlan_str}]"
96
+
97
+
90
98
  def _render_edge_lines(
91
99
  lines: list[str],
92
100
  edges: list[Edge],
@@ -103,8 +111,9 @@ def _render_edge_lines(
103
111
  else:
104
112
  left = id_map[edge.left]
105
113
  right = id_map[edge.right]
106
- if edge.label:
107
- label = _escape(edge.label)
114
+ vlan_suffix = _format_vlan_suffix(edge.active_vlans)
115
+ if edge.label or vlan_suffix:
116
+ label = _escape(f"{edge.label or ''}{vlan_suffix}".strip())
108
117
  lines.append(f' {left} ---|"{label}"| {right};')
109
118
  else:
110
119
  lines.append(f" {left} --- {right};")
@@ -7,7 +7,8 @@ from dataclasses import dataclass
7
7
  from datetime import datetime
8
8
  from zoneinfo import ZoneInfo
9
9
 
10
- from ..model.topology import ClientPortMap, Device, PortMap, build_node_type_map
10
+ from ..model.clients import build_node_type_map
11
+ from ..model.topology import ClientPortMap, Device, PortMap
11
12
  from .device_ports_md import render_device_port_overview
12
13
  from .mermaid import render_legend, render_legend_compact, render_mermaid
13
14
  from .mermaid_theme import MermaidTheme