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.
- unifi_network_maps/__init__.py +1 -1
- unifi_network_maps/adapters/unifi.py +80 -96
- unifi_network_maps/assets/icons/modern/ap.svg +9 -0
- unifi_network_maps/assets/icons/modern/camera.svg +9 -0
- unifi_network_maps/assets/icons/modern/client.svg +9 -0
- unifi_network_maps/assets/icons/modern/game_console.svg +10 -0
- unifi_network_maps/assets/icons/modern/gateway.svg +17 -0
- unifi_network_maps/assets/icons/modern/iot.svg +9 -0
- unifi_network_maps/assets/icons/modern/nas.svg +9 -0
- unifi_network_maps/assets/icons/modern/other.svg +10 -0
- unifi_network_maps/assets/icons/modern/phone.svg +10 -0
- unifi_network_maps/assets/icons/modern/printer.svg +9 -0
- unifi_network_maps/assets/icons/modern/speaker.svg +10 -0
- unifi_network_maps/assets/icons/modern/switch.svg +10 -0
- unifi_network_maps/assets/icons/modern/tv.svg +10 -0
- unifi_network_maps/assets/icons/modern-flat/ap.svg +5 -0
- unifi_network_maps/assets/icons/modern-flat/camera.svg +5 -0
- unifi_network_maps/assets/icons/modern-flat/client.svg +5 -0
- unifi_network_maps/assets/icons/modern-flat/game_console.svg +6 -0
- unifi_network_maps/assets/icons/modern-flat/gateway.svg +13 -0
- unifi_network_maps/assets/icons/modern-flat/iot.svg +5 -0
- unifi_network_maps/assets/icons/modern-flat/nas.svg +5 -0
- unifi_network_maps/assets/icons/modern-flat/other.svg +6 -0
- unifi_network_maps/assets/icons/modern-flat/phone.svg +6 -0
- unifi_network_maps/assets/icons/modern-flat/printer.svg +5 -0
- unifi_network_maps/assets/icons/modern-flat/speaker.svg +6 -0
- unifi_network_maps/assets/icons/modern-flat/switch.svg +6 -0
- unifi_network_maps/assets/icons/modern-flat/tv.svg +6 -0
- unifi_network_maps/assets/themes/dark.yaml +53 -10
- unifi_network_maps/assets/themes/default.yaml +34 -0
- unifi_network_maps/assets/themes/minimal-dark.yaml +98 -0
- unifi_network_maps/assets/themes/minimal.yaml +92 -0
- unifi_network_maps/assets/themes/unifi-dark.yaml +97 -0
- unifi_network_maps/assets/themes/unifi.yaml +92 -0
- unifi_network_maps/cli/args.py +54 -0
- unifi_network_maps/cli/main.py +18 -7
- unifi_network_maps/cli/render.py +79 -27
- unifi_network_maps/cli/runtime.py +29 -15
- unifi_network_maps/io/debug.py +2 -1
- unifi_network_maps/io/export.py +19 -13
- unifi_network_maps/io/mock_data.py +5 -3
- unifi_network_maps/io/paths.py +5 -3
- unifi_network_maps/model/classify.py +199 -0
- unifi_network_maps/model/clients.py +271 -0
- unifi_network_maps/model/connection.py +37 -0
- unifi_network_maps/model/diff.py +544 -0
- unifi_network_maps/model/edges.py +558 -0
- unifi_network_maps/model/helpers.py +64 -0
- unifi_network_maps/model/lldp.py +20 -25
- unifi_network_maps/model/mock.py +110 -23
- unifi_network_maps/model/snapshot.py +294 -0
- unifi_network_maps/model/topology.py +143 -951
- unifi_network_maps/model/topology_coerce.py +339 -0
- unifi_network_maps/model/vlans.py +32 -46
- unifi_network_maps/model/wan.py +132 -0
- unifi_network_maps/render/device_ports_md.py +39 -97
- unifi_network_maps/render/device_summary.py +53 -0
- unifi_network_maps/render/lldp_md.py +29 -219
- unifi_network_maps/render/markdown_tables.py +8 -0
- unifi_network_maps/render/mermaid.py +11 -2
- unifi_network_maps/render/mkdocs.py +2 -1
- unifi_network_maps/render/svg.py +566 -908
- unifi_network_maps/render/svg_icons.py +231 -0
- unifi_network_maps/render/svg_isometric.py +1196 -0
- unifi_network_maps/render/svg_labels.py +184 -0
- unifi_network_maps/render/svg_theme.py +166 -32
- unifi_network_maps/render/theme.py +86 -1
- {unifi_network_maps-1.4.15.dist-info → unifi_network_maps-1.5.0.dist-info}/METADATA +107 -31
- {unifi_network_maps-1.4.15.dist-info → unifi_network_maps-1.5.0.dist-info}/RECORD +73 -31
- unifi_network_maps/assets/icons/isometric/printer.svg +0 -122
- {unifi_network_maps-1.4.15.dist-info → unifi_network_maps-1.5.0.dist-info}/WHEEL +0 -0
- {unifi_network_maps-1.4.15.dist-info → unifi_network_maps-1.5.0.dist-info}/entry_points.txt +0 -0
- {unifi_network_maps-1.4.15.dist-info → unifi_network_maps-1.5.0.dist-info}/licenses/LICENSE +0 -0
- {unifi_network_maps-1.4.15.dist-info → unifi_network_maps-1.5.0.dist-info}/top_level.txt +0 -0
unifi_network_maps/cli/render.py
CHANGED
|
@@ -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.
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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.
|
|
153
|
+
from ..render.svg_isometric import render_svg_isometric
|
|
102
154
|
|
|
103
155
|
return render_svg_isometric(
|
|
104
156
|
edges,
|
|
105
|
-
node_types=
|
|
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=
|
|
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.
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
unifi_network_maps/io/debug.py
CHANGED
|
@@ -6,7 +6,8 @@ import json
|
|
|
6
6
|
import logging
|
|
7
7
|
from collections.abc import Iterable, Sequence
|
|
8
8
|
|
|
9
|
-
from ..model.
|
|
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
|
|
unifi_network_maps/io/export.py
CHANGED
|
@@ -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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
temp_file
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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):
|
unifi_network_maps/io/paths.py
CHANGED
|
@@ -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
|
-
|
|
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")
|