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
@@ -0,0 +1,544 @@
1
+ """Topology comparison and change detection.
2
+
3
+ Provides functions to compare two topology snapshots and generate structured
4
+ change events for integration with monitoring systems like Home Assistant.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from dataclasses import dataclass, field
11
+ from datetime import UTC, datetime
12
+ from typing import Any
13
+
14
+ from .helpers import normalize_mac
15
+ from .snapshot import device_to_dict, edge_to_dict
16
+ from .topology import Device, Edge
17
+
18
+
19
+ @dataclass
20
+ class TopologyChangeEvent:
21
+ """A single change detected between two topology snapshots."""
22
+
23
+ event_type: str
24
+ """One of: node_added, node_removed, node_changed, edge_added, edge_removed, edge_changed"""
25
+
26
+ entity_type: str
27
+ """'device' or 'client'"""
28
+
29
+ identifier: str
30
+ """MAC address - stable identifier across renames"""
31
+
32
+ name: str | None
33
+ """Human-readable name (from newer topology if changed)"""
34
+
35
+ description: str
36
+ """Human-readable message for notifications"""
37
+
38
+ details: dict[str, Any] = field(default_factory=dict)
39
+ """Event-specific payload"""
40
+
41
+ timestamp: str | None = None
42
+ """ISO timestamp if available"""
43
+
44
+ def to_dict(self) -> dict[str, Any]:
45
+ """Convert to JSON-serializable dictionary."""
46
+ return {
47
+ "event_type": self.event_type,
48
+ "entity_type": self.entity_type,
49
+ "identifier": self.identifier,
50
+ "name": self.name,
51
+ "description": self.description,
52
+ "details": self.details,
53
+ "timestamp": self.timestamp,
54
+ }
55
+
56
+
57
+ @dataclass
58
+ class TopologyDiff:
59
+ """Result of comparing two topology snapshots."""
60
+
61
+ events: list[TopologyChangeEvent] = field(default_factory=list)
62
+ """All detected changes"""
63
+
64
+ old_timestamp: str | None = None
65
+ """Timestamp from old topology metadata"""
66
+
67
+ new_timestamp: str | None = None
68
+ """Timestamp from new topology metadata"""
69
+
70
+ summary: str = ""
71
+ """Human-readable summary like '3 devices added, 1 removed'"""
72
+
73
+ def to_dict(self) -> dict[str, Any]:
74
+ """Convert to JSON-serializable dictionary."""
75
+ return {
76
+ "events": [e.to_dict() for e in self.events],
77
+ "old_timestamp": self.old_timestamp,
78
+ "new_timestamp": self.new_timestamp,
79
+ "summary": self.summary,
80
+ }
81
+
82
+ def to_json(self) -> str:
83
+ """Serialize to JSON string."""
84
+ return json.dumps(self.to_dict(), indent=2)
85
+
86
+ def filter(
87
+ self,
88
+ event_types: set[str] | None = None,
89
+ entity_types: set[str] | None = None,
90
+ ) -> TopologyDiff:
91
+ """Return filtered diff with only matching events."""
92
+ filtered = [
93
+ e
94
+ for e in self.events
95
+ if (event_types is None or e.event_type in event_types)
96
+ and (entity_types is None or e.entity_type in entity_types)
97
+ ]
98
+ return TopologyDiff(
99
+ events=filtered,
100
+ old_timestamp=self.old_timestamp,
101
+ new_timestamp=self.new_timestamp,
102
+ summary=_build_summary(filtered),
103
+ )
104
+
105
+
106
+ def _pluralize(count: int, singular: str) -> str:
107
+ """Return 'N item' or 'N items' based on count."""
108
+ return f"{count} {singular}{'s' if count != 1 else ''}"
109
+
110
+
111
+ def _add_count_part(parts: list[str], count: int, noun: str, verb: str) -> None:
112
+ """Add a count part to the summary list if count > 0."""
113
+ if count:
114
+ parts.append(f"{_pluralize(count, noun)} {verb}")
115
+
116
+
117
+ def _build_summary(events: list[TopologyChangeEvent]) -> str:
118
+ """Build a human-readable summary of changes."""
119
+ counts: dict[str, int] = {}
120
+ for event in events:
121
+ key = f"{event.entity_type}_{event.event_type}"
122
+ counts[key] = counts.get(key, 0) + 1
123
+
124
+ parts: list[str] = []
125
+
126
+ # Devices
127
+ _add_count_part(parts, counts.get("device_node_added", 0), "device", "added")
128
+ _add_count_part(parts, counts.get("device_node_removed", 0), "device", "removed")
129
+ _add_count_part(parts, counts.get("device_node_changed", 0), "device", "changed")
130
+
131
+ # Clients
132
+ _add_count_part(parts, counts.get("client_node_added", 0), "client", "added")
133
+ _add_count_part(parts, counts.get("client_node_removed", 0), "client", "removed")
134
+ _add_count_part(parts, counts.get("client_node_changed", 0), "client", "changed")
135
+
136
+ # Edges (combine device and client edges)
137
+ edge_added = counts.get("device_edge_added", 0) + counts.get("client_edge_added", 0)
138
+ edge_removed = counts.get("device_edge_removed", 0) + counts.get("client_edge_removed", 0)
139
+ edge_changed = counts.get("device_edge_changed", 0) + counts.get("client_edge_changed", 0)
140
+ _add_count_part(parts, edge_added, "connection", "added")
141
+ _add_count_part(parts, edge_removed, "connection", "removed")
142
+ _add_count_part(parts, edge_changed, "connection", "changed")
143
+
144
+ return ", ".join(parts) if parts else "No changes"
145
+
146
+
147
+ # --- Node comparison ---
148
+
149
+
150
+ def _device_properties(device: Device) -> dict[str, Any]:
151
+ """Extract comparable properties from a device."""
152
+ return {
153
+ "name": device.name,
154
+ "model": device.model,
155
+ "model_name": device.model_name,
156
+ "ip": device.ip,
157
+ "type": device.type,
158
+ "version": device.version,
159
+ "uplink_mac": device.uplink.mac if device.uplink else None,
160
+ "uplink_port": device.uplink.port if device.uplink else None,
161
+ }
162
+
163
+
164
+ def _client_properties(client: dict[str, Any]) -> dict[str, Any]:
165
+ """Extract comparable properties from a client."""
166
+ return {
167
+ "name": client.get("name") or client.get("hostname"),
168
+ "ip": client.get("ip"),
169
+ "vlan": client.get("vlan") or client.get("vlan_id"),
170
+ "is_wired": client.get("is_wired"),
171
+ "uplink_mac": (client.get("ap_mac") or client.get("sw_mac") or client.get("uplink_mac")),
172
+ "uplink_port": client.get("sw_port") or client.get("uplink_remote_port"),
173
+ "channel": client.get("channel"),
174
+ "signal": client.get("signal"),
175
+ "satisfaction": client.get("satisfaction"),
176
+ }
177
+
178
+
179
+ def _compare_properties(
180
+ old_props: dict[str, Any],
181
+ new_props: dict[str, Any],
182
+ ) -> dict[str, dict[str, Any]]:
183
+ """Compare two property dicts and return changes."""
184
+ changes: dict[str, dict[str, Any]] = {}
185
+ all_keys = set(old_props.keys()) | set(new_props.keys())
186
+ for key in all_keys:
187
+ old_val = old_props.get(key)
188
+ new_val = new_props.get(key)
189
+ if old_val != new_val:
190
+ changes[key] = {"old": old_val, "new": new_val}
191
+ return changes
192
+
193
+
194
+ def _describe_device_added(device: Device) -> str:
195
+ """Generate description for device added event."""
196
+ return f"Device '{device.name}' appeared on network"
197
+
198
+
199
+ def _describe_device_removed(device: Device) -> str:
200
+ """Generate description for device removed event."""
201
+ return f"Device '{device.name}' disappeared from network"
202
+
203
+
204
+ def _describe_device_changed(device: Device, changes: dict[str, dict[str, Any]]) -> str:
205
+ """Generate description for device changed event."""
206
+ if len(changes) == 1:
207
+ key = list(changes.keys())[0]
208
+ old_val = changes[key]["old"]
209
+ new_val = changes[key]["new"]
210
+ if key == "ip":
211
+ return f"Device '{device.name}' IP changed from {old_val} to {new_val}"
212
+ if key == "name":
213
+ return f"Device renamed from '{old_val}' to '{new_val}'"
214
+ if key == "uplink_mac":
215
+ return f"Device '{device.name}' uplink changed"
216
+ if key == "uplink_port":
217
+ return f"Device '{device.name}' moved to port {new_val}"
218
+ return f"Device '{device.name}' {key} changed"
219
+ return f"Device '{device.name}' changed ({len(changes)} properties)"
220
+
221
+
222
+ def _describe_client_added(client: dict[str, Any]) -> str:
223
+ """Generate description for client added event."""
224
+ name = client.get("name") or client.get("hostname") or client.get("mac", "unknown")
225
+ is_wired = client.get("is_wired", True)
226
+ conn_type = "wired" if is_wired else "WiFi"
227
+ return f"Client '{name}' connected via {conn_type}"
228
+
229
+
230
+ def _describe_client_removed(client: dict[str, Any]) -> str:
231
+ """Generate description for client removed event."""
232
+ name = client.get("name") or client.get("hostname") or client.get("mac", "unknown")
233
+ return f"Client '{name}' disconnected"
234
+
235
+
236
+ def _describe_client_changed(client: dict[str, Any], changes: dict[str, dict[str, Any]]) -> str:
237
+ """Generate description for client changed event."""
238
+ name = client.get("name") or client.get("hostname") or client.get("mac", "unknown")
239
+ if len(changes) == 1:
240
+ key = list(changes.keys())[0]
241
+ old_val = changes[key]["old"]
242
+ new_val = changes[key]["new"]
243
+ if key == "vlan":
244
+ return f"Client '{name}' changed VLAN from {old_val} to {new_val}"
245
+ if key == "ip":
246
+ return f"Client '{name}' IP changed from {old_val} to {new_val}"
247
+ if key == "uplink_mac":
248
+ return f"Client '{name}' moved to different device"
249
+ if key == "uplink_port":
250
+ return f"Client '{name}' moved to port {new_val}"
251
+ return f"Client '{name}' {key} changed"
252
+ return f"Client '{name}' changed ({len(changes)} properties)"
253
+
254
+
255
+ # --- Edge comparison ---
256
+
257
+
258
+ def _edge_key(edge: Edge) -> frozenset[str]:
259
+ """Create a stable key for an edge (order-independent)."""
260
+ return frozenset({edge.left, edge.right})
261
+
262
+
263
+ def _edge_properties(edge: Edge) -> dict[str, Any]:
264
+ """Extract comparable properties from an edge."""
265
+ return {
266
+ "label": edge.label,
267
+ "poe": edge.poe,
268
+ "wireless": edge.wireless,
269
+ "speed": edge.speed,
270
+ "channel": edge.channel,
271
+ "vlans": edge.vlans,
272
+ "is_trunk": edge.is_trunk,
273
+ }
274
+
275
+
276
+ def _describe_edge_added(edge: Edge) -> str:
277
+ """Generate description for edge added event."""
278
+ conn_type = "wireless" if edge.wireless else "wired"
279
+ return f"Connection added: {edge.left} <-> {edge.right} ({conn_type})"
280
+
281
+
282
+ def _describe_edge_removed(edge: Edge) -> str:
283
+ """Generate description for edge removed event."""
284
+ return f"Connection removed: {edge.left} <-> {edge.right}"
285
+
286
+
287
+ def _describe_edge_changed(edge: Edge, changes: dict[str, dict[str, Any]]) -> str:
288
+ """Generate description for edge changed event."""
289
+ if len(changes) == 1:
290
+ key = list(changes.keys())[0]
291
+ if key == "speed":
292
+ old_val = changes[key]["old"]
293
+ new_val = changes[key]["new"]
294
+ return (
295
+ f"Connection {edge.left} <-> {edge.right} speed changed from {old_val} to {new_val}"
296
+ )
297
+ if key == "poe":
298
+ new_val = changes[key]["new"]
299
+ poe_state = "enabled" if new_val else "disabled"
300
+ return f"Connection {edge.left} <-> {edge.right} PoE {poe_state}"
301
+ return f"Connection {edge.left} <-> {edge.right} changed"
302
+
303
+
304
+ # --- Main comparison function ---
305
+
306
+
307
+ def compare_topologies(
308
+ old_devices: list[Device],
309
+ new_devices: list[Device],
310
+ old_clients: list[dict[str, Any]] | None = None,
311
+ new_clients: list[dict[str, Any]] | None = None,
312
+ old_edges: list[Edge] | None = None,
313
+ new_edges: list[Edge] | None = None,
314
+ *,
315
+ old_timestamp: str | None = None,
316
+ new_timestamp: str | None = None,
317
+ ) -> TopologyDiff:
318
+ """Compare two topology snapshots and return structured change events.
319
+
320
+ Args:
321
+ old_devices: Devices from the previous snapshot.
322
+ new_devices: Devices from the current snapshot.
323
+ old_clients: Clients from the previous snapshot (optional).
324
+ new_clients: Clients from the current snapshot (optional).
325
+ old_edges: Edges from the previous snapshot (optional).
326
+ new_edges: Edges from the current snapshot (optional).
327
+ old_timestamp: ISO timestamp of old snapshot.
328
+ new_timestamp: ISO timestamp of new snapshot.
329
+
330
+ Returns:
331
+ TopologyDiff containing all detected changes.
332
+ """
333
+ events: list[TopologyChangeEvent] = []
334
+ timestamp = new_timestamp or datetime.now(UTC).isoformat()
335
+
336
+ # Compare devices
337
+ _compare_devices(old_devices, new_devices, events, timestamp)
338
+
339
+ # Compare clients
340
+ if old_clients is not None and new_clients is not None:
341
+ _compare_clients(old_clients, new_clients, events, timestamp)
342
+
343
+ # Compare edges
344
+ if old_edges is not None and new_edges is not None:
345
+ _compare_edges(old_edges, new_edges, events, timestamp)
346
+
347
+ return TopologyDiff(
348
+ events=events,
349
+ old_timestamp=old_timestamp,
350
+ new_timestamp=new_timestamp,
351
+ summary=_build_summary(events),
352
+ )
353
+
354
+
355
+ def _compare_devices(
356
+ old_devices: list[Device],
357
+ new_devices: list[Device],
358
+ events: list[TopologyChangeEvent],
359
+ timestamp: str,
360
+ ) -> None:
361
+ """Compare device lists and add events."""
362
+ old_by_mac = {normalize_mac(d.mac): d for d in old_devices}
363
+ new_by_mac = {normalize_mac(d.mac): d for d in new_devices}
364
+
365
+ old_macs = set(old_by_mac.keys())
366
+ new_macs = set(new_by_mac.keys())
367
+
368
+ # Added devices
369
+ for mac in sorted(new_macs - old_macs):
370
+ device = new_by_mac[mac]
371
+ events.append(
372
+ TopologyChangeEvent(
373
+ event_type="node_added",
374
+ entity_type="device",
375
+ identifier=mac,
376
+ name=device.name,
377
+ description=_describe_device_added(device),
378
+ details=device_to_dict(device),
379
+ timestamp=timestamp,
380
+ )
381
+ )
382
+
383
+ # Removed devices
384
+ for mac in sorted(old_macs - new_macs):
385
+ device = old_by_mac[mac]
386
+ events.append(
387
+ TopologyChangeEvent(
388
+ event_type="node_removed",
389
+ entity_type="device",
390
+ identifier=mac,
391
+ name=device.name,
392
+ description=_describe_device_removed(device),
393
+ details=device_to_dict(device),
394
+ timestamp=timestamp,
395
+ )
396
+ )
397
+
398
+ # Changed devices
399
+ for mac in sorted(old_macs & new_macs):
400
+ old_device = old_by_mac[mac]
401
+ new_device = new_by_mac[mac]
402
+ old_props = _device_properties(old_device)
403
+ new_props = _device_properties(new_device)
404
+ changes = _compare_properties(old_props, new_props)
405
+ if changes:
406
+ events.append(
407
+ TopologyChangeEvent(
408
+ event_type="node_changed",
409
+ entity_type="device",
410
+ identifier=mac,
411
+ name=new_device.name,
412
+ description=_describe_device_changed(new_device, changes),
413
+ details={"changes": changes},
414
+ timestamp=timestamp,
415
+ )
416
+ )
417
+
418
+
419
+ def _compare_clients(
420
+ old_clients: list[dict[str, Any]],
421
+ new_clients: list[dict[str, Any]],
422
+ events: list[TopologyChangeEvent],
423
+ timestamp: str,
424
+ ) -> None:
425
+ """Compare client lists and add events."""
426
+ old_by_mac = {normalize_mac(c.get("mac", "")): c for c in old_clients if c.get("mac")}
427
+ new_by_mac = {normalize_mac(c.get("mac", "")): c for c in new_clients if c.get("mac")}
428
+
429
+ old_macs = set(old_by_mac.keys())
430
+ new_macs = set(new_by_mac.keys())
431
+
432
+ # Added clients
433
+ for mac in sorted(new_macs - old_macs):
434
+ client = new_by_mac[mac]
435
+ events.append(
436
+ TopologyChangeEvent(
437
+ event_type="node_added",
438
+ entity_type="client",
439
+ identifier=mac,
440
+ name=client.get("name") or client.get("hostname"),
441
+ description=_describe_client_added(client),
442
+ details=_client_properties(client),
443
+ timestamp=timestamp,
444
+ )
445
+ )
446
+
447
+ # Removed clients
448
+ for mac in sorted(old_macs - new_macs):
449
+ client = old_by_mac[mac]
450
+ events.append(
451
+ TopologyChangeEvent(
452
+ event_type="node_removed",
453
+ entity_type="client",
454
+ identifier=mac,
455
+ name=client.get("name") or client.get("hostname"),
456
+ description=_describe_client_removed(client),
457
+ details=_client_properties(client),
458
+ timestamp=timestamp,
459
+ )
460
+ )
461
+
462
+ # Changed clients
463
+ for mac in sorted(old_macs & new_macs):
464
+ old_client = old_by_mac[mac]
465
+ new_client = new_by_mac[mac]
466
+ old_props = _client_properties(old_client)
467
+ new_props = _client_properties(new_client)
468
+ changes = _compare_properties(old_props, new_props)
469
+ if changes:
470
+ events.append(
471
+ TopologyChangeEvent(
472
+ event_type="node_changed",
473
+ entity_type="client",
474
+ identifier=mac,
475
+ name=new_client.get("name") or new_client.get("hostname"),
476
+ description=_describe_client_changed(new_client, changes),
477
+ details={"changes": changes},
478
+ timestamp=timestamp,
479
+ )
480
+ )
481
+
482
+
483
+ def _compare_edges(
484
+ old_edges: list[Edge],
485
+ new_edges: list[Edge],
486
+ events: list[TopologyChangeEvent],
487
+ timestamp: str,
488
+ ) -> None:
489
+ """Compare edge lists and add events."""
490
+ old_by_key = {_edge_key(e): e for e in old_edges}
491
+ new_by_key = {_edge_key(e): e for e in new_edges}
492
+
493
+ old_keys = set(old_by_key.keys())
494
+ new_keys = set(new_by_key.keys())
495
+
496
+ # Added edges
497
+ for key in sorted(new_keys - old_keys, key=lambda k: tuple(sorted(k))):
498
+ edge = new_by_key[key]
499
+ events.append(
500
+ TopologyChangeEvent(
501
+ event_type="edge_added",
502
+ entity_type="device", # Could be client edge, but entity_type is for filtering
503
+ identifier=f"{edge.left}:{edge.right}",
504
+ name=None,
505
+ description=_describe_edge_added(edge),
506
+ details=edge_to_dict(edge),
507
+ timestamp=timestamp,
508
+ )
509
+ )
510
+
511
+ # Removed edges
512
+ for key in sorted(old_keys - new_keys, key=lambda k: tuple(sorted(k))):
513
+ edge = old_by_key[key]
514
+ events.append(
515
+ TopologyChangeEvent(
516
+ event_type="edge_removed",
517
+ entity_type="device",
518
+ identifier=f"{edge.left}:{edge.right}",
519
+ name=None,
520
+ description=_describe_edge_removed(edge),
521
+ details=edge_to_dict(edge),
522
+ timestamp=timestamp,
523
+ )
524
+ )
525
+
526
+ # Changed edges
527
+ for key in sorted(old_keys & new_keys, key=lambda k: tuple(sorted(k))):
528
+ old_edge = old_by_key[key]
529
+ new_edge = new_by_key[key]
530
+ old_props = _edge_properties(old_edge)
531
+ new_props = _edge_properties(new_edge)
532
+ changes = _compare_properties(old_props, new_props)
533
+ if changes:
534
+ events.append(
535
+ TopologyChangeEvent(
536
+ event_type="edge_changed",
537
+ entity_type="device",
538
+ identifier=f"{new_edge.left}:{new_edge.right}",
539
+ name=None,
540
+ description=_describe_edge_changed(new_edge, changes),
541
+ details={"changes": changes},
542
+ timestamp=timestamp,
543
+ )
544
+ )