unifi-network-maps 1.3.0__py3-none-any.whl → 1.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,462 @@
1
+ """Render per-device port overview tables."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict
6
+ from html import escape as _escape_html
7
+
8
+ from ..model.ports import extract_port_number
9
+ from ..model.topology import ClientPortMap, Device, PortMap, classify_device_type
10
+
11
+
12
+ def render_device_port_overview(
13
+ devices: list[Device],
14
+ port_map: PortMap,
15
+ *,
16
+ client_ports: ClientPortMap | None = None,
17
+ ) -> str:
18
+ gateways = _collect_devices_by_type(devices, "gateway")
19
+ switches = _collect_devices_by_type(devices, "switch")
20
+ lines: list[str] = []
21
+ if gateways:
22
+ lines.append("## Gateways")
23
+ lines.append("")
24
+ lines.extend(_render_device_group(gateways, port_map, client_ports))
25
+ if switches:
26
+ if lines:
27
+ lines.append("")
28
+ lines.append("## Switches")
29
+ lines.append("")
30
+ lines.extend(_render_device_group(switches, port_map, client_ports))
31
+ return "\n".join(lines).rstrip() + "\n"
32
+
33
+
34
+ def _collect_devices_by_type(devices: list[Device], desired_type: str) -> list[Device]:
35
+ return sorted(
36
+ [device for device in devices if classify_device_type(device) == desired_type],
37
+ key=lambda item: item.name.lower(),
38
+ )
39
+
40
+
41
+ def _render_device_group(
42
+ devices: list[Device],
43
+ port_map: PortMap,
44
+ client_ports: ClientPortMap | None,
45
+ ) -> list[str]:
46
+ lines: list[str] = []
47
+ for device in devices:
48
+ lines.append(f"### {device.name}")
49
+ lines.append("")
50
+ lines.extend(_render_device_details(device))
51
+ lines.extend(_render_device_ports(device, port_map, client_ports))
52
+ lines.append("")
53
+ return lines
54
+
55
+
56
+ def render_device_port_details(
57
+ device: Device,
58
+ port_map: PortMap,
59
+ *,
60
+ client_ports: ClientPortMap | None = None,
61
+ ) -> str:
62
+ lines = _render_device_details(device)
63
+ lines.extend(_render_device_ports(device, port_map, client_ports))
64
+ return "\n".join(lines).rstrip() + "\n"
65
+
66
+
67
+ def _render_device_ports(
68
+ device: Device,
69
+ port_map: PortMap,
70
+ client_ports: ClientPortMap | None,
71
+ ) -> list[str]:
72
+ rows = _build_port_rows(device, port_map, client_ports)
73
+ lines = [
74
+ "#### Ports",
75
+ "",
76
+ "| Port | Connected | Speed | PoE | Power |",
77
+ "| --- | --- | --- | --- | --- |",
78
+ ]
79
+ for port_label, connected, speed, poe_state, power in rows:
80
+ lines.append(
81
+ f"| {_escape_cell(port_label)} | {_escape_cell(connected or '-')} | "
82
+ f"{_escape_cell(speed)} | {_escape_cell(poe_state)} | {_escape_cell(power)} |"
83
+ )
84
+ return lines
85
+
86
+
87
+ def _build_port_rows(
88
+ device: Device,
89
+ port_map: PortMap,
90
+ client_ports: ClientPortMap | None,
91
+ ) -> list[tuple[str, str, str, str, str]]:
92
+ connections = _device_port_connections(device.name, port_map)
93
+ client_connections = _device_client_connections(device.name, client_ports)
94
+ aggregated = _aggregate_ports(device.port_table)
95
+ aggregated_indices = {
96
+ port.port_idx
97
+ for ports in aggregated.values()
98
+ for port in ports
99
+ if getattr(port, "port_idx", None) is not None
100
+ }
101
+ rows: list[tuple[tuple[int, int], tuple[str, str, str, str, str]]] = []
102
+ seen_ports: set[int] = set()
103
+ for port in sorted(device.port_table, key=_port_sort_key):
104
+ if port.port_idx in aggregated_indices:
105
+ port_idx = _port_index(port.port_idx, port.name)
106
+ if port_idx is not None:
107
+ seen_ports.add(port_idx)
108
+ continue
109
+ port_idx = _port_index(port.port_idx, port.name)
110
+ if port_idx is not None:
111
+ seen_ports.add(port_idx)
112
+ port_label = _format_port_label(port_idx, port.name)
113
+ connected = _format_connections(
114
+ device.name,
115
+ port_idx,
116
+ connections,
117
+ client_connections,
118
+ port_map,
119
+ )
120
+ rows.append(
121
+ (
122
+ (0, port_idx or 10_000),
123
+ (
124
+ port_label,
125
+ connected,
126
+ _format_speed(port.speed),
127
+ _format_poe_state(port),
128
+ _format_poe_power(port.poe_power),
129
+ ),
130
+ )
131
+ )
132
+ for _group_id, group_ports in aggregated.items():
133
+ group_label = _format_aggregate_label(group_ports)
134
+ group_sort = _aggregate_sort_key(group_ports)
135
+ group_connections = _format_aggregate_connections(
136
+ device.name,
137
+ group_ports,
138
+ connections,
139
+ client_connections,
140
+ port_map,
141
+ )
142
+ rows.append(
143
+ (
144
+ (0, group_sort),
145
+ (
146
+ group_label,
147
+ group_connections,
148
+ _format_aggregate_speed(group_ports),
149
+ _format_aggregate_poe_state(group_ports),
150
+ _format_aggregate_power(group_ports),
151
+ ),
152
+ )
153
+ )
154
+ for port_idx in sorted(connections):
155
+ if port_idx in seen_ports:
156
+ continue
157
+ port_label = _format_port_label(port_idx, None)
158
+ connected = _format_connections(
159
+ device.name,
160
+ port_idx,
161
+ connections,
162
+ client_connections,
163
+ port_map,
164
+ )
165
+ rows.append(((2, port_idx), (port_label, connected, "-", "-", "-")))
166
+ return [row for _key, row in sorted(rows, key=lambda item: item[0])]
167
+
168
+
169
+ def _device_port_connections(device_name: str, port_map: PortMap) -> dict[int, list[str]]:
170
+ connections: dict[int, list[str]] = defaultdict(list)
171
+ for (src, dst), label in port_map.items():
172
+ if src != device_name:
173
+ continue
174
+ port_idx = extract_port_number(label or "")
175
+ if port_idx is None:
176
+ continue
177
+ connections[port_idx].append(dst)
178
+ return connections
179
+
180
+
181
+ def _device_client_connections(
182
+ device_name: str, client_ports: ClientPortMap | None
183
+ ) -> dict[int, list[str]]:
184
+ if not client_ports:
185
+ return {}
186
+ rows = client_ports.get(device_name, [])
187
+ connections: dict[int, list[str]] = defaultdict(list)
188
+ for port_idx, name in rows:
189
+ connections[port_idx].append(name)
190
+ return connections
191
+
192
+
193
+ def _format_connections(
194
+ device_name: str,
195
+ port_idx: int | None,
196
+ connections: dict[int, list[str]],
197
+ client_connections: dict[int, list[str]],
198
+ port_map: PortMap,
199
+ ) -> str:
200
+ if port_idx is None:
201
+ return ""
202
+ peers = connections.get(port_idx, [])
203
+ clients = client_connections.get(port_idx, [])
204
+ if not peers and not clients:
205
+ return ""
206
+ peer_entries: list[str] = []
207
+ for peer in sorted(peers, key=str.lower):
208
+ peer_label = port_map.get((peer, device_name))
209
+ if peer_label:
210
+ peer_entries.append(f"{peer} ({peer_label})")
211
+ else:
212
+ peer_entries.append(peer)
213
+ peer_text = ", ".join(peer_entries)
214
+ client_text = _format_client_connections(clients)
215
+ if peer_text and client_text:
216
+ return f"{peer_text}<br/>{client_text}"
217
+ return peer_text or client_text
218
+
219
+
220
+ def _format_port_label(port_idx: int | None, name: str | None) -> str:
221
+ if name and name.strip():
222
+ normalized = name.strip()
223
+ if port_idx is None:
224
+ return normalized
225
+ if normalized.lower() != f"port {port_idx}".lower():
226
+ return normalized
227
+ if port_idx is None:
228
+ return "Port ?"
229
+ return f"Port {port_idx}"
230
+
231
+
232
+ def _format_speed(speed: int | None) -> str:
233
+ if speed is None or speed <= 0:
234
+ return "-"
235
+ if speed >= 1000:
236
+ if speed % 1000 == 0:
237
+ return f"{speed // 1000}G"
238
+ return f"{speed / 1000:.1f}G"
239
+ return f"{speed}M"
240
+
241
+
242
+ def _format_poe_state(port: object) -> str:
243
+ poe_power = getattr(port, "poe_power", None)
244
+ poe_good = getattr(port, "poe_good", False)
245
+ poe_enable = getattr(port, "poe_enable", False)
246
+ port_poe = getattr(port, "port_poe", False)
247
+ if (poe_power or 0.0) > 0 or poe_good:
248
+ return "active"
249
+ if port_poe or poe_enable:
250
+ if not poe_enable:
251
+ return "disabled"
252
+ return "capable"
253
+ return "-"
254
+
255
+
256
+ def _format_poe_power(power: float | None) -> str:
257
+ if power is None or power <= 0:
258
+ return "-"
259
+ return f"{power:.2f}W"
260
+
261
+
262
+ def _port_index(port_idx: int | None, name: str | None) -> int | None:
263
+ if port_idx is not None:
264
+ return port_idx
265
+ if name:
266
+ return extract_port_number(name)
267
+ return None
268
+
269
+
270
+ def _port_sort_key(port: object) -> tuple[int, str]:
271
+ port_idx = _port_index(getattr(port, "port_idx", None), getattr(port, "name", None))
272
+ if port_idx is not None:
273
+ return (0, f"{port_idx:04d}")
274
+ name = getattr(port, "name", "") or ""
275
+ return (1, name.lower())
276
+
277
+
278
+ def _escape_cell(value: str) -> str:
279
+ return value.replace("|", "\\|")
280
+
281
+
282
+ def _render_device_details(device: Device) -> list[str]:
283
+ lines = [
284
+ "#### Details",
285
+ "",
286
+ "| Field | Value |",
287
+ "| --- | --- |",
288
+ f"| Model | {_escape_cell(_device_model_label(device))} |",
289
+ f"| Type | {_escape_cell(device.type or '-')} |",
290
+ f"| IP | {_escape_cell(device.ip or '-')} |",
291
+ f"| MAC | {_escape_cell(device.mac or '-')} |",
292
+ f"| Firmware | {_escape_cell(device.version or '-')} |",
293
+ f"| Uplink | {_escape_cell(_uplink_summary(device))} |",
294
+ f"| Ports | {_escape_cell(_port_summary(device))} |",
295
+ f"| PoE | {_escape_cell(_poe_summary(device))} |",
296
+ "",
297
+ ]
298
+ return lines
299
+
300
+
301
+ def _port_summary(device: Device) -> str:
302
+ ports = [port for port in device.port_table if port.port_idx is not None]
303
+ if not ports:
304
+ return "-"
305
+ total_ports = len(ports)
306
+ active_ports = sum(1 for port in ports if (port.speed or 0) > 0)
307
+ return f"{total_ports} total, {active_ports} active"
308
+
309
+
310
+ def _poe_summary(device: Device) -> str:
311
+ ports = [port for port in device.port_table if port.port_idx is not None]
312
+ if not ports:
313
+ return "-"
314
+ poe_capable = sum(1 for port in ports if port.port_poe or port.poe_enable)
315
+ poe_active = sum(1 for port in ports if _format_poe_state(port) == "active")
316
+ total_power = sum(port.poe_power or 0.0 for port in ports)
317
+ summary = f"{poe_capable} capable, {poe_active} active"
318
+ if total_power > 0:
319
+ summary = f"{summary}, {total_power:.2f}W"
320
+ return summary
321
+
322
+
323
+ def _uplink_summary(device: Device) -> str:
324
+ uplink = device.uplink or device.last_uplink
325
+ if not uplink:
326
+ if classify_device_type(device) == "gateway":
327
+ return "Internet"
328
+ return "-"
329
+ name = uplink.name or uplink.mac or "Unknown"
330
+ if classify_device_type(device) == "gateway":
331
+ lowered = name.lower()
332
+ if lowered in {"unknown", "wan", "internet"}:
333
+ name = "Internet"
334
+ elif lowered.startswith(("eth", "wan")):
335
+ name = "Internet"
336
+ if uplink.port is not None:
337
+ return f"{name} (Port {uplink.port})"
338
+ return name
339
+
340
+
341
+ def _device_model_label(device: Device) -> str:
342
+ if device.model_name:
343
+ return device.model_name
344
+ if device.model:
345
+ return device.model
346
+ return device.type or "-"
347
+
348
+
349
+ def _format_client_connections(clients: list[str]) -> str:
350
+ if not clients:
351
+ return ""
352
+ if len(clients) == 1:
353
+ return f"{clients[0]} (client)"
354
+ items = "".join(f"<li>{_escape_html(name)}</li>" for name in clients)
355
+ return f'<ul class="unifi-port-clients">{items}</ul>'
356
+
357
+
358
+ def _aggregate_ports(port_table: list[object]) -> dict[str, list[object]]:
359
+ groups: dict[str, list[object]] = defaultdict(list)
360
+ for port in port_table:
361
+ group = getattr(port, "aggregation_group", None)
362
+ if group:
363
+ groups[str(group)].append(port)
364
+ continue
365
+ if _looks_like_lag(port):
366
+ port_idx = getattr(port, "port_idx", None)
367
+ if port_idx is not None:
368
+ groups[f"lag-{port_idx}"].append(port)
369
+ 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
373
+ }
374
+ for group_id, group_ports in list(groups.items()):
375
+ if len(group_ports) > 1:
376
+ continue
377
+ lone_port = group_ports[0]
378
+ if not _looks_like_lag(lone_port):
379
+ continue
380
+ if getattr(lone_port, "port_idx", None) is None:
381
+ continue
382
+ candidates = []
383
+ for neighbor in (lone_port.port_idx - 1, lone_port.port_idx + 1):
384
+ port = port_by_idx.get(neighbor)
385
+ if port and not getattr(port, "aggregation_group", None):
386
+ if getattr(port, "speed", None) == getattr(lone_port, "speed", None):
387
+ candidates.append(port)
388
+ if candidates:
389
+ groups[group_id].extend(candidates)
390
+ return groups
391
+
392
+
393
+ def _looks_like_lag(port: object) -> bool:
394
+ name = (getattr(port, "name", "") or "").lower()
395
+ ifname = (getattr(port, "ifname", "") or "").lower()
396
+ return "lag" in name or "lag" in ifname or "aggregate" in name
397
+
398
+
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])
401
+ if ports:
402
+ if len(ports) == 1:
403
+ return f"Port {ports[0]} (LAG)"
404
+ if ports == list(range(ports[0], ports[-1] + 1)):
405
+ return f"Port {ports[0]}-{ports[-1]} (LAG)"
406
+ return "Ports " + "+".join(str(port) for port in ports) + " (LAG)"
407
+ return "Aggregated ports"
408
+
409
+
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])
412
+ return ports[0] if ports else 10_000
413
+
414
+
415
+ def _format_aggregate_connections(
416
+ device_name: str,
417
+ group_ports: list[object],
418
+ connections: dict[int, list[str]],
419
+ client_connections: dict[int, list[str]],
420
+ port_map: PortMap,
421
+ ) -> str:
422
+ rendered: list[str] = []
423
+ for port in group_ports:
424
+ port_idx = _port_index(getattr(port, "port_idx", None), getattr(port, "name", None))
425
+ if port_idx is None:
426
+ continue
427
+ text = _format_connections(
428
+ device_name,
429
+ port_idx,
430
+ connections,
431
+ client_connections,
432
+ port_map,
433
+ )
434
+ if text:
435
+ rendered.append(text)
436
+ return ", ".join([item for item in rendered if item])
437
+
438
+
439
+ def _format_aggregate_speed(group_ports: list[object]) -> str:
440
+ speeds = {getattr(port, "speed", None) for port in group_ports}
441
+ speeds.discard(None)
442
+ if not speeds:
443
+ return "-"
444
+ if len(speeds) == 1:
445
+ return _format_speed(next(iter(speeds)))
446
+ return "mixed"
447
+
448
+
449
+ def _format_aggregate_poe_state(group_ports: list[object]) -> str:
450
+ states = {_format_poe_state(port) for port in group_ports}
451
+ if "active" in states:
452
+ return "active"
453
+ if "disabled" in states:
454
+ return "disabled"
455
+ if "capable" in states:
456
+ return "capable"
457
+ return "-"
458
+
459
+
460
+ def _format_aggregate_power(group_ports: list[object]) -> str:
461
+ total = sum(getattr(port, "poe_power", 0.0) or 0.0 for port in group_ports)
462
+ return _format_poe_power(total)