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.
Files changed (81) hide show
  1. iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/METADATA +1282 -0
  2. iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/RECORD +81 -0
  3. iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/WHEEL +4 -0
  4. iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/entry_points.txt +2 -0
  5. iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/licenses/LICENSE +201 -0
  6. src/__init__.py +3 -0
  7. src/__main__.py +6 -0
  8. src/api/__init__.py +5 -0
  9. src/api/client.py +727 -0
  10. src/api/site_manager_client.py +176 -0
  11. src/cache.py +483 -0
  12. src/config/__init__.py +5 -0
  13. src/config/config.py +321 -0
  14. src/main.py +2234 -0
  15. src/models/__init__.py +126 -0
  16. src/models/acl.py +41 -0
  17. src/models/backup.py +272 -0
  18. src/models/client.py +74 -0
  19. src/models/device.py +53 -0
  20. src/models/dpi.py +50 -0
  21. src/models/firewall_policy.py +123 -0
  22. src/models/firewall_zone.py +28 -0
  23. src/models/network.py +62 -0
  24. src/models/qos_profile.py +458 -0
  25. src/models/radius.py +141 -0
  26. src/models/reference_data.py +34 -0
  27. src/models/site.py +59 -0
  28. src/models/site_manager.py +120 -0
  29. src/models/topology.py +138 -0
  30. src/models/traffic_flow.py +137 -0
  31. src/models/traffic_matching_list.py +56 -0
  32. src/models/voucher.py +42 -0
  33. src/models/vpn.py +73 -0
  34. src/models/wan.py +48 -0
  35. src/models/zbf_matrix.py +49 -0
  36. src/resources/__init__.py +8 -0
  37. src/resources/clients.py +111 -0
  38. src/resources/devices.py +102 -0
  39. src/resources/networks.py +93 -0
  40. src/resources/site_manager.py +64 -0
  41. src/resources/sites.py +86 -0
  42. src/tools/__init__.py +25 -0
  43. src/tools/acls.py +328 -0
  44. src/tools/application.py +42 -0
  45. src/tools/backups.py +1173 -0
  46. src/tools/client_management.py +505 -0
  47. src/tools/clients.py +203 -0
  48. src/tools/device_control.py +325 -0
  49. src/tools/devices.py +354 -0
  50. src/tools/dpi.py +241 -0
  51. src/tools/dpi_tools.py +89 -0
  52. src/tools/firewall.py +417 -0
  53. src/tools/firewall_policies.py +430 -0
  54. src/tools/firewall_zones.py +515 -0
  55. src/tools/network_config.py +388 -0
  56. src/tools/networks.py +190 -0
  57. src/tools/port_forwarding.py +263 -0
  58. src/tools/qos.py +1070 -0
  59. src/tools/radius.py +763 -0
  60. src/tools/reference_data.py +107 -0
  61. src/tools/site_manager.py +466 -0
  62. src/tools/site_vpn.py +95 -0
  63. src/tools/sites.py +187 -0
  64. src/tools/topology.py +406 -0
  65. src/tools/traffic_flows.py +1062 -0
  66. src/tools/traffic_matching_lists.py +371 -0
  67. src/tools/vouchers.py +249 -0
  68. src/tools/vpn.py +76 -0
  69. src/tools/wans.py +30 -0
  70. src/tools/wifi.py +498 -0
  71. src/tools/zbf_matrix.py +326 -0
  72. src/utils/__init__.py +88 -0
  73. src/utils/audit.py +213 -0
  74. src/utils/exceptions.py +114 -0
  75. src/utils/helpers.py +159 -0
  76. src/utils/logger.py +105 -0
  77. src/utils/sanitize.py +244 -0
  78. src/utils/validators.py +160 -0
  79. src/webhooks/__init__.py +6 -0
  80. src/webhooks/handlers.py +196 -0
  81. 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}