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,23 @@
1
+ """Load mock UniFi data from JSON fixtures."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+
9
+ def _as_list(value: object, name: str) -> list[object]:
10
+ if value is None:
11
+ return []
12
+ if isinstance(value, list):
13
+ return value
14
+ raise ValueError(f"Mock data field '{name}' must be a list")
15
+
16
+
17
+ def load_mock_data(path: str) -> tuple[list[object], list[object]]:
18
+ payload = json.loads(Path(path).read_text(encoding="utf-8"))
19
+ if not isinstance(payload, dict):
20
+ raise ValueError("Mock data must be a JSON object")
21
+ devices = _as_list(payload.get("devices"), "devices")
22
+ clients = _as_list(payload.get("clients"), "clients")
23
+ return devices, clients
@@ -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
@@ -27,6 +28,7 @@ class Device:
27
28
  poe_ports: dict[int, bool] = field(default_factory=dict)
28
29
  uplink: UplinkInfo | None = None
29
30
  last_uplink: UplinkInfo | None = None
31
+ version: str = ""
30
32
 
31
33
 
32
34
  @dataclass(frozen=True)
@@ -35,6 +37,7 @@ class Edge:
35
37
  right: str
36
38
  label: str | None = None
37
39
  poe: bool = False
40
+ wireless: bool = False
38
41
 
39
42
 
40
43
  class DeviceLike(Protocol):
@@ -56,6 +59,8 @@ class DeviceLike(Protocol):
56
59
  last_uplink_mac: object | None
57
60
  uplink_device_name: object | None
58
61
  uplink_remote_port: object | None
62
+ version: object | None
63
+ displayable_version: object | None
59
64
 
60
65
 
61
66
  @dataclass(frozen=True)
@@ -70,17 +75,22 @@ class PortInfo:
70
75
  port_idx: int | None
71
76
  name: str | None
72
77
  ifname: str | None
78
+ speed: int | None
79
+ aggregation_group: str | None
73
80
  port_poe: bool
74
81
  poe_enable: bool
75
82
  poe_good: bool
76
83
  poe_power: float | None
77
84
 
78
85
 
79
- PortMap: TypeAlias = dict[tuple[str, str], str]
80
- 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]]]
81
89
 
82
90
 
83
91
  def _get_attr(obj: object, name: str) -> object | None:
92
+ if isinstance(obj, dict):
93
+ return obj.get(name)
84
94
  return getattr(obj, name, None)
85
95
 
86
96
 
@@ -119,6 +129,44 @@ def _as_int(value: object | None) -> int | None:
119
129
  return None
120
130
 
121
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
+
122
170
  def _resolve_port_idx_from_lldp(lldp_entry: LLDPEntry, port_table: list[PortInfo]) -> int | None:
123
171
  if lldp_entry.local_port_idx is not None:
124
172
  return lldp_entry.local_port_idx
@@ -149,6 +197,8 @@ def _port_info_from_entry(port_entry: object) -> PortInfo:
149
197
  port_idx = port_entry.get("port_idx") or port_entry.get("portIdx")
150
198
  name = port_entry.get("name")
151
199
  ifname = port_entry.get("ifname")
200
+ speed = port_entry.get("speed")
201
+ aggregation_group = _aggregation_group(port_entry)
152
202
  port_poe = _as_bool(port_entry.get("port_poe"))
153
203
  poe_enable = _as_bool(port_entry.get("poe_enable"))
154
204
  poe_good = _as_bool(port_entry.get("poe_good"))
@@ -157,6 +207,8 @@ def _port_info_from_entry(port_entry: object) -> PortInfo:
157
207
  port_idx = _get_attr(port_entry, "port_idx") or _get_attr(port_entry, "portIdx")
158
208
  name = _get_attr(port_entry, "name")
159
209
  ifname = _get_attr(port_entry, "ifname")
210
+ speed = _get_attr(port_entry, "speed")
211
+ aggregation_group = _aggregation_group(port_entry)
160
212
  port_poe = _as_bool(_get_attr(port_entry, "port_poe"))
161
213
  poe_enable = _as_bool(_get_attr(port_entry, "poe_enable"))
162
214
  poe_good = _as_bool(_get_attr(port_entry, "poe_good"))
@@ -165,6 +217,8 @@ def _port_info_from_entry(port_entry: object) -> PortInfo:
165
217
  port_idx=_as_int(port_idx),
166
218
  name=str(name) if isinstance(name, str) and name.strip() else None,
167
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),
168
222
  port_poe=port_poe,
169
223
  poe_enable=poe_enable,
170
224
  poe_good=poe_good,
@@ -239,9 +293,11 @@ def _uplink_info(device: DeviceLike) -> tuple[UplinkInfo | None, UplinkInfo | No
239
293
  def coerce_device(device: DeviceLike) -> Device:
240
294
  name = _get_attr(device, "name")
241
295
  model_name = _get_attr(device, "model_name") or _get_attr(device, "model")
296
+ model = _get_attr(device, "model")
242
297
  mac = _get_attr(device, "mac")
243
298
  ip = _get_attr(device, "ip") or _get_attr(device, "ip_address")
244
299
  dev_type = _get_attr(device, "type") or _get_attr(device, "device_type")
300
+ version = _get_attr(device, "displayable_version") or _get_attr(device, "version")
245
301
  lldp_info = _get_attr(device, "lldp_info")
246
302
  if lldp_info is None:
247
303
  lldp_info = _get_attr(device, "lldp")
@@ -263,6 +319,7 @@ def coerce_device(device: DeviceLike) -> Device:
263
319
  return Device(
264
320
  name=str(name),
265
321
  model_name=str(model_name or ""),
322
+ model=str(model or ""),
266
323
  mac=str(mac),
267
324
  ip=str(ip or ""),
268
325
  type=str(dev_type or ""),
@@ -271,6 +328,7 @@ def coerce_device(device: DeviceLike) -> Device:
271
328
  poe_ports=poe_ports,
272
329
  uplink=uplink,
273
330
  last_uplink=last_uplink,
331
+ version=str(version or ""),
274
332
  )
275
333
 
276
334
 
@@ -407,16 +465,26 @@ def _client_is_wired(client: object) -> bool:
407
465
  return bool(_client_field(client, "is_wired"))
408
466
 
409
467
 
468
+ def _client_matches_mode(client: object, mode: str) -> bool:
469
+ wired = _client_is_wired(client)
470
+ if mode == "all":
471
+ return True
472
+ if mode == "wireless":
473
+ return not wired
474
+ return wired
475
+
476
+
410
477
  def build_client_edges(
411
478
  clients: Iterable[object],
412
479
  device_index: dict[str, str],
413
480
  *,
414
481
  include_ports: bool = False,
482
+ client_mode: str = "wired",
415
483
  ) -> list[Edge]:
416
484
  edges: list[Edge] = []
417
485
  seen: set[tuple[str, str]] = set()
418
486
  for client in clients:
419
- if not _client_is_wired(client):
487
+ if not _client_matches_mode(client, client_mode):
420
488
  continue
421
489
  name = _client_display_name(client)
422
490
  uplink_mac = _client_uplink_mac(client)
@@ -433,20 +501,30 @@ def build_client_edges(
433
501
  key = (device_name, name)
434
502
  if key in seen:
435
503
  continue
436
- edges.append(Edge(left=device_name, right=name, label=label))
504
+ edges.append(
505
+ Edge(
506
+ left=device_name,
507
+ right=name,
508
+ label=label,
509
+ wireless=not _client_is_wired(client),
510
+ )
511
+ )
437
512
  seen.add(key)
438
513
  return edges
439
514
 
440
515
 
441
516
  def build_node_type_map(
442
- devices: Iterable[Device], clients: Iterable[object] | None = None
517
+ devices: Iterable[Device],
518
+ clients: Iterable[object] | None = None,
519
+ *,
520
+ client_mode: str = "wired",
443
521
  ) -> dict[str, str]:
444
522
  node_types: dict[str, str] = {}
445
523
  for device in devices:
446
524
  node_types[device.name] = classify_device_type(device)
447
525
  if clients:
448
526
  for client in clients:
449
- if not _client_is_wired(client):
527
+ if not _client_matches_mode(client, client_mode):
450
528
  continue
451
529
  name = _client_display_name(client)
452
530
  if name:
@@ -502,6 +580,62 @@ def build_edges(
502
580
  return edges
503
581
 
504
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
+
505
639
  def _collect_lldp_links(
506
640
  devices: list[Device],
507
641
  index: dict[str, str],