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,505 @@
1
+ """Client management 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 block_client(
19
+ site_id: str,
20
+ client_mac: str,
21
+ settings: Settings,
22
+ confirm: bool = False,
23
+ dry_run: bool = False,
24
+ ) -> dict[str, Any]:
25
+ """Block a client from accessing the network.
26
+
27
+ Args:
28
+ site_id: Site identifier
29
+ client_mac: Client MAC address
30
+ settings: Application settings
31
+ confirm: Confirmation flag (must be True to execute)
32
+ dry_run: If True, validate but don't block the client
33
+
34
+ Returns:
35
+ Block result dictionary
36
+
37
+ Raises:
38
+ ConfirmationRequiredError: If confirm is not True
39
+ ResourceNotFoundError: If client not found
40
+ """
41
+ site_id = validate_site_id(site_id)
42
+ client_mac = validate_mac_address(client_mac)
43
+ validate_confirmation(confirm, "client management operation")
44
+ logger = get_logger(__name__, settings.log_level)
45
+
46
+ parameters = {"site_id": site_id, "client_mac": client_mac}
47
+
48
+ if dry_run:
49
+ logger.info(
50
+ sanitize_log_message(f"DRY RUN: Would block client '{client_mac}' in site '{site_id}'")
51
+ )
52
+ log_audit(
53
+ operation="block_client",
54
+ parameters=parameters,
55
+ result="dry_run",
56
+ site_id=site_id,
57
+ dry_run=True,
58
+ )
59
+ return {"dry_run": True, "would_block": client_mac}
60
+
61
+ try:
62
+ async with UniFiClient(settings) as client:
63
+ await client.authenticate()
64
+
65
+ # Verify client exists (check both active and all users)
66
+ response = await client.get(f"/ea/sites/{site_id}/stat/alluser")
67
+ # Client now auto-unwraps the "data" field, so response is the actual data
68
+ clients_data: list[dict[str, Any]] = (
69
+ response if isinstance(response, list) else response.get("data", [])
70
+ )
71
+
72
+ client_exists = any(
73
+ validate_mac_address(c.get("mac", "")) == client_mac for c in clients_data
74
+ )
75
+ if not client_exists:
76
+ raise ResourceNotFoundError("client", client_mac)
77
+
78
+ # Block the client
79
+ block_data = {"mac": client_mac, "cmd": "block-sta"}
80
+ response = await client.post(f"/ea/sites/{site_id}/cmd/stamgr", json_data=block_data)
81
+
82
+ logger.info(sanitize_log_message(f"Blocked client '{client_mac}' in site '{site_id}'"))
83
+ log_audit(
84
+ operation="block_client",
85
+ parameters=parameters,
86
+ result="success",
87
+ site_id=site_id,
88
+ )
89
+
90
+ return {
91
+ "success": True,
92
+ "client_mac": client_mac,
93
+ "message": "Client blocked from network",
94
+ }
95
+
96
+ except Exception as e:
97
+ logger.error(sanitize_log_message(f"Failed to block client '{client_mac}': {e}"))
98
+ log_audit(
99
+ operation="block_client",
100
+ parameters=parameters,
101
+ result="failed",
102
+ site_id=site_id,
103
+ )
104
+ raise
105
+
106
+
107
+ async def unblock_client(
108
+ site_id: str,
109
+ client_mac: str,
110
+ settings: Settings,
111
+ confirm: bool = False,
112
+ dry_run: bool = False,
113
+ ) -> dict[str, Any]:
114
+ """Unblock a previously blocked client.
115
+
116
+ Args:
117
+ site_id: Site identifier
118
+ client_mac: Client MAC address
119
+ settings: Application settings
120
+ confirm: Confirmation flag (must be True to execute)
121
+ dry_run: If True, validate but don't unblock the client
122
+
123
+ Returns:
124
+ Unblock result dictionary
125
+
126
+ Raises:
127
+ ConfirmationRequiredError: If confirm is not True
128
+ """
129
+ site_id = validate_site_id(site_id)
130
+ client_mac = validate_mac_address(client_mac)
131
+ validate_confirmation(confirm, "client management operation")
132
+ logger = get_logger(__name__, settings.log_level)
133
+
134
+ parameters = {"site_id": site_id, "client_mac": client_mac}
135
+
136
+ if dry_run:
137
+ logger.info(
138
+ sanitize_log_message(
139
+ f"DRY RUN: Would unblock client '{client_mac}' in site '{site_id}'"
140
+ )
141
+ )
142
+ log_audit(
143
+ operation="unblock_client",
144
+ parameters=parameters,
145
+ result="dry_run",
146
+ site_id=site_id,
147
+ dry_run=True,
148
+ )
149
+ return {"dry_run": True, "would_unblock": client_mac}
150
+
151
+ try:
152
+ async with UniFiClient(settings) as client:
153
+ await client.authenticate()
154
+
155
+ # Unblock the client
156
+ unblock_data = {"mac": client_mac, "cmd": "unblock-sta"}
157
+ await client.post(f"/ea/sites/{site_id}/cmd/stamgr", json_data=unblock_data)
158
+
159
+ logger.info(
160
+ sanitize_log_message(f"Unblocked client '{client_mac}' in site '{site_id}'")
161
+ )
162
+ log_audit(
163
+ operation="unblock_client",
164
+ parameters=parameters,
165
+ result="success",
166
+ site_id=site_id,
167
+ )
168
+
169
+ return {
170
+ "success": True,
171
+ "client_mac": client_mac,
172
+ "message": "Client unblocked",
173
+ }
174
+
175
+ except Exception as e:
176
+ logger.error(sanitize_log_message(f"Failed to unblock client '{client_mac}': {e}"))
177
+ log_audit(
178
+ operation="unblock_client",
179
+ parameters=parameters,
180
+ result="failed",
181
+ site_id=site_id,
182
+ )
183
+ raise
184
+
185
+
186
+ async def reconnect_client(
187
+ site_id: str,
188
+ client_mac: str,
189
+ settings: Settings,
190
+ confirm: bool = False,
191
+ dry_run: bool = False,
192
+ ) -> dict[str, Any]:
193
+ """Force a client to reconnect (disconnect and re-authenticate).
194
+
195
+ Args:
196
+ site_id: Site identifier
197
+ client_mac: Client MAC address
198
+ settings: Application settings
199
+ confirm: Confirmation flag (must be True to execute)
200
+ dry_run: If True, validate but don't force reconnection
201
+
202
+ Returns:
203
+ Reconnect result dictionary
204
+
205
+ Raises:
206
+ ConfirmationRequiredError: If confirm is not True
207
+ ResourceNotFoundError: If client not found
208
+ """
209
+ site_id = validate_site_id(site_id)
210
+ client_mac = validate_mac_address(client_mac)
211
+ validate_confirmation(confirm, "client management operation")
212
+ logger = get_logger(__name__, settings.log_level)
213
+
214
+ parameters = {"site_id": site_id, "client_mac": client_mac}
215
+
216
+ if dry_run:
217
+ logger.info(
218
+ sanitize_log_message(
219
+ f"DRY RUN: Would force reconnect for client '{client_mac}' in site '{site_id}'"
220
+ )
221
+ )
222
+ log_audit(
223
+ operation="reconnect_client",
224
+ parameters=parameters,
225
+ result="dry_run",
226
+ site_id=site_id,
227
+ dry_run=True,
228
+ )
229
+ return {"dry_run": True, "would_reconnect": client_mac}
230
+
231
+ try:
232
+ async with UniFiClient(settings) as client:
233
+ await client.authenticate()
234
+
235
+ # Verify client is currently connected
236
+ response = await client.get(f"/ea/sites/{site_id}/sta")
237
+ # Client now auto-unwraps the "data" field, so response is the actual data
238
+ clients_data: list[dict[str, Any]] = (
239
+ response if isinstance(response, list) else response.get("data", [])
240
+ )
241
+
242
+ client_exists = any(
243
+ validate_mac_address(c.get("mac", "")) == client_mac for c in clients_data
244
+ )
245
+ if not client_exists:
246
+ raise ResourceNotFoundError("active client", client_mac)
247
+
248
+ # Force client reconnection
249
+ reconnect_data = {"mac": client_mac, "cmd": "kick-sta"}
250
+ response = await client.post(
251
+ f"/ea/sites/{site_id}/cmd/stamgr", json_data=reconnect_data
252
+ )
253
+
254
+ logger.info(
255
+ sanitize_log_message(
256
+ f"Forced reconnect for client '{client_mac}' in site '{site_id}'"
257
+ )
258
+ )
259
+ log_audit(
260
+ operation="reconnect_client",
261
+ parameters=parameters,
262
+ result="success",
263
+ site_id=site_id,
264
+ )
265
+
266
+ return {
267
+ "success": True,
268
+ "client_mac": client_mac,
269
+ "message": "Client forced to reconnect",
270
+ }
271
+
272
+ except Exception as e:
273
+ logger.error(sanitize_log_message(f"Failed to reconnect client '{client_mac}': {e}"))
274
+ log_audit(
275
+ operation="reconnect_client",
276
+ parameters=parameters,
277
+ result="failed",
278
+ site_id=site_id,
279
+ )
280
+ raise
281
+
282
+
283
+ async def authorize_guest(
284
+ site_id: str,
285
+ client_mac: str,
286
+ duration: int,
287
+ settings: Settings,
288
+ upload_limit_kbps: int | None = None,
289
+ download_limit_kbps: int | None = None,
290
+ confirm: bool = False,
291
+ dry_run: bool = False,
292
+ ) -> dict[str, Any]:
293
+ """Authorize a guest client for network access.
294
+
295
+ Args:
296
+ site_id: Site identifier
297
+ client_mac: Client MAC address
298
+ duration: Access duration in seconds
299
+ settings: Application settings
300
+ upload_limit_kbps: Upload speed limit in kbps
301
+ download_limit_kbps: Download speed limit in kbps
302
+ confirm: Confirmation flag (must be True to execute)
303
+ dry_run: If True, validate but don't authorize
304
+
305
+ Returns:
306
+ Authorization result dictionary
307
+
308
+ Raises:
309
+ ConfirmationRequiredError: If confirm is not True
310
+ """
311
+ site_id = validate_site_id(site_id)
312
+ client_mac = validate_mac_address(client_mac)
313
+ validate_confirmation(confirm, "client management operation")
314
+ logger = get_logger(__name__, settings.log_level)
315
+
316
+ parameters = {
317
+ "site_id": site_id,
318
+ "client_mac": client_mac,
319
+ "duration": duration,
320
+ }
321
+
322
+ if dry_run:
323
+ logger.info(
324
+ sanitize_log_message(
325
+ f"DRY RUN: Would authorize guest client '{client_mac}' for {duration}s in site '{site_id}'"
326
+ )
327
+ )
328
+ log_audit(
329
+ operation="authorize_guest",
330
+ parameters=parameters,
331
+ result="dry_run",
332
+ site_id=site_id,
333
+ dry_run=True,
334
+ )
335
+ return {"dry_run": True, "would_authorize": client_mac, "duration": duration}
336
+
337
+ try:
338
+ async with UniFiClient(settings) as client:
339
+ await client.authenticate()
340
+
341
+ # Build authorization payload
342
+ auth_data = {
343
+ "action": "authorize-guest",
344
+ "params": {
345
+ "duration": duration,
346
+ },
347
+ }
348
+
349
+ if upload_limit_kbps is not None:
350
+ auth_data["params"]["uploadLimit"] = upload_limit_kbps # type: ignore[index]
351
+ if download_limit_kbps is not None:
352
+ auth_data["params"]["downloadLimit"] = download_limit_kbps # type: ignore[index]
353
+
354
+ # Authorize guest using new API endpoint
355
+ await client.post(
356
+ f"/integration/v1/sites/{site_id}/clients/{client_mac}/action",
357
+ json_data=auth_data,
358
+ )
359
+
360
+ logger.info(
361
+ sanitize_log_message(
362
+ f"Authorized guest client '{client_mac}' for {duration}s in site '{site_id}'"
363
+ )
364
+ )
365
+ log_audit(
366
+ operation="authorize_guest",
367
+ parameters=parameters,
368
+ result="success",
369
+ site_id=site_id,
370
+ )
371
+
372
+ return {
373
+ "success": True,
374
+ "client_mac": client_mac,
375
+ "duration": duration,
376
+ "message": f"Guest authorized for {duration} seconds",
377
+ }
378
+
379
+ except Exception as e:
380
+ logger.error(sanitize_log_message(f"Failed to authorize guest client '{client_mac}': {e}"))
381
+ log_audit(
382
+ operation="authorize_guest",
383
+ parameters=parameters,
384
+ result="failed",
385
+ site_id=site_id,
386
+ )
387
+ raise
388
+
389
+
390
+ async def limit_bandwidth(
391
+ site_id: str,
392
+ client_mac: str,
393
+ settings: Settings,
394
+ upload_limit_kbps: int | None = None,
395
+ download_limit_kbps: int | None = None,
396
+ confirm: bool = False,
397
+ dry_run: bool = False,
398
+ ) -> dict[str, Any]:
399
+ """Apply bandwidth restrictions to a client.
400
+
401
+ Args:
402
+ site_id: Site identifier
403
+ client_mac: Client MAC address
404
+ settings: Application settings
405
+ upload_limit_kbps: Upload speed limit in kbps
406
+ download_limit_kbps: Download speed limit in kbps
407
+ confirm: Confirmation flag (must be True to execute)
408
+ dry_run: If True, validate but don't apply limits
409
+
410
+ Returns:
411
+ Bandwidth limit result dictionary
412
+
413
+ Raises:
414
+ ConfirmationRequiredError: If confirm is not True
415
+ """
416
+ site_id = validate_site_id(site_id)
417
+ client_mac = validate_mac_address(client_mac)
418
+ validate_confirmation(confirm, "client management operation")
419
+ logger = get_logger(__name__, settings.log_level)
420
+
421
+ # Validate bandwidth limits
422
+ if upload_limit_kbps is not None and upload_limit_kbps <= 0:
423
+ raise ValueError("Upload limit must be positive")
424
+ if download_limit_kbps is not None and download_limit_kbps <= 0:
425
+ raise ValueError("Download limit must be positive")
426
+
427
+ parameters = {
428
+ "site_id": site_id,
429
+ "client_mac": client_mac,
430
+ "upload_limit_kbps": upload_limit_kbps,
431
+ "download_limit_kbps": download_limit_kbps,
432
+ }
433
+
434
+ if dry_run:
435
+ logger.info(
436
+ sanitize_log_message(
437
+ f"DRY RUN: Would apply bandwidth limits to client '{client_mac}' in site '{site_id}'"
438
+ )
439
+ )
440
+ log_audit(
441
+ operation="limit_bandwidth",
442
+ parameters=parameters,
443
+ result="dry_run",
444
+ site_id=site_id,
445
+ dry_run=True,
446
+ )
447
+ return {
448
+ "dry_run": True,
449
+ "would_limit": client_mac,
450
+ "upload_limit_kbps": upload_limit_kbps,
451
+ "download_limit_kbps": download_limit_kbps,
452
+ }
453
+
454
+ try:
455
+ async with UniFiClient(settings) as client:
456
+ await client.authenticate()
457
+
458
+ # Build bandwidth limit payload
459
+ limit_data = {
460
+ "action": "limit-bandwidth",
461
+ "params": {},
462
+ }
463
+
464
+ if upload_limit_kbps is not None:
465
+ limit_data["params"]["uploadLimit"] = upload_limit_kbps # type: ignore[index]
466
+ if download_limit_kbps is not None:
467
+ limit_data["params"]["downloadLimit"] = download_limit_kbps # type: ignore[index]
468
+
469
+ # Apply bandwidth limits using new API endpoint
470
+ await client.post(
471
+ f"/integration/v1/sites/{site_id}/clients/{client_mac}/action",
472
+ json_data=limit_data,
473
+ )
474
+
475
+ logger.info(
476
+ sanitize_log_message(
477
+ f"Applied bandwidth limits to client '{client_mac}' in site '{site_id}'"
478
+ )
479
+ )
480
+ log_audit(
481
+ operation="limit_bandwidth",
482
+ parameters=parameters,
483
+ result="success",
484
+ site_id=site_id,
485
+ )
486
+
487
+ return {
488
+ "success": True,
489
+ "client_mac": client_mac,
490
+ "upload_limit_kbps": upload_limit_kbps,
491
+ "download_limit_kbps": download_limit_kbps,
492
+ "message": "Bandwidth limits applied",
493
+ }
494
+
495
+ except Exception as e:
496
+ logger.error(
497
+ sanitize_log_message(f"Failed to apply bandwidth limits to client '{client_mac}': {e}")
498
+ )
499
+ log_audit(
500
+ operation="limit_bandwidth",
501
+ parameters=parameters,
502
+ result="failed",
503
+ site_id=site_id,
504
+ )
505
+ raise
src/tools/clients.py ADDED
@@ -0,0 +1,203 @@
1
+ """Client management MCP tools."""
2
+
3
+ from typing import Any
4
+
5
+ from ..api import UniFiClient
6
+ from ..config import Settings
7
+ from ..models import Client
8
+ from ..utils import (
9
+ ResourceNotFoundError,
10
+ get_logger,
11
+ sanitize_log_message,
12
+ validate_limit_offset,
13
+ validate_mac_address,
14
+ validate_site_id,
15
+ )
16
+
17
+
18
+ async def get_client_details(site_id: str, client_mac: str, settings: Settings) -> dict[str, Any]:
19
+ """Get detailed information for a specific client.
20
+
21
+ Args:
22
+ site_id: Site identifier
23
+ client_mac: Client MAC address
24
+ settings: Application settings
25
+
26
+ Returns:
27
+ Client details dictionary
28
+
29
+ Raises:
30
+ ResourceNotFoundError: If client not found
31
+ """
32
+ site_id = validate_site_id(site_id)
33
+ client_mac = validate_mac_address(client_mac)
34
+ logger = get_logger(__name__, settings.log_level)
35
+
36
+ async with UniFiClient(settings) as client:
37
+ await client.authenticate()
38
+
39
+ # Try active clients first
40
+ response = await client.get(f"/ea/sites/{site_id}/sta")
41
+ clients_data = response.get("data", []) if isinstance(response, dict) else response
42
+
43
+ for client_data in clients_data:
44
+ if validate_mac_address(client_data.get("mac", "")) == client_mac:
45
+ client_obj = Client(**client_data)
46
+ logger.info(sanitize_log_message(f"Retrieved client details for {client_mac}"))
47
+ return client_obj.model_dump() # type: ignore[no-any-return]
48
+
49
+ # If not found in active, try all users
50
+ response = await client.get(f"/ea/sites/{site_id}/stat/alluser")
51
+ clients_data = response.get("data", []) if isinstance(response, dict) else response
52
+
53
+ for client_data in clients_data:
54
+ if validate_mac_address(client_data.get("mac", "")) == client_mac:
55
+ client_obj = Client(**client_data)
56
+ logger.info(sanitize_log_message(f"Retrieved client details for {client_mac}"))
57
+ return client_obj.model_dump() # type: ignore[no-any-return]
58
+
59
+ raise ResourceNotFoundError("client", client_mac)
60
+
61
+
62
+ async def get_client_statistics(
63
+ site_id: str, client_mac: str, settings: Settings
64
+ ) -> dict[str, Any]:
65
+ """Retrieve bandwidth and connection statistics for a client.
66
+
67
+ Args:
68
+ site_id: Site identifier
69
+ client_mac: Client MAC address
70
+ settings: Application settings
71
+
72
+ Returns:
73
+ Client statistics dictionary
74
+
75
+ Raises:
76
+ ResourceNotFoundError: If client not found
77
+ """
78
+ site_id = validate_site_id(site_id)
79
+ client_mac = validate_mac_address(client_mac)
80
+ logger = get_logger(__name__, settings.log_level)
81
+
82
+ async with UniFiClient(settings) as client:
83
+ await client.authenticate()
84
+
85
+ # Get from active clients
86
+ response = await client.get(f"/ea/sites/{site_id}/sta")
87
+ clients_data = response.get("data", []) if isinstance(response, dict) else response
88
+
89
+ for client_data in clients_data:
90
+ if validate_mac_address(client_data.get("mac", "")) == client_mac:
91
+ # Extract statistics
92
+ stats = {
93
+ "mac": client_mac,
94
+ "tx_bytes": client_data.get("tx_bytes", 0),
95
+ "rx_bytes": client_data.get("rx_bytes", 0),
96
+ "tx_packets": client_data.get("tx_packets", 0),
97
+ "rx_packets": client_data.get("rx_packets", 0),
98
+ "tx_rate": client_data.get("tx_rate"),
99
+ "rx_rate": client_data.get("rx_rate"),
100
+ "signal": client_data.get("signal"),
101
+ "rssi": client_data.get("rssi"),
102
+ "noise": client_data.get("noise"),
103
+ "uptime": client_data.get("uptime", 0),
104
+ "is_wired": client_data.get("is_wired", False),
105
+ }
106
+ logger.info(sanitize_log_message(f"Retrieved statistics for client {client_mac}"))
107
+ return stats
108
+
109
+ raise ResourceNotFoundError("client", client_mac)
110
+
111
+
112
+ async def list_active_clients(
113
+ site_id: str,
114
+ settings: Settings,
115
+ limit: int | None = None,
116
+ offset: int | None = None,
117
+ ) -> list[dict[str, Any]]:
118
+ """List currently connected clients.
119
+
120
+ Args:
121
+ site_id: Site identifier
122
+ settings: Application settings
123
+ limit: Maximum number of clients to return
124
+ offset: Number of clients to skip
125
+
126
+ Returns:
127
+ List of active client dictionaries
128
+ """
129
+ site_id = validate_site_id(site_id)
130
+ limit, offset = validate_limit_offset(limit, offset)
131
+ logger = get_logger(__name__, settings.log_level)
132
+
133
+ async with UniFiClient(settings) as client:
134
+ await client.authenticate()
135
+
136
+ response = await client.get(f"/ea/sites/{site_id}/sta")
137
+ clients_data = response.get("data", []) if isinstance(response, dict) else response
138
+
139
+ # Apply pagination
140
+ paginated = clients_data[offset : offset + limit]
141
+
142
+ # Parse into Client models
143
+ clients = [Client(**c).model_dump() for c in paginated]
144
+
145
+ logger.info(
146
+ sanitize_log_message(f"Retrieved {len(clients)} active clients for site '{site_id}'")
147
+ )
148
+ return clients
149
+
150
+
151
+ async def search_clients(
152
+ site_id: str,
153
+ query: str,
154
+ settings: Settings,
155
+ limit: int | None = None,
156
+ offset: int | None = None,
157
+ ) -> list[dict[str, Any]]:
158
+ """Search clients by MAC, IP, or hostname.
159
+
160
+ Args:
161
+ site_id: Site identifier
162
+ query: Search query string
163
+ settings: Application settings
164
+ limit: Maximum number of clients to return
165
+ offset: Number of clients to skip
166
+
167
+ Returns:
168
+ List of matching client dictionaries
169
+ """
170
+ site_id = validate_site_id(site_id)
171
+ limit, offset = validate_limit_offset(limit, offset)
172
+ logger = get_logger(__name__, settings.log_level)
173
+
174
+ async with UniFiClient(settings) as client:
175
+ await client.authenticate()
176
+
177
+ # Search in all users
178
+ response = await client.get(f"/ea/sites/{site_id}/stat/alluser")
179
+ clients_data = response.get("data", []) if isinstance(response, dict) else response
180
+
181
+ # Search by MAC, IP, hostname, or name
182
+ query_lower = query.lower()
183
+ filtered = [
184
+ c
185
+ for c in clients_data
186
+ if query_lower in c.get("mac", "").lower()
187
+ or query_lower in c.get("ip", "").lower()
188
+ or query_lower in c.get("hostname", "").lower()
189
+ or query_lower in c.get("name", "").lower()
190
+ ]
191
+
192
+ # Apply pagination
193
+ paginated = filtered[offset : offset + limit]
194
+
195
+ # Parse into Client models
196
+ clients = [Client(**c).model_dump() for c in paginated]
197
+
198
+ logger.info(
199
+ sanitize_log_message(
200
+ f"Found {len(clients)} clients matching '{query}' in site '{site_id}'"
201
+ )
202
+ )
203
+ return clients