unifi-network-maps 1.4.14__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 +83 -101
  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 -931
  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.14.dist-info → unifi_network_maps-1.5.0.dist-info}/METADATA +107 -31
  69. {unifi_network_maps-1.4.14.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.14.dist-info → unifi_network_maps-1.5.0.dist-info}/WHEEL +0 -0
  72. {unifi_network_maps-1.4.14.dist-info → unifi_network_maps-1.5.0.dist-info}/entry_points.txt +0 -0
  73. {unifi_network_maps-1.4.14.dist-info → unifi_network_maps-1.5.0.dist-info}/licenses/LICENSE +0 -0
  74. {unifi_network_maps-1.4.14.dist-info → unifi_network_maps-1.5.0.dist-info}/top_level.txt +0 -0
@@ -9,13 +9,11 @@ from ..adapters.config import Config
9
9
  from ..adapters.unifi import fetch_clients
10
10
  from ..io.export import write_output
11
11
  from ..io.mkdocs_assets import write_mkdocs_sidebar_assets
12
- from ..model.topology import (
13
- Device,
14
- TopologyResult,
15
- build_node_type_map,
16
- build_port_map,
17
- group_devices_by_type,
18
- )
12
+ from ..model.classify import classify_device_type
13
+ from ..model.clients import build_node_type_map, collapse_client_edges
14
+ from ..model.edges import build_port_map, group_devices_by_type
15
+ from ..model.topology import Device, Edge, TopologyResult, WanInfo
16
+ from ..model.wan import extract_wan_info
19
17
  from ..render.legend import resolve_legend_style
20
18
  from ..render.lldp_md import render_lldp_md
21
19
  from ..render.mermaid import render_mermaid
@@ -77,6 +75,42 @@ def render_mermaid_output(
77
75
  return content
78
76
 
79
77
 
78
+ def _extract_gateway_wan_info(
79
+ devices: list[Device],
80
+ args: argparse.Namespace,
81
+ ) -> WanInfo | None:
82
+ """Extract WAN info from the first gateway device."""
83
+ for device in devices:
84
+ if classify_device_type(device) == "gateway":
85
+ return extract_wan_info(
86
+ device,
87
+ wan1_label=getattr(args, "wan_label", None),
88
+ wan1_isp_speed=getattr(args, "wan_speed", None),
89
+ wan2_label=getattr(args, "wan2_label", None),
90
+ wan2_isp_speed=getattr(args, "wan2_speed", None),
91
+ )
92
+ return None
93
+
94
+
95
+ def _apply_client_clustering(
96
+ edges: list[Edge],
97
+ node_types: dict[str, str],
98
+ layout_mode: str,
99
+ groups: dict[str, list[str]] | None,
100
+ group_order: list[str] | None,
101
+ ) -> tuple[list[Edge], dict[str, list[str]] | None, list[str] | None]:
102
+ """Apply client clustering and update groups if needed."""
103
+ edges, _counts = collapse_client_edges(edges, node_types)
104
+ if layout_mode == "grouped" and group_order and "client_cluster" not in group_order:
105
+ group_order = [*group_order, "client_cluster"]
106
+ if groups is not None:
107
+ groups = {
108
+ **groups,
109
+ "client_cluster": [n for n, t in node_types.items() if t == "client_cluster"],
110
+ }
111
+ return edges, groups, group_order
112
+
113
+
80
114
  def render_svg_output(
81
115
  args: argparse.Namespace,
82
116
  devices: list[Device],
@@ -89,38 +123,52 @@ def render_svg_output(
89
123
  ) -> str:
90
124
  edges, _has_tree = select_edges(topology)
91
125
  edges, clients = build_edges_with_clients(
92
- args,
93
- edges,
94
- devices,
95
- config,
96
- site,
97
- clients_override=clients_override,
126
+ args, edges, devices, config, site, clients_override=clients_override
127
+ )
128
+ layout_mode = getattr(args, "svg_layout_mode", "physical")
129
+ options = SvgOptions(width=args.svg_width, height=args.svg_height, layout_mode=layout_mode)
130
+
131
+ groups = None
132
+ group_order = None
133
+ if layout_mode == "grouped":
134
+ groups = group_devices_by_type(devices)
135
+ group_order = ["gateway", "switch", "ap", "other"]
136
+ if clients:
137
+ client_names = [c.get("name") or c.get("hostname", "") for c in clients]
138
+ groups["client"] = [n for n in client_names if n]
139
+ group_order.append("client")
140
+
141
+ node_types = build_node_type_map(
142
+ devices, clients, client_mode=args.client_scope, only_unifi=args.only_unifi
98
143
  )
99
- options = SvgOptions(width=args.svg_width, height=args.svg_height)
144
+
145
+ if getattr(args, "collapse_clients", False):
146
+ edges, groups, group_order = _apply_client_clustering(
147
+ edges, node_types, layout_mode, groups, group_order
148
+ )
149
+
150
+ wan_info = _extract_gateway_wan_info(devices, args)
151
+
100
152
  if args.format == "svg-iso":
101
- from ..render.svg import render_svg_isometric
153
+ from ..render.svg_isometric import render_svg_isometric
102
154
 
103
155
  return render_svg_isometric(
104
156
  edges,
105
- node_types=build_node_type_map(
106
- devices,
107
- clients,
108
- client_mode=args.client_scope,
109
- only_unifi=args.only_unifi,
110
- ),
157
+ node_types=node_types,
111
158
  options=options,
112
159
  theme=svg_theme,
160
+ groups=groups,
161
+ group_order=group_order,
162
+ wan_info=wan_info,
113
163
  )
114
164
  return render_svg(
115
165
  edges,
116
- node_types=build_node_type_map(
117
- devices,
118
- clients,
119
- client_mode=args.client_scope,
120
- only_unifi=args.only_unifi,
121
- ),
166
+ node_types=node_types,
122
167
  options=options,
123
168
  theme=svg_theme,
169
+ groups=groups,
170
+ group_order=group_order,
171
+ wan_info=wan_info,
124
172
  )
125
173
 
126
174
 
@@ -181,6 +229,7 @@ def render_lldp_format(
181
229
  site: str,
182
230
  mock_devices: list[object] | None,
183
231
  mock_clients: list[object] | None,
232
+ mock_networks: list[object] | None = None,
184
233
  ) -> int:
185
234
  try:
186
235
  _raw_devices, devices = load_devices_data(
@@ -188,6 +237,7 @@ def render_lldp_format(
188
237
  config,
189
238
  site,
190
239
  raw_devices_override=mock_devices,
240
+ raw_networks_override=mock_networks,
191
241
  )
192
242
  except Exception as exc:
193
243
  logging.error("Failed to load devices: %s", exc)
@@ -219,6 +269,7 @@ def render_standard_format(
219
269
  site: str,
220
270
  mock_devices: list[object] | None,
221
271
  mock_clients: list[object] | None,
272
+ mock_networks: list[object] | None = None,
222
273
  mermaid_theme: MermaidTheme,
223
274
  svg_theme: SvgTheme,
224
275
  ) -> int:
@@ -227,6 +278,7 @@ def render_standard_format(
227
278
  config=config,
228
279
  site=site,
229
280
  mock_devices=mock_devices,
281
+ mock_networks=mock_networks,
230
282
  )
231
283
  if topology_result is None:
232
284
  return 1
@@ -7,19 +7,13 @@ import logging
7
7
  from pathlib import Path
8
8
 
9
9
  from ..adapters.config import Config
10
- from ..adapters.unifi import fetch_clients, fetch_devices
10
+ from ..adapters.unifi import fetch_clients, fetch_devices, fetch_networks
11
11
  from ..io.debug import debug_dump_devices
12
- from ..model.topology import (
13
- ClientPortMap,
14
- Device,
15
- TopologyResult,
16
- build_client_edges,
17
- build_client_port_map,
18
- build_device_index,
19
- build_topology,
20
- group_devices_by_type,
21
- normalize_devices,
22
- )
12
+ from ..model.clients import build_client_edges, build_client_port_map
13
+ from ..model.edges import build_topology, enrich_edges_with_active_vlans, group_devices_by_type
14
+ from ..model.topology import ClientPortMap, Device, TopologyResult, build_device_index
15
+ from ..model.topology_coerce import normalize_devices
16
+ from ..model.vlans import build_network_vlan_map
23
17
  from ..render.mermaid_theme import MermaidTheme
24
18
  from ..render.theme import load_theme
25
19
 
@@ -32,6 +26,7 @@ def load_devices_data(
32
26
  site: str,
33
27
  *,
34
28
  raw_devices_override: list[object] | None = None,
29
+ raw_networks_override: list[object] | None = None,
35
30
  ) -> tuple[list[object], list[Device]]:
36
31
  if raw_devices_override is None:
37
32
  if config is None:
@@ -41,7 +36,19 @@ def load_devices_data(
41
36
  )
42
37
  else:
43
38
  raw_devices = raw_devices_override
44
- devices = normalize_devices(raw_devices)
39
+
40
+ # Build network-to-VLAN mapping for resolving network IDs in port configs
41
+ network_vlan_map: dict[str, int] | None = None
42
+ if raw_networks_override is not None:
43
+ network_vlan_map = build_network_vlan_map(raw_networks_override)
44
+ elif config is not None:
45
+ try:
46
+ raw_networks = list(fetch_networks(config, site=site, use_cache=not args.no_cache))
47
+ network_vlan_map = build_network_vlan_map(raw_networks)
48
+ except Exception as exc: # noqa: BLE001
49
+ logger.debug("Failed to fetch networks for VLAN resolution: %s", exc)
50
+
51
+ devices = normalize_devices(raw_devices, network_vlan_map)
45
52
  if args.debug_dump:
46
53
  debug_dump_devices(raw_devices, devices, sample_count=max(0, args.debug_sample))
47
54
  return raw_devices, devices
@@ -54,12 +61,14 @@ def build_topology_data(
54
61
  *,
55
62
  include_ports: bool | None = None,
56
63
  raw_devices_override: list[object] | None = None,
64
+ raw_networks_override: list[object] | None = None,
57
65
  ) -> tuple[list[Device], list[str], TopologyResult]:
58
66
  _raw_devices, devices = load_devices_data(
59
67
  args,
60
68
  config,
61
69
  site,
62
70
  raw_devices_override=raw_devices_override,
71
+ raw_networks_override=raw_networks_override,
63
72
  )
64
73
  groups_for_rank = group_devices_by_type(devices)
65
74
  gateways = groups_for_rank.get("gateway", [])
@@ -82,6 +91,7 @@ def build_edges_with_clients(
82
91
  clients_override: list[object] | None = None,
83
92
  ) -> tuple[list, list | None]:
84
93
  clients = None
94
+ client_edges: list = []
85
95
  if args.include_clients:
86
96
  if clients_override is None:
87
97
  if config is None:
@@ -90,14 +100,16 @@ def build_edges_with_clients(
90
100
  else:
91
101
  clients = clients_override
92
102
  device_index = build_device_index(devices)
93
- edges = edges + build_client_edges(
103
+ client_edges = build_client_edges(
94
104
  clients,
95
105
  device_index,
96
106
  include_ports=args.include_ports,
97
107
  client_mode=args.client_scope,
98
108
  only_unifi=args.only_unifi,
99
109
  )
100
- return edges, clients
110
+ # Enrich infrastructure edges with active VLANs from client traffic
111
+ enriched_edges = enrich_edges_with_active_vlans(edges, client_edges)
112
+ return enriched_edges + client_edges, clients
101
113
 
102
114
 
103
115
  def select_edges(topology: TopologyResult) -> tuple[list, bool]:
@@ -113,6 +125,7 @@ def load_topology_for_render(
113
125
  config: Config | None,
114
126
  site: str,
115
127
  mock_devices: list[object] | None,
128
+ mock_networks: list[object] | None = None,
116
129
  ) -> tuple[list[Device], TopologyResult] | None:
117
130
  try:
118
131
  include_ports = True if args.format == "mkdocs" else None
@@ -122,6 +135,7 @@ def load_topology_for_render(
122
135
  site,
123
136
  include_ports=include_ports,
124
137
  raw_devices_override=mock_devices,
138
+ raw_networks_override=mock_networks,
125
139
  )
126
140
  except Exception as exc:
127
141
  logging.error("Failed to build topology: %s", exc)
@@ -6,7 +6,8 @@ import json
6
6
  import logging
7
7
  from collections.abc import Iterable, Sequence
8
8
 
9
- from ..model.topology import Device, group_devices_by_type
9
+ from ..model.edges import group_devices_by_type
10
+ from ..model.topology import Device
10
11
 
11
12
  logger = logging.getLogger(__name__)
12
13
 
@@ -26,16 +26,22 @@ def write_output(
26
26
 
27
27
  def _write_atomic(path: Path, content: str) -> None:
28
28
  path.parent.mkdir(parents=True, exist_ok=True)
29
- with tempfile.NamedTemporaryFile(
30
- mode="w",
31
- encoding="utf-8",
32
- dir=str(path.parent),
33
- prefix=f".{path.name}.",
34
- suffix=".tmp",
35
- delete=False,
36
- ) as temp_file:
37
- temp_file.write(content)
38
- temp_file.flush()
39
- os.fsync(temp_file.fileno())
40
- tmp_path = Path(temp_file.name)
41
- tmp_path.replace(path)
29
+ tmp_path: Path | None = None
30
+ try:
31
+ with tempfile.NamedTemporaryFile(
32
+ mode="w",
33
+ encoding="utf-8",
34
+ dir=str(path.parent),
35
+ prefix=f".{path.name}.",
36
+ suffix=".tmp",
37
+ delete=False,
38
+ ) as temp_file:
39
+ temp_file.write(content)
40
+ temp_file.flush()
41
+ os.fsync(temp_file.fileno())
42
+ tmp_path = Path(temp_file.name)
43
+ tmp_path.replace(path)
44
+ except BaseException:
45
+ if tmp_path and tmp_path.exists():
46
+ tmp_path.unlink(missing_ok=True)
47
+ raise
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ from pathlib import Path
6
7
 
7
8
  from .paths import resolve_mock_data_path
8
9
 
@@ -15,17 +16,18 @@ def _as_list(value: object, name: str) -> list[object]:
15
16
  raise ValueError(f"Mock data field '{name}' must be a list")
16
17
 
17
18
 
18
- def load_mock_data(path: str) -> tuple[list[object], list[object]]:
19
+ def load_mock_data(path: str | Path) -> tuple[list[object], list[object], list[object]]:
19
20
  resolved = resolve_mock_data_path(path)
20
21
  payload = json.loads(resolved.read_text(encoding="utf-8"))
21
22
  if not isinstance(payload, dict):
22
23
  raise ValueError("Mock data must be a JSON object")
23
24
  devices = _as_list(payload.get("devices"), "devices")
24
25
  clients = _as_list(payload.get("clients"), "clients")
25
- return devices, clients
26
+ networks = _as_list(payload.get("networks"), "networks")
27
+ return devices, clients, networks
26
28
 
27
29
 
28
- def load_mock_payload(path: str) -> dict[str, list[object] | list[dict[str, object]]]:
30
+ def load_mock_payload(path: str | Path) -> dict[str, list[object] | list[dict[str, object]]]:
29
31
  resolved = resolve_mock_data_path(path)
30
32
  payload = json.loads(resolved.read_text(encoding="utf-8"))
31
33
  if not isinstance(payload, dict):
@@ -194,8 +194,10 @@ def resolve_output_path(path: str | Path, *, format_name: str | None) -> Path:
194
194
 
195
195
 
196
196
  def resolve_cache_dir(path: str | Path) -> Path:
197
- resolved = _resolve_user_path(path)
197
+ # Check for symlinks on the original path before .resolve() follows them.
198
+ raw = Path(path).expanduser()
199
+ _ensure_no_symlink(raw, label="Cache directory")
200
+ _ensure_no_symlink_in_parents(raw, label="Cache directory")
201
+ resolved = raw.resolve(strict=False)
198
202
  _ensure_within_allowed(resolved, _allowed_roots(), label="Cache directory")
199
- _ensure_no_symlink(resolved, label="Cache directory")
200
- _ensure_no_symlink_in_parents(resolved, label="Cache directory")
201
203
  return resolved
@@ -0,0 +1,199 @@
1
+ """Device and client type classification."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .helpers import first_string_field, get_field
6
+
7
+ # Client device category detection patterns
8
+ _CLIENT_NAME_PATTERNS: dict[str, tuple[str, ...]] = {
9
+ "camera": ("camera", "cam", "doorbell", "uvc", "protect", "ring", "nest cam", "arlo"),
10
+ "tv": ("tv", "television", "apple tv", "chromecast", "roku", "fire tv", "shield", "smart tv"),
11
+ "phone": ("phone", "iphone", "android", "pixel", "galaxy", "mobile", "voip", "handset"),
12
+ "printer": ("printer", "print", "laserjet", "inkjet", "epson", "canon", "brother", "hp "),
13
+ "nas": ("nas", "synology", "qnap", "diskstation", "drobo", "freenas", "truenas"),
14
+ "speaker": ("sonos", "homepod", "echo", "alexa", "google home", "speaker", "soundbar"),
15
+ "game_console": ("playstation", "ps4", "ps5", "xbox", "nintendo", "switch", "steam deck"),
16
+ "iot": ("sensor", "thermostat", "nest", "hue", "smart", "zigbee", "z-wave", "iot"),
17
+ }
18
+
19
+ # OUI/vendor patterns for manufacturer-based detection
20
+ _CLIENT_VENDOR_PATTERNS: dict[str, tuple[str, ...]] = {
21
+ "camera": ("ubiquiti", "hikvision", "dahua", "axis", "ring", "arlo", "nest", "wyze"),
22
+ "tv": ("samsung tv", "lg tv", "sony tv", "vizio", "tcl", "roku", "apple tv"),
23
+ "phone": ("apple", "samsung mobile", "google pixel", "oneplus", "xiaomi"),
24
+ "printer": ("hp inc", "canon", "epson", "brother", "lexmark", "xerox", "ricoh"),
25
+ "nas": ("synology", "qnap", "western digital", "seagate", "netgear readynas"),
26
+ "speaker": ("sonos", "bose", "harman", "bang & olufsen", "denon"),
27
+ "game_console": ("sony interactive", "microsoft xbox", "nintendo"),
28
+ }
29
+
30
+ # UniFi product line mappings
31
+ _UNIFI_PRODUCT_CATEGORIES: dict[str, str] = {
32
+ "protect": "camera",
33
+ "talk": "phone",
34
+ "access": "iot",
35
+ "led": "iot",
36
+ "connect": "iot",
37
+ }
38
+
39
+
40
+ def classify_device_type(device: object) -> str:
41
+ """Classify a network device into gateway, switch, ap, or other."""
42
+ raw_type = get_field(device, "type")
43
+ raw_name = get_field(device, "name")
44
+ value = raw_type.strip().lower() if isinstance(raw_type, str) else ""
45
+ if not value:
46
+ name = raw_name.strip().lower() if isinstance(raw_name, str) else ""
47
+ if "gateway" in name or name.startswith("gw"):
48
+ return "gateway"
49
+ if "switch" in name:
50
+ return "switch"
51
+ if "ap" in name:
52
+ return "ap"
53
+ if value in {"gateway", "ugw", "usg", "ux", "udm", "udr"}:
54
+ return "gateway"
55
+ if value in {"switch", "usw"}:
56
+ return "switch"
57
+ if value in {"uap", "ap"} or "ap" in value:
58
+ return "ap"
59
+ return "other"
60
+
61
+
62
+ def _classify_by_name(name: str) -> str | None:
63
+ """Classify client by display name heuristics."""
64
+ name_lower = name.lower()
65
+ for category, patterns in _CLIENT_NAME_PATTERNS.items():
66
+ for pattern in patterns:
67
+ if pattern in name_lower:
68
+ return category
69
+ return None
70
+
71
+
72
+ def _classify_by_vendor(vendor: str) -> str | None:
73
+ """Classify client by OUI/vendor name."""
74
+ vendor_lower = vendor.lower()
75
+ for category, patterns in _CLIENT_VENDOR_PATTERNS.items():
76
+ for pattern in patterns:
77
+ if pattern in vendor_lower:
78
+ return category
79
+ return None
80
+
81
+
82
+ def _classify_by_unifi_info(ucore: dict[str, object]) -> str | None:
83
+ """Classify client by UniFi device info (product_line, model)."""
84
+ product_line = ucore.get("product_line")
85
+ if isinstance(product_line, str):
86
+ line_lower = product_line.lower()
87
+ for line_prefix, category in _UNIFI_PRODUCT_CATEGORIES.items():
88
+ if line_lower.startswith(line_prefix):
89
+ return category
90
+ for key in ("product_shortname", "computed_model", "product_model"):
91
+ value = ucore.get(key)
92
+ if isinstance(value, str):
93
+ value_lower = value.lower()
94
+ if any(cam in value_lower for cam in ("camera", "doorbell", "uvc", "g4", "g5")):
95
+ return "camera"
96
+ if "talk" in value_lower or "phone" in value_lower:
97
+ return "phone"
98
+ return None
99
+
100
+
101
+ def _client_ucore_info(client: object) -> dict[str, object] | None:
102
+ """Get UniFi device info from client."""
103
+ info = get_field(client, "unifi_device_info_from_ucore")
104
+ if isinstance(info, dict):
105
+ return info
106
+ return None
107
+
108
+
109
+ def _client_ucore_display_name(client: object) -> str | None:
110
+ """Get display name from UniFi device info."""
111
+ ucore = _client_ucore_info(client)
112
+ if not ucore:
113
+ return None
114
+ return first_string_field(ucore, "name", "computed_model", "product_model", "product_shortname")
115
+
116
+
117
+ def _client_vendor(client: object) -> str | None:
118
+ """Get vendor/OUI from client."""
119
+ return first_string_field(
120
+ client, "oui", "vendor", "vendor_name", "manufacturer", "manufacturer_name"
121
+ )
122
+
123
+
124
+ def _client_unifi_flag(client: object) -> bool | None:
125
+ """Check explicit UniFi device flags."""
126
+ for key in ("is_unifi", "is_unifi_device", "is_ubnt", "is_uap", "is_managed"):
127
+ value = get_field(client, key)
128
+ if isinstance(value, bool):
129
+ return value
130
+ if isinstance(value, int):
131
+ return value != 0
132
+ return None
133
+
134
+
135
+ def client_is_unifi(client: object) -> bool:
136
+ """Determine if a client is a UniFi device."""
137
+ flag = _client_unifi_flag(client)
138
+ if flag is not None:
139
+ return flag
140
+ ucore = _client_ucore_info(client)
141
+ if ucore:
142
+ managed = ucore.get("managed")
143
+ if isinstance(managed, bool) and managed:
144
+ return True
145
+ if isinstance(ucore.get("product_line"), str) and ucore.get("product_line"):
146
+ return True
147
+ if isinstance(ucore.get("product_shortname"), str) and ucore.get("product_shortname"):
148
+ return True
149
+ for key in ("name", "computed_model", "product_model"):
150
+ value = ucore.get(key)
151
+ if isinstance(value, str) and value.strip():
152
+ return True
153
+ vendor = _client_vendor(client)
154
+ if not vendor:
155
+ return False
156
+ normalized = vendor.lower()
157
+ return "ubiquiti" in normalized or "unifi" in normalized
158
+
159
+
160
+ def classify_client_type(client: object) -> str:
161
+ """Classify a client into a device category.
162
+
163
+ Detection priority:
164
+ 1. UniFi device info (product_line, model)
165
+ 2. Display name heuristics
166
+ 3. OUI/vendor patterns
167
+
168
+ Returns one of: camera, tv, phone, printer, nas, speaker, game_console, iot, client
169
+ """
170
+ ucore = _client_ucore_info(client)
171
+ if ucore:
172
+ category = _classify_by_unifi_info(ucore)
173
+ if category:
174
+ return category
175
+
176
+ name = client_display_name(client)
177
+ if name:
178
+ category = _classify_by_name(name)
179
+ if category:
180
+ return category
181
+
182
+ vendor = _client_vendor(client)
183
+ if vendor:
184
+ category = _classify_by_vendor(vendor)
185
+ if category:
186
+ return category
187
+
188
+ return "client"
189
+
190
+
191
+ def client_display_name(client: object) -> str | None:
192
+ """Get display name for a client."""
193
+ name = first_string_field(client, "name")
194
+ if name:
195
+ return name
196
+ preferred = _client_ucore_display_name(client)
197
+ if preferred:
198
+ return preferred
199
+ return first_string_field(client, "hostname", "mac")