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
@@ -112,8 +112,12 @@ def _build_clients(
112
112
  aps: list[dict[str, Any]],
113
113
  ) -> list[dict[str, Any]]:
114
114
  clients = []
115
- clients.extend(_build_wired_clients(options.wired_client_count, state, core_switch))
116
- clients.extend(_build_wireless_clients(options.wireless_client_count, state, aps))
115
+ wired = _build_wired_clients(options.wired_client_count, state, core_switch, start_index=0)
116
+ wireless = _build_wireless_clients(
117
+ options.wireless_client_count, state, aps, start_index=len(wired)
118
+ )
119
+ clients.extend(wired)
120
+ clients.extend(wireless)
117
121
  return clients
118
122
 
119
123
 
@@ -125,39 +129,62 @@ def _build_networks() -> list[dict[str, Any]]:
125
129
 
126
130
 
127
131
  def _build_wired_clients(
128
- count: int, state: _MockState, core_switch: dict[str, Any]
132
+ count: int, state: _MockState, core_switch: dict[str, Any], start_index: int = 0
129
133
  ) -> list[dict[str, Any]]:
130
134
  clients = []
131
- for _ in range(max(0, count)):
135
+ for i in range(max(0, count)):
132
136
  port_idx = _next_core_port(state)
133
137
  _add_port(core_switch, port_idx, poe_enabled=False, rng=state.rng)
134
- name = _unique_client_name(state)
138
+ name = _unique_client_name(state, client_index=start_index + i)
139
+ # Assign VLANs to wired clients based on index
140
+ vlan_options = [1, 10, 20, 30, 100]
141
+ client_vlan = vlan_options[port_idx % len(vlan_options)]
135
142
  clients.append(
136
143
  {
137
144
  "name": name,
138
145
  "is_wired": True,
139
146
  "sw_mac": core_switch["mac"],
140
147
  "sw_port": port_idx,
148
+ "vlan": client_vlan,
141
149
  }
142
150
  )
143
151
  return clients
144
152
 
145
153
 
146
154
  def _build_wireless_clients(
147
- count: int, state: _MockState, aps: list[dict[str, Any]]
155
+ count: int, state: _MockState, aps: list[dict[str, Any]], start_index: int = 0
148
156
  ) -> list[dict[str, Any]]:
149
157
  if not aps:
150
158
  return []
151
159
  clients = []
160
+ # Wireless VLANs - typically IoT or guest networks
161
+ wireless_vlans = [10, 20]
162
+ # Wireless channels (2.4GHz and 5GHz)
163
+ channels = [1, 6, 11, 36, 44, 149]
152
164
  for idx in range(max(0, count)):
153
165
  ap = aps[idx % len(aps)]
154
- name = _unique_client_name(state)
166
+ name = _unique_client_name(state, client_index=start_index + idx)
167
+ client_vlan = wireless_vlans[idx % len(wireless_vlans)]
168
+ channel = channels[idx % len(channels)]
169
+ # Generate realistic signal quality metrics
170
+ signal = state.rng.randint(-80, -40)
171
+ noise = state.rng.randint(-100, -90)
172
+ tx_rate = state.rng.choice([72, 144, 288, 433, 866, 1200])
173
+ rx_rate = state.rng.choice([72, 144, 288, 433, 866, 1200])
174
+ satisfaction = state.rng.randint(70, 100)
155
175
  clients.append(
156
176
  {
157
177
  "name": name,
158
178
  "is_wired": False,
159
179
  "ap_mac": ap["mac"],
160
180
  "ap_port": 1,
181
+ "vlan": client_vlan,
182
+ "channel": channel,
183
+ "signal": signal,
184
+ "noise": noise,
185
+ "tx_rate": tx_rate,
186
+ "rx_rate": rx_rate,
187
+ "satisfaction": satisfaction,
161
188
  }
162
189
  )
163
190
  return clients
@@ -192,7 +219,7 @@ def _pick_version(state: _MockState, dev_type: str) -> str:
192
219
  def _link_gateway_to_switch(
193
220
  state: _MockState, gateway: dict[str, Any], core_switch: dict[str, Any]
194
221
  ) -> None:
195
- _add_port(core_switch, 1, poe_enabled=False, rng=state.rng)
222
+ _add_port(core_switch, 1, poe_enabled=False, rng=state.rng, is_trunk=True)
196
223
  _add_lldp_link(
197
224
  gateway,
198
225
  core_switch,
@@ -259,7 +286,15 @@ def _add_port(
259
286
  *,
260
287
  poe_enabled: bool,
261
288
  rng: random.Random,
289
+ is_trunk: bool = False,
262
290
  ) -> None:
291
+ # Assign VLAN based on port index for variety
292
+ vlan_options = [1, 10, 20, 30, 100]
293
+ native_vlan = vlan_options[port_idx % len(vlan_options)]
294
+ tagged_vlans: list[int] = []
295
+ if is_trunk:
296
+ # Trunk ports carry multiple VLANs
297
+ tagged_vlans = [v for v in vlan_options if v != native_vlan]
263
298
  device["port_table"].append(
264
299
  {
265
300
  "port_idx": port_idx,
@@ -273,6 +308,8 @@ def _add_port(
273
308
  "poe_good": poe_enabled,
274
309
  "poe_voltage": round(rng.uniform(44.0, 52.0), 1) if poe_enabled else 0.0,
275
310
  "poe_current": round(rng.uniform(0.05, 0.12), 3) if poe_enabled else 0.0,
311
+ "native_vlan": native_vlan,
312
+ "tagged_vlans": tagged_vlans,
276
313
  }
277
314
  )
278
315
 
@@ -283,41 +320,91 @@ def _next_core_port(state: _MockState) -> int:
283
320
  return port_idx
284
321
 
285
322
 
286
- def _unique_name(state: _MockState, prefix: str) -> str:
323
+ def _unique_name(state: _MockState, prefix: str, max_attempts: int = 100) -> str:
287
324
  name = prefix
288
- while name in state.used_names:
289
- name = f"{prefix} {state.rng.randint(2, 9)}"
325
+ for _ in range(max_attempts):
326
+ if name not in state.used_names:
327
+ state.used_names.add(name)
328
+ return name
329
+ name = f"{prefix} {state.rng.randint(2, 99)}"
330
+ # Fallback with random suffix
331
+ name = f"{prefix}_{state.rng.randint(1000, 9999)}"
290
332
  state.used_names.add(name)
291
333
  return name
292
334
 
293
335
 
294
- def _unique_client_name(state: _MockState) -> str:
295
- name = state.fake.first_name()
296
- while name in state.used_names:
336
+ # Device name templates for testing device categorization
337
+ # Ordered by category to ensure variety in mock data
338
+ _DEVICE_NAME_TEMPLATES = [
339
+ "Living Room TV", # tv
340
+ "Sonos One", # speaker
341
+ "Ring Doorbell", # camera
342
+ "HP LaserJet", # printer
343
+ "Synology NAS", # nas
344
+ "PlayStation 5", # game_console
345
+ "iPhone", # phone
346
+ "Hue Bridge", # iot
347
+ "Samsung Smart TV", # tv
348
+ "HomePod Mini", # speaker
349
+ "Front Door Camera", # camera
350
+ "Brother Printer", # printer
351
+ "Xbox Series X", # game_console
352
+ "Nest Thermostat", # iot
353
+ "Echo Dot", # speaker
354
+ ]
355
+
356
+
357
+ def _unique_client_name(state: _MockState, client_index: int = 0, max_attempts: int = 100) -> str:
358
+ # Use device names for first N clients to ensure variety in mock output
359
+ # This guarantees different device types appear in smoketests
360
+ if client_index < len(_DEVICE_NAME_TEMPLATES):
361
+ name = _DEVICE_NAME_TEMPLATES[client_index]
362
+ if name not in state.used_names:
363
+ state.used_names.add(name)
364
+ return name
365
+ # Fall back to person names for additional clients
366
+ for _ in range(max_attempts):
297
367
  name = state.fake.first_name()
368
+ if name not in state.used_names:
369
+ state.used_names.add(name)
370
+ return name
371
+ # Fallback with random suffix
372
+ name = f"Client_{state.rng.randint(1000, 9999)}"
298
373
  state.used_names.add(name)
299
374
  return name
300
375
 
301
376
 
302
- def _unique_room(state: _MockState) -> str:
303
- room = state.fake.word().title()
304
- while room in state.used_rooms:
377
+ def _unique_room(state: _MockState, max_attempts: int = 100) -> str:
378
+ for _ in range(max_attempts):
305
379
  room = state.fake.word().title()
380
+ if room not in state.used_rooms:
381
+ state.used_rooms.add(room)
382
+ return room
383
+ # Fallback with random suffix
384
+ room = f"Room_{state.rng.randint(1000, 9999)}"
306
385
  state.used_rooms.add(room)
307
386
  return room
308
387
 
309
388
 
310
- def _unique_mac(state: _MockState) -> str:
311
- mac = state.fake.mac_address()
312
- while mac in state.used_macs:
389
+ def _unique_mac(state: _MockState, max_attempts: int = 1000) -> str:
390
+ for _ in range(max_attempts):
313
391
  mac = state.fake.mac_address()
392
+ if mac not in state.used_macs:
393
+ state.used_macs.add(mac)
394
+ return mac
395
+ # Fallback - extremely unlikely to reach here
396
+ mac = f"99:{state.rng.randint(10, 99)}:{state.rng.randint(10, 99)}:{state.rng.randint(10, 99)}:{state.rng.randint(10, 99)}:{state.rng.randint(10, 99)}"
314
397
  state.used_macs.add(mac)
315
398
  return mac
316
399
 
317
400
 
318
- def _unique_ip(state: _MockState) -> str:
319
- ip = state.fake.ipv4_private()
320
- while ip in state.used_ips:
401
+ def _unique_ip(state: _MockState, max_attempts: int = 1000) -> str:
402
+ for _ in range(max_attempts):
321
403
  ip = state.fake.ipv4_private()
404
+ if ip not in state.used_ips:
405
+ state.used_ips.add(ip)
406
+ return ip
407
+ # Fallback - extremely unlikely to reach here
408
+ ip = f"10.{state.rng.randint(0, 255)}.{state.rng.randint(0, 255)}.{state.rng.randint(1, 254)}"
322
409
  state.used_ips.add(ip)
323
410
  return ip
@@ -0,0 +1,294 @@
1
+ """Serialization helpers for topology data structures.
2
+
3
+ Provides to_dict/from_dict conversions for persistence and transmission.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import fields, is_dataclass
9
+ from typing import Any, TypeVar
10
+
11
+ from .connection import ConnectionInfo
12
+ from .lldp import LLDPEntry
13
+ from .topology import Device, Edge, PortInfo, UplinkInfo, WanInfo, WanInterface
14
+
15
+ T = TypeVar("T")
16
+
17
+
18
+ def _serialize_value(value: Any) -> Any:
19
+ """Recursively serialize a value to JSON-compatible form."""
20
+ if value is None:
21
+ return None
22
+ if isinstance(value, str | int | float | bool):
23
+ return value
24
+ if isinstance(value, tuple):
25
+ return list(_serialize_value(v) for v in value)
26
+ if isinstance(value, list):
27
+ return [_serialize_value(v) for v in value]
28
+ if isinstance(value, dict):
29
+ return {k: _serialize_value(v) for k, v in value.items()}
30
+ if is_dataclass(value) and not isinstance(value, type):
31
+ return _dataclass_to_dict(value)
32
+ return str(value)
33
+
34
+
35
+ def _dataclass_to_dict(obj: Any) -> dict[str, Any]:
36
+ """Convert a dataclass instance to a dictionary."""
37
+ result: dict[str, Any] = {}
38
+ for field in fields(obj):
39
+ value = getattr(obj, field.name)
40
+ result[field.name] = _serialize_value(value)
41
+ return result
42
+
43
+
44
+ # --- PortInfo ---
45
+
46
+
47
+ def port_info_to_dict(port: PortInfo) -> dict[str, Any]:
48
+ """Serialize a PortInfo to a dictionary."""
49
+ return _dataclass_to_dict(port)
50
+
51
+
52
+ def port_info_from_dict(data: dict[str, Any]) -> PortInfo:
53
+ """Deserialize a PortInfo from a dictionary."""
54
+ return PortInfo(
55
+ port_idx=data.get("port_idx"),
56
+ name=data.get("name"),
57
+ ifname=data.get("ifname"),
58
+ speed=data.get("speed"),
59
+ aggregation_group=data.get("aggregation_group"),
60
+ port_poe=data.get("port_poe", False),
61
+ poe_enable=data.get("poe_enable", False),
62
+ poe_good=data.get("poe_good", False),
63
+ poe_power=data.get("poe_power"),
64
+ native_vlan=data.get("native_vlan"),
65
+ tagged_vlans=tuple(data.get("tagged_vlans", [])),
66
+ wan_networkconf_id=data.get("wan_networkconf_id"),
67
+ )
68
+
69
+
70
+ # --- UplinkInfo ---
71
+
72
+
73
+ def uplink_info_to_dict(uplink: UplinkInfo) -> dict[str, Any]:
74
+ """Serialize an UplinkInfo to a dictionary."""
75
+ return _dataclass_to_dict(uplink)
76
+
77
+
78
+ def uplink_info_from_dict(data: dict[str, Any]) -> UplinkInfo:
79
+ """Deserialize an UplinkInfo from a dictionary."""
80
+ return UplinkInfo(
81
+ mac=data.get("mac"),
82
+ name=data.get("name"),
83
+ port=data.get("port"),
84
+ )
85
+
86
+
87
+ # --- LLDPEntry ---
88
+
89
+
90
+ def lldp_entry_to_dict(entry: LLDPEntry) -> dict[str, Any]:
91
+ """Serialize an LLDPEntry to a dictionary."""
92
+ return _dataclass_to_dict(entry)
93
+
94
+
95
+ def lldp_entry_from_dict(data: dict[str, Any]) -> LLDPEntry:
96
+ """Deserialize an LLDPEntry from a dictionary."""
97
+ return LLDPEntry(
98
+ chassis_id=data.get("chassis_id", ""),
99
+ port_id=data.get("port_id", ""),
100
+ port_desc=data.get("port_desc"),
101
+ local_port_name=data.get("local_port_name"),
102
+ local_port_idx=data.get("local_port_idx"),
103
+ )
104
+
105
+
106
+ # --- WanInterface ---
107
+
108
+
109
+ def wan_interface_to_dict(wan: WanInterface) -> dict[str, Any]:
110
+ """Serialize a WanInterface to a dictionary."""
111
+ return _dataclass_to_dict(wan)
112
+
113
+
114
+ def wan_interface_from_dict(data: dict[str, Any]) -> WanInterface:
115
+ """Deserialize a WanInterface from a dictionary."""
116
+ return WanInterface(
117
+ port_idx=data.get("port_idx", 0),
118
+ link_speed=data.get("link_speed"),
119
+ ip_address=data.get("ip_address"),
120
+ enabled=data.get("enabled", False),
121
+ label=data.get("label"),
122
+ isp_speed=data.get("isp_speed"),
123
+ )
124
+
125
+
126
+ # --- WanInfo ---
127
+
128
+
129
+ def wan_info_to_dict(wan_info: WanInfo) -> dict[str, Any]:
130
+ """Serialize a WanInfo to a dictionary."""
131
+ result: dict[str, Any] = {}
132
+ if wan_info.wan1:
133
+ result["wan1"] = wan_interface_to_dict(wan_info.wan1)
134
+ else:
135
+ result["wan1"] = None
136
+ if wan_info.wan2:
137
+ result["wan2"] = wan_interface_to_dict(wan_info.wan2)
138
+ else:
139
+ result["wan2"] = None
140
+ return result
141
+
142
+
143
+ def wan_info_from_dict(data: dict[str, Any]) -> WanInfo:
144
+ """Deserialize a WanInfo from a dictionary."""
145
+ wan1 = None
146
+ wan2 = None
147
+ if data.get("wan1"):
148
+ wan1 = wan_interface_from_dict(data["wan1"])
149
+ if data.get("wan2"):
150
+ wan2 = wan_interface_from_dict(data["wan2"])
151
+ return WanInfo(wan1=wan1, wan2=wan2)
152
+
153
+
154
+ # --- Device ---
155
+
156
+
157
+ def device_to_dict(device: Device) -> dict[str, Any]:
158
+ """Serialize a Device to a dictionary."""
159
+ return {
160
+ "name": device.name,
161
+ "model_name": device.model_name,
162
+ "model": device.model,
163
+ "mac": device.mac,
164
+ "ip": device.ip,
165
+ "type": device.type,
166
+ "lldp_info": [lldp_entry_to_dict(e) for e in device.lldp_info],
167
+ "port_table": [port_info_to_dict(p) for p in device.port_table],
168
+ "poe_ports": {str(k): v for k, v in device.poe_ports.items()},
169
+ "uplink": uplink_info_to_dict(device.uplink) if device.uplink else None,
170
+ "last_uplink": uplink_info_to_dict(device.last_uplink) if device.last_uplink else None,
171
+ "version": device.version,
172
+ }
173
+
174
+
175
+ def device_from_dict(data: dict[str, Any]) -> Device:
176
+ """Deserialize a Device from a dictionary."""
177
+ lldp_info = [lldp_entry_from_dict(e) for e in data.get("lldp_info", [])]
178
+ port_table = [port_info_from_dict(p) for p in data.get("port_table", [])]
179
+ poe_ports = {int(k): v for k, v in data.get("poe_ports", {}).items()}
180
+ uplink = uplink_info_from_dict(data["uplink"]) if data.get("uplink") else None
181
+ last_uplink = uplink_info_from_dict(data["last_uplink"]) if data.get("last_uplink") else None
182
+ return Device(
183
+ name=data.get("name", ""),
184
+ model_name=data.get("model_name", ""),
185
+ model=data.get("model", ""),
186
+ mac=data.get("mac", ""),
187
+ ip=data.get("ip", ""),
188
+ type=data.get("type", ""),
189
+ lldp_info=lldp_info,
190
+ port_table=port_table,
191
+ poe_ports=poe_ports,
192
+ uplink=uplink,
193
+ last_uplink=last_uplink,
194
+ version=data.get("version", ""),
195
+ )
196
+
197
+
198
+ # --- ConnectionInfo ---
199
+
200
+
201
+ def connection_info_to_dict(conn: ConnectionInfo) -> dict[str, Any]:
202
+ """Serialize a ConnectionInfo to a dictionary."""
203
+ return _dataclass_to_dict(conn)
204
+
205
+
206
+ def connection_info_from_dict(data: dict[str, Any]) -> ConnectionInfo:
207
+ """Deserialize a ConnectionInfo from a dictionary."""
208
+ return ConnectionInfo(
209
+ signal_dbm=data.get("signal_dbm"),
210
+ noise_dbm=data.get("noise_dbm"),
211
+ tx_rate_mbps=data.get("tx_rate_mbps"),
212
+ rx_rate_mbps=data.get("rx_rate_mbps"),
213
+ satisfaction=data.get("satisfaction"),
214
+ quality=data.get("quality"),
215
+ )
216
+
217
+
218
+ # --- Edge ---
219
+
220
+
221
+ def edge_to_dict(edge: Edge) -> dict[str, Any]:
222
+ """Serialize an Edge to a dictionary."""
223
+ return {
224
+ "left": edge.left,
225
+ "right": edge.right,
226
+ "label": edge.label,
227
+ "poe": edge.poe,
228
+ "wireless": edge.wireless,
229
+ "speed": edge.speed,
230
+ "channel": edge.channel,
231
+ "vlans": list(edge.vlans),
232
+ "active_vlans": list(edge.active_vlans),
233
+ "is_trunk": edge.is_trunk,
234
+ "connection": connection_info_to_dict(edge.connection) if edge.connection else None,
235
+ }
236
+
237
+
238
+ def edge_from_dict(data: dict[str, Any]) -> Edge:
239
+ """Deserialize an Edge from a dictionary."""
240
+ connection = None
241
+ if data.get("connection"):
242
+ connection = connection_info_from_dict(data["connection"])
243
+ return Edge(
244
+ left=data.get("left", ""),
245
+ right=data.get("right", ""),
246
+ label=data.get("label"),
247
+ poe=data.get("poe", False),
248
+ wireless=data.get("wireless", False),
249
+ speed=data.get("speed"),
250
+ channel=data.get("channel"),
251
+ vlans=tuple(data.get("vlans", [])),
252
+ active_vlans=tuple(data.get("active_vlans", [])),
253
+ is_trunk=data.get("is_trunk", False),
254
+ connection=connection,
255
+ )
256
+
257
+
258
+ # --- Client (dict-based, since clients come from API as dicts) ---
259
+
260
+
261
+ def client_to_dict(client: dict[str, Any]) -> dict[str, Any]:
262
+ """Serialize a client dict, keeping only relevant fields."""
263
+ relevant_keys = {
264
+ "mac",
265
+ "name",
266
+ "hostname",
267
+ "ip",
268
+ "vlan",
269
+ "vlan_id",
270
+ "is_wired",
271
+ "is_unifi",
272
+ "is_unifi_device",
273
+ "ap_mac",
274
+ "sw_mac",
275
+ "uplink_mac",
276
+ "uplink_device_mac",
277
+ "sw_port",
278
+ "uplink_remote_port",
279
+ "channel",
280
+ "signal",
281
+ "noise",
282
+ "tx_rate",
283
+ "rx_rate",
284
+ "satisfaction",
285
+ "oui",
286
+ "vendor",
287
+ "unifi_device_info_from_ucore",
288
+ }
289
+ return {k: v for k, v in client.items() if k in relevant_keys}
290
+
291
+
292
+ def client_from_dict(data: dict[str, Any]) -> dict[str, Any]:
293
+ """Deserialize a client dict (identity for now, but validates structure)."""
294
+ return dict(data)