unifi-network-maps 1.4.1__py3-none-any.whl → 1.4.3__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.
@@ -6,7 +6,6 @@ import logging
6
6
  from collections import deque
7
7
  from collections.abc import Iterable
8
8
  from dataclasses import dataclass, field
9
- from typing import Protocol
10
9
 
11
10
  from .labels import compose_port_label, order_edge_names
12
11
  from .lldp import LLDPEntry, coerce_lldp, local_port_label
@@ -40,27 +39,7 @@ class Edge:
40
39
  wireless: bool = False
41
40
 
42
41
 
43
- class DeviceLike(Protocol):
44
- name: str | None
45
- model_name: str | None
46
- model: str | None
47
- mac: str | None
48
- ip: str | None
49
- ip_address: str | None
50
- type: str | None
51
- device_type: str | None
52
- lldp_info: object | None
53
- lldp: object | None
54
- port_table: object | None
55
- uplink: object | None
56
- last_uplink: object | None
57
- uplink_mac: object | None
58
- uplink_device_mac: object | None
59
- last_uplink_mac: object | None
60
- uplink_device_name: object | None
61
- uplink_remote_port: object | None
62
- version: object | None
63
- displayable_version: object | None
42
+ type DeviceSource = object
64
43
 
65
44
 
66
45
  @dataclass(frozen=True)
@@ -94,6 +73,20 @@ def _get_attr(obj: object, name: str) -> object | None:
94
73
  return getattr(obj, name, None)
95
74
 
96
75
 
76
+ def _as_list(value: object | None) -> list[object]:
77
+ if value is None:
78
+ return []
79
+ if isinstance(value, list):
80
+ return value
81
+ if isinstance(value, dict):
82
+ return [value]
83
+ if isinstance(value, str | bytes):
84
+ return []
85
+ if isinstance(value, Iterable):
86
+ return list(value)
87
+ return []
88
+
89
+
97
90
  def _normalize_mac(value: str) -> str:
98
91
  return value.strip().lower()
99
92
 
@@ -167,14 +160,16 @@ def _aggregation_group(port_entry: object) -> object | None:
167
160
  return None
168
161
 
169
162
 
170
- def _resolve_port_idx_from_lldp(lldp_entry: LLDPEntry, port_table: list[PortInfo]) -> int | None:
171
- if lldp_entry.local_port_idx is not None:
172
- return lldp_entry.local_port_idx
173
- candidates = []
174
- if lldp_entry.local_port_name:
175
- candidates.append(lldp_entry.local_port_name)
176
- if lldp_entry.port_id:
177
- candidates.append(lldp_entry.port_id)
163
+ def _lldp_candidates(entry: LLDPEntry) -> list[str]:
164
+ candidates: list[str] = []
165
+ if entry.local_port_name:
166
+ candidates.append(entry.local_port_name)
167
+ if entry.port_id:
168
+ candidates.append(entry.port_id)
169
+ return candidates
170
+
171
+
172
+ def _match_port_by_name(candidates: list[str], port_table: list[PortInfo]) -> int | None:
178
173
  for candidate in candidates:
179
174
  normalized = candidate.strip().lower()
180
175
  for port in port_table:
@@ -182,6 +177,10 @@ def _resolve_port_idx_from_lldp(lldp_entry: LLDPEntry, port_table: list[PortInfo
182
177
  return port.port_idx
183
178
  if port.name and port.name.strip().lower() == normalized:
184
179
  return port.port_idx
180
+ return None
181
+
182
+
183
+ def _match_port_by_number(candidates: list[str], port_table: list[PortInfo]) -> int | None:
185
184
  for candidate in candidates:
186
185
  number = extract_port_number(candidate)
187
186
  if number is None:
@@ -192,6 +191,16 @@ def _resolve_port_idx_from_lldp(lldp_entry: LLDPEntry, port_table: list[PortInfo
192
191
  return None
193
192
 
194
193
 
194
+ def _resolve_port_idx_from_lldp(lldp_entry: LLDPEntry, port_table: list[PortInfo]) -> int | None:
195
+ if lldp_entry.local_port_idx is not None:
196
+ return lldp_entry.local_port_idx
197
+ candidates = _lldp_candidates(lldp_entry)
198
+ matched = _match_port_by_name(candidates, port_table)
199
+ if matched is not None:
200
+ return matched
201
+ return _match_port_by_number(candidates, port_table)
202
+
203
+
195
204
  def _port_info_from_entry(port_entry: object) -> PortInfo:
196
205
  if isinstance(port_entry, dict):
197
206
  port_idx = port_entry.get("port_idx") or port_entry.get("portIdx")
@@ -226,12 +235,12 @@ def _port_info_from_entry(port_entry: object) -> PortInfo:
226
235
  )
227
236
 
228
237
 
229
- def _coerce_port_table(device: DeviceLike) -> list[PortInfo]:
230
- port_table = _get_attr(device, "port_table") or []
238
+ def _coerce_port_table(device: DeviceSource) -> list[PortInfo]:
239
+ port_table = _as_list(_get_attr(device, "port_table"))
231
240
  return [_port_info_from_entry(port_entry) for port_entry in port_table]
232
241
 
233
242
 
234
- def _poe_ports_from_device(device: DeviceLike) -> dict[int, bool]:
243
+ def _poe_ports_from_device(device: DeviceSource) -> dict[int, bool]:
235
244
  port_table = _coerce_port_table(device)
236
245
  poe_ports: dict[int, bool] = {}
237
246
  for port_entry in port_table:
@@ -271,7 +280,7 @@ def _parse_uplink(value: object | None) -> UplinkInfo | None:
271
280
  return UplinkInfo(mac=mac_value, name=name_value, port=port)
272
281
 
273
282
 
274
- def _uplink_info(device: DeviceLike) -> tuple[UplinkInfo | None, UplinkInfo | None]:
283
+ def _uplink_info(device: DeviceSource) -> tuple[UplinkInfo | None, UplinkInfo | None]:
275
284
  uplink = _parse_uplink(_device_field(device, "uplink"))
276
285
  last_uplink = _parse_uplink(_device_field(device, "last_uplink"))
277
286
 
@@ -290,7 +299,7 @@ def _uplink_info(device: DeviceLike) -> tuple[UplinkInfo | None, UplinkInfo | No
290
299
  return uplink, last_uplink
291
300
 
292
301
 
293
- def coerce_device(device: DeviceLike) -> Device:
302
+ def coerce_device(device: DeviceSource) -> Device:
294
303
  name = _get_attr(device, "name")
295
304
  model_name = _get_attr(device, "model_name") or _get_attr(device, "model")
296
305
  model = _get_attr(device, "model")
@@ -301,6 +310,8 @@ def coerce_device(device: DeviceLike) -> Device:
301
310
  lldp_info = _get_attr(device, "lldp_info")
302
311
  if lldp_info is None:
303
312
  lldp_info = _get_attr(device, "lldp")
313
+ if lldp_info is None:
314
+ lldp_info = _get_attr(device, "lldp_table")
304
315
 
305
316
  if not name or not mac:
306
317
  raise ValueError("Device missing name or mac")
@@ -312,7 +323,8 @@ def coerce_device(device: DeviceLike) -> Device:
312
323
  else:
313
324
  raise ValueError(f"Device {name} missing LLDP info")
314
325
 
315
- coerced_lldp = [coerce_lldp(lldp_entry) for lldp_entry in lldp_info]
326
+ lldp_entries = _as_list(lldp_info)
327
+ coerced_lldp = [coerce_lldp(lldp_entry) for lldp_entry in lldp_entries]
316
328
  port_table = _coerce_port_table(device)
317
329
  poe_ports = _poe_ports_from_device(device)
318
330
 
@@ -332,14 +344,16 @@ def coerce_device(device: DeviceLike) -> Device:
332
344
  )
333
345
 
334
346
 
335
- def normalize_devices(devices: Iterable[DeviceLike]) -> list[Device]:
347
+ def normalize_devices(devices: Iterable[DeviceSource]) -> list[Device]:
336
348
  return [coerce_device(device) for device in devices]
337
349
 
338
350
 
339
- def classify_device_type(device: Device) -> str:
340
- value = device.type.strip().lower()
351
+ def classify_device_type(device: object) -> str:
352
+ raw_type = _device_field(device, "type")
353
+ raw_name = _device_field(device, "name")
354
+ value = raw_type.strip().lower() if isinstance(raw_type, str) else ""
341
355
  if not value:
342
- name = device.name.strip().lower()
356
+ name = raw_name.strip().lower() if isinstance(raw_name, str) else ""
343
357
  if "gateway" in name or name.startswith("gw"):
344
358
  return "gateway"
345
359
  if "switch" in name:
@@ -363,19 +377,19 @@ def group_devices_by_type(devices: Iterable[Device]) -> dict[str, list[str]]:
363
377
  return groups
364
378
 
365
379
 
366
- def build_tree_edges_by_topology(edges: Iterable[Edge], gateways: list[str]) -> list[Edge]:
380
+ def _build_adjacency(edges: Iterable[Edge]) -> dict[str, set[str]]:
367
381
  adjacency: dict[str, set[str]] = {}
368
382
  for edge in edges:
369
383
  adjacency.setdefault(edge.left, set()).add(edge.right)
370
384
  adjacency.setdefault(edge.right, set()).add(edge.left)
385
+ return adjacency
371
386
 
372
- if not gateways:
373
- return []
374
387
 
375
- edge_map: dict[frozenset[str], Edge] = {}
376
- for edge in edges:
377
- edge_map[frozenset({edge.left, edge.right})] = edge
388
+ def _build_edge_map(edges: Iterable[Edge]) -> dict[frozenset[str], Edge]:
389
+ return {frozenset({edge.left, edge.right}): edge for edge in edges}
378
390
 
391
+
392
+ def _tree_parents(adjacency: dict[str, set[str]], gateways: list[str]) -> dict[str, str]:
379
393
  visited: set[str] = set()
380
394
  parent: dict[str, str] = {}
381
395
  queue: deque[str] = deque()
@@ -393,7 +407,12 @@ def build_tree_edges_by_topology(edges: Iterable[Edge], gateways: list[str]) ->
393
407
  visited.add(neighbor)
394
408
  parent[neighbor] = current
395
409
  queue.append(neighbor)
410
+ return parent
411
+
396
412
 
413
+ def _tree_edges_from_parent(
414
+ parent: dict[str, str], edge_map: dict[frozenset[str], Edge]
415
+ ) -> list[Edge]:
397
416
  tree_edges: list[Edge] = []
398
417
  for child in sorted(parent):
399
418
  parent_name = parent[child]
@@ -404,10 +423,18 @@ def build_tree_edges_by_topology(edges: Iterable[Edge], gateways: list[str]) ->
404
423
  tree_edges.append(
405
424
  Edge(left=parent_name, right=child, label=original.label, poe=original.poe)
406
425
  )
407
-
408
426
  return tree_edges
409
427
 
410
428
 
429
+ def build_tree_edges_by_topology(edges: Iterable[Edge], gateways: list[str]) -> list[Edge]:
430
+ if not gateways:
431
+ return []
432
+ adjacency = _build_adjacency(edges)
433
+ edge_map = _build_edge_map(edges)
434
+ parent = _tree_parents(adjacency, gateways)
435
+ return _tree_edges_from_parent(parent, edge_map)
436
+
437
+
411
438
  def build_device_index(devices: Iterable[Device]) -> dict[str, str]:
412
439
  index: dict[str, str] = {}
413
440
  for device in devices:
@@ -561,7 +588,6 @@ def build_edges(
561
588
  index,
562
589
  device_by_name,
563
590
  port_map,
564
- poe_map,
565
591
  raw_links,
566
592
  seen,
567
593
  include_ports=include_ports,
@@ -604,7 +630,6 @@ def build_port_map(devices: Iterable[Device], *, only_unifi: bool = True) -> Por
604
630
  index,
605
631
  device_by_name,
606
632
  port_map,
607
- poe_map,
608
633
  raw_links,
609
634
  seen,
610
635
  include_ports=True,
@@ -692,13 +717,52 @@ def _collect_lldp_links(
692
717
  return devices_with_lldp_edges
693
718
 
694
719
 
720
+ def _uplink_name(
721
+ uplink: UplinkInfo | None,
722
+ index: dict[str, str],
723
+ *,
724
+ only_unifi: bool,
725
+ ) -> str | None:
726
+ if not uplink:
727
+ return None
728
+ if uplink.mac:
729
+ resolved = index.get(_normalize_mac(uplink.mac))
730
+ if resolved:
731
+ return resolved
732
+ if uplink.name:
733
+ return uplink.name
734
+ if not only_unifi and uplink.mac:
735
+ return uplink.mac
736
+ return None
737
+
738
+
739
+ def _maybe_add_uplink_link(
740
+ device: Device,
741
+ upstream_name: str,
742
+ *,
743
+ uplink: UplinkInfo | None,
744
+ device_by_name: dict[str, Device],
745
+ port_map: PortMap,
746
+ raw_links: list[tuple[str, str]],
747
+ seen: set[frozenset[str]],
748
+ include_ports: bool,
749
+ ) -> None:
750
+ key = frozenset({device.name, upstream_name})
751
+ if key in seen:
752
+ return
753
+ if uplink and uplink.port is not None:
754
+ if include_ports:
755
+ port_map[(upstream_name, device.name)] = f"Port {uplink.port}"
756
+ raw_links.append((upstream_name, device.name))
757
+ seen.add(key)
758
+
759
+
695
760
  def _collect_uplink_links(
696
761
  devices: list[Device],
697
762
  devices_with_lldp_edges: set[str],
698
763
  index: dict[str, str],
699
764
  device_by_name: dict[str, Device],
700
765
  port_map: PortMap,
701
- poe_map: PoeMap,
702
766
  raw_links: list[tuple[str, str]],
703
767
  seen: set[frozenset[str]],
704
768
  *,
@@ -709,31 +773,21 @@ def _collect_uplink_links(
709
773
  if device.name in devices_with_lldp_edges:
710
774
  continue
711
775
  uplink = device.uplink or device.last_uplink
712
- upstream_name = None
713
- if uplink and uplink.mac:
714
- upstream_name = index.get(_normalize_mac(uplink.mac))
715
- if not upstream_name and uplink and uplink.name:
716
- upstream_name = uplink.name
717
- if not upstream_name and not only_unifi and uplink and uplink.mac:
718
- upstream_name = uplink.mac
776
+ upstream_name = _uplink_name(uplink, index, only_unifi=only_unifi)
719
777
  if not upstream_name:
720
778
  continue
721
779
  if only_unifi and upstream_name not in device_by_name:
722
780
  continue
723
- key = frozenset({device.name, upstream_name})
724
- if key in seen:
725
- continue
726
- poe = False
727
- if uplink and uplink.port is not None:
728
- if include_ports:
729
- port_map[(upstream_name, device.name)] = f"Port {uplink.port}"
730
- uplink_device = device_by_name.get(upstream_name)
731
- if uplink_device and uplink.port in uplink_device.poe_ports:
732
- poe = uplink_device.poe_ports[uplink.port]
733
- raw_links.append((upstream_name, device.name))
734
- seen.add(key)
735
- if poe:
736
- poe_map[(upstream_name, device.name)] = poe
781
+ _maybe_add_uplink_link(
782
+ device,
783
+ upstream_name,
784
+ uplink=uplink,
785
+ device_by_name=device_by_name,
786
+ port_map=port_map,
787
+ raw_links=raw_links,
788
+ seen=seen,
789
+ include_ports=include_ports,
790
+ )
737
791
 
738
792
 
739
793
  def _build_ordered_edges(
@@ -6,7 +6,7 @@ from collections import defaultdict
6
6
  from html import escape as _escape_html
7
7
 
8
8
  from ..model.ports import extract_port_number
9
- from ..model.topology import ClientPortMap, Device, PortMap, classify_device_type
9
+ from ..model.topology import ClientPortMap, Device, PortInfo, PortMap, classify_device_type
10
10
 
11
11
 
12
12
  def render_device_port_overview(
@@ -355,8 +355,8 @@ def _format_client_connections(clients: list[str]) -> str:
355
355
  return f'<ul class="unifi-port-clients">{items}</ul>'
356
356
 
357
357
 
358
- def _aggregate_ports(port_table: list[object]) -> dict[str, list[object]]:
359
- groups: dict[str, list[object]] = defaultdict(list)
358
+ def _aggregate_base_groups(port_table: list[PortInfo]) -> dict[str, list[PortInfo]]:
359
+ groups: dict[str, list[PortInfo]] = defaultdict(list)
360
360
  for port in port_table:
361
361
  group = getattr(port, "aggregation_group", None)
362
362
  if group:
@@ -366,10 +366,17 @@ def _aggregate_ports(port_table: list[object]) -> dict[str, list[object]]:
366
366
  port_idx = getattr(port, "port_idx", None)
367
367
  if port_idx is not None:
368
368
  groups[f"lag-{port_idx}"].append(port)
369
+ return groups
370
+
371
+
372
+ def _extend_singleton_groups(
373
+ groups: dict[str, list[PortInfo]],
374
+ port_table: list[PortInfo],
375
+ ) -> None:
369
376
  if not groups:
370
- return groups
371
- port_by_idx = {
372
- getattr(port, "port_idx", None): port for port in port_table if port.port_idx is not None
377
+ return
378
+ port_by_idx: dict[int, PortInfo] = {
379
+ port.port_idx: port for port in port_table if port.port_idx is not None
373
380
  }
374
381
  for group_id, group_ports in list(groups.items()):
375
382
  if len(group_ports) > 1:
@@ -377,27 +384,33 @@ def _aggregate_ports(port_table: list[object]) -> dict[str, list[object]]:
377
384
  lone_port = group_ports[0]
378
385
  if not _looks_like_lag(lone_port):
379
386
  continue
380
- if getattr(lone_port, "port_idx", None) is None:
387
+ port_idx = lone_port.port_idx
388
+ if port_idx is None:
381
389
  continue
382
- candidates = []
383
- for neighbor in (lone_port.port_idx - 1, lone_port.port_idx + 1):
390
+ candidates: list[PortInfo] = []
391
+ for neighbor in (port_idx - 1, port_idx + 1):
384
392
  port = port_by_idx.get(neighbor)
385
393
  if port and not getattr(port, "aggregation_group", None):
386
394
  if getattr(port, "speed", None) == getattr(lone_port, "speed", None):
387
395
  candidates.append(port)
388
396
  if candidates:
389
397
  groups[group_id].extend(candidates)
398
+
399
+
400
+ def _aggregate_ports(port_table: list[PortInfo]) -> dict[str, list[PortInfo]]:
401
+ groups = _aggregate_base_groups(port_table)
402
+ _extend_singleton_groups(groups, port_table)
390
403
  return groups
391
404
 
392
405
 
393
- def _looks_like_lag(port: object) -> bool:
406
+ def _looks_like_lag(port: PortInfo) -> bool:
394
407
  name = (getattr(port, "name", "") or "").lower()
395
408
  ifname = (getattr(port, "ifname", "") or "").lower()
396
409
  return "lag" in name or "lag" in ifname or "aggregate" in name
397
410
 
398
411
 
399
- def _format_aggregate_label(group_ports: list[object]) -> str:
400
- ports = sorted([p.port_idx for p in group_ports if getattr(p, "port_idx", None) is not None])
412
+ def _format_aggregate_label(group_ports: list[PortInfo]) -> str:
413
+ ports = sorted([int(p.port_idx) for p in group_ports if p.port_idx is not None])
401
414
  if ports:
402
415
  if len(ports) == 1:
403
416
  return f"Port {ports[0]} (LAG)"
@@ -407,14 +420,14 @@ def _format_aggregate_label(group_ports: list[object]) -> str:
407
420
  return "Aggregated ports"
408
421
 
409
422
 
410
- def _aggregate_sort_key(group_ports: list[object]) -> int:
411
- ports = sorted([p.port_idx for p in group_ports if getattr(p, "port_idx", None) is not None])
423
+ def _aggregate_sort_key(group_ports: list[PortInfo]) -> int:
424
+ ports = sorted([int(p.port_idx) for p in group_ports if p.port_idx is not None])
412
425
  return ports[0] if ports else 10_000
413
426
 
414
427
 
415
428
  def _format_aggregate_connections(
416
429
  device_name: str,
417
- group_ports: list[object],
430
+ group_ports: list[PortInfo],
418
431
  connections: dict[int, list[str]],
419
432
  client_connections: dict[int, list[str]],
420
433
  port_map: PortMap,
@@ -436,7 +449,7 @@ def _format_aggregate_connections(
436
449
  return ", ".join([item for item in rendered if item])
437
450
 
438
451
 
439
- def _format_aggregate_speed(group_ports: list[object]) -> str:
452
+ def _format_aggregate_speed(group_ports: list[PortInfo]) -> str:
440
453
  speeds = {getattr(port, "speed", None) for port in group_ports}
441
454
  speeds.discard(None)
442
455
  if not speeds:
@@ -446,7 +459,7 @@ def _format_aggregate_speed(group_ports: list[object]) -> str:
446
459
  return "mixed"
447
460
 
448
461
 
449
- def _format_aggregate_poe_state(group_ports: list[object]) -> str:
462
+ def _format_aggregate_poe_state(group_ports: list[PortInfo]) -> str:
450
463
  states = {_format_poe_state(port) for port in group_ports}
451
464
  if "active" in states:
452
465
  return "active"
@@ -457,6 +470,6 @@ def _format_aggregate_poe_state(group_ports: list[object]) -> str:
457
470
  return "-"
458
471
 
459
472
 
460
- def _format_aggregate_power(group_ports: list[object]) -> str:
473
+ def _format_aggregate_power(group_ports: list[PortInfo]) -> str:
461
474
  total = sum(getattr(port, "poe_power", 0.0) or 0.0 for port in group_ports)
462
475
  return _format_poe_power(total)
@@ -213,63 +213,107 @@ def _client_rows(
213
213
  return rows_by_device
214
214
 
215
215
 
216
- def render_lldp_md(
216
+ def _prepare_lldp_maps(
217
217
  devices: list[Device],
218
218
  *,
219
- clients: Iterable[object] | None = None,
220
- include_ports: bool = False,
221
- show_clients: bool = False,
222
- client_mode: str = "wired",
223
- ) -> str:
219
+ clients: Iterable[object] | None,
220
+ include_ports: bool,
221
+ show_clients: bool,
222
+ client_mode: str,
223
+ ) -> tuple[
224
+ dict[tuple[str, str], str],
225
+ dict[str, list[tuple[int, str]]] | None,
226
+ dict[str, list[tuple[str, str | None]]],
227
+ ]:
224
228
  device_index = build_device_index(devices)
225
- port_map = {}
226
- client_port_map = None
227
229
  client_rows = (
228
230
  _client_rows(clients, device_index, include_ports=include_ports, client_mode=client_mode)
229
231
  if clients
230
232
  else {}
231
233
  )
234
+ port_map: dict[tuple[str, str], str] = {}
235
+ client_port_map = None
232
236
  if include_ports:
233
237
  port_map = build_port_map(devices, only_unifi=False)
234
238
  if clients and show_clients:
235
239
  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))
240
+ return port_map, client_port_map, client_rows
241
+
242
+
243
+ def _render_device_lldp_section(
244
+ lines: list[str],
245
+ device: Device,
246
+ *,
247
+ device_index: dict[str, str],
248
+ port_map: dict[tuple[str, str], str],
249
+ client_port_map: dict[str, list[tuple[int, str]]] | None,
250
+ client_rows: dict[str, list[tuple[str, str | None]]],
251
+ include_ports: bool,
252
+ show_clients: bool,
253
+ client_mode: str,
254
+ ) -> None:
255
+ lines.extend(_device_header_lines(device))
256
+ lines.append("")
257
+ lines.extend(_details_table_lines(device, client_rows, client_mode))
258
+ if include_ports:
259
+ lines.append("### Ports")
260
+ lines.append("")
261
+ lines.append(
262
+ render_device_port_details(device, port_map, client_ports=client_port_map).strip()
263
+ )
264
+ if device.lldp_info:
265
+ lines.append("")
266
+ lines.append("| Local Port | Neighbor | Neighbor Port | Chassis ID | Port Description |")
267
+ lines.append("| --- | --- | --- | --- | --- |")
268
+ for row in _lldp_rows(device.lldp_info, device_index):
269
+ lines.append("| " + " | ".join(_escape_cell(cell) for cell in row) + " |")
239
270
  lines.append("")
240
- lines.extend(_details_table_lines(device, client_rows, client_mode))
271
+ else:
272
+ lines.append("_No LLDP neighbors._")
273
+ lines.append("")
274
+ rows = client_rows.get(device.name)
275
+ if rows and show_clients:
276
+ lines.append("")
277
+ lines.append("### Clients")
241
278
  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
279
  lines.append("")
280
+ lines.append("| Client | Port |")
281
+ lines.append("| --- | --- |")
282
+ for client_name, port_label in rows:
283
+ lines.append(f"| {_escape_cell(client_name)} | {_escape_cell(port_label or '-')} |")
256
284
  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("")
285
+ for client_name, _port_label in rows:
286
+ lines.append(f"- {_escape_cell(client_name)}")
287
+ lines.append("")
288
+
289
+
290
+ def render_lldp_md(
291
+ devices: list[Device],
292
+ *,
293
+ clients: Iterable[object] | None = None,
294
+ include_ports: bool = False,
295
+ show_clients: bool = False,
296
+ client_mode: str = "wired",
297
+ ) -> str:
298
+ device_index = build_device_index(devices)
299
+ port_map, client_port_map, client_rows = _prepare_lldp_maps(
300
+ devices,
301
+ clients=clients,
302
+ include_ports=include_ports,
303
+ show_clients=show_clients,
304
+ client_mode=client_mode,
305
+ )
306
+ lines: list[str] = ["# LLDP Neighbors", ""]
307
+ for device in sorted(devices, key=lambda item: item.name.lower()):
308
+ _render_device_lldp_section(
309
+ lines,
310
+ device,
311
+ device_index=device_index,
312
+ port_map=port_map,
313
+ client_port_map=client_port_map,
314
+ client_rows=client_rows,
315
+ include_ports=include_ports,
316
+ show_clients=show_clients,
317
+ client_mode=client_mode,
318
+ )
275
319
  return "\n".join(lines).rstrip() + "\n"