iflow-mcp_enuno-unifi-mcp-server 0.2.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.
- iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/METADATA +1282 -0
- iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/RECORD +81 -0
- iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/WHEEL +4 -0
- iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/entry_points.txt +2 -0
- iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/licenses/LICENSE +201 -0
- src/__init__.py +3 -0
- src/__main__.py +6 -0
- src/api/__init__.py +5 -0
- src/api/client.py +727 -0
- src/api/site_manager_client.py +176 -0
- src/cache.py +483 -0
- src/config/__init__.py +5 -0
- src/config/config.py +321 -0
- src/main.py +2234 -0
- src/models/__init__.py +126 -0
- src/models/acl.py +41 -0
- src/models/backup.py +272 -0
- src/models/client.py +74 -0
- src/models/device.py +53 -0
- src/models/dpi.py +50 -0
- src/models/firewall_policy.py +123 -0
- src/models/firewall_zone.py +28 -0
- src/models/network.py +62 -0
- src/models/qos_profile.py +458 -0
- src/models/radius.py +141 -0
- src/models/reference_data.py +34 -0
- src/models/site.py +59 -0
- src/models/site_manager.py +120 -0
- src/models/topology.py +138 -0
- src/models/traffic_flow.py +137 -0
- src/models/traffic_matching_list.py +56 -0
- src/models/voucher.py +42 -0
- src/models/vpn.py +73 -0
- src/models/wan.py +48 -0
- src/models/zbf_matrix.py +49 -0
- src/resources/__init__.py +8 -0
- src/resources/clients.py +111 -0
- src/resources/devices.py +102 -0
- src/resources/networks.py +93 -0
- src/resources/site_manager.py +64 -0
- src/resources/sites.py +86 -0
- src/tools/__init__.py +25 -0
- src/tools/acls.py +328 -0
- src/tools/application.py +42 -0
- src/tools/backups.py +1173 -0
- src/tools/client_management.py +505 -0
- src/tools/clients.py +203 -0
- src/tools/device_control.py +325 -0
- src/tools/devices.py +354 -0
- src/tools/dpi.py +241 -0
- src/tools/dpi_tools.py +89 -0
- src/tools/firewall.py +417 -0
- src/tools/firewall_policies.py +430 -0
- src/tools/firewall_zones.py +515 -0
- src/tools/network_config.py +388 -0
- src/tools/networks.py +190 -0
- src/tools/port_forwarding.py +263 -0
- src/tools/qos.py +1070 -0
- src/tools/radius.py +763 -0
- src/tools/reference_data.py +107 -0
- src/tools/site_manager.py +466 -0
- src/tools/site_vpn.py +95 -0
- src/tools/sites.py +187 -0
- src/tools/topology.py +406 -0
- src/tools/traffic_flows.py +1062 -0
- src/tools/traffic_matching_lists.py +371 -0
- src/tools/vouchers.py +249 -0
- src/tools/vpn.py +76 -0
- src/tools/wans.py +30 -0
- src/tools/wifi.py +498 -0
- src/tools/zbf_matrix.py +326 -0
- src/utils/__init__.py +88 -0
- src/utils/audit.py +213 -0
- src/utils/exceptions.py +114 -0
- src/utils/helpers.py +159 -0
- src/utils/logger.py +105 -0
- src/utils/sanitize.py +244 -0
- src/utils/validators.py +160 -0
- src/webhooks/__init__.py +6 -0
- src/webhooks/handlers.py +196 -0
- src/webhooks/receiver.py +290 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""Device control MCP tools."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ..api import UniFiClient
|
|
6
|
+
from ..config import Settings
|
|
7
|
+
from ..utils import (
|
|
8
|
+
ResourceNotFoundError,
|
|
9
|
+
get_logger,
|
|
10
|
+
log_audit,
|
|
11
|
+
sanitize_log_message,
|
|
12
|
+
validate_confirmation,
|
|
13
|
+
validate_mac_address,
|
|
14
|
+
validate_site_id,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def restart_device(
|
|
19
|
+
site_id: str,
|
|
20
|
+
device_mac: str,
|
|
21
|
+
settings: Settings,
|
|
22
|
+
confirm: bool = False,
|
|
23
|
+
dry_run: bool = False,
|
|
24
|
+
) -> dict[str, Any]:
|
|
25
|
+
"""Restart a UniFi device.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
site_id: Site identifier
|
|
29
|
+
device_mac: Device MAC address
|
|
30
|
+
settings: Application settings
|
|
31
|
+
confirm: Confirmation flag (must be True to execute)
|
|
32
|
+
dry_run: If True, validate but don't restart the device
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Restart result dictionary
|
|
36
|
+
|
|
37
|
+
Raises:
|
|
38
|
+
ConfirmationRequiredError: If confirm is not True
|
|
39
|
+
ResourceNotFoundError: If device not found
|
|
40
|
+
"""
|
|
41
|
+
site_id = validate_site_id(site_id)
|
|
42
|
+
device_mac = validate_mac_address(device_mac)
|
|
43
|
+
validate_confirmation(confirm, "device control operation")
|
|
44
|
+
logger = get_logger(__name__, settings.log_level)
|
|
45
|
+
|
|
46
|
+
parameters = {"site_id": site_id, "device_mac": device_mac}
|
|
47
|
+
|
|
48
|
+
if dry_run:
|
|
49
|
+
logger.info(
|
|
50
|
+
sanitize_log_message(
|
|
51
|
+
f"DRY RUN: Would restart device '{device_mac}' in site '{site_id}'"
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
log_audit(
|
|
55
|
+
operation="restart_device",
|
|
56
|
+
parameters=parameters,
|
|
57
|
+
result="dry_run",
|
|
58
|
+
site_id=site_id,
|
|
59
|
+
dry_run=True,
|
|
60
|
+
)
|
|
61
|
+
return {"dry_run": True, "would_restart": device_mac}
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
async with UniFiClient(settings) as client:
|
|
65
|
+
await client.authenticate()
|
|
66
|
+
|
|
67
|
+
# Verify device exists
|
|
68
|
+
response = await client.get(f"/ea/sites/{site_id}/devices")
|
|
69
|
+
# Client now auto-unwraps the "data" field, so response is the actual data
|
|
70
|
+
devices_data: list[dict[str, Any]] = (
|
|
71
|
+
response if isinstance(response, list) else response.get("data", [])
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
device_exists = any(
|
|
75
|
+
validate_mac_address(d.get("mac", "")) == device_mac for d in devices_data
|
|
76
|
+
)
|
|
77
|
+
if not device_exists:
|
|
78
|
+
raise ResourceNotFoundError("device", device_mac)
|
|
79
|
+
|
|
80
|
+
# Restart the device
|
|
81
|
+
restart_data = {"mac": device_mac, "cmd": "restart"}
|
|
82
|
+
response = await client.post(f"/ea/sites/{site_id}/cmd/devmgr", json_data=restart_data)
|
|
83
|
+
|
|
84
|
+
logger.info(
|
|
85
|
+
sanitize_log_message(
|
|
86
|
+
f"Initiated restart for device '{device_mac}' in site '{site_id}'"
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
log_audit(
|
|
90
|
+
operation="restart_device",
|
|
91
|
+
parameters=parameters,
|
|
92
|
+
result="success",
|
|
93
|
+
site_id=site_id,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
"success": True,
|
|
98
|
+
"device_mac": device_mac,
|
|
99
|
+
"message": "Device restart initiated",
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
except Exception as e:
|
|
103
|
+
logger.error(sanitize_log_message(f"Failed to restart device '{device_mac}': {e}"))
|
|
104
|
+
log_audit(
|
|
105
|
+
operation="restart_device",
|
|
106
|
+
parameters=parameters,
|
|
107
|
+
result="failed",
|
|
108
|
+
site_id=site_id,
|
|
109
|
+
)
|
|
110
|
+
raise
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
async def locate_device(
|
|
114
|
+
site_id: str,
|
|
115
|
+
device_mac: str,
|
|
116
|
+
settings: Settings,
|
|
117
|
+
enabled: bool = True,
|
|
118
|
+
confirm: bool = False,
|
|
119
|
+
dry_run: bool = False,
|
|
120
|
+
) -> dict[str, Any]:
|
|
121
|
+
"""Enable or disable LED locate mode on a device.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
site_id: Site identifier
|
|
125
|
+
device_mac: Device MAC address
|
|
126
|
+
settings: Application settings
|
|
127
|
+
enabled: Enable (True) or disable (False) locate mode
|
|
128
|
+
confirm: Confirmation flag (must be True to execute)
|
|
129
|
+
dry_run: If True, validate but don't change locate state
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
Locate result dictionary
|
|
133
|
+
|
|
134
|
+
Raises:
|
|
135
|
+
ConfirmationRequiredError: If confirm is not True
|
|
136
|
+
ResourceNotFoundError: If device not found
|
|
137
|
+
"""
|
|
138
|
+
site_id = validate_site_id(site_id)
|
|
139
|
+
device_mac = validate_mac_address(device_mac)
|
|
140
|
+
validate_confirmation(confirm, "device control operation")
|
|
141
|
+
logger = get_logger(__name__, settings.log_level)
|
|
142
|
+
|
|
143
|
+
parameters = {"site_id": site_id, "device_mac": device_mac, "enabled": enabled}
|
|
144
|
+
|
|
145
|
+
action = "enable" if enabled else "disable"
|
|
146
|
+
|
|
147
|
+
if dry_run:
|
|
148
|
+
logger.info(
|
|
149
|
+
sanitize_log_message(
|
|
150
|
+
f"DRY RUN: Would {action} locate mode for device '{device_mac}' "
|
|
151
|
+
f"in site '{site_id}'"
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
log_audit(
|
|
155
|
+
operation="locate_device",
|
|
156
|
+
parameters=parameters,
|
|
157
|
+
result="dry_run",
|
|
158
|
+
site_id=site_id,
|
|
159
|
+
dry_run=True,
|
|
160
|
+
)
|
|
161
|
+
return {"dry_run": True, f"would_{action}": device_mac}
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
async with UniFiClient(settings) as client:
|
|
165
|
+
await client.authenticate()
|
|
166
|
+
|
|
167
|
+
# Verify device exists
|
|
168
|
+
response = await client.get(f"/ea/sites/{site_id}/devices")
|
|
169
|
+
# Client now auto-unwraps the "data" field, so response is the actual data
|
|
170
|
+
devices_data: list[dict[str, Any]] = (
|
|
171
|
+
response if isinstance(response, list) else response.get("data", [])
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
device_exists = any(
|
|
175
|
+
validate_mac_address(d.get("mac", "")) == device_mac for d in devices_data
|
|
176
|
+
)
|
|
177
|
+
if not device_exists:
|
|
178
|
+
raise ResourceNotFoundError("device", device_mac)
|
|
179
|
+
|
|
180
|
+
# Set locate state
|
|
181
|
+
cmd = "set-locate" if enabled else "unset-locate"
|
|
182
|
+
locate_data = {"mac": device_mac, "cmd": cmd}
|
|
183
|
+
response = await client.post(f"/ea/sites/{site_id}/cmd/devmgr", json_data=locate_data)
|
|
184
|
+
|
|
185
|
+
logger.info(
|
|
186
|
+
sanitize_log_message(
|
|
187
|
+
f"{action.capitalize()}d locate mode for device '{device_mac}' "
|
|
188
|
+
f"in site '{site_id}'"
|
|
189
|
+
)
|
|
190
|
+
)
|
|
191
|
+
log_audit(
|
|
192
|
+
operation="locate_device",
|
|
193
|
+
parameters=parameters,
|
|
194
|
+
result="success",
|
|
195
|
+
site_id=site_id,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
"success": True,
|
|
200
|
+
"device_mac": device_mac,
|
|
201
|
+
"locate_enabled": enabled,
|
|
202
|
+
"message": f"Locate mode {action}d",
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
except Exception as e:
|
|
206
|
+
logger.error(
|
|
207
|
+
sanitize_log_message(f"Failed to {action} locate for device '{device_mac}': {e}")
|
|
208
|
+
)
|
|
209
|
+
log_audit(
|
|
210
|
+
operation="locate_device",
|
|
211
|
+
parameters=parameters,
|
|
212
|
+
result="failed",
|
|
213
|
+
site_id=site_id,
|
|
214
|
+
)
|
|
215
|
+
raise
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
async def upgrade_device(
|
|
219
|
+
site_id: str,
|
|
220
|
+
device_mac: str,
|
|
221
|
+
settings: Settings,
|
|
222
|
+
firmware_url: str | None = None,
|
|
223
|
+
confirm: bool = False,
|
|
224
|
+
dry_run: bool = False,
|
|
225
|
+
) -> dict[str, Any]:
|
|
226
|
+
"""Trigger firmware upgrade for a device.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
site_id: Site identifier
|
|
230
|
+
device_mac: Device MAC address
|
|
231
|
+
settings: Application settings
|
|
232
|
+
firmware_url: Optional custom firmware URL (uses latest if not provided)
|
|
233
|
+
confirm: Confirmation flag (must be True to execute)
|
|
234
|
+
dry_run: If True, validate but don't initiate upgrade
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
Upgrade result dictionary
|
|
238
|
+
|
|
239
|
+
Raises:
|
|
240
|
+
ConfirmationRequiredError: If confirm is not True
|
|
241
|
+
ResourceNotFoundError: If device not found
|
|
242
|
+
"""
|
|
243
|
+
site_id = validate_site_id(site_id)
|
|
244
|
+
device_mac = validate_mac_address(device_mac)
|
|
245
|
+
validate_confirmation(confirm, "device control operation")
|
|
246
|
+
logger = get_logger(__name__, settings.log_level)
|
|
247
|
+
|
|
248
|
+
parameters = {
|
|
249
|
+
"site_id": site_id,
|
|
250
|
+
"device_mac": device_mac,
|
|
251
|
+
"firmware_url": firmware_url,
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if dry_run:
|
|
255
|
+
logger.info(
|
|
256
|
+
sanitize_log_message(
|
|
257
|
+
f"DRY RUN: Would initiate firmware upgrade for device '{device_mac}' "
|
|
258
|
+
f"in site '{site_id}'"
|
|
259
|
+
)
|
|
260
|
+
)
|
|
261
|
+
log_audit(
|
|
262
|
+
operation="upgrade_device",
|
|
263
|
+
parameters=parameters,
|
|
264
|
+
result="dry_run",
|
|
265
|
+
site_id=site_id,
|
|
266
|
+
dry_run=True,
|
|
267
|
+
)
|
|
268
|
+
return {"dry_run": True, "would_upgrade": device_mac}
|
|
269
|
+
|
|
270
|
+
try:
|
|
271
|
+
async with UniFiClient(settings) as client:
|
|
272
|
+
await client.authenticate()
|
|
273
|
+
|
|
274
|
+
# Verify device exists and get details
|
|
275
|
+
response = await client.get(f"/ea/sites/{site_id}/devices")
|
|
276
|
+
# Client now auto-unwraps the "data" field, so response is the actual data
|
|
277
|
+
devices_data: list[dict[str, Any]] = (
|
|
278
|
+
response if isinstance(response, list) else response.get("data", [])
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
device = None
|
|
282
|
+
for d in devices_data:
|
|
283
|
+
if validate_mac_address(d.get("mac", "")) == device_mac:
|
|
284
|
+
device = d
|
|
285
|
+
break
|
|
286
|
+
|
|
287
|
+
if not device:
|
|
288
|
+
raise ResourceNotFoundError("device", device_mac)
|
|
289
|
+
|
|
290
|
+
# Build upgrade command
|
|
291
|
+
upgrade_data = {"mac": device_mac, "cmd": "upgrade"}
|
|
292
|
+
|
|
293
|
+
if firmware_url:
|
|
294
|
+
upgrade_data["url"] = firmware_url
|
|
295
|
+
|
|
296
|
+
response = await client.post(f"/ea/sites/{site_id}/cmd/devmgr", json_data=upgrade_data)
|
|
297
|
+
|
|
298
|
+
logger.info(
|
|
299
|
+
sanitize_log_message(
|
|
300
|
+
f"Initiated firmware upgrade for device '{device_mac}' " f"in site '{site_id}'"
|
|
301
|
+
)
|
|
302
|
+
)
|
|
303
|
+
log_audit(
|
|
304
|
+
operation="upgrade_device",
|
|
305
|
+
parameters=parameters,
|
|
306
|
+
result="success",
|
|
307
|
+
site_id=site_id,
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
"success": True,
|
|
312
|
+
"device_mac": device_mac,
|
|
313
|
+
"message": "Firmware upgrade initiated",
|
|
314
|
+
"current_version": device.get("version"),
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
except Exception as e:
|
|
318
|
+
logger.error(sanitize_log_message(f"Failed to upgrade device '{device_mac}': {e}"))
|
|
319
|
+
log_audit(
|
|
320
|
+
operation="upgrade_device",
|
|
321
|
+
parameters=parameters,
|
|
322
|
+
result="failed",
|
|
323
|
+
site_id=site_id,
|
|
324
|
+
)
|
|
325
|
+
raise
|
src/tools/devices.py
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
"""Device management MCP tools."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ..api import UniFiClient
|
|
6
|
+
from ..config import Settings
|
|
7
|
+
from ..models import Device
|
|
8
|
+
from ..utils import (
|
|
9
|
+
ResourceNotFoundError,
|
|
10
|
+
audit_action,
|
|
11
|
+
get_logger,
|
|
12
|
+
validate_confirmation,
|
|
13
|
+
validate_device_id,
|
|
14
|
+
validate_limit_offset,
|
|
15
|
+
validate_site_id,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def get_device_details(site_id: str, device_id: str, settings: Settings) -> dict[str, Any]:
|
|
20
|
+
"""Get detailed information for a specific device.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
site_id: Site identifier
|
|
24
|
+
device_id: Device identifier
|
|
25
|
+
settings: Application settings
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
Device details dictionary
|
|
29
|
+
|
|
30
|
+
Raises:
|
|
31
|
+
ResourceNotFoundError: If device not found
|
|
32
|
+
"""
|
|
33
|
+
site_id = validate_site_id(site_id)
|
|
34
|
+
device_id = validate_device_id(device_id)
|
|
35
|
+
logger = get_logger(__name__, settings.log_level)
|
|
36
|
+
|
|
37
|
+
async with UniFiClient(settings) as client:
|
|
38
|
+
await client.authenticate()
|
|
39
|
+
|
|
40
|
+
# Get all devices and find the specific one
|
|
41
|
+
response = await client.get(f"/ea/sites/{site_id}/devices")
|
|
42
|
+
devices_data = response.get("data", []) if isinstance(response, dict) else response
|
|
43
|
+
|
|
44
|
+
for device_data in devices_data:
|
|
45
|
+
if device_data.get("_id") == device_id:
|
|
46
|
+
device = Device(**device_data)
|
|
47
|
+
logger.info(f"Retrieved device details for {device_id}")
|
|
48
|
+
return device.model_dump() # type: ignore[no-any-return]
|
|
49
|
+
|
|
50
|
+
raise ResourceNotFoundError("device", device_id)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def get_device_statistics(site_id: str, device_id: str, settings: Settings) -> dict[str, Any]:
|
|
54
|
+
"""Retrieve real-time statistics for a device.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
site_id: Site identifier
|
|
58
|
+
device_id: Device identifier
|
|
59
|
+
settings: Application settings
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Device statistics dictionary
|
|
63
|
+
"""
|
|
64
|
+
site_id = validate_site_id(site_id)
|
|
65
|
+
device_id = validate_device_id(device_id)
|
|
66
|
+
logger = get_logger(__name__, settings.log_level)
|
|
67
|
+
|
|
68
|
+
async with UniFiClient(settings) as client:
|
|
69
|
+
await client.authenticate()
|
|
70
|
+
|
|
71
|
+
response = await client.get(f"/ea/sites/{site_id}/devices")
|
|
72
|
+
devices_data = response.get("data", []) if isinstance(response, dict) else response
|
|
73
|
+
|
|
74
|
+
for device_data in devices_data:
|
|
75
|
+
if device_data.get("_id") == device_id:
|
|
76
|
+
# Extract statistics
|
|
77
|
+
stats = {
|
|
78
|
+
"device_id": device_id,
|
|
79
|
+
"uptime": device_data.get("uptime", 0),
|
|
80
|
+
"cpu": device_data.get("cpu"),
|
|
81
|
+
"mem": device_data.get("mem"),
|
|
82
|
+
"tx_bytes": device_data.get("tx_bytes", 0),
|
|
83
|
+
"rx_bytes": device_data.get("rx_bytes", 0),
|
|
84
|
+
"bytes": device_data.get("bytes", 0),
|
|
85
|
+
"state": device_data.get("state"),
|
|
86
|
+
"uplink_depth": device_data.get("uplink_depth"),
|
|
87
|
+
}
|
|
88
|
+
logger.info(f"Retrieved statistics for device {device_id}")
|
|
89
|
+
return stats
|
|
90
|
+
|
|
91
|
+
raise ResourceNotFoundError("device", device_id)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def list_devices_by_type(
|
|
95
|
+
site_id: str,
|
|
96
|
+
device_type: str,
|
|
97
|
+
settings: Settings,
|
|
98
|
+
limit: int | None = None,
|
|
99
|
+
offset: int | None = None,
|
|
100
|
+
) -> list[dict[str, Any]]:
|
|
101
|
+
"""Filter devices by type (AP, switch, gateway).
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
site_id: Site identifier
|
|
105
|
+
device_type: Device type filter (uap, usw, ugw, etc.)
|
|
106
|
+
settings: Application settings
|
|
107
|
+
limit: Maximum number of devices to return
|
|
108
|
+
offset: Number of devices to skip
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
List of device dictionaries
|
|
112
|
+
"""
|
|
113
|
+
site_id = validate_site_id(site_id)
|
|
114
|
+
limit, offset = validate_limit_offset(limit, offset)
|
|
115
|
+
logger = get_logger(__name__, settings.log_level)
|
|
116
|
+
|
|
117
|
+
async with UniFiClient(settings) as client:
|
|
118
|
+
await client.authenticate()
|
|
119
|
+
|
|
120
|
+
response = await client.get(f"/ea/sites/{site_id}/devices")
|
|
121
|
+
devices_data = response.get("data", []) if isinstance(response, dict) else response
|
|
122
|
+
|
|
123
|
+
# Filter by type
|
|
124
|
+
filtered = [
|
|
125
|
+
d
|
|
126
|
+
for d in devices_data
|
|
127
|
+
if d.get("type", "").lower() == device_type.lower()
|
|
128
|
+
or device_type.lower() in d.get("model", "").lower()
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
# Apply pagination
|
|
132
|
+
paginated = filtered[offset : offset + limit]
|
|
133
|
+
|
|
134
|
+
# Parse into Device models
|
|
135
|
+
devices = [Device(**d).model_dump() for d in paginated]
|
|
136
|
+
|
|
137
|
+
logger.info(
|
|
138
|
+
f"Retrieved {len(devices)} devices of type '{device_type}' " f"for site '{site_id}'"
|
|
139
|
+
)
|
|
140
|
+
return devices
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
async def search_devices(
|
|
144
|
+
site_id: str,
|
|
145
|
+
query: str,
|
|
146
|
+
settings: Settings,
|
|
147
|
+
limit: int | None = None,
|
|
148
|
+
offset: int | None = None,
|
|
149
|
+
) -> list[dict[str, Any]]:
|
|
150
|
+
"""Search devices by name, MAC, or IP address.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
site_id: Site identifier
|
|
154
|
+
query: Search query string
|
|
155
|
+
settings: Application settings
|
|
156
|
+
limit: Maximum number of devices to return
|
|
157
|
+
offset: Number of devices to skip
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
List of matching device dictionaries
|
|
161
|
+
"""
|
|
162
|
+
site_id = validate_site_id(site_id)
|
|
163
|
+
limit, offset = validate_limit_offset(limit, offset)
|
|
164
|
+
logger = get_logger(__name__, settings.log_level)
|
|
165
|
+
|
|
166
|
+
async with UniFiClient(settings) as client:
|
|
167
|
+
await client.authenticate()
|
|
168
|
+
|
|
169
|
+
response = await client.get(f"/ea/sites/{site_id}/devices")
|
|
170
|
+
devices_data = response.get("data", []) if isinstance(response, dict) else response
|
|
171
|
+
|
|
172
|
+
# Search by name, MAC, or IP
|
|
173
|
+
query_lower = query.lower()
|
|
174
|
+
filtered = [
|
|
175
|
+
d
|
|
176
|
+
for d in devices_data
|
|
177
|
+
if query_lower in d.get("name", "").lower()
|
|
178
|
+
or query_lower in d.get("mac", "").lower()
|
|
179
|
+
or query_lower in d.get("ip", "").lower()
|
|
180
|
+
or query_lower in d.get("model", "").lower()
|
|
181
|
+
]
|
|
182
|
+
|
|
183
|
+
# Apply pagination
|
|
184
|
+
paginated = filtered[offset : offset + limit]
|
|
185
|
+
|
|
186
|
+
# Parse into Device models
|
|
187
|
+
devices = [Device(**d).model_dump() for d in paginated]
|
|
188
|
+
|
|
189
|
+
logger.info(f"Found {len(devices)} devices matching '{query}' in site '{site_id}'")
|
|
190
|
+
return devices
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
async def list_pending_devices(
|
|
194
|
+
site_id: str,
|
|
195
|
+
settings: Settings,
|
|
196
|
+
limit: int | None = None,
|
|
197
|
+
offset: int | None = None,
|
|
198
|
+
) -> list[dict[str, Any]]:
|
|
199
|
+
"""List devices awaiting adoption on the specified site.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
site_id: Site identifier
|
|
203
|
+
settings: Application settings
|
|
204
|
+
limit: Maximum number of devices to return
|
|
205
|
+
offset: Number of devices to skip
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
List of pending device dictionaries
|
|
209
|
+
"""
|
|
210
|
+
site_id = validate_site_id(site_id)
|
|
211
|
+
limit, offset = validate_limit_offset(limit, offset)
|
|
212
|
+
logger = get_logger(__name__, settings.log_level)
|
|
213
|
+
|
|
214
|
+
async with UniFiClient(settings) as client:
|
|
215
|
+
await client.authenticate()
|
|
216
|
+
|
|
217
|
+
params = {}
|
|
218
|
+
if limit is not None:
|
|
219
|
+
params["limit"] = limit
|
|
220
|
+
if offset is not None:
|
|
221
|
+
params["offset"] = offset
|
|
222
|
+
|
|
223
|
+
response = await client.get(
|
|
224
|
+
f"/integration/v1/sites/{site_id}/devices/pending", params=params
|
|
225
|
+
)
|
|
226
|
+
devices_data = response.get("data", [])
|
|
227
|
+
|
|
228
|
+
# Parse into Device models
|
|
229
|
+
devices = [Device(**d).model_dump() for d in devices_data]
|
|
230
|
+
|
|
231
|
+
logger.info(f"Retrieved {len(devices)} pending devices for site '{site_id}'")
|
|
232
|
+
return devices
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
async def adopt_device(
|
|
236
|
+
site_id: str,
|
|
237
|
+
device_id: str,
|
|
238
|
+
settings: Settings,
|
|
239
|
+
name: str | None = None,
|
|
240
|
+
confirm: bool = False,
|
|
241
|
+
dry_run: bool = False,
|
|
242
|
+
) -> dict[str, Any]:
|
|
243
|
+
"""Adopt a pending device onto the specified site.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
site_id: Site identifier
|
|
247
|
+
device_id: Device identifier to adopt
|
|
248
|
+
settings: Application settings
|
|
249
|
+
name: Optional device name
|
|
250
|
+
confirm: Confirmation flag (required)
|
|
251
|
+
dry_run: If True, validate but don't execute
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
Adopted device information
|
|
255
|
+
"""
|
|
256
|
+
validate_confirmation(confirm, "adopt device")
|
|
257
|
+
site_id = validate_site_id(site_id)
|
|
258
|
+
device_id = validate_device_id(device_id)
|
|
259
|
+
logger = get_logger(__name__, settings.log_level)
|
|
260
|
+
|
|
261
|
+
async with UniFiClient(settings) as client:
|
|
262
|
+
await client.authenticate()
|
|
263
|
+
|
|
264
|
+
payload = {}
|
|
265
|
+
if name:
|
|
266
|
+
payload["name"] = name
|
|
267
|
+
|
|
268
|
+
if dry_run:
|
|
269
|
+
logger.info(f"[DRY RUN] Would adopt device {device_id} with payload: {payload}")
|
|
270
|
+
return {"dry_run": True, "device_id": device_id, "payload": payload}
|
|
271
|
+
|
|
272
|
+
response = await client.post(
|
|
273
|
+
f"/integration/v1/sites/{site_id}/devices/{device_id}/adopt", json_data=payload
|
|
274
|
+
)
|
|
275
|
+
data = response.get("data", response)
|
|
276
|
+
|
|
277
|
+
# Audit the action
|
|
278
|
+
await audit_action(
|
|
279
|
+
settings,
|
|
280
|
+
action_type="adopt_device",
|
|
281
|
+
resource_type="device",
|
|
282
|
+
resource_id=device_id,
|
|
283
|
+
site_id=site_id,
|
|
284
|
+
details={"name": name} if name else {},
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
logger.info(f"Successfully adopted device {device_id}")
|
|
288
|
+
return Device(**data).model_dump() # type: ignore[no-any-return]
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
async def execute_port_action(
|
|
292
|
+
site_id: str,
|
|
293
|
+
device_id: str,
|
|
294
|
+
port_idx: int,
|
|
295
|
+
action: str,
|
|
296
|
+
settings: Settings,
|
|
297
|
+
params: dict[str, Any] | None = None,
|
|
298
|
+
confirm: bool = False,
|
|
299
|
+
dry_run: bool = False,
|
|
300
|
+
) -> dict[str, Any]:
|
|
301
|
+
"""Execute an action on a specific port of a device.
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
site_id: Site identifier
|
|
305
|
+
device_id: Device identifier
|
|
306
|
+
port_idx: Port index number
|
|
307
|
+
action: Action to perform (power-cycle, enable, disable)
|
|
308
|
+
settings: Application settings
|
|
309
|
+
params: Additional action parameters
|
|
310
|
+
confirm: Confirmation flag (required)
|
|
311
|
+
dry_run: If True, validate but don't execute
|
|
312
|
+
|
|
313
|
+
Returns:
|
|
314
|
+
Action result
|
|
315
|
+
"""
|
|
316
|
+
validate_confirmation(confirm, f"execute port action '{action}'")
|
|
317
|
+
site_id = validate_site_id(site_id)
|
|
318
|
+
device_id = validate_device_id(device_id)
|
|
319
|
+
logger = get_logger(__name__, settings.log_level)
|
|
320
|
+
|
|
321
|
+
async with UniFiClient(settings) as client:
|
|
322
|
+
await client.authenticate()
|
|
323
|
+
|
|
324
|
+
payload = {"action": action, "params": params or {}}
|
|
325
|
+
|
|
326
|
+
if dry_run:
|
|
327
|
+
logger.info(
|
|
328
|
+
f"[DRY RUN] Would execute port action '{action}' on device {device_id} port {port_idx}"
|
|
329
|
+
)
|
|
330
|
+
return {
|
|
331
|
+
"dry_run": True,
|
|
332
|
+
"device_id": device_id,
|
|
333
|
+
"port_idx": port_idx,
|
|
334
|
+
"payload": payload,
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
response = await client.post(
|
|
338
|
+
f"/integration/v1/sites/{site_id}/devices/{device_id}/ports/{port_idx}/action",
|
|
339
|
+
json_data=payload,
|
|
340
|
+
)
|
|
341
|
+
data = response.get("data", response)
|
|
342
|
+
|
|
343
|
+
# Audit the action
|
|
344
|
+
await audit_action(
|
|
345
|
+
settings,
|
|
346
|
+
action_type="port_action",
|
|
347
|
+
resource_type="device_port",
|
|
348
|
+
resource_id=f"{device_id}:{port_idx}",
|
|
349
|
+
site_id=site_id,
|
|
350
|
+
details={"action": action},
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
logger.info(f"Successfully executed port action '{action}' on port {port_idx}")
|
|
354
|
+
return {"success": True, "action": action, "port_idx": port_idx, "result": data}
|