unifi-network-mcp 0.2.0__tar.gz → 0.3.0__tar.gz

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 (54) hide show
  1. {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/PKG-INFO +3 -1
  2. {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/README.md +2 -0
  3. {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/pyproject.toml +13 -1
  4. {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/bootstrap.py +8 -20
  5. {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/config/config.yaml +17 -1
  6. {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/jobs.py +1 -3
  7. {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/main.py +20 -45
  8. {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/managers/client_manager.py +112 -40
  9. {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/managers/connection_manager.py +40 -121
  10. {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/managers/device_manager.py +9 -19
  11. unifi_network_mcp-0.3.0/src/managers/event_manager.py +182 -0
  12. {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/managers/firewall_manager.py +63 -186
  13. unifi_network_mcp-0.3.0/src/managers/hotspot_manager.py +196 -0
  14. {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/managers/network_manager.py +28 -81
  15. {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/managers/qos_manager.py +10 -25
  16. unifi_network_mcp-0.3.0/src/managers/routing_manager.py +232 -0
  17. {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/managers/stats_manager.py +24 -65
  18. {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/managers/system_manager.py +50 -143
  19. unifi_network_mcp-0.3.0/src/managers/traffic_route_manager.py +191 -0
  20. unifi_network_mcp-0.3.0/src/managers/usergroup_manager.py +186 -0
  21. unifi_network_mcp-0.3.0/src/managers/vpn_manager.py +304 -0
  22. {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/runtime.py +41 -6
  23. {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/schemas.py +2 -4
  24. {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/tool_index.py +4 -8
  25. unifi_network_mcp-0.3.0/src/tools/clients.py +578 -0
  26. {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/tools/config.py +4 -9
  27. {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/tools/devices.py +149 -48
  28. unifi_network_mcp-0.3.0/src/tools/events.py +181 -0
  29. {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/tools/firewall.py +89 -138
  30. unifi_network_mcp-0.3.0/src/tools/hotspot.py +232 -0
  31. {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/tools/network.py +85 -100
  32. {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/tools/port_forwards.py +64 -125
  33. {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/tools/qos.py +79 -95
  34. unifi_network_mcp-0.3.0/src/tools/routing.py +332 -0
  35. {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/tools/stats.py +18 -55
  36. {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/tools/system.py +2 -4
  37. unifi_network_mcp-0.3.0/src/tools/traffic_routes.py +242 -0
  38. unifi_network_mcp-0.3.0/src/tools/usergroups.py +238 -0
  39. {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/tools/vpn.py +14 -38
  40. {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/tools_manifest.json +384 -21
  41. unifi_network_mcp-0.3.0/src/utils/confirmation.py +218 -0
  42. {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/utils/diagnostics.py +6 -13
  43. {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/utils/lazy_tool_loader.py +37 -7
  44. {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/utils/meta_tools.py +2 -6
  45. {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/utils/permissions.py +6 -4
  46. {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/validator_registry.py +22 -35
  47. {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/validators.py +5 -8
  48. unifi_network_mcp-0.2.0/src/managers/vpn_manager.py +0 -206
  49. unifi_network_mcp-0.2.0/src/tools/clients.py +0 -342
  50. unifi_network_mcp-0.2.0/src/tools/traffic_routes.py +0 -705
  51. {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/.gitignore +0 -0
  52. {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/.well-known/mcp-server.json +0 -0
  53. {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/LICENSE +0 -0
  54. {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/utils/tool_loader.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unifi-network-mcp
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Unifi Network MCP Server
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.13
@@ -62,6 +62,7 @@ A self-hosted [Model Context Protocol](https://github.com/modelcontextprotocol)
62
62
 
63
63
  * Full catalog of UniFi controller operations – firewall, traffic-routes, port-forwards, QoS, VPN, WLANs, stats, devices, clients **and more**.
64
64
  * All mutating tools require `confirm=true` so nothing can change your network by accident.
65
+ * **Workflow automation friendly** – set `UNIFI_AUTO_CONFIRM=true` to skip confirmation prompts (ideal for n8n, Make, Zapier).
65
66
  * Works over **stdio** (FastMCP). Optional SSE HTTP endpoint can be enabled via config.
66
67
  * **Code execution mode** with tool index, async operations, and TypeScript examples.
67
68
  * One-liner launch via the console-script **`unifi-network-mcp`**.
@@ -520,6 +521,7 @@ The server merges settings from **environment variables**, an optional `.env` fi
520
521
  | `UNIFI_VERIFY_SSL` | Set to `false` if using self-signed certs |
521
522
  | `UNIFI_CONTROLLER_TYPE` | Controller API path type: `auto` (detect), `proxy` (UniFi OS), `direct` (standalone). Default `auto` |
522
523
  | `UNIFI_MCP_HTTP_ENABLED` | Set `true` to enable optional HTTP SSE server (default `false`) |
524
+ | `UNIFI_AUTO_CONFIRM` | Set `true` to auto-confirm all mutating operations (skips preview step). Ideal for workflow automation (n8n, Make, Zapier). Default `false` |
523
525
 
524
526
  ### Controller Type Detection
525
527
 
@@ -46,6 +46,7 @@ A self-hosted [Model Context Protocol](https://github.com/modelcontextprotocol)
46
46
 
47
47
  * Full catalog of UniFi controller operations – firewall, traffic-routes, port-forwards, QoS, VPN, WLANs, stats, devices, clients **and more**.
48
48
  * All mutating tools require `confirm=true` so nothing can change your network by accident.
49
+ * **Workflow automation friendly** – set `UNIFI_AUTO_CONFIRM=true` to skip confirmation prompts (ideal for n8n, Make, Zapier).
49
50
  * Works over **stdio** (FastMCP). Optional SSE HTTP endpoint can be enabled via config.
50
51
  * **Code execution mode** with tool index, async operations, and TypeScript examples.
51
52
  * One-liner launch via the console-script **`unifi-network-mcp`**.
@@ -504,6 +505,7 @@ The server merges settings from **environment variables**, an optional `.env` fi
504
505
  | `UNIFI_VERIFY_SSL` | Set to `false` if using self-signed certs |
505
506
  | `UNIFI_CONTROLLER_TYPE` | Controller API path type: `auto` (detect), `proxy` (UniFi OS), `direct` (standalone). Default `auto` |
506
507
  | `UNIFI_MCP_HTTP_ENABLED` | Set `true` to enable optional HTTP SSE server (default `false`) |
508
+ | `UNIFI_AUTO_CONFIRM` | Set `true` to auto-confirm all mutating operations (skips preview step). Ideal for workflow automation (n8n, Make, Zapier). Default `false` |
507
509
 
508
510
  ### Controller Type Detection
509
511
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "unifi-network-mcp"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Unifi Network MCP Server"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -58,3 +58,15 @@ dev-dependencies = [
58
58
  [project.scripts]
59
59
  # CLI entrypoint that launches the UniFi Network MCP server
60
60
  unifi-network-mcp = "src.main:main"
61
+
62
+ [tool.ruff]
63
+ line-length = 120
64
+
65
+ [tool.ruff.lint]
66
+ select = ["E", "F", "I"]
67
+ ignore = [
68
+ "E501", # Line too long - handled by line-length setting above
69
+ ]
70
+
71
+ [tool.ruff.lint.isort]
72
+ known-first-party = ["src"]
@@ -12,13 +12,13 @@ Importing it early guarantees deterministic side‑effects (env + logging) and
12
12
  exposes a `load_config()` helper that the rest of the codebase can share.
13
13
  """
14
14
 
15
- from dataclasses import dataclass
15
+ import importlib.resources
16
16
  import logging
17
17
  import os
18
18
  import sys
19
+ from dataclasses import dataclass
19
20
  from pathlib import Path
20
21
  from typing import Any
21
- import importlib.resources
22
22
 
23
23
  from dotenv import load_dotenv
24
24
  from omegaconf import OmegaConf
@@ -118,32 +118,20 @@ def load_config(path_override: str | Path | None = None) -> OmegaConf:
118
118
  relative_path = Path("config/config.yaml")
119
119
  if relative_path.exists() and relative_path.is_file():
120
120
  resolved_path = relative_path
121
- logger.info(
122
- "Using configuration file from relative path: %s", relative_path
123
- )
121
+ logger.info("Using configuration file from relative path: %s", relative_path)
124
122
  else:
125
123
  # 3. Use bundled default config
126
124
  try:
127
125
  # Use importlib.resources to safely access package data
128
- config_file_ref = importlib.resources.files("src.config").joinpath(
129
- "config.yaml"
130
- )
126
+ config_file_ref = importlib.resources.files("src.config").joinpath("config.yaml")
131
127
  if config_file_ref.is_file():
132
- resolved_path = Path(
133
- str(config_file_ref)
134
- ) # Convert Traversable to Path
135
- logger.info(
136
- "Using bundled default configuration: %s", resolved_path
137
- )
128
+ resolved_path = Path(str(config_file_ref)) # Convert Traversable to Path
129
+ logger.info("Using bundled default configuration: %s", resolved_path)
138
130
  else:
139
- logger.error(
140
- "Bundled default configuration file could not be accessed (not a file)."
141
- )
131
+ logger.error("Bundled default configuration file could not be accessed (not a file).")
142
132
  raise SystemExit(3) # Exit if bundled config isn't a file
143
133
  except (ModuleNotFoundError, FileNotFoundError, Exception) as e:
144
- logger.error(
145
- "Could not find or access bundled default configuration: %s", e
146
- )
134
+ logger.error("Could not find or access bundled default configuration: %s", e)
147
135
  raise SystemExit(3) # Exit if bundled config cannot be loaded
148
136
 
149
137
  if resolved_path is None:
@@ -71,4 +71,20 @@ permissions:
71
71
 
72
72
  vpn_servers:
73
73
  create: false
74
- update: true
74
+ update: true
75
+
76
+ events:
77
+ create: false # Events are read-only
78
+ update: true # Allow archiving alarms
79
+
80
+ vouchers:
81
+ create: true # Allow creating guest vouchers
82
+ update: true # Allow revoking vouchers
83
+
84
+ usergroups:
85
+ create: true # Allow creating bandwidth profiles
86
+ update: true # Allow modifying bandwidth limits
87
+
88
+ routes:
89
+ create: false # Static routes can disrupt connectivity
90
+ update: false # Modifying routes requires careful planning
@@ -78,9 +78,7 @@ class JobStore:
78
78
  self._jobs[job_id]["status"] = "error"
79
79
  self._jobs[job_id]["error"] = str(e)
80
80
  self._jobs[job_id]["completed"] = time.time()
81
- logger.error(
82
- f"Background job {job_id} failed with error: {e}", exc_info=True
83
- )
81
+ logger.error(f"Background job {job_id} failed with error: {e}", exc_info=True)
84
82
 
85
83
  # Launch the runner as a background task
86
84
  asyncio.create_task(_runner())
@@ -9,29 +9,28 @@ Responsibilities:
9
9
 
10
10
  import asyncio
11
11
  import logging
12
- import traceback
13
- import sys # Removed uvicorn import
14
12
  import os
13
+ import sys # Removed uvicorn import
14
+ import traceback
15
15
 
16
16
  from src.bootstrap import (
17
- logger,
18
17
  UNIFI_TOOL_REGISTRATION_MODE,
18
+ logger,
19
19
  ) # ensures logging/env setup early
20
+ from src.jobs import get_job_status, start_async_tool
20
21
 
21
22
  # Shared singletons
22
23
  from src.runtime import (
23
- server,
24
24
  config,
25
25
  connection_manager,
26
+ server,
26
27
  )
27
-
28
- from src.utils.tool_loader import auto_load_tools
29
- from src.utils.lazy_tool_loader import setup_lazy_loading
28
+ from src.tool_index import register_tool, tool_index_handler
30
29
  from src.utils.diagnostics import diagnostics_enabled, wrap_tool
31
- from src.utils.permissions import parse_permission # noqa: E402
30
+ from src.utils.lazy_tool_loader import setup_lazy_loading
32
31
  from src.utils.meta_tools import register_meta_tools
33
- from src.tool_index import register_tool, tool_index_handler
34
- from src.jobs import start_async_tool, get_job_status
32
+ from src.utils.permissions import parse_permission # noqa: E402
33
+ from src.utils.tool_loader import auto_load_tools
35
34
 
36
35
  _original_tool_decorator = server.tool # keep reference to wrap later
37
36
 
@@ -39,11 +38,7 @@ _original_tool_decorator = server.tool # keep reference to wrap later
39
38
  def permissioned_tool(*d_args, **d_kwargs): # acts like @server.tool
40
39
  """Decorator that only registers the tool if permission allows."""
41
40
 
42
- tool_name = (
43
- d_kwargs.get("name")
44
- if d_kwargs.get("name")
45
- else (d_args[0] if d_args else None)
46
- )
41
+ tool_name = d_kwargs.get("name") if d_kwargs.get("name") else (d_args[0] if d_args else None)
47
42
 
48
43
  category = d_kwargs.pop("permission_category", None)
49
44
  action = d_kwargs.pop("permission_action", None)
@@ -141,9 +136,7 @@ def permissioned_tool(*d_args, **d_kwargs): # acts like @server.tool
141
136
  if allowed:
142
137
  # Permission granted - register with MCP server
143
138
  wrapped = (
144
- wrap_tool(func, tool_name or getattr(func, "__name__", "<tool>"))
145
- if diagnostics_enabled()
146
- else func
139
+ wrap_tool(func, tool_name or getattr(func, "__name__", "<tool>")) if diagnostics_enabled() else func
147
140
  )
148
141
  return _original_tool_decorator(*d_args, **d_kwargs)(wrapped)
149
142
 
@@ -168,9 +161,7 @@ try:
168
161
 
169
162
  logger.info(f"MCP Python SDK version: {getattr(mcp, '__version__', 'unknown')}")
170
163
  logger.info(f"Server methods: {dir(server)}")
171
- logger.info(
172
- f"Server tool methods: {[m for m in dir(server) if 'tool' in m.lower()]}"
173
- )
164
+ logger.info(f"Server tool methods: {[m for m in dir(server) if 'tool' in m.lower()]}")
174
165
  except Exception as e:
175
166
  logger.error(f"Error inspecting server: {e}")
176
167
 
@@ -194,9 +185,7 @@ async def main_async():
194
185
  try:
195
186
  from src.bootstrap import logger as bootstrap_logger_async
196
187
 
197
- bootstrap_logger_async.critical(
198
- "ASYNCHRONOUS main_async() FUNCTION ENTERED - TEST MESSAGE"
199
- )
188
+ bootstrap_logger_async.critical("ASYNCHRONOUS main_async() FUNCTION ENTERED - TEST MESSAGE")
200
189
  except Exception as e:
201
190
  print(f"Logging in main_async() failed: {e}", file=sys.stderr) # Fallback
202
191
  # ---- END VERY EARLY ASYNC LOG TEST ----
@@ -220,9 +209,7 @@ async def main_async():
220
209
  context["exception"].__traceback__,
221
210
  )
222
211
  )
223
- logger.error(
224
- f"Original traceback for global asyncio exception:\n{orig_traceback}"
225
- )
212
+ logger.error(f"Original traceback for global asyncio exception:\n{orig_traceback}")
226
213
 
227
214
  loop.set_exception_handler(handle_asyncio_exception)
228
215
  logger.info("Global asyncio exception handler set.")
@@ -232,17 +219,13 @@ async def main_async():
232
219
  log_level = config.server.get("log_level", "INFO").upper()
233
220
  # Ensure logging is configured (might be redundant if already set by bootstrap)
234
221
  # but this ensures the level is applied if changed post-bootstrap.
235
- logging.basicConfig(
236
- level=getattr(logging, log_level, logging.INFO), force=True
237
- ) # Use default format
222
+ logging.basicConfig(level=getattr(logging, log_level, logging.INFO), force=True) # Use default format
238
223
  logger.info(f"Log level set to {log_level} in main_async.")
239
224
 
240
225
  # Initialize the global Unifi connection
241
226
  logger.info("Initializing global Unifi connection from main_async...")
242
227
  if not await connection_manager.initialize():
243
- logger.error(
244
- "Failed to connect to Unifi Controller from main_async. Tool functionality may be impaired."
245
- )
228
+ logger.error("Failed to connect to Unifi Controller from main_async. Tool functionality may be impaired.")
246
229
  else:
247
230
  logger.info("Global Unifi connection initialized successfully from main_async.")
248
231
 
@@ -276,9 +259,7 @@ async def main_async():
276
259
  # They'll be registered on first use
277
260
  from src.utils.lazy_tool_loader import TOOL_MODULE_MAP
278
261
 
279
- logger.info(
280
- f" Lazy loader ready - {len(TOOL_MODULE_MAP)} tools available on-demand"
281
- )
262
+ logger.info(f" Lazy loader ready - {len(TOOL_MODULE_MAP)} tools available on-demand")
282
263
  else: # eager (default)
283
264
  logger.info("📚 Tool registration mode: eager")
284
265
  logger.info(" All 64+ UniFi tools registered immediately")
@@ -324,9 +305,7 @@ async def main_async():
324
305
  server.settings.host = host
325
306
  server.settings.port = port
326
307
  await server.run_sse_async()
327
- logger.info(
328
- "HTTP SSE started via run_sse_async() using server.settings host/port."
329
- )
308
+ logger.info("HTTP SSE started via run_sse_async() using server.settings host/port.")
330
309
  except Exception as http_e:
331
310
  logger.error(f"HTTP SSE server failed to start: {http_e}")
332
311
  logger.error(traceback.format_exc())
@@ -353,17 +332,13 @@ def main():
353
332
  print(f"Logging in main() failed: {e}", file=sys.stderr) # Fallback
354
333
  # ---- END VERY EARLY LOG TEST ----
355
334
 
356
- logger.debug(
357
- "Starting main()"
358
- ) # This uses the logger from bootstrap via global scope
335
+ logger.debug("Starting main()") # This uses the logger from bootstrap via global scope
359
336
  try:
360
337
  asyncio.run(main_async())
361
338
  except KeyboardInterrupt:
362
339
  logger.info("Server stopped by user (KeyboardInterrupt).")
363
340
  except Exception as e:
364
- logger.exception(
365
- "Unhandled exception during server run (from asyncio.run): %s", e
366
- )
341
+ logger.exception("Unhandled exception during server run (from asyncio.run): %s", e)
367
342
  finally:
368
343
  logger.info("Server process exiting.")
369
344
 
@@ -3,6 +3,7 @@ from typing import List, Optional
3
3
 
4
4
  from aiounifi.models.api import ApiRequest
5
5
  from aiounifi.models.client import Client
6
+
6
7
  from .connection_manager import ConnectionManager
7
8
 
8
9
  logger = logging.getLogger("unifi-network-mcp")
@@ -23,10 +24,7 @@ class ClientManager:
23
24
 
24
25
  async def get_clients(self) -> List[Client]:
25
26
  """Get list of currently online clients for the current site."""
26
- if (
27
- not await self._connection.ensure_connected()
28
- or not self._connection.controller
29
- ):
27
+ if not await self._connection.ensure_connected() or not self._connection.controller:
30
28
  return []
31
29
 
32
30
  cache_key = f"{CACHE_PREFIX_CLIENTS}_online_{self._connection.site}"
@@ -45,9 +43,7 @@ class ClientManager:
45
43
  # Therefore for "online" we fallback to GET /stat/sta.
46
44
  if not clients:
47
45
  try:
48
- raw_clients = await self._connection.request(
49
- ApiRequest(method="get", path="/stat/sta")
50
- )
46
+ raw_clients = await self._connection.request(ApiRequest(method="get", path="/stat/sta"))
51
47
  if isinstance(raw_clients, list) and raw_clients:
52
48
  # Cache raw dicts; tool layer handles dict or Client
53
49
  self._connection._update_cache(cache_key, raw_clients)
@@ -62,10 +58,7 @@ class ClientManager:
62
58
 
63
59
  async def get_all_clients(self) -> List[Client]:
64
60
  """Get list of all clients (including offline/historical) for the current site."""
65
- if (
66
- not await self._connection.ensure_connected()
67
- or not self._connection.controller
68
- ):
61
+ if not await self._connection.ensure_connected() or not self._connection.controller:
69
62
  return []
70
63
 
71
64
  cache_key = f"{CACHE_PREFIX_CLIENTS}_all_{self._connection.site}"
@@ -75,9 +68,7 @@ class ClientManager:
75
68
 
76
69
  try:
77
70
  await self._connection.controller.clients_all.update()
78
- all_clients: List[Client] = list(
79
- self._connection.controller.clients_all.values()
80
- )
71
+ all_clients: List[Client] = list(self._connection.controller.clients_all.values())
81
72
  # Fallback rationale:
82
73
  # - When the clients_all collection is empty, query the canonical
83
74
  # UniFi endpoint for all/historical client records.
@@ -86,9 +77,7 @@ class ClientManager:
86
77
  # connected. This complements GET /stat/sta used for online-only.
87
78
  if not all_clients:
88
79
  try:
89
- raw_all = await self._connection.request(
90
- ApiRequest(method="get", path="/rest/user")
91
- )
80
+ raw_all = await self._connection.request(ApiRequest(method="get", path="/rest/user"))
92
81
  if isinstance(raw_all, list) and raw_all:
93
82
  self._connection._update_cache(cache_key, raw_all)
94
83
  return raw_all # type: ignore[return-value]
@@ -103,13 +92,9 @@ class ClientManager:
103
92
  async def get_client_details(self, client_mac: str) -> Optional[Client]:
104
93
  """Get detailed information for a specific client by MAC address."""
105
94
  all_clients = await self.get_all_clients()
106
- client: Optional[Client] = next(
107
- (c for c in all_clients if c.mac == client_mac), None
108
- )
95
+ client: Optional[Client] = next((c for c in all_clients if c.mac == client_mac), None)
109
96
  if not client:
110
- logger.debug(
111
- f"Client details for MAC {client_mac} not found in clients_all list."
112
- )
97
+ logger.debug(f"Client details for MAC {client_mac} not found in clients_all list.")
113
98
  return client
114
99
 
115
100
  async def block_client(self, client_mac: str) -> bool:
@@ -119,14 +104,12 @@ class ClientManager:
119
104
  api_request = ApiRequest(
120
105
  method="post",
121
106
  path="/cmd/stamgr",
122
- json={"mac": client_mac, "cmd": "block-sta"},
107
+ data={"mac": client_mac, "cmd": "block-sta"},
123
108
  )
124
109
  # Call the updated request method
125
110
  await self._connection.request(api_request)
126
111
  logger.info(f"Block command sent for client {client_mac}")
127
- self._connection._invalidate_cache(
128
- f"{CACHE_PREFIX_CLIENTS}"
129
- ) # Invalidate all client caches
112
+ self._connection._invalidate_cache(f"{CACHE_PREFIX_CLIENTS}") # Invalidate all client caches
130
113
  return True
131
114
  except Exception as e:
132
115
  logger.error(f"Error blocking client {client_mac}: {e}")
@@ -139,7 +122,7 @@ class ClientManager:
139
122
  api_request = ApiRequest(
140
123
  method="post",
141
124
  path="/cmd/stamgr",
142
- json={"mac": client_mac, "cmd": "unblock-sta"},
125
+ data={"mac": client_mac, "cmd": "unblock-sta"},
143
126
  )
144
127
  # Call the updated request method
145
128
  await self._connection.request(api_request)
@@ -155,15 +138,11 @@ class ClientManager:
155
138
  try:
156
139
  client = await self.get_client_details(client_mac)
157
140
  if not client or "_id" not in client.raw:
158
- logger.error(
159
- f"Cannot rename client {client_mac}: Not found or missing ID."
160
- )
141
+ logger.error(f"Cannot rename client {client_mac}: Not found or missing ID.")
161
142
  return False
162
143
  client_id = client.raw["_id"]
163
144
 
164
- api_request = ApiRequest(
165
- method="put", path=f"/upd/user/{client_id}", json={"name": name}
166
- )
145
+ api_request = ApiRequest(method="put", path=f"/upd/user/{client_id}", data={"name": name})
167
146
  await self._connection.request(api_request)
168
147
  logger.info(f"Rename command sent for client {client_mac} to '{name}'")
169
148
  self._connection._invalidate_cache(f"{CACHE_PREFIX_CLIENTS}")
@@ -178,7 +157,7 @@ class ClientManager:
178
157
  api_request = ApiRequest(
179
158
  method="post",
180
159
  path="/cmd/stamgr",
181
- json={"mac": client_mac, "cmd": "kick-sta"},
160
+ data={"mac": client_mac, "cmd": "kick-sta"},
182
161
  )
183
162
  await self._connection.request(api_request)
184
163
  logger.info(f"Force reconnect (kick) command sent for client {client_mac}")
@@ -213,12 +192,10 @@ class ClientManager:
213
192
  payload["bytes"] = bytes_quota
214
193
 
215
194
  # Construct ApiRequest
216
- api_request = ApiRequest(method="post", path="/cmd/stamgr", json=payload)
195
+ api_request = ApiRequest(method="post", path="/cmd/stamgr", data=payload)
217
196
  # Call the updated request method
218
197
  await self._connection.request(api_request)
219
- logger.info(
220
- f"Authorize command sent for guest {client_mac} for {minutes} minutes"
221
- )
198
+ logger.info(f"Authorize command sent for guest {client_mac} for {minutes} minutes")
222
199
  self._connection._invalidate_cache(f"{CACHE_PREFIX_CLIENTS}")
223
200
  return True
224
201
  except Exception as e:
@@ -231,7 +208,7 @@ class ClientManager:
231
208
  api_request = ApiRequest(
232
209
  method="post",
233
210
  path="/cmd/stamgr",
234
- json={"mac": client_mac, "cmd": "unauthorize-guest"},
211
+ data={"mac": client_mac, "cmd": "unauthorize-guest"},
235
212
  )
236
213
  await self._connection.request(api_request)
237
214
  logger.info(f"Unauthorize command sent for guest {client_mac}")
@@ -240,3 +217,98 @@ class ClientManager:
240
217
  except Exception as e:
241
218
  logger.error(f"Error unauthorizing guest {client_mac}: {e}")
242
219
  return False
220
+
221
+ async def set_client_ip_settings(
222
+ self,
223
+ client_mac: str,
224
+ use_fixedip: Optional[bool] = None,
225
+ fixed_ip: Optional[str] = None,
226
+ local_dns_record_enabled: Optional[bool] = None,
227
+ local_dns_record: Optional[str] = None,
228
+ ) -> bool:
229
+ """Set fixed IP and/or local DNS record for a client.
230
+
231
+ Uses the UniFi REST API endpoint PUT /rest/user/{client_id}.
232
+ Local DNS records require UniFi Network 7.2+.
233
+
234
+ Args:
235
+ client_mac: MAC address of the client to update.
236
+ use_fixedip: Enable (True) or disable (False) fixed IP.
237
+ fixed_ip: The fixed IP address to assign (required if use_fixedip=True).
238
+ local_dns_record_enabled: Enable (True) or disable (False) local DNS.
239
+ local_dns_record: The DNS hostname to assign (e.g., "mydevice.local").
240
+
241
+ Returns:
242
+ True if the update was successful, False otherwise.
243
+ """
244
+ try:
245
+ # Get client to find their internal _id
246
+ client = await self.get_client_details(client_mac)
247
+ if not client:
248
+ logger.error(f"Cannot set IP settings for {client_mac}: Client not found")
249
+ return False
250
+
251
+ client_raw = client.raw if hasattr(client, "raw") else client
252
+ if "_id" not in client_raw:
253
+ logger.error(f"Cannot set IP settings for {client_mac}: Missing _id")
254
+ return False
255
+
256
+ client_id = client_raw["_id"]
257
+
258
+ # If client is not "noted" (known), mark it first to enable IP config
259
+ if not client_raw.get("noted"):
260
+ logger.info(f"Client {client_mac} not noted, marking as known first")
261
+ note_payload = {"noted": True}
262
+ if not client_raw.get("name") and client_raw.get("hostname"):
263
+ note_payload["name"] = client_raw["hostname"]
264
+ try:
265
+ note_request = ApiRequest(
266
+ method="put",
267
+ path=f"/rest/user/{client_id}",
268
+ data=note_payload,
269
+ )
270
+ await self._connection.request(note_request)
271
+ except Exception as note_err:
272
+ logger.warning(f"Could not mark client as noted: {note_err}")
273
+
274
+ # Build payload with only explicitly provided fields
275
+ payload: dict = {}
276
+
277
+ if use_fixedip is not None:
278
+ payload["use_fixedip"] = use_fixedip
279
+ if use_fixedip and fixed_ip:
280
+ payload["fixed_ip"] = fixed_ip
281
+ elif not use_fixedip:
282
+ payload["fixed_ip"] = ""
283
+ elif fixed_ip is not None:
284
+ # If only fixed_ip provided, assume enabling
285
+ payload["use_fixedip"] = True
286
+ payload["fixed_ip"] = fixed_ip
287
+
288
+ if local_dns_record_enabled is not None:
289
+ payload["local_dns_record_enabled"] = local_dns_record_enabled
290
+ if local_dns_record_enabled and local_dns_record:
291
+ payload["local_dns_record"] = local_dns_record
292
+ elif not local_dns_record_enabled:
293
+ payload["local_dns_record"] = ""
294
+ elif local_dns_record is not None:
295
+ # If only local_dns_record provided, assume enabling
296
+ payload["local_dns_record_enabled"] = True
297
+ payload["local_dns_record"] = local_dns_record
298
+
299
+ if not payload:
300
+ logger.warning(f"No IP settings provided for {client_mac}")
301
+ return False
302
+
303
+ api_request = ApiRequest(
304
+ method="put",
305
+ path=f"/rest/user/{client_id}",
306
+ data=payload,
307
+ )
308
+ await self._connection.request(api_request)
309
+ logger.info(f"IP settings updated for client {client_mac}: {payload}")
310
+ self._connection._invalidate_cache(f"{CACHE_PREFIX_CLIENTS}")
311
+ return True
312
+ except Exception as e:
313
+ logger.error(f"Error setting IP settings for {client_mac}: {e}")
314
+ return False