unifi-network-maps 1.3.1__py3-none-any.whl → 1.4.1__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,299 @@
1
+ """Generate mock UniFi data using Faker."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import random
7
+ from dataclasses import dataclass, field
8
+ from typing import Any
9
+
10
+ from faker import Faker
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class MockOptions:
15
+ seed: int = 1337
16
+ switch_count: int = 1
17
+ ap_count: int = 2
18
+ wired_client_count: int = 2
19
+ wireless_client_count: int = 2
20
+
21
+
22
+ @dataclass
23
+ class _MockState:
24
+ fake: Faker
25
+ rng: random.Random
26
+ used_macs: set[str] = field(default_factory=set)
27
+ used_ips: set[str] = field(default_factory=set)
28
+ used_names: set[str] = field(default_factory=set)
29
+ used_rooms: set[str] = field(default_factory=set)
30
+ core_port_next: int = 2
31
+
32
+
33
+ def generate_mock_payload(options: MockOptions) -> dict[str, list[dict[str, Any]]]:
34
+ state = _build_state(options.seed)
35
+ devices, core_switch, aps = _build_devices(options, state)
36
+ clients = _build_clients(options, state, core_switch, aps)
37
+ return {"devices": devices, "clients": clients}
38
+
39
+
40
+ def mock_payload_json(options: MockOptions) -> str:
41
+ payload = generate_mock_payload(options)
42
+ return json.dumps(payload, indent=2, sort_keys=True)
43
+
44
+
45
+ def _build_state(seed: int) -> _MockState:
46
+ fake = Faker("en_US")
47
+ fake.seed_instance(seed)
48
+ rng = random.Random(seed)
49
+ return _MockState(fake=fake, rng=rng)
50
+
51
+
52
+ def _build_devices(
53
+ options: MockOptions, state: _MockState
54
+ ) -> tuple[list[dict[str, Any]], dict[str, Any], list[dict[str, Any]]]:
55
+ gateway = _build_gateway(state)
56
+ core_switch = _build_core_switch(state)
57
+ _link_gateway_to_switch(state, gateway, core_switch)
58
+ access_switches = _build_access_switches(options.switch_count - 1, state, core_switch)
59
+ aps = _build_aps(options.ap_count, state, core_switch)
60
+ devices = [gateway, core_switch] + access_switches + aps
61
+ return devices, core_switch, aps
62
+
63
+
64
+ def _build_gateway(state: _MockState) -> dict[str, Any]:
65
+ device = _device_base(state, "Cloud Gateway", "udm", "UniFi Dream Machine Pro", "UDM-Pro")
66
+ _add_port(device, 9, poe_enabled=False, rng=state.rng)
67
+ return device
68
+
69
+
70
+ def _build_core_switch(state: _MockState) -> dict[str, Any]:
71
+ return _device_base(state, "Core Switch", "usw", "UniFi Switch 24 PoE", "USW-24-PoE")
72
+
73
+
74
+ def _build_access_switches(
75
+ count: int, state: _MockState, core_switch: dict[str, Any]
76
+ ) -> list[dict[str, Any]]:
77
+ switches = []
78
+ for _ in range(max(0, count)):
79
+ room = _unique_room(state)
80
+ name = _unique_name(state, f"Switch {room}")
81
+ device = _device_base(state, name, "usw", "UniFi Switch Lite 8 PoE", "USW-Lite-8-PoE")
82
+ _link_core_device(state, core_switch, device, poe_enabled=False)
83
+ switches.append(device)
84
+ return switches
85
+
86
+
87
+ def _build_aps(count: int, state: _MockState, core_switch: dict[str, Any]) -> list[dict[str, Any]]:
88
+ aps = []
89
+ for _ in range(max(0, count)):
90
+ room = _unique_room(state)
91
+ name = _unique_name(state, f"AP {room}")
92
+ device = _device_base(state, name, "uap", "UniFi AP 6 Lite", "U6-Lite")
93
+ _add_port(device, 1, poe_enabled=True, rng=state.rng)
94
+ _link_core_device(state, core_switch, device, poe_enabled=True)
95
+ aps.append(device)
96
+ return aps
97
+
98
+
99
+ def _build_clients(
100
+ options: MockOptions,
101
+ state: _MockState,
102
+ core_switch: dict[str, Any],
103
+ aps: list[dict[str, Any]],
104
+ ) -> list[dict[str, Any]]:
105
+ clients = []
106
+ clients.extend(_build_wired_clients(options.wired_client_count, state, core_switch))
107
+ clients.extend(_build_wireless_clients(options.wireless_client_count, state, aps))
108
+ return clients
109
+
110
+
111
+ def _build_wired_clients(
112
+ count: int, state: _MockState, core_switch: dict[str, Any]
113
+ ) -> list[dict[str, Any]]:
114
+ clients = []
115
+ for _ in range(max(0, count)):
116
+ port_idx = _next_core_port(state)
117
+ _add_port(core_switch, port_idx, poe_enabled=False, rng=state.rng)
118
+ name = _unique_client_name(state)
119
+ clients.append(
120
+ {
121
+ "name": name,
122
+ "is_wired": True,
123
+ "sw_mac": core_switch["mac"],
124
+ "sw_port": port_idx,
125
+ }
126
+ )
127
+ return clients
128
+
129
+
130
+ def _build_wireless_clients(
131
+ count: int, state: _MockState, aps: list[dict[str, Any]]
132
+ ) -> list[dict[str, Any]]:
133
+ if not aps:
134
+ return []
135
+ clients = []
136
+ for idx in range(max(0, count)):
137
+ ap = aps[idx % len(aps)]
138
+ name = _unique_client_name(state)
139
+ clients.append(
140
+ {
141
+ "name": name,
142
+ "is_wired": False,
143
+ "ap_mac": ap["mac"],
144
+ "ap_port": 1,
145
+ }
146
+ )
147
+ return clients
148
+
149
+
150
+ def _device_base(
151
+ state: _MockState, name: str, dev_type: str, model_name: str, model: str
152
+ ) -> dict[str, Any]:
153
+ version = _pick_version(state, dev_type)
154
+ return {
155
+ "name": name,
156
+ "model_name": model_name,
157
+ "model": model,
158
+ "mac": _unique_mac(state),
159
+ "ip": _unique_ip(state),
160
+ "type": dev_type,
161
+ "version": version,
162
+ "port_table": [],
163
+ "lldp_info": [],
164
+ }
165
+
166
+
167
+ def _pick_version(state: _MockState, dev_type: str) -> str:
168
+ versions = {
169
+ "udm": ["3.1.0", "3.1.1"],
170
+ "usw": ["7.0.0", "7.1.2"],
171
+ "uap": ["6.6.55", "6.7.10"],
172
+ }
173
+ return state.rng.choice(versions.get(dev_type, ["1.0.0"]))
174
+
175
+
176
+ def _link_gateway_to_switch(
177
+ state: _MockState, gateway: dict[str, Any], core_switch: dict[str, Any]
178
+ ) -> None:
179
+ _add_port(core_switch, 1, poe_enabled=False, rng=state.rng)
180
+ _add_lldp_link(
181
+ gateway,
182
+ core_switch,
183
+ local_port=9,
184
+ remote_port=1,
185
+ remote_name=core_switch["name"],
186
+ )
187
+ _add_lldp_link(
188
+ core_switch,
189
+ gateway,
190
+ local_port=1,
191
+ remote_port=9,
192
+ remote_name=gateway["name"],
193
+ )
194
+
195
+
196
+ def _link_core_device(
197
+ state: _MockState,
198
+ core_switch: dict[str, Any],
199
+ device: dict[str, Any],
200
+ *,
201
+ poe_enabled: bool,
202
+ ) -> None:
203
+ port_idx = _next_core_port(state)
204
+ _add_port(core_switch, port_idx, poe_enabled=poe_enabled, rng=state.rng)
205
+ _add_lldp_link(
206
+ core_switch,
207
+ device,
208
+ local_port=port_idx,
209
+ remote_port=1,
210
+ remote_name=device["name"],
211
+ )
212
+ _add_lldp_link(
213
+ device,
214
+ core_switch,
215
+ local_port=1,
216
+ remote_port=port_idx,
217
+ remote_name=core_switch["name"],
218
+ )
219
+
220
+
221
+ def _add_lldp_link(
222
+ source: dict[str, Any],
223
+ dest: dict[str, Any],
224
+ *,
225
+ local_port: int,
226
+ remote_port: int,
227
+ remote_name: str,
228
+ ) -> None:
229
+ source["lldp_info"].append(
230
+ {
231
+ "chassis_id": dest["mac"],
232
+ "port_id": f"Port {remote_port}",
233
+ "port_desc": remote_name,
234
+ "local_port_name": f"Port {local_port}",
235
+ "local_port_idx": local_port,
236
+ }
237
+ )
238
+
239
+
240
+ def _add_port(
241
+ device: dict[str, Any], port_idx: int, *, poe_enabled: bool, rng: random.Random
242
+ ) -> None:
243
+ entry = {
244
+ "port_idx": port_idx,
245
+ "name": f"Port {port_idx}",
246
+ "ifname": f"eth{max(0, port_idx - 1)}",
247
+ "poe_enable": poe_enabled,
248
+ "port_poe": poe_enabled,
249
+ }
250
+ if poe_enabled:
251
+ entry["poe_power"] = round(rng.uniform(4.5, 7.5), 1)
252
+ device["port_table"].append(entry)
253
+
254
+
255
+ def _unique_mac(state: _MockState) -> str:
256
+ return _unique_value(state, state.fake.mac_address, state.used_macs)
257
+
258
+
259
+ def _unique_ip(state: _MockState) -> str:
260
+ return _unique_value(state, state.fake.ipv4_private, state.used_ips)
261
+
262
+
263
+ def _unique_room(state: _MockState) -> str:
264
+ def _room() -> str:
265
+ return state.fake.word().title()
266
+
267
+ return _unique_value(state, _room, state.used_rooms)
268
+
269
+
270
+ def _unique_name(state: _MockState, candidate: str) -> str:
271
+ if candidate not in state.used_names:
272
+ state.used_names.add(candidate)
273
+ return candidate
274
+ suffix = state.rng.randint(2, 99)
275
+ value = f"{candidate} {suffix}"
276
+ state.used_names.add(value)
277
+ return value
278
+
279
+
280
+ def _unique_client_name(state: _MockState) -> str:
281
+ def _name() -> str:
282
+ return state.fake.hostname()
283
+
284
+ return _unique_value(state, _name, state.used_names)
285
+
286
+
287
+ def _unique_value(state: _MockState, generator, used: set[str]) -> str:
288
+ for _ in range(100):
289
+ value = str(generator())
290
+ if value not in used:
291
+ used.add(value)
292
+ return value
293
+ raise ValueError("Failed to generate a unique value")
294
+
295
+
296
+ def _next_core_port(state: _MockState) -> int:
297
+ port_idx = state.core_port_next
298
+ state.core_port_next += 1
299
+ return port_idx
@@ -17,18 +17,32 @@ class LLDPEntry:
17
17
 
18
18
 
19
19
  def coerce_lldp(entry: object) -> LLDPEntry:
20
- chassis_id = getattr(entry, "chassis_id", None) or getattr(entry, "chassisId", None)
21
- port_id = getattr(entry, "port_id", None) or getattr(entry, "portId", None)
22
- port_desc = (
23
- getattr(entry, "port_desc", None)
24
- or getattr(entry, "portDesc", None)
25
- or getattr(entry, "port_descr", None)
26
- or getattr(entry, "portDescr", None)
27
- )
28
- local_port_name = getattr(entry, "local_port_name", None) or getattr(
29
- entry, "localPortName", None
30
- )
31
- local_port_idx = getattr(entry, "local_port_idx", None) or getattr(entry, "localPortIdx", None)
20
+ if isinstance(entry, dict):
21
+ chassis_id = entry.get("chassis_id") or entry.get("chassisId")
22
+ port_id = entry.get("port_id") or entry.get("portId")
23
+ port_desc = (
24
+ entry.get("port_desc")
25
+ or entry.get("portDesc")
26
+ or entry.get("port_descr")
27
+ or entry.get("portDescr")
28
+ )
29
+ local_port_name = entry.get("local_port_name") or entry.get("localPortName")
30
+ local_port_idx = entry.get("local_port_idx") or entry.get("localPortIdx")
31
+ else:
32
+ chassis_id = getattr(entry, "chassis_id", None) or getattr(entry, "chassisId", None)
33
+ port_id = getattr(entry, "port_id", None) or getattr(entry, "portId", None)
34
+ port_desc = (
35
+ getattr(entry, "port_desc", None)
36
+ or getattr(entry, "portDesc", None)
37
+ or getattr(entry, "port_descr", None)
38
+ or getattr(entry, "portDescr", None)
39
+ )
40
+ local_port_name = getattr(entry, "local_port_name", None) or getattr(
41
+ entry, "localPortName", None
42
+ )
43
+ local_port_idx = getattr(entry, "local_port_idx", None) or getattr(
44
+ entry, "localPortIdx", None
45
+ )
32
46
  if not chassis_id or not port_id:
33
47
  raise ValueError("LLDP entry missing chassis_id or port_id")
34
48
  return LLDPEntry(
@@ -6,7 +6,7 @@ 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, TypeAlias
9
+ from typing import Protocol
10
10
 
11
11
  from .labels import compose_port_label, order_edge_names
12
12
  from .lldp import LLDPEntry, coerce_lldp, local_port_label
@@ -19,6 +19,7 @@ logger = logging.getLogger(__name__)
19
19
  class Device:
20
20
  name: str
21
21
  model_name: str
22
+ model: str
22
23
  mac: str
23
24
  ip: str
24
25
  type: str
@@ -74,17 +75,22 @@ class PortInfo:
74
75
  port_idx: int | None
75
76
  name: str | None
76
77
  ifname: str | None
78
+ speed: int | None
79
+ aggregation_group: str | None
77
80
  port_poe: bool
78
81
  poe_enable: bool
79
82
  poe_good: bool
80
83
  poe_power: float | None
81
84
 
82
85
 
83
- PortMap: TypeAlias = dict[tuple[str, str], str]
84
- PoeMap: TypeAlias = dict[tuple[str, str], bool]
86
+ type PortMap = dict[tuple[str, str], str]
87
+ type PoeMap = dict[tuple[str, str], bool]
88
+ type ClientPortMap = dict[str, list[tuple[int, str]]]
85
89
 
86
90
 
87
91
  def _get_attr(obj: object, name: str) -> object | None:
92
+ if isinstance(obj, dict):
93
+ return obj.get(name)
88
94
  return getattr(obj, name, None)
89
95
 
90
96
 
@@ -123,6 +129,44 @@ def _as_int(value: object | None) -> int | None:
123
129
  return None
124
130
 
125
131
 
132
+ def _as_group_id(value: object | None) -> str | None:
133
+ if value is None:
134
+ return None
135
+ if isinstance(value, bool):
136
+ return None
137
+ if isinstance(value, int):
138
+ return str(value)
139
+ if isinstance(value, str):
140
+ return value.strip() or None
141
+ return None
142
+
143
+
144
+ def _aggregation_group(port_entry: object) -> object | None:
145
+ keys = (
146
+ "aggregation_group",
147
+ "aggregation_id",
148
+ "aggregate_id",
149
+ "agg_id",
150
+ "lag_id",
151
+ "lag_group",
152
+ "link_aggregation_group",
153
+ "link_aggregation_id",
154
+ "aggregate",
155
+ "aggregated_by",
156
+ )
157
+ if isinstance(port_entry, dict):
158
+ for key in keys:
159
+ value = port_entry.get(key)
160
+ if value not in (None, "", False):
161
+ return value
162
+ return None
163
+ for key in keys:
164
+ value = _get_attr(port_entry, key)
165
+ if value not in (None, "", False):
166
+ return value
167
+ return None
168
+
169
+
126
170
  def _resolve_port_idx_from_lldp(lldp_entry: LLDPEntry, port_table: list[PortInfo]) -> int | None:
127
171
  if lldp_entry.local_port_idx is not None:
128
172
  return lldp_entry.local_port_idx
@@ -153,6 +197,8 @@ def _port_info_from_entry(port_entry: object) -> PortInfo:
153
197
  port_idx = port_entry.get("port_idx") or port_entry.get("portIdx")
154
198
  name = port_entry.get("name")
155
199
  ifname = port_entry.get("ifname")
200
+ speed = port_entry.get("speed")
201
+ aggregation_group = _aggregation_group(port_entry)
156
202
  port_poe = _as_bool(port_entry.get("port_poe"))
157
203
  poe_enable = _as_bool(port_entry.get("poe_enable"))
158
204
  poe_good = _as_bool(port_entry.get("poe_good"))
@@ -161,6 +207,8 @@ def _port_info_from_entry(port_entry: object) -> PortInfo:
161
207
  port_idx = _get_attr(port_entry, "port_idx") or _get_attr(port_entry, "portIdx")
162
208
  name = _get_attr(port_entry, "name")
163
209
  ifname = _get_attr(port_entry, "ifname")
210
+ speed = _get_attr(port_entry, "speed")
211
+ aggregation_group = _aggregation_group(port_entry)
164
212
  port_poe = _as_bool(_get_attr(port_entry, "port_poe"))
165
213
  poe_enable = _as_bool(_get_attr(port_entry, "poe_enable"))
166
214
  poe_good = _as_bool(_get_attr(port_entry, "poe_good"))
@@ -169,6 +217,8 @@ def _port_info_from_entry(port_entry: object) -> PortInfo:
169
217
  port_idx=_as_int(port_idx),
170
218
  name=str(name) if isinstance(name, str) and name.strip() else None,
171
219
  ifname=str(ifname) if isinstance(ifname, str) and ifname.strip() else None,
220
+ speed=_as_int(speed),
221
+ aggregation_group=_as_group_id(aggregation_group),
172
222
  port_poe=port_poe,
173
223
  poe_enable=poe_enable,
174
224
  poe_good=poe_good,
@@ -243,6 +293,7 @@ def _uplink_info(device: DeviceLike) -> tuple[UplinkInfo | None, UplinkInfo | No
243
293
  def coerce_device(device: DeviceLike) -> Device:
244
294
  name = _get_attr(device, "name")
245
295
  model_name = _get_attr(device, "model_name") or _get_attr(device, "model")
296
+ model = _get_attr(device, "model")
246
297
  mac = _get_attr(device, "mac")
247
298
  ip = _get_attr(device, "ip") or _get_attr(device, "ip_address")
248
299
  dev_type = _get_attr(device, "type") or _get_attr(device, "device_type")
@@ -268,6 +319,7 @@ def coerce_device(device: DeviceLike) -> Device:
268
319
  return Device(
269
320
  name=str(name),
270
321
  model_name=str(model_name or ""),
322
+ model=str(model or ""),
271
323
  mac=str(mac),
272
324
  ip=str(ip or ""),
273
325
  type=str(dev_type or ""),
@@ -528,6 +580,62 @@ def build_edges(
528
580
  return edges
529
581
 
530
582
 
583
+ def build_port_map(devices: Iterable[Device], *, only_unifi: bool = True) -> PortMap:
584
+ ordered_devices = sorted(devices, key=lambda item: (item.name.lower(), item.mac.lower()))
585
+ index = build_device_index(ordered_devices)
586
+ device_by_name = {device.name: device for device in ordered_devices}
587
+ raw_links: list[tuple[str, str]] = []
588
+ seen: set[frozenset[str]] = set()
589
+ port_map: PortMap = {}
590
+ poe_map: PoeMap = {}
591
+
592
+ devices_with_lldp_edges = _collect_lldp_links(
593
+ ordered_devices,
594
+ index,
595
+ port_map,
596
+ poe_map,
597
+ raw_links,
598
+ seen,
599
+ only_unifi=only_unifi,
600
+ )
601
+ _collect_uplink_links(
602
+ ordered_devices,
603
+ devices_with_lldp_edges,
604
+ index,
605
+ device_by_name,
606
+ port_map,
607
+ poe_map,
608
+ raw_links,
609
+ seen,
610
+ include_ports=True,
611
+ only_unifi=only_unifi,
612
+ )
613
+ return port_map
614
+
615
+
616
+ def build_client_port_map(
617
+ devices: Iterable[Device],
618
+ clients: Iterable[object],
619
+ *,
620
+ client_mode: str,
621
+ ) -> ClientPortMap:
622
+ device_index = build_device_index(devices)
623
+ port_map: ClientPortMap = {}
624
+ for client in clients:
625
+ if not _client_matches_mode(client, client_mode):
626
+ continue
627
+ name = _client_display_name(client)
628
+ uplink_mac = _client_uplink_mac(client)
629
+ uplink_port = _client_uplink_port(client)
630
+ if not name or not uplink_mac or uplink_port is None:
631
+ continue
632
+ device_name = device_index.get(_normalize_mac(uplink_mac))
633
+ if not device_name:
634
+ continue
635
+ port_map.setdefault(device_name, []).append((uplink_port, name))
636
+ return port_map
637
+
638
+
531
639
  def _collect_lldp_links(
532
640
  devices: list[Device],
533
641
  index: dict[str, str],