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,388 @@
1
+ """Network configuration 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
+ ValidationError,
10
+ get_logger,
11
+ log_audit,
12
+ validate_confirmation,
13
+ validate_site_id,
14
+ )
15
+
16
+
17
+ async def create_network(
18
+ site_id: str,
19
+ name: str,
20
+ vlan_id: int,
21
+ subnet: str,
22
+ settings: Settings,
23
+ purpose: str = "corporate",
24
+ dhcp_enabled: bool = True,
25
+ dhcp_start: str | None = None,
26
+ dhcp_stop: str | None = None,
27
+ dhcp_dns_1: str | None = None,
28
+ dhcp_dns_2: str | None = None,
29
+ dhcp_dns_3: str | None = None,
30
+ dhcp_dns_4: str | None = None,
31
+ domain_name: str | None = None,
32
+ confirm: bool = False,
33
+ dry_run: bool = False,
34
+ ) -> dict[str, Any]:
35
+ """Create a new network/VLAN.
36
+
37
+ Args:
38
+ site_id: Site identifier
39
+ name: Network name
40
+ vlan_id: VLAN ID (1-4094)
41
+ subnet: Network subnet in CIDR notation (e.g., "192.168.1.0/24")
42
+ settings: Application settings
43
+ purpose: Network purpose (corporate, guest, vlan-only)
44
+ dhcp_enabled: Enable DHCP server
45
+ dhcp_start: DHCP range start IP
46
+ dhcp_stop: DHCP range stop IP
47
+ dhcp_dns_1: Primary DNS server
48
+ dhcp_dns_2: Secondary DNS server
49
+ domain_name: Domain name for DHCP
50
+ confirm: Confirmation flag (must be True to execute)
51
+ dry_run: If True, validate but don't create the network
52
+
53
+ Returns:
54
+ Created network dictionary or dry-run result
55
+
56
+ Raises:
57
+ ConfirmationRequiredError: If confirm is not True
58
+ ValidationError: If validation fails
59
+ """
60
+ site_id = validate_site_id(site_id)
61
+ validate_confirmation(confirm, "network configuration operation")
62
+ logger = get_logger(__name__, settings.log_level)
63
+
64
+ # Validate VLAN ID
65
+ if not 1 <= vlan_id <= 4094:
66
+ raise ValidationError(f"Invalid VLAN ID {vlan_id}. Must be between 1 and 4094")
67
+
68
+ # Validate purpose
69
+ valid_purposes = ["corporate", "guest", "vlan-only", "wan"]
70
+ if purpose not in valid_purposes:
71
+ raise ValidationError(f"Invalid purpose '{purpose}'. Must be one of: {valid_purposes}")
72
+
73
+ # Validate subnet format
74
+ if "/" not in subnet:
75
+ raise ValidationError(f"Invalid subnet '{subnet}'. Must be in CIDR notation")
76
+
77
+ # Build network data
78
+ network_data = {
79
+ "name": name,
80
+ "purpose": purpose,
81
+ "vlan": vlan_id,
82
+ "ip_subnet": subnet,
83
+ "dhcpd_enabled": dhcp_enabled,
84
+ }
85
+
86
+ if dhcp_enabled:
87
+ if dhcp_start:
88
+ network_data["dhcpd_start"] = dhcp_start
89
+ if dhcp_stop:
90
+ network_data["dhcpd_stop"] = dhcp_stop
91
+ if dhcp_dns_1:
92
+ network_data["dhcpd_dns_1"] = dhcp_dns_1
93
+ if dhcp_dns_2:
94
+ network_data["dhcpd_dns_2"] = dhcp_dns_2
95
+ if dhcp_dns_3:
96
+ network_data["dhcpd_dns_3"] = dhcp_dns_3
97
+ if dhcp_dns_4:
98
+ network_data["dhcpd_dns_4"] = dhcp_dns_4
99
+ if domain_name:
100
+ network_data["domain_name"] = domain_name
101
+
102
+ # Log parameters for audit
103
+ parameters = {
104
+ "site_id": site_id,
105
+ "name": name,
106
+ "vlan_id": vlan_id,
107
+ "subnet": subnet,
108
+ "purpose": purpose,
109
+ "dhcp_enabled": dhcp_enabled,
110
+ "dhcp_start": dhcp_start,
111
+ "dhcp_stop": dhcp_stop,
112
+ }
113
+
114
+ if dry_run:
115
+ logger.info(f"DRY RUN: Would create network '{name}' in site '{site_id}'")
116
+ log_audit(
117
+ operation="create_network",
118
+ parameters=parameters,
119
+ result="dry_run",
120
+ site_id=site_id,
121
+ dry_run=True,
122
+ )
123
+ return {"dry_run": True, "would_create": network_data}
124
+
125
+ try:
126
+ async with UniFiClient(settings) as client:
127
+ await client.authenticate()
128
+
129
+ response = await client.post(
130
+ f"/ea/sites/{site_id}/rest/networkconf", json_data=network_data
131
+ )
132
+ created_network: dict[str, Any] = response.get("data", [{}])[0]
133
+
134
+ logger.info(f"Created network '{name}' in site '{site_id}'")
135
+ log_audit(
136
+ operation="create_network",
137
+ parameters=parameters,
138
+ result="success",
139
+ site_id=site_id,
140
+ )
141
+
142
+ return created_network
143
+
144
+ except Exception as e:
145
+ logger.error(f"Failed to create network '{name}': {e}")
146
+ log_audit(
147
+ operation="create_network",
148
+ parameters=parameters,
149
+ result="failed",
150
+ site_id=site_id,
151
+ )
152
+ raise
153
+
154
+
155
+ async def update_network(
156
+ site_id: str,
157
+ network_id: str,
158
+ settings: Settings,
159
+ name: str | None = None,
160
+ vlan_id: int | None = None,
161
+ subnet: str | None = None,
162
+ purpose: str | None = None,
163
+ dhcp_enabled: bool | None = None,
164
+ dhcp_start: str | None = None,
165
+ dhcp_stop: str | None = None,
166
+ dhcp_dns_1: str | None = None,
167
+ dhcp_dns_2: str | None = None,
168
+ dhcp_dns_3: str | None = None,
169
+ dhcp_dns_4: str | None = None,
170
+ domain_name: str | None = None,
171
+ confirm: bool = False,
172
+ dry_run: bool = False,
173
+ ) -> dict[str, Any]:
174
+ """Update an existing network.
175
+
176
+ Args:
177
+ site_id: Site identifier
178
+ network_id: Network ID
179
+ settings: Application settings
180
+ name: New network name
181
+ vlan_id: New VLAN ID (1-4094)
182
+ subnet: New subnet in CIDR notation
183
+ purpose: New purpose (corporate, guest, vlan-only)
184
+ dhcp_enabled: Enable/disable DHCP
185
+ dhcp_start: New DHCP range start IP
186
+ dhcp_stop: New DHCP range stop IP
187
+ dhcp_dns_1: New primary DNS server
188
+ dhcp_dns_2: New secondary DNS server
189
+ domain_name: New domain name
190
+ confirm: Confirmation flag (must be True to execute)
191
+ dry_run: If True, validate but don't update the network
192
+
193
+ Returns:
194
+ Updated network dictionary or dry-run result
195
+
196
+ Raises:
197
+ ConfirmationRequiredError: If confirm is not True
198
+ ResourceNotFoundError: If network not found
199
+ """
200
+ site_id = validate_site_id(site_id)
201
+ validate_confirmation(confirm, "network configuration operation")
202
+ logger = get_logger(__name__, settings.log_level)
203
+
204
+ # Validate VLAN ID if provided
205
+ if vlan_id is not None and not 1 <= vlan_id <= 4094:
206
+ raise ValidationError(f"Invalid VLAN ID {vlan_id}. Must be between 1 and 4094")
207
+
208
+ # Validate purpose if provided
209
+ if purpose is not None:
210
+ valid_purposes = ["corporate", "guest", "vlan-only", "wan"]
211
+ if purpose not in valid_purposes:
212
+ raise ValidationError(f"Invalid purpose '{purpose}'. Must be one of: {valid_purposes}")
213
+
214
+ # Validate subnet format if provided
215
+ if subnet is not None and "/" not in subnet:
216
+ raise ValidationError(f"Invalid subnet '{subnet}'. Must be in CIDR notation")
217
+
218
+ parameters = {
219
+ "site_id": site_id,
220
+ "network_id": network_id,
221
+ "name": name,
222
+ "vlan_id": vlan_id,
223
+ "subnet": subnet,
224
+ "purpose": purpose,
225
+ "dhcp_enabled": dhcp_enabled,
226
+ }
227
+
228
+ if dry_run:
229
+ logger.info(f"DRY RUN: Would update network '{network_id}' in site '{site_id}'")
230
+ log_audit(
231
+ operation="update_network",
232
+ parameters=parameters,
233
+ result="dry_run",
234
+ site_id=site_id,
235
+ dry_run=True,
236
+ )
237
+ return {"dry_run": True, "would_update": parameters}
238
+
239
+ try:
240
+ async with UniFiClient(settings) as client:
241
+ await client.authenticate()
242
+
243
+ # Get existing network
244
+ response = await client.get(f"/ea/sites/{site_id}/rest/networkconf")
245
+ if isinstance(response, list):
246
+ networks_data: list[dict[str, Any]] = response
247
+ else:
248
+ networks_data = response.get("data", [])
249
+
250
+ existing_network = None
251
+ for network in networks_data:
252
+ if network.get("_id") == network_id:
253
+ existing_network = network
254
+ break
255
+
256
+ if not existing_network:
257
+ raise ResourceNotFoundError("network", network_id)
258
+
259
+ # Build update data
260
+ update_data = existing_network.copy()
261
+
262
+ if name is not None:
263
+ update_data["name"] = name
264
+ if vlan_id is not None:
265
+ update_data["vlan"] = vlan_id
266
+ if subnet is not None:
267
+ update_data["ip_subnet"] = subnet
268
+ if purpose is not None:
269
+ update_data["purpose"] = purpose
270
+ if dhcp_enabled is not None:
271
+ update_data["dhcpd_enabled"] = dhcp_enabled
272
+ if dhcp_start is not None:
273
+ update_data["dhcpd_start"] = dhcp_start
274
+ if dhcp_stop is not None:
275
+ update_data["dhcpd_stop"] = dhcp_stop
276
+ if dhcp_dns_1 is not None:
277
+ update_data["dhcpd_dns_1"] = dhcp_dns_1
278
+ if dhcp_dns_2 is not None:
279
+ update_data["dhcpd_dns_2"] = dhcp_dns_2
280
+ if dhcp_dns_3 is not None:
281
+ update_data["dhcpd_dns_3"] = dhcp_dns_3
282
+ if dhcp_dns_4 is not None:
283
+ update_data["dhcpd_dns_4"] = dhcp_dns_4
284
+ if domain_name is not None:
285
+ update_data["domain_name"] = domain_name
286
+
287
+ response = await client.put(
288
+ f"/ea/sites/{site_id}/rest/networkconf/{network_id}", json_data=update_data
289
+ )
290
+ if isinstance(response, list):
291
+ updated_network: dict[str, Any] = response[0] if response else {}
292
+ else:
293
+ updated_network = response.get("data", [{}])[0]
294
+
295
+ logger.info(f"Updated network '{network_id}' in site '{site_id}'")
296
+ log_audit(
297
+ operation="update_network",
298
+ parameters=parameters,
299
+ result="success",
300
+ site_id=site_id,
301
+ )
302
+
303
+ return updated_network
304
+
305
+ except Exception as e:
306
+ logger.error(f"Failed to update network '{network_id}': {e}")
307
+ log_audit(
308
+ operation="update_network",
309
+ parameters=parameters,
310
+ result="failed",
311
+ site_id=site_id,
312
+ )
313
+ raise
314
+
315
+
316
+ async def delete_network(
317
+ site_id: str,
318
+ network_id: str,
319
+ settings: Settings,
320
+ confirm: bool = False,
321
+ dry_run: bool = False,
322
+ ) -> dict[str, Any]:
323
+ """Delete a network.
324
+
325
+ Args:
326
+ site_id: Site identifier
327
+ network_id: Network ID
328
+ settings: Application settings
329
+ confirm: Confirmation flag (must be True to execute)
330
+ dry_run: If True, validate but don't delete the network
331
+
332
+ Returns:
333
+ Deletion result dictionary
334
+
335
+ Raises:
336
+ ConfirmationRequiredError: If confirm is not True
337
+ ResourceNotFoundError: If network not found
338
+ """
339
+ site_id = validate_site_id(site_id)
340
+ validate_confirmation(confirm, "network configuration operation")
341
+ logger = get_logger(__name__, settings.log_level)
342
+
343
+ parameters = {"site_id": site_id, "network_id": network_id}
344
+
345
+ if dry_run:
346
+ logger.info(f"DRY RUN: Would delete network '{network_id}' from site '{site_id}'")
347
+ log_audit(
348
+ operation="delete_network",
349
+ parameters=parameters,
350
+ result="dry_run",
351
+ site_id=site_id,
352
+ dry_run=True,
353
+ )
354
+ return {"dry_run": True, "would_delete": network_id}
355
+
356
+ try:
357
+ async with UniFiClient(settings) as client:
358
+ await client.authenticate()
359
+
360
+ # Verify network exists before deleting
361
+ response = await client.get(f"/ea/sites/{site_id}/rest/networkconf")
362
+ networks_data: list[dict[str, Any]] = response.get("data", [])
363
+
364
+ network_exists = any(net.get("_id") == network_id for net in networks_data)
365
+ if not network_exists:
366
+ raise ResourceNotFoundError("network", network_id)
367
+
368
+ response = await client.delete(f"/ea/sites/{site_id}/rest/networkconf/{network_id}")
369
+
370
+ logger.info(f"Deleted network '{network_id}' from site '{site_id}'")
371
+ log_audit(
372
+ operation="delete_network",
373
+ parameters=parameters,
374
+ result="success",
375
+ site_id=site_id,
376
+ )
377
+
378
+ return {"success": True, "deleted_network_id": network_id}
379
+
380
+ except Exception as e:
381
+ logger.error(f"Failed to delete network '{network_id}': {e}")
382
+ log_audit(
383
+ operation="delete_network",
384
+ parameters=parameters,
385
+ result="failed",
386
+ site_id=site_id,
387
+ )
388
+ raise
src/tools/networks.py ADDED
@@ -0,0 +1,190 @@
1
+ """Network information MCP tools."""
2
+
3
+ from typing import Any
4
+
5
+ from ..api import UniFiClient
6
+ from ..config import Settings
7
+ from ..models import Network
8
+ from ..utils import ResourceNotFoundError, get_logger, validate_limit_offset, validate_site_id
9
+
10
+
11
+ async def get_network_details(site_id: str, network_id: str, settings: Settings) -> dict[str, Any]:
12
+ """Get detailed network configuration.
13
+
14
+ Args:
15
+ site_id: Site identifier
16
+ network_id: Network identifier
17
+ settings: Application settings
18
+
19
+ Returns:
20
+ Network details dictionary
21
+
22
+ Raises:
23
+ ResourceNotFoundError: If network not found
24
+ """
25
+ site_id = validate_site_id(site_id)
26
+ logger = get_logger(__name__, settings.log_level)
27
+
28
+ async with UniFiClient(settings) as client:
29
+ await client.authenticate()
30
+
31
+ response = await client.get(f"/ea/sites/{site_id}/rest/networkconf")
32
+ networks_data = response.get("data", []) if isinstance(response, dict) else response
33
+
34
+ for network_data in networks_data:
35
+ if network_data.get("_id") == network_id:
36
+ network = Network(**network_data)
37
+ logger.info(f"Retrieved network details for {network_id}")
38
+ return network.model_dump() # type: ignore[no-any-return]
39
+
40
+ raise ResourceNotFoundError("network", network_id)
41
+
42
+
43
+ async def list_vlans(
44
+ site_id: str,
45
+ settings: Settings,
46
+ limit: int | None = None,
47
+ offset: int | None = None,
48
+ ) -> list[dict[str, Any]]:
49
+ """List all VLANs in a site.
50
+
51
+ Args:
52
+ site_id: Site identifier
53
+ settings: Application settings
54
+ limit: Maximum number of VLANs to return
55
+ offset: Number of VLANs to skip
56
+
57
+ Returns:
58
+ List of VLAN dictionaries
59
+ """
60
+ site_id = validate_site_id(site_id)
61
+ limit, offset = validate_limit_offset(limit, offset)
62
+ logger = get_logger(__name__, settings.log_level)
63
+
64
+ async with UniFiClient(settings) as client:
65
+ await client.authenticate()
66
+
67
+ response = await client.get(f"/ea/sites/{site_id}/rest/networkconf")
68
+ networks_data = response.get("data", []) if isinstance(response, dict) else response
69
+
70
+ # Return all networks (not just those with vlan_id set)
71
+ # Local gateway API may not populate vlan_id for all network types
72
+ logger.debug(f"Found {len(networks_data)} networks before pagination")
73
+
74
+ # Apply pagination
75
+ paginated = networks_data[offset : offset + limit]
76
+
77
+ # Parse into Network models
78
+ networks = [Network(**n).model_dump() for n in paginated]
79
+
80
+ logger.info(f"Retrieved {len(networks)} VLANs for site '{site_id}'")
81
+ return networks
82
+
83
+
84
+ async def get_subnet_info(site_id: str, network_id: str, settings: Settings) -> dict[str, Any]:
85
+ """Get subnet and DHCP information for a network.
86
+
87
+ Args:
88
+ site_id: Site identifier
89
+ network_id: Network identifier
90
+ settings: Application settings
91
+
92
+ Returns:
93
+ Subnet and DHCP information dictionary
94
+
95
+ Raises:
96
+ ResourceNotFoundError: If network not found
97
+ """
98
+ site_id = validate_site_id(site_id)
99
+ logger = get_logger(__name__, settings.log_level)
100
+
101
+ async with UniFiClient(settings) as client:
102
+ await client.authenticate()
103
+
104
+ response = await client.get(f"/ea/sites/{site_id}/rest/networkconf")
105
+ networks_data = response.get("data", []) if isinstance(response, dict) else response
106
+
107
+ for network_data in networks_data:
108
+ if network_data.get("_id") == network_id:
109
+ # Extract subnet and DHCP information
110
+ subnet_info = {
111
+ "network_id": network_id,
112
+ "name": network_data.get("name"),
113
+ "ip_subnet": network_data.get("ip_subnet"),
114
+ "vlan_id": network_data.get("vlan_id"),
115
+ "dhcpd_enabled": network_data.get("dhcpd_enabled", False),
116
+ "dhcpd_start": network_data.get("dhcpd_start"),
117
+ "dhcpd_stop": network_data.get("dhcpd_stop"),
118
+ "dhcpd_leasetime": network_data.get("dhcpd_leasetime"),
119
+ "dhcpd_dns_1": network_data.get("dhcpd_dns_1"),
120
+ "dhcpd_dns_2": network_data.get("dhcpd_dns_2"),
121
+ "dhcpd_dns_3": network_data.get("dhcpd_dns_3"),
122
+ "dhcpd_dns_4": network_data.get("dhcpd_dns_4"),
123
+ "dhcpd_gateway": network_data.get("dhcpd_gateway"),
124
+ "domain_name": network_data.get("domain_name"),
125
+ }
126
+ logger.info(f"Retrieved subnet info for network {network_id}")
127
+ return subnet_info
128
+
129
+ raise ResourceNotFoundError("network", network_id)
130
+
131
+
132
+ async def get_network_statistics(site_id: str, settings: Settings) -> dict[str, Any]:
133
+ """Retrieve network usage statistics for a site.
134
+
135
+ Args:
136
+ site_id: Site identifier
137
+ settings: Application settings
138
+
139
+ Returns:
140
+ Network statistics dictionary
141
+ """
142
+ site_id = validate_site_id(site_id)
143
+ logger = get_logger(__name__, settings.log_level)
144
+
145
+ async with UniFiClient(settings) as client:
146
+ await client.authenticate()
147
+
148
+ # Get network configurations
149
+ networks_response = await client.get(f"/ea/sites/{site_id}/rest/networkconf")
150
+ networks_data = (
151
+ networks_response.get("data", [])
152
+ if isinstance(networks_response, dict)
153
+ else networks_response
154
+ )
155
+
156
+ # Get active clients to count usage per network
157
+ clients_response = await client.get(f"/ea/sites/{site_id}/sta")
158
+ clients_data = (
159
+ clients_response.get("data", [])
160
+ if isinstance(clients_response, dict)
161
+ else clients_response
162
+ )
163
+
164
+ # Calculate statistics per network
165
+ network_stats = []
166
+ for network in networks_data:
167
+ network_id = network.get("_id")
168
+ vlan_id = network.get("vlan_id")
169
+
170
+ # Count clients on this network
171
+ clients_on_network = [c for c in clients_data if c.get("vlan") == vlan_id]
172
+
173
+ # Calculate total bandwidth
174
+ total_tx = sum(c.get("tx_bytes", 0) for c in clients_on_network)
175
+ total_rx = sum(c.get("rx_bytes", 0) for c in clients_on_network)
176
+
177
+ network_stats.append(
178
+ {
179
+ "network_id": network_id,
180
+ "name": network.get("name"),
181
+ "vlan_id": vlan_id,
182
+ "client_count": len(clients_on_network),
183
+ "total_tx_bytes": total_tx,
184
+ "total_rx_bytes": total_rx,
185
+ "total_bytes": total_tx + total_rx,
186
+ }
187
+ )
188
+
189
+ logger.info(f"Retrieved network statistics for site '{site_id}'")
190
+ return {"site_id": site_id, "networks": network_stats}